예외 종류별 처리 표준 원칙 5는 Spring이 던지는 요청 오류까지 Advice에 등록하라고 정했다. 여기서 「요청 오류」는 내 코드가 던지는 예외가 아니라, 바깥에서 서버로 보낸 메시지(요청)가 내 Controller 메서드에 닿기 전에 Spring이 「이 요청은 받을 수 없다」고 판단해서 스스로 던지는 예외를 말한다. 표준이 적은 근거는 이랬다.

@Valid·JSON 파싱 실패만 등록하면 타입 불일치·필수 파라미터 누락·405·415·없는 경로가
500으로 새어 모니터링에서 서버 장애로 잡힌다.

말을 하나씩 풀면 이렇다.

  • @Valid는 Request DTO에 붙은 검증 규칙을 지금 검사하라는 표시(어노테이션)다
  • JSON 파싱 실패는 요청 본문(요청의 주소와 헤더 뒤에 실려 오는 실제 데이터)이 JSON 형식에 맞지 않아 자바 객체로 못 바꿀 때 나는 오류다
  • 타입 불일치 · 필수 파라미터 누락 · 없는 경로가 각각 어떤 요청인지는 아래 「설계」에서 실제 요청으로 보여 준다
  • 405는 “주소는 있는데 그 방식(HTTP 메서드. GET·POST 등)은 받지 않는다”는 뜻이다
  • 415는 요청이 Content-Type 헤더로 밝힌 본문 형식을 서버가 처리할 수 없다는 뜻이다
  • 500은 상태 코드 중 5로 시작하는 것, 곧 “서버 쪽 잘못”이다. 400번대는 “보낸 쪽 잘못”이다

마지막 줄이 이 근거의 핵심이다. 모니터링은 보통 5로 시작하는 응답이 늘면 서버에 문제가 생겼다고 보고 경보를 울린다. 그런데 사용자가 잘못 보낸 요청이 500으로 나가면, 서버는 멀쩡한데 「서버 장애」로 세어진다. 그래서 「모니터링이 서버 장애로 본다」가 규칙의 이유였다. 그 전제가 맞는지 — 정말 500이 나가는지 — 를 재야 했다.


주장 — 등록하지 않은 요청 오류는 500으로 나간다

@Valid 실패와 JSON 파싱 실패만 Advice에 등록하면,
타입 불일치·필수 파라미터 누락·405·415·없는 경로는 500으로 나간다.

근거 종류는 1(기술 사실)이다. 이 블로그는 표준의 근거를 기술 사실·안 비교·에이전트 규칙·명세 사실 네 가지로 나누는데, 1번(기술 사실)은 “실제로 돌려 보면 숫자로 확인할 수 있는 것”이라는 뜻이다. 그래서 이 글처럼 서버를 직접 띄워 요청을 보내고 상태 코드를 재는 방식으로 증명한다.


반증 조건 — 다섯 중 하나라도 500이 아니면 반증이다

판정은 셋 중 하나이고, 기준은 실험을 돌리기 전에 적었다(반증 조건). 「두 환경」은 아래 「설계」에서 말하는 Spring Boot(Spring을 설정까지 끝내 바로 쓰게 묶은 것) 두 버전이다.

  • refuted(주장이 틀렸다) — 두 환경 중 하나라도 다섯 중 하나라도 500이 아니다
  • supported(주장이 맞다) — 두 환경 모두 다섯 전부 500이다
  • inconclusive(판단할 수 없다) — 서버가 뜨지 않거나, 대조군 @Valid 실패가 Advice를 타지 않는다(Advice의 처리 메서드가 그 요청을 처리하지 않는다)

마지막 줄의 대조군은 「Advice가 정말 켜져 있는가」를 확인하려고 함께 보내는 요청이다. Advice가 아예 동작하지 않는 상태라면 다섯 요청의 결과도 믿을 수 없으니, 그때는 판단을 미룬다.


설계 — catch-all을 빼고 표준이 말한 그 상태를 그대로 만든다

먼저 요청을 받을 Controller다. /probe 주소 하나에 GET과 POST만 연다.

@RestController
public class ProbeController {

    @GetMapping("/probe")
    public String get(@RequestParam int count) {
        return "ok:" + count;
    }

