빌드노트
Spring 예외 처리기가 에러 JSON을 반환해도 200인 이유
오류 DTO의 status 필드가 400이어도 HTTP 응답은 200일 수 있다. 같은 예외와 JSON을 POJO·ResponseEntity·ResponseStatus로 비교하고, 처리 메서드에서 상태를 지정한 뒤 본문과 상태를 함께 검사하는 방법을 설명한다.
Spring MVC의 @ExceptionHandler (새 탭에서 열림)에서 오류 JSON을 반환했는데 응답 상태는 200이라면, 예외 처리 메서드가 HTTP 상태도 지정하는지 확인한다. 일반 DTO를 오류 내용으로 채워 반환하는 것과 HTTP 상태를 400으로 설정하는 것은 별개다. ResponseEntity.badRequest().body(...)로 상태와 본문을 함께 반환하거나, 상태가 고정된 처리 메서드에 @ResponseStatus(HttpStatus.BAD_REQUEST)를 붙인다.
본문에 쓴 400은 HTTP 상태가 아니다
다음은 문제를 확인하기 위한 합성 예외와 오류 DTO다. InvalidInput은 입력 오류를 나타내도록 이 예제에서 만든 예외이며, 예외 클래스 자체에는 상태 애너테이션이 없다.
public static class InvalidInput extends RuntimeException {}
public record ErrorBody(int status, String code) {}
@RestControllerAdvice
public static class PlainAdvice {
@ExceptionHandler(InvalidInput.class)
public ErrorBody handle(InvalidInput ex) {
return new ErrorBody(400, "INVALID_INPUT");
}
}
이 메서드는 예외를 받아 오류 객체를 만든다. 하지만 HTTP 상태를 바꾸는 코드는 없다. DTO의 status는 JSON에 들어갈 평범한 필드다. 필드 이름이나 값이 HTTP 응답 상태를 대신 지정하지 않는다.
@RestControllerAdvice (새 탭에서 열림)는 @ControllerAdvice와 @ResponseBody를 합친 애너테이션이다. 반환값을 응답 본문으로 쓰게 하지만, 모든 예외 응답을 자동으로 400이나 500으로 정하지는 않는다. 여기서는 Jackson 메시지 변환기가 ErrorBody를 JSON으로 썼다. 일반 @ControllerAdvice에서 DTO 본문을 반환하려면 @ResponseBody도 필요하다.
같은 예외와 JSON으로 비교했다
Windows 11, OpenJDK 17.0.2, Spring Framework 6.2.19, Jackson 2.19.2에서 별도 예제를 실행했다. standaloneSetup (새 탭에서 열림)으로 합성 컨트롤러를 등록하고, 예외 처리기를 하나씩 바꿔 넣었다. JSON 변환기는 명시적으로 등록했으며 실제 서버는 띄우지 않았다.
GET /reject는 컨트롤러 본문에서 매번 new InvalidInput()을 던진다. 요청에는 Accept: application/json을 넣었고, 세 처리기는 모두 같은 오류 DTO를 반환하게 했다. 관찰한 상태와 본문은 다음과 같다.
POJO → HTTP 200 {"status":400,"code":"INVALID_INPUT"}
ResponseEntity → HTTP 400 {"status":400,"code":"INVALID_INPUT"}
ResponseStatus → HTTP 400 {"status":400,"code":"INVALID_INPUT"}
세 응답의 Content-Type은 모두 application/json이었다. 요청마다 컨트롤러와 예외 처리기 본문은 각각 한 번 실행됐고, MockMvc에는 같은 InvalidInput이 해결된 예외로 남았다. 즉 첫 번째 결과는 예외 처리기가 호출되지 않아서 나온 200이 아니다. 예외를 처리하고 JSON까지 썼지만, 상태는 바꾸지 않은 결과다.
정상 대조 요청인 GET /ok는 {"result":"ok"}를 반환한다. 세 구성 모두 HTTP 200이었고 예외 처리기 호출은 0회였다. 상태를 명시한 두 구성에서도 정상 응답까지 400으로 바뀌지는 않았다.
이 비교에서 JSON 본문만 검사했다면 세 오류 응답을 구별하지 못했을 것이다. HTTP 상태로 성공 여부를 판단하는 클라이언트에는 첫 응답이 성공 상태로 전달된다. 특정 클라이언트 라이브러리의 콜백이나 화면 동작까지 실행한 것은 아니다.
예외 처리 메서드에서 상태를 정한다
먼저 PlainAdvice의 처리 메서드를 다음으로 교체할 수 있다. 반환형도 ResponseEntity<ErrorBody>로 바꾼다.
@ExceptionHandler(InvalidInput.class)
public ResponseEntity<ErrorBody> handle(InvalidInput ex) {
return ResponseEntity.badRequest()
.body(new ErrorBody(400, "INVALID_INPUT"));
}
ResponseEntity 문서 (새 탭에서 열림)는 본문과 함께 상태 및 헤더를 지정하는 반환형으로 설명한다. 이 예제에서 badRequest()가 지정하는 것은 HTTP 400이고, body(...)에 넣는 것은 JSON으로 쓸 데이터다. 실제 API에서 DTO에 status 필드를 꼭 둘 필요는 없다.
해당 처리 메서드가 항상 400을 반환한다면, DTO 반환형을 유지하면서 메서드에 상태를 선언하는 방법도 있다. 아래는 앞의 메서드와 함께 등록하는 코드가 아니라 또 다른 교체안이다.
@ExceptionHandler(InvalidInput.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ErrorBody handle(InvalidInput ex) {
return new ErrorBody(400, "INVALID_INPUT");
}
위 메서드 역시 @RestControllerAdvice 안에 둔다. 이 예제에서는 @ResponseStatus의 reason을 지정하지 않았다. 6.2.19 API 문서 (새 탭에서 열림)에 따르면 reason을 지정하면 sendError를 사용하고 처리 메서드의 반환값을 무시한다. 오류 설명을 JSON으로 내려주려면 그 설명을 DTO에 넣는다.
처리 결과에 따라 상태나 헤더를 달리해야 하면 ResponseEntity가 그 결정을 코드에 드러낸다. 이 메서드의 상태가 언제나 같으면 메서드의 @ResponseStatus로 표현할 수 있다. 여기서 400은 합성 입력 오류에 정한 상태다. 다른 예외까지 전부 잡아 400으로 바꾸는 근거는 아니다.
수정 확인은 상태와 본문을 함께 본다
자기 API에서는 브라우저 개발자 도구 Network나 API 도구에서 실제 응답의 상태 코드와 본문을 따로 읽는다. 본문의 status: 400만 보지 않는다. 405·415처럼 요청 매핑이나 입력 조건에서 거절된 응답과 달리, 이 실험에서는 컨트롤러가 실행된 뒤 사용자 예외 처리기가 응답을 만들었다.
이미 Spring MVC와 spring-test, Jackson이 준비된 테스트라면, 해당 컨트롤러와 수정한 Advice가 등록된 MockMvc에 오류를 일으키는 요청을 보낸다. 위 예제의 /reject에 적용할 핵심 검사는 다음과 같다. get은 MockMvcRequestBuilders, status와 content는 MockMvcResultMatchers에서 정적으로 가져온다.
mvc.perform(get("/reject").accept(MediaType.APPLICATION_JSON))
.andExpect(status().isBadRequest())
.andExpect(content().string("{\"status\":400,\"code\":\"INVALID_INPUT\"}"));
이 문자열 검사는 예제의 두 필드와 출력 순서에 맞춘 것이다. 자기 DTO가 다르면 본문 검사를 그 계약에 맞게 바꾸되, 상태 검사는 유지한다. 기존 POJO 처리기라면 본문은 같아도 isBadRequest()에서 실패한다. 상태를 고친 뒤에도 200이라면 먼저 의도한 예외 처리기가 실제로 호출되는지 확인한다. standalone 방식에서는 Advice를 직접 등록했는지도 확인해야 한다.
이번 관찰은 다른 코드가 상태를 설정하지 않는 일반 DTO 반환과 두 가지 명시적 상태 지정의 비교다. 실제 Servlet 컨테이너, Spring Boot 자동 설정, 보안 필터, 프록시가 포함된 응답 경로는 검사하지 않았다. standalone 결과와 외부 응답이 다르면 실제 애플리케이션 설정을 포함한 테스트와 외부 응답을 대조해야 한다.