diff --git a/src/main/java/org/kakaoshare/backend/logging/ApiQueryCounterAspect.java b/src/main/java/org/kakaoshare/backend/logging/ApiQueryCounterAspect.java new file mode 100644 index 00000000..6f4445fe --- /dev/null +++ b/src/main/java/org/kakaoshare/backend/logging/ApiQueryCounterAspect.java @@ -0,0 +1,28 @@ +package org.kakaoshare.backend.logging; + +import lombok.RequiredArgsConstructor; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.kakaoshare.backend.logging.handler.ConnectionInvocationHandler; +import org.kakaoshare.backend.logging.util.ApiQueryCounter; +import org.springframework.stereotype.Component; + +import java.lang.reflect.Proxy; + +@Aspect +@Component +@RequiredArgsConstructor +public class ApiQueryCounterAspect { + private final ApiQueryCounter apiQueryCounter; + + @Around("execution(* javax.sql.DataSource.getConnection())") + public Object getConnection(final ProceedingJoinPoint proceedingJoinPoint) throws Throwable { + final Object connection = proceedingJoinPoint.proceed(); + return Proxy.newProxyInstance( + connection.getClass().getClassLoader(), + connection.getClass().getInterfaces(), + new ConnectionInvocationHandler(apiQueryCounter, connection) + ); + } +} diff --git a/src/main/java/org/kakaoshare/backend/logging/config/LoggingConfig.java b/src/main/java/org/kakaoshare/backend/logging/config/LoggingConfig.java new file mode 100644 index 00000000..53867e6f --- /dev/null +++ b/src/main/java/org/kakaoshare/backend/logging/config/LoggingConfig.java @@ -0,0 +1,15 @@ +package org.kakaoshare.backend.logging.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.util.StopWatch; +import org.springframework.web.context.annotation.RequestScope; + +@Configuration +public class LoggingConfig { + @Bean + @RequestScope + public StopWatch stopWatch() { + return new StopWatch(); + } +} diff --git a/src/main/java/org/kakaoshare/backend/logging/filter/LoggingFilter.java b/src/main/java/org/kakaoshare/backend/logging/filter/LoggingFilter.java new file mode 100644 index 00000000..ce1a2ec0 --- /dev/null +++ b/src/main/java/org/kakaoshare/backend/logging/filter/LoggingFilter.java @@ -0,0 +1,129 @@ +package org.kakaoshare.backend.logging.filter; + +import com.querydsl.core.util.StringUtils; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.kakaoshare.backend.logging.util.ApiQueryCounter; +import org.springframework.stereotype.Component; +import org.springframework.util.StopWatch; +import org.springframework.web.filter.OncePerRequestFilter; +import org.springframework.web.util.ContentCachingRequestWrapper; +import org.springframework.web.util.ContentCachingResponseWrapper; + +import java.io.IOException; +import java.util.Arrays; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.stream.Collectors; + +import static org.springframework.http.HttpHeaders.AUTHORIZATION; +import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; + +@Component +@Slf4j +@RequiredArgsConstructor +public class LoggingFilter extends OncePerRequestFilter { + private static final String REQUEST_LOG_NO_BODY_FORMAT = "REQUEST :: METHOD: {}, URL: {}, HAS_AUTHORIZATION: {}"; + private static final String REQUEST_LOG_FORMAT = REQUEST_LOG_NO_BODY_FORMAT + ", BODY: {}"; + private static final String RESPONSE_LOG_NO_BODY_FORMAT = "RESPONSE :: STATUS_CODE: {}, METHOD: {}, URL: {}, QUERY_COUNT: {}, TIME_TAKEN: {}ms"; + private static final String QUERY_COUNT_WARNING_LOG_FORMAT = "쿼리가 {}번 이상 실행되었습니다."; + + private static final int QUERY_COUNT_WARNING_STANDARD = 10; + private static final String START_OF_PARAMS = "?"; + private static final String PARAM_DELIMITER = "&"; + private static final String KEY_VALUE_DELIMITER = "="; + + private final StopWatch apiTimer; + private final ApiQueryCounter apiQueryCounter; + + @Override + protected void doFilterInternal(final HttpServletRequest request, + final HttpServletResponse response, + final FilterChain filterChain) throws ServletException, IOException { + final ContentCachingRequestWrapper cachingRequest = new ContentCachingRequestWrapper(request); + final ContentCachingResponseWrapper cachingResponse = new ContentCachingResponseWrapper(response); + + apiTimer.start(); + filterChain.doFilter(cachingRequest, cachingResponse); + apiTimer.stop(); + + logRequestAndResponse(cachingRequest, cachingResponse); + cachingResponse.copyBodyToResponse(); + } + + private void logRequestAndResponse(final ContentCachingRequestWrapper request, + final ContentCachingResponseWrapper response) { + logRequest(request); + logResponse(request, response); + warnByQueryCount(); + } + + private void logRequest(final ContentCachingRequestWrapper request) { + final String requestBody = new String(request.getContentAsByteArray()); + final String requestURIWithParams = getRequestURIWithParams(request); + + if (requestBody.isBlank()) { + log.info(REQUEST_LOG_NO_BODY_FORMAT, request.getMethod(), requestURIWithParams, StringUtils.isNullOrEmpty(request.getHeader(AUTHORIZATION))); + return; + } + + log.info(REQUEST_LOG_FORMAT, request.getMethod(), requestURIWithParams, StringUtils.isNullOrEmpty(request.getHeader(AUTHORIZATION)), requestBody); + } + + private void logResponse(final ContentCachingRequestWrapper request, + final ContentCachingResponseWrapper response) { + final int queryCount = apiQueryCounter.getCount(); + final String requestURIWithParams = getRequestURIWithParams(request); + log.info(RESPONSE_LOG_NO_BODY_FORMAT, response.getStatus(), request.getMethod(), + requestURIWithParams, queryCount, apiTimer.getLastTaskTimeMillis()); + } + + private String getRequestURIWithParams(final ContentCachingRequestWrapper request) { + final String requestURI = request.getRequestURI(); + final Map params = request.getParameterMap(); + + if (params.isEmpty()) { + return requestURI; + } + + final String parsedParams = parseParams(params); + return requestURI + parsedParams; + } + + private String parseParams(final Map params) { + final String everyParamStrings = params.entrySet().stream() + .map(this::toParamString) + .collect(Collectors.joining(PARAM_DELIMITER)); + + return START_OF_PARAMS + everyParamStrings; + } + + private String toParamString(final Map.Entry entry) { + final String key = entry.getKey(); + final StringBuilder builder = new StringBuilder(); + + return Arrays.stream(entry.getValue()) + .map(value -> builder.append(key).append(KEY_VALUE_DELIMITER).append(value)) + .collect(Collectors.joining(PARAM_DELIMITER)); + } + + private Optional getJsonResponseBody(final ContentCachingResponseWrapper response) { + if (Objects.equals(response.getContentType(), APPLICATION_JSON_VALUE)) { + return Optional.of(new String(response.getContentAsByteArray())); + } + + return Optional.empty(); + } + + private void warnByQueryCount() { + final int queryCount = apiQueryCounter.getCount(); + if (queryCount >= QUERY_COUNT_WARNING_STANDARD) { + log.warn(QUERY_COUNT_WARNING_LOG_FORMAT, QUERY_COUNT_WARNING_STANDARD); + } + } +} diff --git a/src/main/java/org/kakaoshare/backend/logging/handler/ConnectionInvocationHandler.java b/src/main/java/org/kakaoshare/backend/logging/handler/ConnectionInvocationHandler.java new file mode 100644 index 00000000..b4a90da1 --- /dev/null +++ b/src/main/java/org/kakaoshare/backend/logging/handler/ConnectionInvocationHandler.java @@ -0,0 +1,28 @@ +package org.kakaoshare.backend.logging.handler; + +import lombok.RequiredArgsConstructor; +import org.kakaoshare.backend.logging.util.ApiQueryCounter; + +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; + +@RequiredArgsConstructor +public class ConnectionInvocationHandler implements InvocationHandler { + private final ApiQueryCounter apiQueryCounter; + private final Object connection; + + @Override + public Object invoke(final Object proxy, final Method method, final Object[] args) throws Throwable { + final Object result = method.invoke(connection, args); + if (method.getName().equals("prepareStatement")) { + return Proxy.newProxyInstance( + result.getClass().getClassLoader(), + result.getClass().getInterfaces(), + new PreparedStatementInvocationHandler(apiQueryCounter, result) + ); + } + + return result; + } +} diff --git a/src/main/java/org/kakaoshare/backend/logging/handler/PreparedStatementInvocationHandler.java b/src/main/java/org/kakaoshare/backend/logging/handler/PreparedStatementInvocationHandler.java new file mode 100644 index 00000000..6975f366 --- /dev/null +++ b/src/main/java/org/kakaoshare/backend/logging/handler/PreparedStatementInvocationHandler.java @@ -0,0 +1,23 @@ +package org.kakaoshare.backend.logging.handler; + +import lombok.RequiredArgsConstructor; +import org.kakaoshare.backend.logging.util.ApiQueryCounter; +import org.springframework.web.context.request.RequestContextHolder; + +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; + +@RequiredArgsConstructor +public class PreparedStatementInvocationHandler implements InvocationHandler { + private final ApiQueryCounter apiQueryCounter; + private final Object preparedStatement; + + @Override + public Object invoke(final Object proxy, final Method method, final Object[] args) throws Throwable { + if (method.getName().contains("execute") && RequestContextHolder.getRequestAttributes() != null) { + apiQueryCounter.increaseCount(); + } + + return method.invoke(preparedStatement, args); + } +} diff --git a/src/main/java/org/kakaoshare/backend/logging/util/ApiQueryCounter.java b/src/main/java/org/kakaoshare/backend/logging/util/ApiQueryCounter.java new file mode 100644 index 00000000..cef33e56 --- /dev/null +++ b/src/main/java/org/kakaoshare/backend/logging/util/ApiQueryCounter.java @@ -0,0 +1,16 @@ +package org.kakaoshare.backend.logging.util; + +import lombok.Getter; +import org.springframework.stereotype.Component; +import org.springframework.web.context.annotation.RequestScope; + +@Component +@RequestScope +@Getter +public class ApiQueryCounter { + private int count; + + public void increaseCount() { + count++; + } +}