Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<Void> 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
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
}
Loading