From 4bc556e18dbd009bbb895c95c1fbceac7384d40f Mon Sep 17 00:00:00 2001 From: "dev.geonwoo_" Date: Fri, 18 Sep 2026 14:09:47 +0900 Subject: [PATCH 1/2] =?UTF-8?q?[Fix]#61=20API=20=EC=98=A4=EB=A5=98=20?= =?UTF-8?q?=EB=A9=94=ED=8A=B8=EB=A6=AD=EC=97=90=20=EC=9A=94=EC=B2=AD=20?= =?UTF-8?q?=EB=A9=94=EC=84=9C=EB=93=9C=C2=B7=EA=B2=BD=EB=A1=9C=20=EB=9D=BC?= =?UTF-8?q?=EB=B2=A8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- .../handler/GeneralExceptionAdvice.java | 54 ++++++++++++------- .../global/monitoring/ApiErrorMetrics.java | 19 ++++++- .../handler/GeneralExceptionAdviceTest.java | 6 ++- .../monitoring/ApiErrorMetricsTest.java | 15 ++++-- 5 files changed, 72 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index d5e0212..5b8b3e1 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ CREATE DATABASE IF NOT EXISTS quespot ## 모니터링 - Spring Boot Actuator가 요청 수·응답 시간·상태 코드, JVM, 시스템, HikariCP 메트릭을 Prometheus 형식으로 제공합니다. -- 공통 예외 응답은 `quespot_api_errors_total`에 Quespot 에러 코드와 HTTP 상태별로 집계됩니다. +- 공통 예외 응답은 `quespot_api_errors_total`에 Quespot 에러 코드, HTTP 상태, 요청 메서드, 템플릿화된 API 경로별로 집계됩니다. - 운영 환경의 Grafana Alloy는 내부 Docker 네트워크에서 애플리케이션 메트릭을 수집해 Grafana Cloud Hosted Prometheus로 전송합니다. - `/actuator/prometheus`는 Spring Security에서 Alloy 수집을 허용하지만 Nginx에서는 차단하므로 외부 API 도메인에 공개되지 않습니다. - 운영 EC2의 `.env`에는 Grafana Cloud 연결 화면에서 확인한 `GRAFANA_CLOUD_PROMETHEUS_URL`, `GRAFANA_CLOUD_PROMETHEUS_USERNAME`, `GRAFANA_CLOUD_API_TOKEN`을 등록합니다. diff --git a/src/main/java/com/quespot/global/apiPayload/handler/GeneralExceptionAdvice.java b/src/main/java/com/quespot/global/apiPayload/handler/GeneralExceptionAdvice.java index 445c0b4..8d8b2cd 100644 --- a/src/main/java/com/quespot/global/apiPayload/handler/GeneralExceptionAdvice.java +++ b/src/main/java/com/quespot/global/apiPayload/handler/GeneralExceptionAdvice.java @@ -7,6 +7,7 @@ import com.quespot.global.apiPayload.exception.GeneralException; import com.quespot.global.monitoring.ApiErrorMetrics; import jakarta.validation.ConstraintViolationException; +import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.dao.DataAccessException; @@ -29,13 +30,17 @@ public class GeneralExceptionAdvice { private final ApiErrorMetrics apiErrorMetrics; @ExceptionHandler(GeneralException.class) - protected ResponseEntity> handleGeneralException(GeneralException exception) { - return handleExceptionInternal(exception.getErrorCode()); + protected ResponseEntity> handleGeneralException( + GeneralException exception, + HttpServletRequest request + ) { + return handleExceptionInternal(exception.getErrorCode(), request); } @ExceptionHandler(MethodArgumentNotValidException.class) protected ResponseEntity> handleMethodArgumentNotValidException( - MethodArgumentNotValidException exception + MethodArgumentNotValidException exception, + HttpServletRequest request ) { Map errors = new LinkedHashMap<>(); @@ -43,58 +48,71 @@ protected ResponseEntity> handleMethodArgumentNotValidExceptio errors.putIfAbsent(fieldError.getField(), fieldError.getDefaultMessage()); } - return handleExceptionInternal(GeneralErrorCode.COMMON_400_002, errors); + return handleExceptionInternal(GeneralErrorCode.COMMON_400_002, errors, request); } @ExceptionHandler(ConstraintViolationException.class) protected ResponseEntity> handleConstraintViolationException( - ConstraintViolationException exception + ConstraintViolationException exception, + HttpServletRequest request ) { return handleExceptionInternal( GeneralErrorCode.COMMON_400_002, - Map.of("message", exception.getMessage()) + Map.of("message", exception.getMessage()), + request ); } @ExceptionHandler(MethodArgumentTypeMismatchException.class) protected ResponseEntity> handleMethodArgumentTypeMismatchException( - MethodArgumentTypeMismatchException exception + MethodArgumentTypeMismatchException exception, + HttpServletRequest request ) { return handleExceptionInternal( GeneralErrorCode.COMMON_400_002, - Map.of(exception.getName(), "요청 파라미터 형식이 올바르지 않습니다.") + Map.of(exception.getName(), "요청 파라미터 형식이 올바르지 않습니다."), + request ); } @ExceptionHandler(HttpRequestMethodNotSupportedException.class) - protected ResponseEntity> handleHttpRequestMethodNotSupportedException() { - return handleExceptionInternal(GeneralErrorCode.COMMON_405_001); + protected ResponseEntity> handleHttpRequestMethodNotSupportedException( + HttpServletRequest request + ) { + return handleExceptionInternal(GeneralErrorCode.COMMON_405_001, request); } @ExceptionHandler(DataAccessException.class) - protected ResponseEntity> handleDataAccessException() { - return handleExceptionInternal(GeneralErrorCode.COMMON_503_001); + protected ResponseEntity> handleDataAccessException(HttpServletRequest request) { + return handleExceptionInternal(GeneralErrorCode.COMMON_503_001, request); } // 처리되지 않은 예외는 응답이 COMMON_500_001로만 나가서 원인을 알 수 없다(#67에서 // LazyInitializationException이 서버 로그에도 안 남아 코드 추적으로만 찾았다). // 스택트레이스를 반드시 남긴다. @ExceptionHandler(Exception.class) - protected ResponseEntity> handleException(Exception exception) { + protected ResponseEntity> handleException( + Exception exception, + HttpServletRequest request + ) { log.error("처리되지 않은 예외", exception); - return handleExceptionInternal(GeneralErrorCode.COMMON_500_001); + return handleExceptionInternal(GeneralErrorCode.COMMON_500_001, request); } - private ResponseEntity> handleExceptionInternal(BaseErrorCode errorCode) { - return handleExceptionInternal(errorCode, Map.of()); + private ResponseEntity> handleExceptionInternal( + BaseErrorCode errorCode, + HttpServletRequest request + ) { + return handleExceptionInternal(errorCode, Map.of(), request); } private ResponseEntity> handleExceptionInternal( BaseErrorCode errorCode, - Object errorDetail + Object errorDetail, + HttpServletRequest request ) { ErrorReasonDTO reason = errorCode.getReasonHttpStatus(); - apiErrorMetrics.increment(errorCode); + apiErrorMetrics.increment(errorCode, request); return ResponseEntity .status(reason.getHttpStatus()) diff --git a/src/main/java/com/quespot/global/monitoring/ApiErrorMetrics.java b/src/main/java/com/quespot/global/monitoring/ApiErrorMetrics.java index 73e2d42..a5f1fd4 100644 --- a/src/main/java/com/quespot/global/monitoring/ApiErrorMetrics.java +++ b/src/main/java/com/quespot/global/monitoring/ApiErrorMetrics.java @@ -3,24 +3,41 @@ import com.quespot.global.apiPayload.code.BaseErrorCode; import com.quespot.global.apiPayload.code.ErrorReasonDTO; import io.micrometer.core.instrument.MeterRegistry; +import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerMapping; @Component @RequiredArgsConstructor public class ApiErrorMetrics { private static final String METRIC_NAME = "quespot.api.errors"; + private static final String UNKNOWN_METHOD = "UNKNOWN"; + private static final String UNMATCHED_URI = "UNMATCHED"; private final MeterRegistry meterRegistry; public void increment(BaseErrorCode errorCode) { + increment(errorCode, UNKNOWN_METHOD, UNMATCHED_URI); + } + + public void increment(BaseErrorCode errorCode, HttpServletRequest request) { + Object matchedPattern = request.getAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE); + String uri = matchedPattern == null ? UNMATCHED_URI : matchedPattern.toString(); + + increment(errorCode, request.getMethod(), uri); + } + + private void increment(BaseErrorCode errorCode, String method, String uri) { ErrorReasonDTO reason = errorCode.getReasonHttpStatus(); meterRegistry.counter( METRIC_NAME, "code", reason.getCode(), - "status", String.valueOf(reason.getHttpStatus().value()) + "status", String.valueOf(reason.getHttpStatus().value()), + "method", method, + "uri", uri ).increment(); } } diff --git a/src/test/java/com/quespot/global/apiPayload/handler/GeneralExceptionAdviceTest.java b/src/test/java/com/quespot/global/apiPayload/handler/GeneralExceptionAdviceTest.java index 69786d2..cc1dffe 100644 --- a/src/test/java/com/quespot/global/apiPayload/handler/GeneralExceptionAdviceTest.java +++ b/src/test/java/com/quespot/global/apiPayload/handler/GeneralExceptionAdviceTest.java @@ -5,6 +5,7 @@ import org.junit.jupiter.api.Test; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; import static org.assertj.core.api.Assertions.assertThat; @@ -20,7 +21,10 @@ void mapsMethodArgumentTypeMismatchExceptionTo400() { MethodArgumentTypeMismatchException exception = mock(MethodArgumentTypeMismatchException.class); when(exception.getName()).thenReturn("size"); - ResponseEntity> response = advice.handleMethodArgumentTypeMismatchException(exception); + ResponseEntity> response = advice.handleMethodArgumentTypeMismatchException( + exception, + new MockHttpServletRequest("GET", "/api/missions") + ); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); assertThat(response.getBody().getIsSuccess()).isFalse(); diff --git a/src/test/java/com/quespot/global/monitoring/ApiErrorMetricsTest.java b/src/test/java/com/quespot/global/monitoring/ApiErrorMetricsTest.java index 60c6596..393cd15 100644 --- a/src/test/java/com/quespot/global/monitoring/ApiErrorMetricsTest.java +++ b/src/test/java/com/quespot/global/monitoring/ApiErrorMetricsTest.java @@ -3,20 +3,29 @@ import com.quespot.global.apiPayload.code.GeneralErrorCode; import io.micrometer.core.instrument.simple.SimpleMeterRegistry; import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.web.servlet.HandlerMapping; import static org.assertj.core.api.Assertions.assertThat; class ApiErrorMetricsTest { @Test - void incrementsCounterByErrorCodeAndStatus() { + void incrementsCounterByErrorCodeStatusMethodAndMatchedUri() { SimpleMeterRegistry meterRegistry = new SimpleMeterRegistry(); ApiErrorMetrics apiErrorMetrics = new ApiErrorMetrics(meterRegistry); + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/missions/1"); + request.setAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE, "/api/missions/{missionId}"); - apiErrorMetrics.increment(GeneralErrorCode.COMMON_503_001); + apiErrorMetrics.increment(GeneralErrorCode.COMMON_503_001, request); assertThat(meterRegistry.get("quespot.api.errors") - .tags("code", "COMMON_503_001", "status", "503") + .tags( + "code", "COMMON_503_001", + "status", "503", + "method", "GET", + "uri", "/api/missions/{missionId}" + ) .counter() .count()).isEqualTo(1.0); } From 8841e10f36248a76a58823a739be1e59a9e135c3 Mon Sep 17 00:00:00 2001 From: "dev.geonwoo_" Date: Fri, 18 Sep 2026 14:48:12 +0900 Subject: [PATCH 2/2] =?UTF-8?q?[Fix]#61=20API=20=EC=98=A4=EB=A5=98=20?= =?UTF-8?q?=EB=A9=94=ED=8A=B8=EB=A6=AD=20=EB=9D=BC=EB=B2=A8=20=EC=A0=95?= =?UTF-8?q?=EA=B7=9C=ED=99=94=20=EB=B0=8F=20fallback=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EB=B3=B4=EC=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/monitoring/ApiErrorMetrics.java | 5 ++- .../monitoring/ApiErrorMetricsTest.java | 41 ++++++++++++++++++- 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/quespot/global/monitoring/ApiErrorMetrics.java b/src/main/java/com/quespot/global/monitoring/ApiErrorMetrics.java index a5f1fd4..af885d6 100644 --- a/src/main/java/com/quespot/global/monitoring/ApiErrorMetrics.java +++ b/src/main/java/com/quespot/global/monitoring/ApiErrorMetrics.java @@ -6,6 +6,7 @@ import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; +import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.servlet.HandlerMapping; @Component @@ -25,8 +26,10 @@ public void increment(BaseErrorCode errorCode) { public void increment(BaseErrorCode errorCode, HttpServletRequest request) { Object matchedPattern = request.getAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE); String uri = matchedPattern == null ? UNMATCHED_URI : matchedPattern.toString(); + RequestMethod requestMethod = RequestMethod.resolve(request.getMethod()); + String method = requestMethod == null ? UNKNOWN_METHOD : requestMethod.name(); - increment(errorCode, request.getMethod(), uri); + increment(errorCode, method, uri); } private void increment(BaseErrorCode errorCode, String method, String uri) { diff --git a/src/test/java/com/quespot/global/monitoring/ApiErrorMetricsTest.java b/src/test/java/com/quespot/global/monitoring/ApiErrorMetricsTest.java index 393cd15..c3d058f 100644 --- a/src/test/java/com/quespot/global/monitoring/ApiErrorMetricsTest.java +++ b/src/test/java/com/quespot/global/monitoring/ApiErrorMetricsTest.java @@ -10,6 +10,39 @@ class ApiErrorMetricsTest { + @Test + void incrementsCounterWithFallbackTagsWithoutRequest() { + SimpleMeterRegistry meterRegistry = new SimpleMeterRegistry(); + ApiErrorMetrics apiErrorMetrics = new ApiErrorMetrics(meterRegistry); + + apiErrorMetrics.increment(GeneralErrorCode.COMMON_503_001); + + assertCounter(meterRegistry, "UNKNOWN", "UNMATCHED"); + } + + @Test + void incrementsCounterWithUnmatchedUriWithoutMatchedPattern() { + SimpleMeterRegistry meterRegistry = new SimpleMeterRegistry(); + ApiErrorMetrics apiErrorMetrics = new ApiErrorMetrics(meterRegistry); + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/unmatched"); + + apiErrorMetrics.increment(GeneralErrorCode.COMMON_503_001, request); + + assertCounter(meterRegistry, "POST", "UNMATCHED"); + } + + @Test + void incrementsCounterWithUnknownMethodForUnrecognizedHttpMethod() { + SimpleMeterRegistry meterRegistry = new SimpleMeterRegistry(); + ApiErrorMetrics apiErrorMetrics = new ApiErrorMetrics(meterRegistry); + MockHttpServletRequest request = new MockHttpServletRequest("CUSTOM", "/api/missions"); + request.setAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE, "/api/missions"); + + apiErrorMetrics.increment(GeneralErrorCode.COMMON_503_001, request); + + assertCounter(meterRegistry, "UNKNOWN", "/api/missions"); + } + @Test void incrementsCounterByErrorCodeStatusMethodAndMatchedUri() { SimpleMeterRegistry meterRegistry = new SimpleMeterRegistry(); @@ -19,12 +52,16 @@ void incrementsCounterByErrorCodeStatusMethodAndMatchedUri() { apiErrorMetrics.increment(GeneralErrorCode.COMMON_503_001, request); + assertCounter(meterRegistry, "GET", "/api/missions/{missionId}"); + } + + private void assertCounter(SimpleMeterRegistry meterRegistry, String method, String uri) { assertThat(meterRegistry.get("quespot.api.errors") .tags( "code", "COMMON_503_001", "status", "503", - "method", "GET", - "uri", "/api/missions/{missionId}" + "method", method, + "uri", uri ) .counter() .count()).isEqualTo(1.0);