-
Notifications
You must be signed in to change notification settings - Fork 8
feat: API 성능 로깅, 쿼리 별 메트릭 전송 추가 #602
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
bd909d5
ff145a8
88dee74
69ccdd6
222980c
d49b83b
d6793d3
db2c7f3
5a11264
1e4cfe2
ac27d4f
109a4e4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| package com.example.solidconnection.common.config.datasource; | ||
|
|
||
| import com.example.solidconnection.common.listener.QueryMetricsListener; | ||
| import javax.sql.DataSource; | ||
| import lombok.RequiredArgsConstructor; | ||
| import net.ttddyy.dsproxy.support.ProxyDataSourceBuilder; | ||
| import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties; | ||
| import org.springframework.context.annotation.Bean; | ||
| import org.springframework.context.annotation.Configuration; | ||
| import org.springframework.context.annotation.Primary; | ||
|
|
||
| @RequiredArgsConstructor | ||
| @Configuration | ||
| public class DataSourceProxyConfig { | ||
|
|
||
| private final QueryMetricsListener queryMetricsListener; | ||
|
|
||
| @Bean | ||
| @Primary | ||
| public DataSource proxyDataSource(DataSourceProperties props) { | ||
| DataSource dataSource = props.initializeDataSourceBuilder().build(); | ||
|
|
||
| return ProxyDataSourceBuilder | ||
| .create(dataSource) | ||
| .listener(queryMetricsListener) | ||
| .name("main") | ||
| .build(); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,11 +1,18 @@ | ||
| package com.example.solidconnection.common.config.web; | ||
|
|
||
| import com.example.solidconnection.common.filter.HttpLoggingFilter; | ||
| import com.example.solidconnection.common.interceptor.ApiPerformanceInterceptor; | ||
| import com.example.solidconnection.common.interceptor.RequestContextInterceptor; | ||
| import com.example.solidconnection.common.resolver.AuthorizedUserResolver; | ||
| import com.example.solidconnection.common.resolver.CustomPageableHandlerMethodArgumentResolver; | ||
| import java.util.List; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.boot.web.servlet.FilterRegistrationBean; | ||
| import org.springframework.context.annotation.Bean; | ||
| import org.springframework.context.annotation.Configuration; | ||
| import org.springframework.core.Ordered; | ||
| import org.springframework.web.method.support.HandlerMethodArgumentResolver; | ||
| import org.springframework.web.servlet.config.annotation.InterceptorRegistry; | ||
| import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; | ||
|
|
||
| @Configuration | ||
|
|
@@ -14,6 +21,9 @@ public class WebMvcConfig implements WebMvcConfigurer { | |
|
|
||
| private final AuthorizedUserResolver authorizedUserResolver; | ||
| private final CustomPageableHandlerMethodArgumentResolver customPageableHandlerMethodArgumentResolver; | ||
| private final HttpLoggingFilter httpLoggingFilter; | ||
| private final ApiPerformanceInterceptor apiPerformanceInterceptor; | ||
| private final RequestContextInterceptor requestContextInterceptor; | ||
|
|
||
| @Override | ||
| public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) { | ||
|
|
@@ -22,4 +32,21 @@ public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) | |
| customPageableHandlerMethodArgumentResolver | ||
| )); | ||
| } | ||
|
|
||
| @Override | ||
| public void addInterceptors(InterceptorRegistry registry){ | ||
| registry.addInterceptor(apiPerformanceInterceptor) | ||
| .addPathPatterns("/**"); | ||
|
|
||
| registry.addInterceptor(requestContextInterceptor) | ||
| .addPathPatterns("/**"); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 필터에서 |
||
| } | ||
|
|
||
| @Bean | ||
| public FilterRegistrationBean<HttpLoggingFilter> customHttpLoggingFilter() { | ||
| FilterRegistrationBean<HttpLoggingFilter> filterBean = new FilterRegistrationBean<>(); | ||
| filterBean.setFilter(httpLoggingFilter); | ||
| filterBean.setOrder(Ordered.HIGHEST_PRECEDENCE); | ||
| return filterBean; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -9,6 +9,7 @@ | |
| import com.example.solidconnection.common.response.ErrorResponse; | ||
| import com.fasterxml.jackson.databind.exc.InvalidFormatException; | ||
| import io.jsonwebtoken.JwtException; | ||
| import jakarta.servlet.http.HttpServletRequest; | ||
| import java.util.ArrayList; | ||
| import java.util.List; | ||
| import lombok.extern.slf4j.Slf4j; | ||
|
|
@@ -24,62 +25,96 @@ | |
| public class CustomExceptionHandler { | ||
|
|
||
| @ExceptionHandler(CustomException.class) | ||
| protected ResponseEntity<ErrorResponse> handleCustomException(CustomException ex) { | ||
| log.error("커스텀 예외 발생 : {}", ex.getMessage()); | ||
| protected ResponseEntity<ErrorResponse> handleCustomException( | ||
| CustomException ex, | ||
| HttpServletRequest request | ||
| ) { | ||
| request.setAttribute("exceptionHandlerLogged", true); | ||
| Long userId = (Long) request.getAttribute("userId"); | ||
| log.error("커스텀 예외 발생 userId : {} msg: {}", userId, ex.getMessage()); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 예외 로그에 |
||
| ErrorResponse errorResponse = new ErrorResponse(ex); | ||
| return ResponseEntity | ||
| .status(ex.getCode()) | ||
| .body(errorResponse); | ||
| } | ||
|
|
||
| @ExceptionHandler(InvalidFormatException.class) | ||
| public ResponseEntity<Object> handleInvalidFormatException(InvalidFormatException ex) { | ||
| public ResponseEntity<Object> handleInvalidFormatException( | ||
| InvalidFormatException ex, | ||
| HttpServletRequest request | ||
| ) { | ||
| request.setAttribute("exceptionHandlerLogged", true); | ||
| Long userId = (Long) request.getAttribute("userId"); | ||
| String errorMessage = ex.getValue() + " 은(는) 유효하지 않은 값입니다."; | ||
| log.error("JSON 파싱 예외 발생 : {}", errorMessage); | ||
| log.error("JSON 파싱 예외 발생 userId: {} msg: {}", userId, errorMessage); | ||
| ErrorResponse errorResponse = new ErrorResponse(JSON_PARSING_FAILED, errorMessage); | ||
| return ResponseEntity | ||
| .status(JSON_PARSING_FAILED.getCode()) | ||
| .body(errorResponse); | ||
| } | ||
|
|
||
| @ExceptionHandler(MethodArgumentNotValidException.class) | ||
| public ResponseEntity<ErrorResponse> handleValidationExceptions(MethodArgumentNotValidException ex) { | ||
| public ResponseEntity<ErrorResponse> handleValidationExceptions( | ||
| MethodArgumentNotValidException ex, | ||
| HttpServletRequest request | ||
| ) { | ||
| request.setAttribute("exceptionHandlerLogged", true); | ||
| Long userId = (Long) request.getAttribute("userId"); | ||
|
|
||
| List<String> errors = new ArrayList<>(); | ||
| ex.getBindingResult() | ||
| .getFieldErrors() | ||
| .forEach(fieldError -> errors.add(fieldError.getDefaultMessage())); | ||
|
|
||
| String errorMessage = errors.toString(); | ||
| log.error("입력값 검증 예외 발생 : {}", errorMessage); | ||
| log.error("입력값 검증 예외 발생 userId : {} msg: {}", userId, errorMessage); | ||
| ErrorResponse errorResponse = new ErrorResponse(INVALID_INPUT, errorMessage); | ||
| return ResponseEntity | ||
| .status(HttpStatus.BAD_REQUEST) | ||
| .body(errorResponse); | ||
| } | ||
|
|
||
| @ExceptionHandler(DataIntegrityViolationException.class) | ||
| public ResponseEntity<Object> handleDataIntegrityViolationException(DataIntegrityViolationException ex) { | ||
| log.error("데이터 무결성 제약조건 위반 예외 발생 : {}", ex.getMessage()); | ||
| public ResponseEntity<Object> handleDataIntegrityViolationException( | ||
| DataIntegrityViolationException ex, | ||
| HttpServletRequest request | ||
| ) { | ||
| request.setAttribute("exceptionHandlerLogged", true); | ||
| Long userId = (Long) request.getAttribute("userId"); | ||
|
|
||
| log.error("데이터 무결성 제약조건 위반 예외 발생 userId : {} msg : {}", userId, ex.getMessage()); | ||
| ErrorResponse errorResponse = new ErrorResponse(DATA_INTEGRITY_VIOLATION, "데이터 무결성 제약조건 위반 예외 발생"); | ||
| return ResponseEntity | ||
| .status(DATA_INTEGRITY_VIOLATION.getCode()) | ||
| .body(errorResponse); | ||
| } | ||
|
|
||
| @ExceptionHandler(JwtException.class) | ||
| public ResponseEntity<Object> handleJwtException(JwtException ex) { | ||
| public ResponseEntity<Object> handleJwtException( | ||
| JwtException ex, | ||
| HttpServletRequest request | ||
| ) { | ||
| request.setAttribute("exceptionHandlerLogged", true); | ||
| Long userId = (Long) request.getAttribute("userId"); | ||
|
|
||
| String errorMessage = ex.getMessage(); | ||
| log.error("JWT 예외 발생 : {}", errorMessage); | ||
| log.error("JWT 예외 발생 userId : {} msg : {}", userId, errorMessage); | ||
| ErrorResponse errorResponse = new ErrorResponse(JWT_EXCEPTION, errorMessage); | ||
| return ResponseEntity | ||
| .status(HttpStatus.BAD_REQUEST) | ||
| .body(errorResponse); | ||
| } | ||
|
|
||
| @ExceptionHandler(Exception.class) | ||
| public ResponseEntity<Object> handleOtherException(Exception ex) { | ||
| public ResponseEntity<Object> handleOtherException( | ||
| Exception ex, | ||
| HttpServletRequest request | ||
| ) { | ||
| request.setAttribute("exceptionHandlerLogged", true); | ||
| Long userId = (Long) request.getAttribute("userId"); | ||
|
|
||
| String errorMessage = ex.getMessage(); | ||
| log.error("서버 내부 예외 발생 : {}", errorMessage); | ||
| log.error("서버 내부 예외 발생 userId : {} , msg : {}", userId, errorMessage); | ||
| ErrorResponse errorResponse = new ErrorResponse(NOT_DEFINED_ERROR, errorMessage); | ||
| return ResponseEntity | ||
| .status(HttpStatus.INTERNAL_SERVER_ERROR) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,116 @@ | ||
| package com.example.solidconnection.common.filter; | ||
|
|
||
| import com.fasterxml.jackson.databind.ObjectMapper; | ||
| import jakarta.servlet.FilterChain; | ||
| import jakarta.servlet.ServletException; | ||
| import jakarta.servlet.http.HttpServletRequest; | ||
| import jakarta.servlet.http.HttpServletResponse; | ||
| import java.io.IOException; | ||
| import java.net.URLDecoder; | ||
| import java.nio.charset.StandardCharsets; | ||
| import java.util.List; | ||
| import lombok.RequiredArgsConstructor; | ||
| import lombok.extern.slf4j.Slf4j; | ||
| import org.slf4j.MDC; | ||
| import org.springframework.http.HttpStatus; | ||
| import org.springframework.stereotype.Component; | ||
| import org.springframework.util.AntPathMatcher; | ||
| import org.springframework.web.filter.OncePerRequestFilter; | ||
|
|
||
| @Slf4j | ||
| @RequiredArgsConstructor | ||
| @Component | ||
| public class HttpLoggingFilter extends OncePerRequestFilter { | ||
|
|
||
| private static final AntPathMatcher PATH_MATCHER = new AntPathMatcher(); | ||
|
|
||
| private static final List<String> EXCLUDE_PATTERNS = List.of( | ||
| "/actuator/**" | ||
| ); | ||
|
|
||
| @Override | ||
| protected void doFilterInternal( | ||
| HttpServletRequest request, | ||
| HttpServletResponse response, | ||
| FilterChain filterChain | ||
| ) throws ServletException, IOException { | ||
|
|
||
| // 1) traceId 부여 | ||
| String traceId = generateTraceId(); | ||
| MDC.put("traceId", traceId); | ||
|
|
||
| boolean excluded = isExcluded(request); | ||
|
|
||
| // 2) 로깅 제외 대상이면 그냥 통과 (traceId는 유지: 추후 하위 레이어 로그에도 붙음) | ||
| if (excluded) { | ||
| try { | ||
| filterChain.doFilter(request, response); | ||
| } finally { | ||
| MDC.clear(); | ||
| } | ||
| return; | ||
| } | ||
|
|
||
| printRequestUri(request); | ||
|
|
||
| try { | ||
| filterChain.doFilter(request, response); | ||
|
|
||
| Boolean alreadyExceptionLogging = (Boolean) request.getAttribute("exceptionHandlerLogged"); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 추가로 |
||
| if (alreadyExceptionLogging == null || !alreadyExceptionLogging) { | ||
| printResponse(request, response); | ||
| } | ||
|
|
||
| } finally { | ||
| MDC.clear(); | ||
| } | ||
| } | ||
|
|
||
| private boolean isExcluded(HttpServletRequest req) { | ||
| String path = req.getRequestURI(); | ||
| for (String p : EXCLUDE_PATTERNS) { | ||
| if (PATH_MATCHER.match(p, path)) { | ||
| return true; | ||
| } | ||
| } | ||
| return false; | ||
| } | ||
|
|
||
| private String generateTraceId() { | ||
| return java.util.UUID.randomUUID().toString().replace("-", "").substring(0, 16); | ||
| } | ||
|
|
||
| private void printResponse( | ||
| HttpServletRequest request, | ||
| HttpServletResponse response | ||
| ) { | ||
| Long userId = (Long) request.getAttribute("userId"); | ||
| String uri = buildDecodedRequestUri(request); | ||
| HttpStatus status = HttpStatus.valueOf(response.getStatus()); | ||
|
|
||
| log.info("[RESPONSE] {} userId = {}, ({})", uri, userId, status); | ||
| } | ||
|
|
||
| private void printRequestUri(HttpServletRequest request) { | ||
| String methodType = request.getMethod(); | ||
| String uri = buildDecodedRequestUri(request); | ||
| log.info("[REQUEST] {} {}", methodType, uri); | ||
| } | ||
|
|
||
| private String decodeQuery(String rawQuery) { | ||
| if (rawQuery == null) { | ||
| return null; | ||
| } | ||
| try { | ||
| return URLDecoder.decode(rawQuery, StandardCharsets.UTF_8); | ||
| } catch (IllegalArgumentException e) { | ||
| return rawQuery; | ||
| } | ||
| } | ||
|
|
||
| private String buildDecodedRequestUri(HttpServletRequest request) { | ||
| String path = request.getRequestURI(); | ||
| String query = decodeQuery(request.getQueryString()); | ||
| return (query == null || query.isBlank()) ? path : path + "?" + query; | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🌐 Web query:
datasource-proxy net.ttddyy latest version Maven Central 2025💡 Result:
Latest Maven Central version: 1.11.0 (released July 14, 2025).
Maven dependency:
net.ttddyy
datasource-proxy
1.11.0
Sources: [Maven Repository (1.11.0)] [1], [javadoc.io (1.11.0)] [2], [Maven Central index/metadata] [3].
References
🌐 Web query:
datasource-micrometer net.ttddyy.observation latest version Maven Central 2025💡 Result:
The latest Maven Central version is net.ttddyy.observation:datasource-micrometer: 2.0.1. [1][2]
Sources:
🌐 Web query:
datasource-proxy 1.11.0 security vulnerability CVE💡 Result:
I found no public CVE that specifically affects the net.ttddyy datasource‑proxy 1.11.0 library itself. Maven listings for datasource‑proxy 1.11.0 show reported vulnerabilities coming from its dependencies (examples listed there include CVE-2025-68161, CVE-2024-38819, CVE-2023-6378), not a direct CVE for datasource‑proxy. [1]
Note: There is a separate, unrelated Grafana "datasource proxy" advisory (CVE-2025-3454) that affects Grafana’s server API — not the net.ttddyy datasource‑proxy Java library. Do not confuse the two. [2]
Sources:
References:
[1] https://mvnrepository.com/artifact/net.ttddyy/datasource-proxy/1.11.0
[2] https://grafana.com/security/security-advisories/cve-2025-3454/
🌐 Web query:
datasource-micrometer 1.2.0 security vulnerability CVE💡 Result:
I found no public CVE specifically affecting net.ttddyy.observation:datasource-micrometer version 1.2.0. The Maven listing for that artifact/version shows no CVE entry for the artifact itself. [1]
There are unrelated CVEs for other metric libraries (for example CVE-2020-1960 affecting Apache Flink's JMX reporter), but those do not apply to datasource-micrometer. [2][3]
If you want, I can:
Sources:
[1] mvnrepository — net.ttddyy.observation:datasource-micrometer 1.2.0.
[2] Micrometer security policy / advisories.
[3] NVD / GitLab advisory — CVE-2020-1960 (example of an unrelated metrics CVE).
데이터소스 프록시 의존성이 추가되었습니다. datasource-micrometer 버전 업그레이드를 권장합니다.
변경 내용:
datasource-proxy:1.11.0- 최신 버전이며, 쿼리 인터셉션을 위한 핵심 프록시 라이브러리입니다.datasource-micrometer:1.2.0- 최신 버전은 2.0.1이므로 업그레이드를 고려하세요. Micrometer와의 통합을 위한 라이브러리입니다.DataSourceProxyConfig 및 QueryMetricsListener와 연동되어 쿼리별 메트릭 수집을 지원합니다. 현재 datasource-micrometer는 1.2.0 사용 중이지만, 2.0.1이 최신 버전으로 이용 가능합니다. 특정 보안 취약점은 발견되지 않았으나, 최신 버전으로 업그레이드하면 개선 사항과 안정성을 함께 얻을 수 있습니다.
🤖 Prompt for AI Agents
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
datasource-micrometer의존성이 내부적으로datasource-proxy를 포함하기에,datasource-proxy의존성은 제거해도 될 듯 합니다.버전은 1.2.x 그대로 사용하면 될 듯 합니다. 메이저 버전이 1인 경우 마이너 버전 2가 최신이고, 저희 Spring Boot 버전과 호환됩니다. 메이저 버전 2는 Spring Boot 4.x에서 지원하네요. [참고1], [참고2]
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@whqtker 정확한 지적 감사합니다! 제 분석이 잘못되었네요.
말씀하신 내용 정리:
중복 의존성 제거 필요
datasource-micrometer가 내부적으로datasource-proxy를 포함하고 있어, Line 72의implementation 'net.ttddyy:datasource-proxy:1.11.0'라인은 제거해도 됩니다.버전 1.2.0이 적절함
datasource-micrometer:1.2.0이 올바른 버전입니다.참고 자료 제공해주셔서 이해가 명확해졌습니다. 중복 의존성을 제거하면 의존성 관리가 더 깔끔해질 것 같습니다!
✏️ Learnings added