From 5e65295aebcf3c6ee5e5d3ee70199d2bdcc50c68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <2dh2@naver.com> Date: Wed, 29 Apr 2026 15:40:42 +0900 Subject: [PATCH 1/3] =?UTF-8?q?fix:=20=EC=9A=94=EC=B2=AD=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=20=EB=A7=A4=EC=B9=AD=EC=9D=84=20=EC=9C=84=ED=95=B4=20?= =?UTF-8?q?requestId=EB=A5=BC=20=EB=AA=85=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 런타임 에러 Slack 알림과 DEBUG 요청 로그가 같은 요청을 가리키는지 확정할 수 있도록 requestId를 함께 남긴다. - runtime.error 로거가 별도 Slack appender만 사용해 콘솔/파일 로그 패턴의 MDC만으로는 매칭이 어려운 구조를 보완한다. - DEBUG 요청 상세 로그에도 같은 requestId를 포함해 동시 요청 상황에서도 에러와 요청 본문을 구분할 수 있게 한다. --- .../exception/GlobalExceptionHandler.java | 28 +++++++++++++------ .../exception/GlobalExceptionHandlerTest.java | 13 ++++++--- 2 files changed, 29 insertions(+), 12 deletions(-) diff --git a/src/main/java/gg/agit/konect/global/exception/GlobalExceptionHandler.java b/src/main/java/gg/agit/konect/global/exception/GlobalExceptionHandler.java index 1a5c8d2d..dcd9d8a6 100644 --- a/src/main/java/gg/agit/konect/global/exception/GlobalExceptionHandler.java +++ b/src/main/java/gg/agit/konect/global/exception/GlobalExceptionHandler.java @@ -13,6 +13,7 @@ import org.apache.catalina.connector.ClientAbortException; import org.slf4j.Logger; +import org.slf4j.MDC; import org.springframework.web.multipart.MaxUploadSizeExceededException; import org.slf4j.LoggerFactory; import org.springframework.http.HttpHeaders; @@ -44,6 +45,7 @@ public class GlobalExceptionHandler extends ResponseEntityExceptionHandler { private static final Logger RUNTIME_ERROR_LOGGER = LoggerFactory.getLogger("runtime.error"); + private static final String REQUEST_ID_MDC_KEY = "requestId"; private static final String MASKED_HEADER_VALUE = "***"; private static final List SENSITIVE_HEADER_NAMES = List.of( "authorization", @@ -209,19 +211,21 @@ public ResponseEntity handleException(HttpServletRequest request, Except String exception = e.getClass().getSimpleName(); String location = String.format("%s:%d", origin.getFileName(), origin.getLineNumber()); String message = e.getMessage(); + String requestId = getRequestId(); String slackMessage = String.format( """ + Request ID: `%s` URI: `%s` Location: `%s` Exception: `%s` ```%s``` """, - uri, location, exception, message + requestId, uri, location, exception, message ); RUNTIME_ERROR_LOGGER.error(slackMessage); - requestDebugLogging(request); + requestDebugLogging(request, requestId); return buildErrorResponse(ApiResponseCode.UNEXPECTED_SERVER_ERROR); } @@ -272,17 +276,25 @@ private void requestLogging( String errorTraceId ) { log.warn("[{}] {} | errorTraceId={}", httpStatus, errorMessage, errorTraceId); - requestDebugLogging(request); + requestDebugLogging(request, getRequestId()); } - private void requestDebugLogging(HttpServletRequest request) { + private void requestDebugLogging(HttpServletRequest request, String requestId) { if (!log.isDebugEnabled()) { return; } - log.debug("Request: {} {}", request.getMethod(), request.getRequestURI()); - log.debug("Headers: {}", getLoggableHeaders(request)); - log.debug("Query String: {}", getQueryString(request)); - log.debug("Body: {}", getRequestBody(request)); + log.debug("Request [requestId: {}]: {} {}", requestId, request.getMethod(), request.getRequestURI()); + log.debug("Headers [requestId: {}]: {}", requestId, getLoggableHeaders(request)); + log.debug("Query String [requestId: {}]: {}", requestId, getQueryString(request)); + log.debug("Body [requestId: {}]: {}", requestId, getRequestBody(request)); + } + + private String getRequestId() { + String requestId = MDC.get(REQUEST_ID_MDC_KEY); + if (requestId == null) { + return " - "; + } + return requestId; } private Map getLoggableHeaders(HttpServletRequest request) { diff --git a/src/test/java/gg/agit/konect/unit/global/exception/GlobalExceptionHandlerTest.java b/src/test/java/gg/agit/konect/unit/global/exception/GlobalExceptionHandlerTest.java index 5b39ec07..89a8c4f0 100644 --- a/src/test/java/gg/agit/konect/unit/global/exception/GlobalExceptionHandlerTest.java +++ b/src/test/java/gg/agit/konect/unit/global/exception/GlobalExceptionHandlerTest.java @@ -8,6 +8,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.slf4j.LoggerFactory; +import org.slf4j.MDC; import org.springframework.boot.test.system.CapturedOutput; import org.springframework.boot.test.system.OutputCaptureExtension; import org.springframework.http.HttpStatus; @@ -34,12 +35,14 @@ void setUp() { @AfterEach void tearDown() { exceptionHandlerLogger.setLevel(originalLevel); + MDC.clear(); } @Test @DisplayName("예상하지 못한 예외도 디버그 로그에서 요청 본문을 확인할 수 있다") void logsRequestBodyAtDebugLevelForUnexpectedException(CapturedOutput output) { // given + MDC.put("requestId", "request-123"); GlobalExceptionHandler handler = new GlobalExceptionHandler(); MockHttpServletRequest request = new MockHttpServletRequest("POST", "/clubs"); request.setContentType("application/json"); @@ -61,21 +64,23 @@ void logsRequestBodyAtDebugLevelForUnexpectedException(CapturedOutput output) { // then assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); assertThat(output) - .contains("Request: POST /clubs") + .contains("Request ID: `request-123`") + .contains("Request [requestId: request-123]: POST /clubs") .contains("Authorization=***") .contains("Cookie=***") .contains("X-Request-ID=request-1") .doesNotContain("secret-token") .doesNotContain("secret-cookie") .contains( - "Query String: access%5Ftoken=***&access_token=***&code=***&name=KONECT&token=***&token=***" + "Query String [requestId: request-123]: " + + "access%5Ftoken=***&access_token=***&code=***&name=KONECT&token=***&token=***" ) .doesNotContain("encoded-query-secret") .doesNotContain("query-secret") .doesNotContain("oauth-code") .doesNotContain("repeat-secret-one") .doesNotContain("repeat-secret-two") - .contains("Body: {\"name\":\"KONECT\"}"); + .contains("Body [requestId: request-123]: {\"name\":\"KONECT\"}"); } @Test @@ -97,7 +102,7 @@ void skipsRequestDetailLoggingWhenDebugIsDisabled(CapturedOutput output) { // then assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); assertThat(output) - .doesNotContain("Request: POST /clubs") + .doesNotContain("Request [requestId:") .doesNotContain("Body: {\"name\":\"KONECT\"}"); } } From 6e9175a289f5a044d9053613ebaefa1658efbd41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <2dh2@naver.com> Date: Mon, 4 May 2026 13:35:27 +0900 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20=EC=98=88=EC=99=B8=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EC=A4=91=20=EB=B9=88=20=EC=8A=A4=ED=83=9D=ED=8A=B8?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=8A=A4=20=EB=B0=A9=EC=96=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 예외 핸들러가 스택트레이스 첫 요소에 바로 접근하면 2차 예외로 장애 분석 로그와 응답 생성이 깨질 수 있어 길이 검사를 추가한다. - 스택트레이스가 비어 있을 때는 고정된 기본 위치를 남겨 Slack 알림 포맷을 유지한다. - DEBUG 비활성화 테스트는 현재 Body 로그 포맷을 기준으로 민감 정보 로깅 회귀를 검출하도록 정리한다. --- .../exception/GlobalExceptionHandler.java | 14 +++++++++--- .../exception/GlobalExceptionHandlerTest.java | 22 ++++++++++++++++++- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/src/main/java/gg/agit/konect/global/exception/GlobalExceptionHandler.java b/src/main/java/gg/agit/konect/global/exception/GlobalExceptionHandler.java index dcd9d8a6..49ebe426 100644 --- a/src/main/java/gg/agit/konect/global/exception/GlobalExceptionHandler.java +++ b/src/main/java/gg/agit/konect/global/exception/GlobalExceptionHandler.java @@ -205,11 +205,9 @@ protected ResponseEntity handleHttpMessageNotReadable( @ExceptionHandler(Exception.class) public ResponseEntity handleException(HttpServletRequest request, Exception e) { - StackTraceElement origin = e.getStackTrace()[0]; - String uri = String.format("%s %s", request.getMethod(), request.getRequestURI()); String exception = e.getClass().getSimpleName(); - String location = String.format("%s:%d", origin.getFileName(), origin.getLineNumber()); + String location = getExceptionLocation(e); String message = e.getMessage(); String requestId = getRequestId(); @@ -230,6 +228,16 @@ public ResponseEntity handleException(HttpServletRequest request, Except return buildErrorResponse(ApiResponseCode.UNEXPECTED_SERVER_ERROR); } + private String getExceptionLocation(Exception e) { + StackTraceElement[] stackTrace = e.getStackTrace(); + if (stackTrace.length == 0) { + return "unknown:0"; + } + + StackTraceElement origin = stackTrace[0]; + return String.format("%s:%d", origin.getFileName(), origin.getLineNumber()); + } + private ResponseEntity buildErrorResponse(ApiResponseCode errorCode) { String errorTraceId = UUID.randomUUID().toString(); diff --git a/src/test/java/gg/agit/konect/unit/global/exception/GlobalExceptionHandlerTest.java b/src/test/java/gg/agit/konect/unit/global/exception/GlobalExceptionHandlerTest.java index 89a8c4f0..98e23ab9 100644 --- a/src/test/java/gg/agit/konect/unit/global/exception/GlobalExceptionHandlerTest.java +++ b/src/test/java/gg/agit/konect/unit/global/exception/GlobalExceptionHandlerTest.java @@ -103,6 +103,26 @@ void skipsRequestDetailLoggingWhenDebugIsDisabled(CapturedOutput output) { assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); assertThat(output) .doesNotContain("Request [requestId:") - .doesNotContain("Body: {\"name\":\"KONECT\"}"); + .doesNotContain("Body [requestId:"); + } + + @Test + @DisplayName("스택트레이스가 비어 있는 예외도 2차 예외 없이 처리한다") + void handlesUnexpectedExceptionWithoutStackTrace(CapturedOutput output) { + // given + MDC.put("requestId", "request-empty-stack"); + GlobalExceptionHandler handler = new GlobalExceptionHandler(); + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/clubs"); + RuntimeException exception = new RuntimeException("boom"); + exception.setStackTrace(new StackTraceElement[0]); + + // when + var response = handler.handleException(request, exception); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); + assertThat(output) + .contains("Request ID: `request-empty-stack`") + .contains("Location: `unknown:0`"); } } From 8c7afd0be3b14db4b027b2c5cd16ec3dd0dc78c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <2dh2@naver.com> Date: Mon, 4 May 2026 13:42:04 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20=EC=98=88=EC=99=B8=20=EC=9C=84?= =?UTF-8?q?=EC=B9=98=20=EC=B6=94=EC=B6=9C=EC=9D=98=20null=20=EC=8A=A4?= =?UTF-8?q?=ED=83=9D=ED=8A=B8=EB=A0=88=EC=9D=B4=EC=8A=A4=20=EB=B0=A9?= =?UTF-8?q?=EC=96=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 예외 타입이 스택트레이스를 null로 반환해도 예외 핸들러가 2차 예외를 만들지 않도록 기본 위치를 사용한다. - 빈 스택트레이스와 같은 fallback 포맷을 유지해 Slack 알림과 에러 응답 흐름을 안정적으로 보존한다. - null 스택트레이스 회귀 테스트를 추가해 예외 처리 경계 조건을 고정한다. --- .../exception/GlobalExceptionHandler.java | 2 +- .../exception/GlobalExceptionHandlerTest.java | 24 +++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/main/java/gg/agit/konect/global/exception/GlobalExceptionHandler.java b/src/main/java/gg/agit/konect/global/exception/GlobalExceptionHandler.java index 49ebe426..992ec5fb 100644 --- a/src/main/java/gg/agit/konect/global/exception/GlobalExceptionHandler.java +++ b/src/main/java/gg/agit/konect/global/exception/GlobalExceptionHandler.java @@ -230,7 +230,7 @@ public ResponseEntity handleException(HttpServletRequest request, Except private String getExceptionLocation(Exception e) { StackTraceElement[] stackTrace = e.getStackTrace(); - if (stackTrace.length == 0) { + if (stackTrace == null || stackTrace.length == 0) { return "unknown:0"; } diff --git a/src/test/java/gg/agit/konect/unit/global/exception/GlobalExceptionHandlerTest.java b/src/test/java/gg/agit/konect/unit/global/exception/GlobalExceptionHandlerTest.java index 98e23ab9..febaad45 100644 --- a/src/test/java/gg/agit/konect/unit/global/exception/GlobalExceptionHandlerTest.java +++ b/src/test/java/gg/agit/konect/unit/global/exception/GlobalExceptionHandlerTest.java @@ -125,4 +125,28 @@ void handlesUnexpectedExceptionWithoutStackTrace(CapturedOutput output) { .contains("Request ID: `request-empty-stack`") .contains("Location: `unknown:0`"); } + + @Test + @DisplayName("스택트레이스가 null인 예외도 2차 예외 없이 처리한다") + void handlesUnexpectedExceptionWithNullStackTrace(CapturedOutput output) { + // given + MDC.put("requestId", "request-null-stack"); + GlobalExceptionHandler handler = new GlobalExceptionHandler(); + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/clubs"); + RuntimeException exception = new RuntimeException("boom") { + @Override + public StackTraceElement[] getStackTrace() { + return null; + } + }; + + // when + var response = handler.handleException(request, exception); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); + assertThat(output) + .contains("Request ID: `request-null-stack`") + .contains("Location: `unknown:0`"); + } }