예외 처리 표준과 예외 종류별 처리 표준은 Error 계열을 잡지 않기로 정했고, 그 근거로 이렇게 적었다.

AssertionError는 Error 계열이라 @ExceptionHandler(Exception.class)에 잡히지 않고,
서블릿 컨테이너 기본 오류 응답이 나가 응답 형식이 깨진다.

여기서 AssertionError는 「있을 수 없는 상태」를 알리려고 던지는 예외이고, Error 계열은 자바에서 보통 잡지 않는 더 심각한 실패를 말한다. @ExceptionHandler(Exception.class)는 「Exception이나 그 하위 타입이 던져지면 이 메서드가 받는다」는 애너테이션(@로 시작하는 표시)이다. 이 표시를 붙인 메서드는 Advice라는 예외 처리 전용 클래스 안에 둔다(Advice는 아래 「반증 조건」에서 다시 푼다). Error는 Exception의 하위 타입이 아니니 여기에 안 걸린다는 것이 표준 글의 생각이었다. 서블릿 컨테이너는 서블릿을 실행하고 관리하는 서버 프로그램이다 — 톰캣이 그 예다.

「응답 형식」은 서버가 돌려주는 응답의 모양을 말한다. 표준 글은 실패 응답도 늘 같은 모양(HttpApiResponse, code 필드를 실은 모양)으로 내보내기로 했다(예외 처리 표준 원칙 3). 그 모양은 Advice가 만든다. Advice가 예외를 못 받으면 서블릿 컨테이너가 자기 기본 오류 응답을 대신 내보내고, 그건 Advice가 만든 모양이 아니다 — 이것이 「형식이 깨진다」의 뜻이다. 기본 오류 응답이 실제로 어떻게 생겼는지는 이 실험에서 뽑아 보지 않았다.

「형식이 깨진다」가 규칙의 이유였다. 그래서 정말 안 잡히는지를 재야 했다.


주장 — 핸들러가 던진 AssertionError는 catch-all Advice에 닿지 않는다

claim.md에 한 문장으로 적었다.

Controller 핸들러가 던진 AssertionError는 @ExceptionHandler(Exception.class)에 잡히지 않는다.

이 문장의 「Controller 핸들러」는 Controller 안에서 요청 하나를 받아 처리하는 메서드를 말한다. 「catch-all Advice」는 바로 아래 「반증 조건」에서 푼다. 이 글에서 「핸들러」라는 말은 두 곳에 쓰인다 — Controller의 메서드(예외를 던지는 쪽)와 Advice의 @ExceptionHandler 메서드(예외를 받는 쪽)다. 헷갈리지 않게 아래에서는 「Controller 메서드」와 「Advice 메서드」로 나눠 부른다.

근거 종류는 1(기술 사실)이다.


반증 조건 — Advice가 만든 응답 표식이 하나라도 보이면 반증이다

아래 「설계」에서 만들 Advice 메서드는 예외를 받으면 응답 본문에 handledBy=exception-handler라는 글자를 찍는다. 이 글자(표식)가 보이면 Advice가 그 요청을 처리했다는 뜻이다. 「두 환경」은 Spring Boot 3.5.16과 4.1.1 두 버전이다.

  • refuted — 두 환경 중 하나라도 응답 본문에 handledBy=exception-handler 표식이 있다
  • supported — 두 환경 모두 표식이 없고, 대조군 RuntimeException에는 표식이 있다
  • inconclusive — 서버가 뜨지 않거나 대조군에도 표식이 없다

세 낱말은 판정이다 — refuted는 주장이 틀렸다고 밝혀진 것(반증), supported는 주장이 뒷받침된 것, inconclusive는 어느 쪽인지 가릴 수 없는 것이다. 응답 본문은 응답에서 상태 코드 다음에 오는 실제 내용이고, 「서버가 뜬다」는 서버 프로그램이 실행되어 요청을 받을 수 있는 상태가 됐다는 뜻이다.

Advice는 모든 Controller의 예외를 한곳에서 받아 처리하는 클래스이고, 그중에서도 @ExceptionHandler(Exception.class)처럼 예외 종류를 가리지 않고 전부 받는 Advice 메서드를 catch-all이라고 부른다. 대조군을 둔 이유가 여기 있다. Advice 설정 자체가 틀려서 아무것도 안 걸린 경우를 「Error라서 안 잡혔다」로 읽으면 안 된다.


설계 — MockMvc 대신 임베디드 톰캣을 실제로 띄운다

표준 글의 문장은 한 경로를 깔고 있다. Controller 메서드가 던진 예외가 Advice에 안 잡히면 Spring(요청을 알맞은 메서드로 연결해 주는 틀)이 처리하는 범위 밖으로 빠져나가고, 그 바깥에 있는 서블릿 컨테이너가 기본 오류 응답을 만든다는 경로다. 그러니 이 주장을 재려면 예외가 정말 서블릿 컨테이너까지 갈 수 있는 환경이어야 한다. MockMvc는 서버를 띄우지 않고 Controller에 가짜 요청을 보내는 도구라 그 바깥 서버가 없다. 그래서 이 실험은 그 대신 임베디드 톰캣을 실제로 띄운다.

