계층별 검증 표준은 요청 값이 규칙에 어긋났을 때 돌려주는 실패 응답(서버가 요청을 보낸 쪽에 돌려주는 메시지)을 한 자리에서 만들기로 했고, 근거는 이랬다.

@RequestBody @Valid 실패는 MethodArgumentNotValidException 으로 올라와
@RestControllerAdvice 의 한 핸들러에서 필드별 오류를 만든다.

이 인용문에서 @RequestBody는 요청 본문의 JSON을 객체로 바꿔 받는 표시이고, @Valid는 그렇게 받은 객체에 붙은 Bean Validation 규칙을 컨트롤러 메서드(요청을 실제로 처리하는 메서드)가 실행되기 전에 검사하라는 표시다. 규칙을 어기면 내가 throw를 쓰지 않아도 스프링이 MethodArgumentNotValidException을 던지고, Advice(예외를 한곳에서 받아 응답으로 바꿔주는 클래스)의 핸들러가 그 예외를 받아 필드별 오류로 바꾼다. 여기서 핸들러는 Advice 안에 @ExceptionHandler(예외 타입.class)를 붙여 둔 메서드 하나를 말한다(.class는 클래스 그 자체를 값으로 넘기는 클래스 리터럴이다). 스프링 서버에서는 요청이 오면 스프링이 내 컨트롤러 메서드를 대신 불러 준다. 내가 직접 부르지 않는 메서드라 예외도 스프링이 받고, 그 타입의 예외가 나면 스프링이 이 핸들러 메서드를 불러 응답을 만든다.

필드별 오류는 표준 글 원칙 4에서 정한 errors 배열이다. 응답 본문(응답에 담겨 돌아오는 실제 내용) 안에 "errors": [ { "field": "productName", "message": "공백일 수 없습니다" } ]처럼 어느 필드가 왜 틀렸는지를 하나씩 담는다.

「한 핸들러」가 errors 응답 형식을 보장하는 자리다. 그 핸들러 하나만 errors를 만들기 때문에, 검증 실패가 전부 그 핸들러로 와야 형식이 지켜진다. 검증 실패가 정말 한 타입으로 모이는지 쟀다.


주장 — 요청 값 검증 실패는 한 예외로 올라온다

요청 값 검증 실패는 MethodArgumentNotValidException 하나로 올라와
Advice 의 한 핸들러에서 받는다.

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


반증 조건 — 잡힌 핸들러가 갈리면 반증이다

아래에서 「세 경우」는 다음 섹션 「설계」의 (가) · (나) · (다) 세 가지 검증 실패이고, 「두 환경」은 같은 실험을 돌린 Spring Boot 두 버전(3.5.16 · 4.1.1)이다. 반증 조건은 설계보다 앞에 둔다. 결과를 본 뒤에 기준을 정하면 어떤 결과가 나와도 주장에 맞게 읽을 수 있어서, 돌리기 전에 먼저 적었다.

  • refuted — 두 환경 중 하나라도 세 경우가 한 핸들러로 모이지 않는다
  • supported — 두 환경 모두 세 경우가 MethodArgumentNotValidException 핸들러 하나로 모인다
  • inconclusive — 서버가 뜨지 않거나 어느 경우가 검증 실패 없이 200으로 끝난다

「서버가 뜬다」는 요청을 받을 수 있게 서버가 시작된다는 뜻이다. 200은 「요청이 성공했다」는 상태 코드라서, 200이 나왔다면 검증이 아예 안 걸린 것이고 어느 핸들러가 잡는지 볼 수 없다.


설계 — 예외 타입마다 핸들러를 두고 어느 쪽이 잡는지 표식을 찍는다

Advice에 MethodArgumentNotValidException · HandlerMethodValidationException · BindException · ConstraintViolationException · Exception 핸들러를 각각 두고, 응답 본문에 어느 핸들러가 잡았는지 표식을 넣었다. 실험 코드(proofs/bean-validation-exception-type/app)의 Advice는 이렇게 생겼다. 다섯 핸들러 중 둘만 옮기고 나머지는 같은 모양이라 줄였다.

@RestControllerAdvice
public class ProbeAdvice {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<String> manv(MethodArgumentNotValidException e) {
        return body(400, "manv", e);
    }