    @PostMapping(path = "/probe", consumes = MediaType.APPLICATION_JSON_VALUE)
    public String post(@Valid @RequestBody ProbeRequest request) {
        return "ok:" + request.name();
    }
}

public record ProbeRequest(@NotBlank String name) {
}

한 줄씩 보면 이렇다.

  • @RestController — 이 클래스가 요청을 받는 Controller이고, 메서드가 돌려준 값을 그대로 응답 본문으로 보낸다는 표시다
  • @GetMapping("/probe") — GET으로 /probe에 온 요청을 get 메서드가 받는다
  • @RequestParam int count — 주소 뒤의 count 값을 정수로 받는다. @RequestParam은 required = false라고 적지 않으면 기본이 「꼭 있어야 함」이라, 여기서 count는 빠지면 안 되는 값이다
  • @PostMapping(path = "/probe", consumes = ...APPLICATION_JSON_VALUE) — POST로 온 요청 중 본문이 JSON이라고 밝힌 것만 post 메서드가 받는다
  • @Valid @RequestBody ProbeRequest request — 본문 JSON을 ProbeRequest로 바꾸고, 붙은 검증 규칙을 검사한다
  • public record ProbeRequest(...) — record는 값만 담는 클래스를 짧게 쓰는 자바 문법이다. 괄호 안에 적은 name을 꺼내는 메서드 name()을 자바가 만들어 주므로, 위의 request.name()이 이것을 부른다
  • @NotBlank String name — name이 비어 있으면 안 된다는 규칙이다. 그래서 빈 JSON {}를 보내면 name이 없어 검증에 걸린다

다음은 Advice다. 처리 메서드(핸들러)를 딱 둘만 둔다.

@RestControllerAdvice
public class ProbeAdvice {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<String> valid(MethodArgumentNotValidException e) {
        return ResponseEntity.badRequest()
                .header("Content-Type", "text/plain;charset=UTF-8")
                .body("handledBy=advice kind=valid");
    }

    @ExceptionHandler(HttpMessageNotReadableException.class)
    public ResponseEntity<String> unreadable(HttpMessageNotReadableException e) {
        return ResponseEntity.badRequest()
                .header("Content-Type", "text/plain;charset=UTF-8")
                .body("handledBy=advice kind=unreadable");
    }
}
  • @ExceptionHandler(X.class) — 「X 예외가 나면 이 메서드를 불러 응답을 만든다」는 표시다. X.class는 클래스 그 자체를 값으로 넘기는 문법(클래스 리터럴)이다
  • ResponseEntity.badRequest() — 상태 코드 400(Bad Request)짜리 응답을 만들기 시작한다. 뒤에 점으로 이어 붙인 .header(...)가 헤더를, .body(...)가 본문을 채우고, 완성된 응답 객체가 돌아간다
  • .header("Content-Type", "text/plain;charset=UTF-8") — 본문이 JSON이 아니라 꾸밈 없는 글자이고, 글자를 UTF-8 규칙(인코딩)으로 읽으라고 밝힌다. 본문이 JSON이 아닌 한 줄짜리 표식이라 그렇게 적었다
  • MethodArgumentNotValidException — @Valid 검사에 걸리면 Spring이 던지는 예외다. 이걸 받으면 400과 함께 본문 handledBy=advice kind=valid를 돌려준다
  • HttpMessageNotReadableException — 본문 JSON을 읽지 못하면(파싱 실패) Spring이 던지는 예외다. 이걸 받으면 400과 handledBy=advice kind=unreadable을 돌려준다
  • 본문의 handledBy=advice ... 글자는 이 실험이 정한 표식이다. 응답 본문에 이 글자가 있으면 「Advice가 처리한 응답」이라고 알아본다

catch-all 핸들러(Exception 전체를 받는 핸들러)는 두지 않는다. 그걸 두면 다섯 요청이 일으키는 예외도 전부 그 핸들러로 가서, 「등록하지 않으면 Spring이 무엇을 내보내는가」를 잴 수 없게 된다. 표준이 말한 「@Valid와 JSON 파싱 실패만 등록한 상태」를 그대로 만들려면 빼야 한다.

