개발 학습 실습실

이 사이트의 실습은 자바스크립트를 켜야 움직입니다. 아래는 같은 내용을 글로 적은 것입니다.

공부 › Spring › 웹 MVC

검증

필드에 @NotBlank를 붙였는데 왜 빈 값이 그대로 들어오나

입력값에는 "이름은 비어 있으면 안 된다" 같은 조건이 있습니다. Bean Validation은 이런 조건을 @NotBlank 같은 애너테이션으로 필드 위에 적어 두는 방법이고, 이렇게 적은 조건을 제약이라고 부릅니다. 그런데 제약은 적어 둔 것만으로는 아무 일도 하지 않습니다. 누군가 "이 객체를 검사하라"고 시켜야 하고, 스프링 MVC에서는 컨트롤러 파라미터에 붙인 @Valid가 그 신호입니다.

실행으로 대조 · 스프링 부트 4.1.1 서버(Hibernate Validator 9.1.3)에 마흔여덟 가지 조합의 요청을 보내 대조

이 실습에 나오는 개념

Bean Validation과 @Valid
Bean Validation은 "이 값은 비어 있으면 안 된다" 같은 조건을 @NotBlank, @Min 같은 애너테이션으로 필드에 적어 두고, 검증기가 객체를 받아 그 조건을 검사하게 하는 자바 표준이다. 이렇게 적은 조건을 제약이라고 부르며, 표준은 약속만 정하고 실제 검사는 Hibernate Validator 같은 구현 라이브러리가 한다. 제약은 적어 둔 것만으로는 동작하지 않는다. 스프링 MVC에서는 컨트롤러 파라미터에 @Valid(또는 스프링의 @Validated)를 붙여야 스프링이 그 객체를 검증기에 넘긴다. 객체 안에 다른 객체가 필드로 들어 있을 때는 그 필드에도 @Valid가 있어야 안쪽 객체의 제약까지 검사한다.
@NotNull, @NotEmpty, @NotBlank
값이 "있어야 한다"는 조건을 나타내는 세 가지 제약으로, 어디까지를 "없다"로 보는지가 서로 다르다. @NotNull은 null만 막고, @NotEmpty는 null과 길이가 0인 값(빈 문자열 "")을 막으며, @NotBlank는 거기에 더해 빈칸만으로 된 문자열(" ")까지 막는다. 그래서 @NotNull만 붙인 문자열 필드는 빈 문자열을 그대로 통과시킨다. 사람이 입력하는 글자 값에는 보통 @NotBlank를 쓴다. @NotBlank는 문자열에 쓰는 제약이어서 Integer 같은 숫자 필드에 붙이면 검증할 때 오류가 난다.
BindingResult
BindingResult는 요청의 값을 객체에 담고 검증하는 과정에서 생긴 오류를 모아 두는 객체이다. 컨트롤러 메서드에서 @Valid가 붙은 파라미터 바로 뒤에 BindingResult 파라미터를 두면, 검증에 실패해도 스프링이 예외를 던지지 않고 오류를 여기에 담아 컨트롤러를 그대로 실행한다. 컨트롤러는 hasErrors()로 오류가 있는지 확인하고 어떻게 응답할지 직접 정한다. 확인하지 않으면 잘못된 값이 그대로 처리되므로, BindingResult를 받았다면 반드시 확인해야 한다. 자리도 중요해서, 검증할 파라미터의 바로 다음이 아닌 곳에 두면 요청을 처리할 때 오류가 난다.
MethodArgumentNotValidException
MethodArgumentNotValidException은 @Valid가 붙은 컨트롤러 파라미터의 객체가 검증에 실패했을 때 스프링 MVC가 던지는 예외이다. 이 예외가 나면 컨트롤러 메서드는 실행되지 않고, 따로 처리하지 않으면 400 Bad Request로 응답한다. 예외 안에는 어느 필드가 어느 제약을 어겼는지가 들어 있어서, @ExceptionHandler로 이 예외를 받아 오류 응답의 본문을 직접 만들 수 있다. 객체가 아니라 @RequestParam 같은 파라미터에 직접 붙인 제약이 실패하면 HandlerMethodValidationException이라는 다른 예외가 나온다. 예외의 이름은 스프링 버전에 따라 달라져 왔으므로, 쓰는 버전에서 확인하는 것이 안전하다.
HTTP 상태 코드
서버가 요청을 처리한 결과를 나타내는 세 자리 숫자다. 2xx는 성공, 3xx는 다른 위치로의 이동(리다이렉션), 4xx는 클라이언트의 요청에 문제가 있음, 5xx는 서버 쪽 오류를 뜻한다. 200(성공), 201(생성됨), 400(잘못된 요청), 401(인증 필요), 403(권한 없음), 404(없음), 500(서버 오류)이 자주 쓰인다.

이렇게 바꿔 보세요