    @ExceptionHandler(ConstraintViolationException.class)
    public ResponseEntity<String> cv(ConstraintViolationException e) {
        return body(400, "cv", e);
    }

    // hmv · bind · fallback(Exception, 500) 핸들러도 같은 모양이다

    private ResponseEntity<String> body(int status, String mark, Exception e) {
        return ResponseEntity.status(status)
                .header("Content-Type", "text/plain;charset=UTF-8")
                .body("handledBy=" + mark + " exc=" + e.getClass().getSimpleName());
    }
}
  • @RestControllerAdvice — 이 클래스가 모든 컨트롤러의 예외를 받는 Advice라는 표시다
  • @ExceptionHandler(MethodArgumentNotValidException.class) — 이 예외가 나면 manv 메서드를 부르라는 뜻이다. cv도 같다
  • body(400, "manv", e) — 상태 코드 400과 표식 manv를 넘겨 응답을 만든다
  • .body("handledBy=" + mark + " exc=" + ...) — 응답 본문을 handledBy=manv exc=MethodArgumentNotValidException 같은 글자 한 줄로 만든다. 앞은 잡은 핸들러의 표식이고, 뒤는 실제로 들어온 예외의 클래스 이름이다

다섯을 둔 이유는 이렇다. 앞의 넷은 검증이 실패했을 때 스프링이 던질 수 있는 후보 예외다. BindException은 @ModelAttribute로 객체를 채우다 실패할 때 나는 예외로 알려져 있어서 (다)의 후보로 넣었다. ConstraintViolationException은 스프링 소스 주석에 「@Validated를 붙인 클래스의 메서드 검증이 실패하면 던질 수 있다」고 적혀 있어서, HandlerMethodValidationException은 「웹 컨트롤러 메서드의 메서드 검증이 실패했을 때 던진다」고 적혀 있어서 (나)의 후보로 넣었다. 여기서 「메서드 검증」은 (나)처럼 메서드 파라미터에 직접 붙은 규칙을 검사하는 일이다. 마지막 Exception은 모든 예외의 부모라서, 앞의 넷 어디에도 안 걸린 예외가 오면 여기서 잡힌다. 무엇이 와도 표식 하나는 남게 하는 마지막 그물이다. 스프링은 예외 하나에 맞는 핸들러가 여럿이면 그 예외 타입에 가장 가까운(상속 단계가 가장 적은) 핸들러를 고르므로, 세 경우가 서로 다른 예외로 올라오면 표식도 갈린다.

(가) 본문 객체      POST /probe        @RequestBody @Valid, @NotBlank 위반
(나) 파라미터 제약   GET  /probe?size=0  컨트롤러 @Validated + 파라미터 @Min(1)
(다) 폼·쿼리 객체   GET  /probe/search  @ModelAttribute @Valid, @NotBlank 위반

각 줄의 POST · GET은 HTTP 메서드이고, /probe · /probe/search는 이 실험을 위해 만든 주소다. 세 줄을 하나씩 보면, (가)는 JSON 본문을 @RequestBody로 객체에 담아 @Valid로 검사하다가 @NotBlank(Bean Validation이 제공하는, 빈 문자열이면 안 된다는 규칙) 위반을 낸 경우다. (나)는 컨트롤러 클래스에 @Validated(객체 전체가 아니라 메서드 파라미터 하나하나에도 검증을 걸어 주는 표시)를 붙이고, 그 파라미터에 직접 @Min(1)(1보다 작으면 안 된다는 규칙)을 달아 이 값이 깨진 경우다 — @Valid는 객체 안의 필드를 검사하고 @Validated는 메서드 파라미터 자체를 검사한다는 점이 다르다. (다)는 주소의 쿼리 파라미터들을 @ModelAttribute(쿼리 파라미터를 객체 하나의 필드로 모아 채워 주는 표시)로 객체에 담은 뒤 @Valid로 검사하다가 @NotBlank 위반을 낸 경우다. 「폼·쿼리 객체」라고 부른 것은 @ModelAttribute가 폼으로 보낸 값과 쿼리 파라미터를 같은 방식으로 객체에 채우기 때문이다. 이 실험은 쿼리 파라미터(?keyword=, 빈 값)로 보냈다.

세 경우를 받는 컨트롤러는 이렇다(실험 코드에서 메서드 머리만 옮겼다).