서버는 실제로 띄웠다. 「띄운다」는 프로그램을 실행해서 요청을 받을 수 있는 상태로 켜 두는 것이고, 여기서는 임베디드 톰캣(내 프로그램 안에서 함께 뜨는 톰캣)이 요청을 받는다. server.port=0은 포트 번호를 정하지 않고 비어 있는 아무 번호나 쓰라는 설정이다. 그렇게 뜬 서버에 자바 표준 라이브러리의 java.net.http.HttpClient로 다섯 요청을 보냈다. 서버를 띄우고 요청을 보내는 실행 코드 전체는 저장소의 proofs/spring-default-request-error-status/ 폴더에 있다(사이트에는 싣지 않는다).

타입 불일치   GET  /probe?count=abc     (count 는 int)
필수 누락     GET  /probe
메서드        DELETE /probe
미디어 타입   POST /probe  text/plain
없는 경로     GET  /no-such-path

한 줄씩 따라가면, ?count=abc처럼 주소 뒤에 붙는 것은 쿼리 파라미터다.

  • 타입 불일치 — count를 정수(int)로 받는 자리에 문자열 abc를 넣어 보냈다
  • 필수 누락 — count 자체를 아예 안 보냈다
  • 메서드 — /probe는 GET과 POST만 받는데, 둘 다 아닌 DELETE로 보냈다
  • 미디어 타입 — POST는 본문이 JSON이어야 받는데, Content-Type을 text/plain(MIME 타입 중 「꾸밈 없는 글자」)으로 밝혀 보냈다
  • 없는 경로 — 아예 등록되지 않은 주소로 보냈다

대조군은 @Valid 실패(POST /probe에 빈 JSON {})다. 앞에서 본 대로 name이 없어 검증에 걸리므로, Advice가 살아 있으면 표식이 붙은 400이 와야 한다. 환경은 Spring Boot 3.5.16과 4.1.1이다. 버전 번호의 맨 앞 숫자(3과 4)가 메이저 버전이고, 이 숫자가 바뀌면 큰 변경이 들어간다. 두 메이저 버전에서 같은 결과가 나오면 한 버전만의 동작이라고 보기 어렵다.


결과 — 500이 하나도 없었다

PROBE boot=3.5.16 mismatch=400 missing=400 method=405 media=415 nopath=404 valid=400 valid_marked=true
PROBE boot=4.1.1  mismatch=400 missing=400 method=405 media=415 nopath=404 valid=400 valid_marked=true

한 줄이 한 버전의 결과다. mismatch는 타입 불일치, missing은 필수 누락, method는 DELETE, media는 text/plain, nopath는 없는 경로의 상태 코드다. valid는 대조군의 상태 코드이고, valid_marked=true는 대조군 응답 본문에 handledBy=advice 표식이 있었다는 뜻이다.

다섯이 전부 의미에 맞는 상태로 나갔다. 대조군은 본문이 handledBy=advice kind=valid인 400이라 Advice도 살아 있었다. 판정은 refuted다.

Advice에 핸들러가 없는데 누가 400·405·415·404를 붙였을까. Spring Boot는 Spring을 감싼 것이라, 요청 처리는 안쪽의 Spring이 한다. 그중 웹 요청을 다루는 부분(Spring MVC)에는 내 Advice가 처리하지 않은 예외를 이어받는 기본 처리기 DefaultHandlerExceptionResolver가 들어 있다. 이 저장소에 받아 둔 Spring 본체(Spring Framework) 소스 — 버전은 아직 정식 출시 전의 개발판인 7.1.0-SNAPSHOT이다 — 에서 보면, 이 처리기는 지원하지 않는 메서드를 405로, 지원하지 않는 Content-Type을 415로, 필수 파라미터 누락과 타입 불일치를 400으로, 없는 경로를 404로 바꾼다. 그리고 예외 처리 순서는 @ExceptionHandler 쪽이 먼저, 이 기본 처리기가 나중이다. 그래서 Advice에 핸들러가 없으면 이 기본 처리기가 상태 코드를 붙인다. Boot 3.5.16 · 4.1.1이 쓰는 Spring 버전의 소스는 따로 열어 보지 않았다 — 두 버전의 실측 결과가 이 동작과 같다는 것까지가 확인한 것이다.


