From 9df1708190049d8fafce17c5e61fa520631cefde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EB=AF=B8?= Date: Mon, 24 Aug 2026 16:12:38 +0900 Subject: [PATCH] =?UTF-8?q?=EC=9E=98=EB=AA=BB=EB=90=9C=20=ED=98=95?= =?UTF-8?q?=ED=83=9C=EC=9D=98=20=EC=9A=94=EC=B2=AD=EC=9D=B4=20500+Slack/Se?= =?UTF-8?q?ntry=20=EC=95=8C=EB=A6=BC=EC=9C=BC=EB=A1=9C=20=EC=83=88?= =?UTF-8?q?=EB=8D=98=20=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit prod Grafana 로그 확인 결과 최근 7일간 5xx 40건이 전부 정상 앱 트래픽이 아니라 Postman 등으로 잘못된 메서드/형식을 직접 찌른 요청(GET /api/auth, 잘못된 Content-Type의 POST /api/auth)과 취약점 스캐너 프로빙(.env, /etc/passwd 경로 접근 시도)이었다. 원래 405/415/406으로 응답해야 할 요청인데, ControllerExceptionAdvice의 범용 Exception 핸들러가 전부 500으로 뭉개고 Slack/Sentry 알림까지 매번 보내고 있었다 — 실제 장애가 아닌데 알림만 계속 쌓이는 구조. HttpRequestMethodNotSupportedException(405)/HttpMediaTypeNotSupportedException (415)/HttpMediaTypeNotAcceptableException(406) 전용 핸들러를 추가해 WARN 로그만 남기고 정상 상태 코드로 응답하도록 분리했다. 406은 본문(JSON)을 만들려는 시도 자체가 다시 협상 실패로 이어질 수 있어(실제 로그에서 "Failure in @ExceptionHandler" 이중 오류로 확인) 본문 없이 상태 코드만 응답한다. MockMvc로 수정 전 실제로 500이 나고, 수정 후 405/415가 나며 Slack 알림이 호출되지 않음을 재현 테스트로 검증했다. --- .../advice/ControllerExceptionAdvice.java | 36 +++++++++++++++++++ .../server/common/constant/ErrorStatus.java | 11 ++++++ .../auth/controller/AuthControllerTest.java | 30 ++++++++++++++++ 3 files changed, 77 insertions(+) diff --git a/src/main/java/org/runnect/server/common/advice/ControllerExceptionAdvice.java b/src/main/java/org/runnect/server/common/advice/ControllerExceptionAdvice.java index 5e0bdf2..771a040 100644 --- a/src/main/java/org/runnect/server/common/advice/ControllerExceptionAdvice.java +++ b/src/main/java/org/runnect/server/common/advice/ControllerExceptionAdvice.java @@ -14,6 +14,9 @@ import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Component; import org.springframework.validation.FieldError; +import org.springframework.web.HttpMediaTypeNotAcceptableException; +import org.springframework.web.HttpMediaTypeNotSupportedException; +import org.springframework.web.HttpRequestMethodNotSupportedException; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.MissingRequestHeaderException; import org.springframework.web.bind.MissingServletRequestParameterException; @@ -66,6 +69,39 @@ protected ApiResponseDto handleMissingRequestParameterException(final MissingSer + /** + * 405 / 415 / 406 — 클라이언트가 잘못된 형태로 요청한 경우 (서버 장애 아님) + * + * 실제 prod 로그를 보면 GET /api/auth(POST만 지원), 잘못된 Content-Type의 POST + * 요청처럼 정상 앱 트래픽이라면 나올 수 없는 요청(Postman 등으로 직접 호출하거나 + * 취약점 스캐너가 찌른 것)이 계속 들어온다. 이런 경우까지 아래 범용 Exception + * 핸들러가 500으로 뭉개고 Slack/Sentry로 매번 알림을 보내면, 실제 장애가 아닌데도 + * 알림만 계속 쌓인다. 정확한 상태 코드로 응답하고 로그만 WARN으로 남긴다. + */ + @ResponseStatus(HttpStatus.METHOD_NOT_ALLOWED) + @ExceptionHandler(HttpRequestMethodNotSupportedException.class) + protected ApiResponseDto handleHttpRequestMethodNotSupportedException(final HttpRequestMethodNotSupportedException e, final HttpServletRequest request) { + log.warn("[405] {} {} - {}", request.getMethod(), request.getRequestURI(), e.getMessage()); + return ApiResponseDto.error(ErrorStatus.INVALID_HTTP_METHOD_EXCEPTION); + } + + @ResponseStatus(HttpStatus.UNSUPPORTED_MEDIA_TYPE) + @ExceptionHandler(HttpMediaTypeNotSupportedException.class) + protected ApiResponseDto handleHttpMediaTypeNotSupportedException(final HttpMediaTypeNotSupportedException e, final HttpServletRequest request) { + log.warn("[415] {} {} - {}", request.getMethod(), request.getRequestURI(), e.getMessage()); + return ApiResponseDto.error(ErrorStatus.UNSUPPORTED_MEDIA_TYPE_EXCEPTION); + } + + // 클라이언트가 Accept 헤더로 서버가 만들 수 없는 응답 형식을 요구한 경우. + // 본문(JSON)을 굳이 만들려 하면 그 시도 자체가 다시 협상에 실패해 이중 오류로 + // 이어질 수 있어(handleException 안에서 실패 → DefaultHandlerExceptionResolver로 + // 전파되는 게 실제 로그에서 확인됨), 본문 없이 상태 코드만 응답한다. + @ExceptionHandler(HttpMediaTypeNotAcceptableException.class) + protected ResponseEntity handleHttpMediaTypeNotAcceptableException(final HttpMediaTypeNotAcceptableException e, final HttpServletRequest request) { + log.warn("[406] {} {} - {}", request.getMethod(), request.getRequestURI(), e.getMessage()); + return ResponseEntity.status(HttpStatus.NOT_ACCEPTABLE).build(); + } + /** * 500 Internal Server Error * diff --git a/src/main/java/org/runnect/server/common/constant/ErrorStatus.java b/src/main/java/org/runnect/server/common/constant/ErrorStatus.java index 4da3538..cdd2ec1 100644 --- a/src/main/java/org/runnect/server/common/constant/ErrorStatus.java +++ b/src/main/java/org/runnect/server/common/constant/ErrorStatus.java @@ -65,6 +65,17 @@ public enum ErrorStatus { */ NOT_FOUND_USER_EXCEPTION(HttpStatus.NOT_FOUND, "존재하지 않는 유저입니다"), NOT_FOUND_MARATHON_PUBLIC_COURSE_EXCEPTION(HttpStatus.NOT_FOUND, "마라톤 코스가 존재하지 않습니다."), + + /** + * 405 METHOD NOT ALLOWED + */ + INVALID_HTTP_METHOD_EXCEPTION(HttpStatus.METHOD_NOT_ALLOWED, "지원하지 않는 HTTP 메서드입니다."), + + /** + * 415 UNSUPPORTED MEDIA TYPE + */ + UNSUPPORTED_MEDIA_TYPE_EXCEPTION(HttpStatus.UNSUPPORTED_MEDIA_TYPE, "지원하지 않는 요청 형식입니다."), + /** * 409 CONFLICT */ diff --git a/src/test/java/org/runnect/server/auth/controller/AuthControllerTest.java b/src/test/java/org/runnect/server/auth/controller/AuthControllerTest.java index 788245c..9f49e93 100644 --- a/src/test/java/org/runnect/server/auth/controller/AuthControllerTest.java +++ b/src/test/java/org/runnect/server/auth/controller/AuthControllerTest.java @@ -140,4 +140,34 @@ class GetNewToken { .andExpect(status().isUnauthorized()); } } + + // prod Grafana 로그 확인 결과, 최근 7일간 5xx 40건이 전부 정상 트래픽이 아니라 + // Postman 등으로 잘못된 메서드/형식을 직접 찌른 요청(GET /api/auth, 잘못된 + // Content-Type의 POST /api/auth)이었다. 원래는 405/415로 응답해야 할 요청인데 + // ControllerExceptionAdvice의 범용 Exception 핸들러가 이걸 500으로 뭉개고 + // Slack/Sentry 알림까지 매번 보내고 있었다 — 진짜 장애가 아닌데 노이즈만 쌓이는 구조. + @Nested + @DisplayName("잘못된 형태의 요청 (클라이언트 오류, 서버 장애 아님)") + class InvalidRequestShape { + + @Test + @DisplayName("POST만 지원하는 /api/auth에 GET으로 요청하면 405를 반환하고 Slack 알림을 보내지 않는다") + void 지원하지_않는_메서드() throws Exception { + mockMvc.perform(get("/api/auth")) + .andExpect(status().isMethodNotAllowed()); + + BDDMockito.verifyNoInteractions(slackApi); + } + + @Test + @DisplayName("JSON을 기대하는 /api/auth에 multipart로 요청하면 415를 반환하고 Slack 알림을 보내지 않는다") + void 지원하지_않는_컨텐츠타입() throws Exception { + mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders + .multipart("/api/auth") + .file("token", "kakao-token".getBytes())) + .andExpect(status().isUnsupportedMediaType()); + + BDDMockito.verifyNoInteractions(slackApi); + } + } }