@RestController
@Validated
public class ProbeController {

    @PostMapping(path = "/probe", consumes = MediaType.APPLICATION_JSON_VALUE)
    public String body(@Valid @RequestBody ProbeRequest request) { ... }      // (가)

    @GetMapping("/probe")
    public String param(@RequestParam @Min(1) int size) { ... }              // (나)

    @GetMapping("/probe/search")
    public String search(@Valid @ModelAttribute SearchForm form) { ... }     // (다)
}
  • @Validated — 클래스에 붙어 있다. (나)의 @Min(1)이 검사되게 하는 표시다
  • body(...) — POST /probe로 온 JSON 본문을 ProbeRequest 객체로 받는다. ProbeRequest의 name 필드에 @NotBlank가 붙어 있고, 실험은 빈 JSON {}을 보내 이 규칙을 깨뜨렸다
  • param(...) — @RequestParam이 주소의 ?size=0에서 size 값을 꺼내 int size에 넣는다. 그 파라미터에 @Min(1)이 직접 붙어 있어서 0이면 규칙 위반이다
  • search(...) — ?keyword=의 값을 SearchForm 객체의 keyword 필드에 채운다. 그 필드에 @NotBlank가 붙어 있어서 빈 값이면 규칙 위반이다

임베디드 톰캣(따로 설치해 띄우지 않고 내 프로그램 코드 안에 톰캣을 그대로 심어 함께 띄우는 방식)을 실제로 띄웠고(server.port=0) 환경은 Spring Boot 3.5.16과 4.1.1이다. server.port=0은 실험 앱의 설정 파일 application.properties에 적은 한 줄로, 포트 번호를 정해 두지 않고 운영체제가 비어 있는 포트를 골라 쓰게 하는 설정이다. 정해 둔 번호를 다른 프로그램이 쓰고 있어도 서버가 뜬다. 버전을 둘로 잰 것은 결과가 Boot 버전에 따라 갈리는지도 보려고서다. 네 예외 타입이 두 버전에 다 존재하는지도 함께 기록했다 — 전부 있었다.


결과 — 파라미터 제약만 다른 핸들러로 갔다

PROBE boot=3.5.16 (가) 400 manv MethodArgumentNotValidException
                  (나) 400 cv   ConstraintViolationException
                  (다) 400 manv MethodArgumentNotValidException
PROBE boot=4.1.1  (같음)

이 출력은 실험 프로그램이 서버를 띄운 뒤 세 요청을 직접 보내고 받은 응답을 찍은 것을 간추린 것이다. PROBE는 결과 줄이라는 표시이고, boot=는 그 줄을 찍은 Spring Boot 버전이다. 줄마다 경우 다음에 오는 400은 HTTP 상태 코드이고, 「보낸 값이 잘못됐다」는 뜻이다. manv·cv는 각각 MethodArgumentNotValidException·ConstraintViolationException을 줄인 표기다. 줄인 이름 바로 뒤에 전체 클래스 이름을 그대로 적어 두어, 어떤 핸들러가 잡았는지 줄임말만으로도 전체 이름을 다시 찾아볼 수 있게 했다.

판정은 refuted다. 상태 코드는 셋 다 400으로 같지만 잡은 핸들러가 갈린다.


예상과 달랐던 점 — 갈림은 Boot 버전이 아니라 검증이 걸리는 자리였다

두 Boot 버전이 완전히 같았다. 갈린 기준은 값이 어디에 실려 오는가다.

경우 예외 왜 갈리나
본문 객체 MethodArgumentNotValidException 객체를 바인딩한 뒤 검증한다
폼·쿼리 객체 MethodArgumentNotValidException 같은 경로다
파라미터 제약 ConstraintViolationException 메서드 파라미터 검증이라 경로가 다르다