포트는 application.properties 파일(스프링 부트가 시작할 때 읽는 설정 파일)에 server.port=0으로 적었다. 0을 주면 특정 번호를 고정하지 않고 OS가 그 순간 비어 있는 포트를 하나 골라 대신 쓴다 — 어떤 환경에서 돌려도 이미 쓰고 있는 포트와 겹칠 일이 없다. 실험 프로그램의 main 메서드는 먼저 스프링 부트 애플리케이션을 실행한다. 이때 임베디드 톰캣이 같이 떠서 요청을 받기 시작한다. 그다음 같은 main 안에서 java.net.http.HttpClient로 http://127.0.0.1:<포트>/throw-assertion-error처럼 자기 자신에게 요청을 보낸다. 서버와 호출하는 쪽이 한 프로그램 안에 같이 있는 셈이다.

Controller와 Advice는 아래 두 클래스가 전부다(proofs/assertion-error-handler-reach/app에서 그대로 옮겼다).

@RestController
public class ProbeController {

    @GetMapping("/throw-assertion-error")
    public String assertionError() {
        throw new AssertionError("probe");
    }

    /** 대조군 — Advice 자체가 안 걸린 경우를 「Error 라서 안 잡혔다」로 잘못 읽지 않기 위해 둔다. */
    @GetMapping("/throw-runtime")
    public String runtime() {
        throw new RuntimeException("probe");
    }
}

@RestControllerAdvice
public class ProbeAdvice {

    @ExceptionHandler(Exception.class)
    public ResponseEntity<String> handle(Exception e) {
        String cause = (e.getCause() == null) ? "none" : e.getCause().getClass().getName();
        return ResponseEntity.status(500)
                .header("Content-Type", "text/plain;charset=UTF-8")
                .body("handledBy=exception-handler type=" + e.getClass().getName() + " cause=" + cause);
    }
}

한 줄씩 따라가면,

  • @RestController는 이 클래스가 요청을 받는 Controller라고 스프링에 알리는 표시다. 이게 없으면 스프링이 이 클래스로 요청을 보내지 않는다
  • @GetMapping("/throw-assertion-error")는 「주소 뒤쪽이 /throw-assertion-error인 GET 요청이 오면 이 메서드를 부른다」는 표시다. /로 시작하는 이 부분은 URL에서 서버 안의 위치를 가리키는 경로다
  • assertionError()는 아무 일도 하지 않고 바로 AssertionError를 던진다. runtime()은 같은 자리에서 RuntimeException을 던지는 대조군이다
  • @RestControllerAdvice는 이 클래스를 Advice로 만드는 표시다. 스프링은 Controller 메서드가 예외를 던지면 이 클래스에서 맞는 @ExceptionHandler 메서드를 찾아 부른다
  • ProbeAdvice에는 @ExceptionHandler(Exception.class) Advice 메서드 handle 하나만 있다. 반환 타입 ResponseEntity<String>은 상태 코드 · 헤더 · 본문(여기서는 글자열)을 한데 담은 응답이고, 스프링이 이 반환값을 받아 그대로 응답으로 내보낸다
  • e.getCause()는 이 예외 안에 품고 있는 원인 예외(cause)를 꺼낸다. 예외는 다른 예외를 감싸서 안에 넣어 둘 수 있다. 없으면 none, 있으면 그 클래스 이름을 적는다
  • ResponseEntity.status(500)은 응답의 상태 코드를 500으로 정한다. Advice가 받으면 500은 Advice가 직접 정한 값이다
  • .header("Content-Type", "text/plain;charset=UTF-8")는 응답의 헤더에 Content-Type을 적어, 본문이 UTF-8 글자로 된 평범한 텍스트라고 알린다
  • .body(...)는 응답 본문에 handledBy=exception-handler 표식과, 받은 예외의 타입(e.getClass().getName())과 cause를 한 줄로 찍는다. 표식이 보이면 이 Advice 메서드가 그 요청을 처리했다는 뜻이다

환경은 둘이다 — Spring Boot 3.5.16, 4.1.1. 메이저 버전이 다른 두 줄기에서 같은 결과가 나오는지 본다. 왜 이 두 버전을 골랐는지는 claim.md에 적어 두지 않았다.

이 실험이 보는 것은 Controller 메서드 안에서 던진 Error뿐이다. 필터·인터셉터·비동기에서 난 Error는 재지 않았다.


결과 — 두 버전 모두 Advice가 응답을 만들었다

PROBE boot=3.5.16 ae_status=500 ae_marked=true rt_status=500 rt_marked=true
PROBE boot=4.1.1  ae_status=500 ae_marked=true rt_status=500 rt_marked=true

