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); + } + } }