예상과 달랐던 점 — 새는 것은 상태 코드가 아니라 응답 형식이다

본문을 보면 다른 것이 드러난다.

GET /probe?count=abc -> 400
{"timestamp":"...","status":400,"error":"Bad Request","path":"/probe"}

POST /probe {} -> 400
handledBy=advice kind=valid

첫 응답의 timestamp·status·error·path는 Spring Boot가 기본으로 만드는 오류 응답에 늘 들어가는 필드다 — 언제 일어났는지, HTTP 상태 코드가 몇 번인지, 그 코드의 이름, 어느 주소로 온 요청인지를 담는다. 두 번째 응답의 handledBy=advice kind=valid는 이 실험의 Advice가 응답 본문에 직접 써서 돌려준 표식으로, “이 요청은 Advice가 처리했고 종류는 valid(검증 실패)다”라는 뜻이다. 즉 같은 400인데 하나는 Boot가 만든 모양이고 하나는 이 글의 Advice가 만든 모양이다.

첫 응답이 왜 그 모양인지는 이렇다. 기본 처리기는 상태 코드만 정하고 본문은 만들지 않는다. 한편 Spring Boot는 시작할 때 톰캣에 「오류 응답이 나오면 /error 주소로 보내라」는 오류 페이지를 등록해 둔다. 그래서 기본 처리기가 오류 상태만 정하고 손을 떼면, 톰캣이 그 등록대로 요청을 서버 안에서 /error로 한 번 더 보낸다. 그 주소를 맡은 BasicErrorController가 위의 JSON 본문을 채운다. 「Boot 기본 /error JSON」은 이렇게 만들어진 본문을 가리킨다.

다섯은 Boot 기본 /error JSON으로 나가고, Advice를 탄 요청만 우리 형식(이 실험에서는 handledBy=... 한 줄짜리 글자)으로 나간다. 상태 코드는 제대로 붙지만 같은 400이 두 가지 모양으로 나간다. 요청을 보내는 쪽(클라이언트, 예를 들어 화면 코드)은 어느 형식이 올지 모른 채 둘 다 다뤄야 한다.

표준의 규칙(요청 오류도 Advice에 등록한다)은 이 결과로도 여전히 쓸모가 있다. 다만 이유가 「500으로 샌다」가 아니라 「응답 형식이 갈린다」로 바뀐다. 이건 내 해석이고, 「형식이 갈린다」까지가 측정된 사실이다.


이 결과가 표준에 남기는 것 — 규칙은 두고 근거 문장을 다시 쓴다

측정된 것은 둘이다. 상태 코드는 새지 않는다(반증된 부분), 형식은 갈린다(원본 출력에 그대로 있다). 규칙을 지울 이유는 이 실험에 없지만, 지금 글에 적힌 근거는 틀렸다.

규칙은 바로 고치지 않는다 — 반증 하나로 규칙을 바로 고치면, 실험 설계가 틀렸을 때도 규칙만 바뀌어 버린다. 대신 표준 글의 원칙 5 아래에 > 반증: <결과 요약> — [증명](이 글 링크) 모양의 인용 줄을 달고, 깃허브 이슈를 열어 “두 사실이 서로 안 맞는다”는 뜻의 kind: 모순 라벨을 붙인다. 그 이슈에서 실험 설계가 잘못됐는지 규칙이 잘못됐는지를 따로 따진 뒤, 근거 문장을 다시 쓸지 정한다.


정리

  • 다섯 요청이 전부 400·400·405·415·404로 나갔다. 두 Boot 버전이 같았다
  • 「500으로 샌다」는 설정을 바꾸지 않은 Spring Boot 웹 애플리케이션에서 성립하지 않는다
  • 대신 응답 형식이 갈린다. Advice를 탄 것만 우리 형식이고 나머지는 Boot 기본 /error JSON이다
  • 규칙을 지우자는 결론이 아니다. 근거 문장을 바꿀지는 모순 이슈에서 정한다

자신만의 철학을 만들어가는 중입니다.
최상단으로 이동했습니다!
확대 이미지

댓글남기기