표의 「바인딩」은 요청에 실려 온 값을 객체의 필드에 채워 넣는 일이고, 「경로」는 검증이 스프링 안의 어느 코드에서 일어나느냐다. 스프링 소스를 따라가 보면 둘이 이렇게 갈린다.

  • (가)·(다) — 스프링은 컨트롤러 메서드를 부르기 전에 메서드에 넘길 인자부터 만든다. 본문 객체는 RequestResponseBodyMethodProcessor, 폼·쿼리 객체는 ModelAttributeMethodProcessor가 그 일을 맡는데, 둘 다 객체를 채운 뒤 @Valid 검증을 돌리고 실패하면 MethodArgumentNotValidException을 던진다
  • (나) — 클래스에 @Validated가 붙으면 스프링은 그 객체를 프록시로 감싼다. 메서드가 불리기 직전에, 프록시가 원본 메서드를 부르기 전에 끼워 넣어 돌리는 객체인 MethodValidationInterceptor가 파라미터에 붙은 @Min(1) 같은 규칙을 검사하고, 어기면 ConstraintViolationException을 던진다

같은 「검증 실패」라도 던지는 코드가 달라서 예외 타입이 다른 것이다.

폼 객체가 BindException이 아니라 MethodArgumentNotValidException이었다는 것도 예상 밖이다. 처음에는 @ModelAttribute 객체가 검증에 실패하면 BindException으로 올라온다고 알고 있었다. 소스를 보면 MethodArgumentNotValidException은 BindException을 상속한 자식 클래스다. 그래서 BindException 핸들러만 두었다면 (다)도 거기서 잡혀 BindException인 줄 알았을 것이다. 두 타입의 핸들러를 다 두었기 때문에 스프링이 더 가까운 MethodArgumentNotValidException 핸들러를 골랐고, 실제 타입이 구분됐다.

범위를 밝힌다. claim.md가 컨트롤러에 @Validated를 달도록 지정했고, 그 설정 때문에 (나)는 위의 프록시 쪽 경로로 검증된다. @Validated 없이 파라미터에 제약만 단 변형은 돌리지 않았으므로 그쪽 결과는 모른다.


이 결과가 표준에 남기는 것 — 핸들러가 하나로 부족하다

규칙(검증 실패는 errors로 필드별로 내려준다)은 흔들리지 않는다. 깨진 것은 한 핸들러로 다 받는다는 전제다. 지금 글대로 MethodArgumentNotValidException 핸들러만 두면 파라미터 제약 위반은 그 핸들러를 안 타고, errors 형식이 아닌 응답이 나간다. @ExceptionHandler(MethodArgumentNotValidException.class) 핸들러는 그 타입(과 자식 타입)만 받는다. ConstraintViolationException은 그 타입이 아니라서 errors를 만드는 코드를 한 번도 지나지 않고, 응답은 그 핸들러가 아닌 다른 곳(Advice의 다른 핸들러나 스프링의 기본 오류 처리)이 만든다. 그때 실제로 어떤 상태 코드와 본문이 나가는지는 이 실험에서 재지 않았다(실험은 다섯 타입 핸들러를 모두 둔 채로만 돌렸다).

규칙은 바로 고치지 않는다. 실험 쪽이 틀렸을 수도 있어서다. 대신 표준 글의 해당 원칙 첫머리에 > 반증:으로 시작하는 인용 줄을 단다. 줄 앞의 >는 마크다운에서 그 줄을 인용문 상자로 보이게 하는 기호다. 이 실험의 결과 요약과 이 글 링크를 붙여 두는 한 줄이라, 그 원칙을 읽는 사람이 반증된 사실을 바로 본다. 그리고 이 저장소의 GitHub 이슈를 kind: 모순 라벨로 연다. 이 라벨은 원래 「이미 정한 것끼리 서로를 부정한다」는 뜻인데, 이 블로그는 실험이 표준 문장을 반증했을 때도 이 라벨을 쓴다. 두 라벨은 이슈를 받은 뒤 할 일이 다르다. 모순은 이미 쓴 글을 고쳐야 하고, 미정의(아직 정한 적이 없다)는 새로 정해야 한다. 이번 일은 이미 쓴 표준 문장을 고쳐야 할 수 있는 일이라 모순이다. 이슈에서는 핸들러를 몇 개 둘지, 세 경로의 응답을 같은 형식으로 맞출지를 정한다.


정리

  • 본문 객체와 폼 객체는 MethodArgumentNotValidException으로 모인다
  • 파라미터 제약은 ConstraintViolationException으로 갈린다. 두 Boot 버전이 같았다
  • 상태 코드는 셋 다 400이다. 갈리는 것은 예외 타입과 응답 형식이다
  • 재지 않은 것 — @Validated 없이 제약만 달았을 때

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

댓글남기기