처음 설정으로 실행하면

  1. 요청이 도착합니다: POST /items, 본문은 {"name": ""}입니다. name에 빈 문자열(글자가 하나도 없는 문자열)을 실어 보냈습니다.
  2. @RequestBody ItemForm form: 스프링이 JSON 본문을 읽어 ItemForm 객체를 만들고 name 필드를 채웁니다. name 필드는 ""입니다. 이 단계는 값을 옮겨 담기만 합니다. 필드 위의 @NotBlank 제약은 아직 아무 일도 하지 않습니다.
  3. 검증할지 정하기: 스프링은 파라미터에 @Valid(또는 @Validated)가 붙어 있을 때만 그 객체를 검증기에 넘깁니다. 이 파라미터에는 @Valid가 없으므로 검증을 건너뜁니다. ItemForm의 @NotBlank 제약은 "이 필드는 이래야 한다"고 적어 둔 표시일 뿐, 스스로 값을 막지 못합니다.
  4. add(form) 호출: 컨트롤러 메서드가 실행됩니다. form.getName()의 값은 ""입니다. 돌려주는 글은 "등록: []"이고, 200으로 응답합니다. @NotBlank 제약을 어긴 값이 아무 제지 없이 컨트롤러까지 들어왔습니다.

실행 결과

코드

// 보내는 요청
// POST /items
// Content-Type: application/json
//
// {"name": ""}

class ItemForm {
    @NotBlank
    private String name;
    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
}

@RestController
class ItemController {

    @PostMapping("/items")
    String add(@RequestBody ItemForm form) {
        return "등록: [" + form.getName() + "]";
    }
}

이 모형의 전제

받는 방법 네 가지, 제약 세 가지, 보내는 값 네 가지를 곱한 마흔여덟 조합을 모두 실제 스프링 부트 4.1.1 서버(스프링 프레임워크 7.0.9, 내장 톰캣 11, Hibernate Validator 9.1.3.Final, jakarta.validation-api 3.1.1, JDK 21)에 HTTP 요청으로 보내, 상태 코드와 검증기가 실제로 돌았는지, 어긴 제약, 컨트롤러 실행 여부와 돌려준 글, 스프링이 던진 예외의 이름이 화면과 일치하는 것을 확인했습니다. 검증기가 돌았는지는 항상 통과하는 제약을 하나 더 붙여 그것이 불렸는지로 확인했습니다(화면의 코드에는 없습니다). 화면의 주소는 모두 /items이지만 대조할 때는 조합마다 주소를 따로 두었습니다. 스프링 부트는 검증 라이브러리(spring-boot-starter-validation)가 있을 때 검증기를 자동으로 준비하며, 이 실습은 그 상태를 전제로 합니다. 라이브러리가 없을 때의 동작은 확인하지 않았습니다. 예외의 이름과 파라미터에 직접 붙인 제약의 동작은 스프링 버전에 따라 다릅니다. 여기 적은 것은 스프링 프레임워크 7.0.9에서 본 것이고, 6.1보다 앞선 버전은 다르다고 알려져 있으나 실행해 보지 못했습니다. 오류 응답의 본문과 오류 메시지의 글은 설정과 서버의 언어에 따라 달라 결과로 삼지 않았습니다. 이 PC에서 본 기본 오류 응답 본문에는 timestamp, status, error, path만 있었고 어느 필드가 틀렸는지는 들어 있지 않았습니다. 화면에 없는 경우도 같은 서버에서 확인했습니다. @Valid 대신 스프링의 @Validated를 파라미터에 붙여도 결과가 같습니다. @Valid @ModelAttribute로 폼 본문을 받아도 열두 가지(제약 셋, 값 넷) 결과가 @Valid @RequestBody와 같고 예외도 MethodArgumentNotValidException입니다. JSON에 "name": null이라고 적어 보내도 보내지 않은 것과 같습니다. 객체 안에 다른 객체가 필드로 들어 있을 때는 그 필드에 @Valid가 있어야 안쪽 객체의 제약까지 검사하고(없으면 200), 안쪽 객체가 null이면 @Valid가 있어도 내려가지 않고 통과합니다. 제약에 groups를 지정하면 @Valid로는 검사되지 않고 @Validated(그 그룹)으로 받아야 검사되며, 반대로 groups가 없는 제약은 @Validated(그 그룹)으로 받으면 검사되지 않습니다. 필드 둘이 모두 어기면 오류는 2개로, 검증은 첫 오류에서 멈추지 않습니다. @Valid 객체와 제약이 직접 붙은 파라미터를 한 메서드에서 함께 받으면 객체 쪽 실패도 HandlerMethodValidationException으로 나옵니다. @RequestParam(required = false)로 받고 값을 보내지 않으면 null이 들어와 @NotNull과 @NotBlank에 걸립니다. 컨트롤러 클래스에 @Validated를 붙이면 파라미터 제약의 실패가 ConstraintViolationException과 500으로 바뀝니다. Integer 필드에 @NotBlank를 붙이면 검증할 때 UnexpectedTypeException으로 500이 됩니다. BindingResult를 @Valid 파라미터 앞에 두면 IllegalStateException으로 500이 됩니다. @ExceptionHandler로 MethodArgumentNotValidException을 받으면 어느 필드가 어느 제약을 어겼는지 꺼내 400 응답 본문에 직접 적을 수 있습니다. 화면은 필드 하나, 제약 하나인 경우로 단순화했고, 빈칸은 스페이스 한 글자만 확인했습니다.

앞 실습: 요청 매핑과 파라미터 바인딩 · 다음 실습: REST API와 메시지 컨버터

이 실습에 나오는 개념

이 실습에서 보는 것

설정

코드


      
    

그림

실행 비교

이렇게 바꿔 보세요