BODY /throw-assertion-error :: handledBy=exception-handler
                               type=jakarta.servlet.ServletException cause=java.lang.AssertionError
BODY /throw-runtime         :: handledBy=exception-handler
                               type=java.lang.RuntimeException     cause=none

한 줄씩 따라가면, PROBE 줄의 ae_는 /throw-assertion-error 요청을, rt_는 대조군 /throw-runtime 요청을 가리킨다. ae_status=500은 그 요청의 HTTP 상태 코드가 500이라는 뜻이고, ae_marked=true는 응답 본문에 handledBy=exception-handler 표식이 실제로 찍혔다는 뜻이다 — Advice가 그 요청을 처리했다는 신호다. BODY 줄은 그 응답 본문을 그대로 보여준다. /throw-assertion-error의 type=jakarta.servlet.ServletException은(jakarta.servlet은 서블릿 관련 클래스가 든 패키지 이름이다) Advice가 받은 예외의 실제 타입이 AssertionError가 아니라 ServletException이었다는 뜻이고, cause=java.lang.AssertionError는 그 안에 진짜 원인이 들어 있었다는 뜻이다. 대조군 /throw-runtime은 감싸이지 않은 RuntimeException을 그대로 받아 cause=none이다.

판정은 refuted다. 대조군에 표식이 있으므로 Advice가 죽어 있던 경우도 아니다.


예상과 달랐던 점 — Advice가 받은 것은 AssertionError가 아니라 ServletException이었다

type= 칸이 답을 준다. Advice 메서드가 받은 예외는 jakarta.servlet.ServletException이고, 그 cause가 AssertionError였다. 감싼 쪽이 Exception 계열이므로 catch-all에 걸린다. 대조군 RuntimeException은 감싸이지 않고 그대로 왔다(cause=none).

누가 감쌌는지는 이 실험이 재지 않았다. 측정된 것은 「Advice가 받은 타입이 ServletException이고 cause가 AssertionError」까지다.

표준 글의 문장 두 갈래에 상태 코드 한 줄을 더해 세 줄로 견줬다. 상태 코드 줄은 위에 인용한 문장에는 없고, 이 실험이 함께 잰 값이다. 그리고 위 코드에서 봤듯 이 500은 Advice가 ResponseEntity.status(500)으로 직접 정한 값이다. 그래서 첫 줄의 「맞다」는 두 버전 모두 응답이 500으로 나왔다는 뜻일 뿐, 서블릿 컨테이너가 500을 냈다는 뜻이 아니다. 컨테이너의 기본 오류 응답이 몇 번을 내는지는 이 실험이 재지 않았다.

견준 것 (둘째 · 셋째 줄이 표준 글의 문장) 결과
상태 코드 500이 나간다 맞다 (두 버전 모두 500)
@ExceptionHandler(Exception.class)에 잡히지 않는다 틀렸다
서블릿 컨테이너 기본 오류 응답이 나가 형식이 깨진다 틀렸다 — Advice가 만든 응답이 나갔다

이 결과가 표준에 남기는 것 — 「Error는 잡지 않는다」의 근거가 바뀐다

규칙 자체(Error를 잡지 않는다)는 이 실험이 판정하지 않는다. 무너진 것은 그 규칙이 기대던 설명이다. 「어차피 안 잡히니 형식이 깨진다」가 아니라, catch-all을 두면 Error까지 같이 잡힌다가 실제 모양이다. 뒤집으면 「Error를 안 잡으려면 따로 손을 써야 한다」가 된다. 어떤 손을 쓸지는 이 실험이 재지 않았다. 표준 글이 이 결과를 받아 어떻게 바꿨는지는 예외 처리 표준 원칙 5에 있다.

표준을 증명하는 방식 원칙 8대로 규칙은 바로 고치지 않는다. 표준 글의 해당 원칙 안에 > 반증:으로 시작하는 인용 줄(마크다운에서 줄 앞에 >를 붙이면 들여 쓴 인용 상자로 보인다) 한 줄을 달아 반증된 사실과 이 글 링크를 남기고, 이 저장소의 GitHub 이슈를 kind: 모순 라벨(이슈에 붙이는 분류 꼬리표)로 새로 열어 실험이 잘못됐는지 규칙이 잘못됐는지를 그 이슈에서 먼저 따진 뒤에 규칙을 고친다.


정리

  • 두 Boot 버전 모두 AssertionError가 catch-all Advice에 걸렸다. 상태는 500, 응답은 Advice가 만들었다
  • Advice가 받은 타입은 ServletException이고 cause가 AssertionError였다. 감싼 주체는 재지 않았다
  • 맞은 것은 상태 코드뿐이다. 「안 잡힌다」와 「형식이 깨진다」는 틀렸다. 그 500도 Advice가 정한 값이다
  • 재지 않은 범위 — Controller 메서드 밖(필터·인터셉터·비동기)에서 난 Error

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

댓글남기기