학습 자료

검증 — @Valid 가 400 을 돌려준다


Article

19강 끝에서 이런 책이 만들어졌다.

curl -X POST http://localhost:8080/books \
  -H 'Content-Type: application/json' \
  -d '{"title":"","price":-5000}'
터미널
  • HTTP/1.1 201
  • {"id":4,"title":"","price":-5000}

제목이 없는 책이 가격 -5000 원에 표에 들어갔다. 서버는 201 Created 라고 답했다.

3강에서 배운 약속이 있다. 4xx 는 손님이 잘못한 것이다. 빈 제목과 음수 가격은 손님이 잘못 보낸 것이니 400 이어야 한다.

손으로 막아본다

막는 건 어렵지 않다. if 를 쌓으면 된다.

@PostMapping
public ResponseEntity<BookResponse> create(@RequestBody BookRequest request) {
    if (request.title() == null || request.title().isBlank()) {
        return ResponseEntity.badRequest().build();
    }
    if (request.price() <= 0) {
        return ResponseEntity.badRequest().build();
    }
    Book saved = repository.save(new Book(request.title(), request.price()));
    ...
}

돌아간다. 400 이 나간다.

그런데 PUT 도 같은 값을 받는다. 저 두 개의 if 를 거기에도 복사해야 한다. 검사할 항목이 하나 늘면 두 곳을 고친다. 필드가 하나 늘면 또 두 곳이다.

13강이 떠오른다. 그때 store.get(id)null 을 줘서 if (found == null)기억해서 적어야 했다. 17강에서 그 자리를 Optional 로 걷어냈다. 지금 우리는 그 자리로 되돌아가고 있다.

11강이 이미 답을 예고해뒀다.

빠뜨린 값을 제대로 막는 것은 타입의 일이 아니다. 20강에서 @Valid 로 제대로 막는다.

if 를 지운다.

규칙을 값 옆에 적는다

먼저 의존성 하나를 넣는다.

implementation 'org.springframework.boot:spring-boot-starter-validation'

그리고 규칙을 BookRequest 안에 적는다. 검사하는 코드가 아니라, 값이 어떠해야 하는지를 적는다.

public record BookRequest(

        @NotBlank(message = "제목은 비어 있을 수 없다")
        String title,

        @Positive(message = "가격은 음수일 수 없다")
        int price
) { }

@NotBlanknull 도, 빈 글자도, 공백뿐인 글자도 막는다. @Positive0 보다 커야 한다는 뜻이다.

message 는 규칙이 깨졌을 때 남길 말이다. 자바 편 18강에서 우리가 손으로 던지던 그 문장이다.

public void setPrice(int price) {
    if (price < 0) {
        throw new IllegalArgumentException("가격은 음수일 수 없다: " + price);
    }
    this.price = price;
}

같은 규칙이 ifthrow 에서 표식 하나로 옮겨왔다.

표식만으로는 아무 일도 안 일어난다

표식을 붙였으니 이제 막히는가. 그대로 던져본다.

curl -i -X POST http://localhost:8080/books \
  -H 'Content-Type: application/json' \
  -d '{"title":"","price":-5000}'
터미널
  • HTTP/1.1 201
  • {"id":4,"title":"","price":-5000}

아무것도 안 달라졌다. 빈 제목에 -5000 원짜리 책이 또 들어갔다.

표식은 적어둔 사실일 뿐이다. 자바 편 21강에서 우리는 @Test 를 손으로 만들어봤고, 그 표식이 저절로 아무 일도 하지 않는다는 것을 봤다. 표식을 읽어서 무언가 하는 러너를 우리가 직접 짰다. 7강에서는 Spring 이 그 러너의 큰 버전이라고 했다.

그러니 누가 이 표식을 읽으라고 말해줘야 한다. 그게 @Valid 다.

@PostMapping
public ResponseEntity<BookResponse> create(@Valid @RequestBody BookRequest request) {

@RequestBody 앞에 @Valid 한 단어를 붙였다. PUT 에도 붙인다.

@PutMapping("/{id}")
@Transactional
public ResponseEntity<BookResponse> update(@PathVariable Long id, @Valid @RequestBody BookRequest request) {
누가 무엇을 하는가
  1. @NotBlank · @Positive규칙을 적어둔다. 스스로는 아무 일도 안 한다
  2. @Valid저 표식들을 읽고 검사하라고 시킨다
  3. 400규칙이 깨지면 컨트롤러 메서드는 아예 안 불린다
표식은 사실이고, @Valid 는 방아쇠다. 자바 편 21강의 자작 러너와 같은 구조다.

이제 막힌다

같은 요청을 다시 보낸다.

터미널
  • HTTP/1.1 400
  • Content-Type: application/json
  • {"timestamp":"...","status":400,"error":"Bad Request","path":"/books"}

400 이다. 3강의 약속이 지켜졌다.

몇 가지를 더 던져본다.

무엇이 막히나
보낸 본문상태
{"title":"","price":-5000}400둘 다 어겼다
{"title":" ","price":100}400공백뿐인 제목도 @NotBlank 가 막는다
{"title":"자바","price":0}400@Positive0 보다 커야 한다
{"title":"정상","price":10000}201통과
0 원짜리 책을 허용하려면 @Positive 대신 @PositiveOrZero 를 쓴다. 규칙을 고치는 자리가 한 곳뿐이다.

PUT 도 막힌다. 표식을 다시 적지 않았다. BookRequest 에 한 번 적은 규칙을 두 메서드가 같이 쓴다.

터미널
  • $ curl -o /dev/null -w '%{http_code}' -X PUT http://localhost:8080/books/1 -d '{"title":"","price":-1}'
  • 400
  • $ curl http://localhost:8080/books/1
  • {"id":1,"title":"자바","price":20000}

1번 책은 손도 안 탔다. 검사에서 걸리면 컨트롤러 메서드가 아예 안 불린다. 11강에서 JSON 을 못 읽었을 때와 같다. 그 강의는 이렇게 정리했다.

HttpMessageNotReadableException400. 컨트롤러 안은 이미 걸러진 값만 본다.

이제 걸러지는 것이 하나 늘었다.

if 로 막을 때와 표식으로 막을 때

컨트롤러의 if

우리가 검사한다

  • 규칙이 컨트롤러 안에 흩어진다
  • POSTPUT 에 같은 검사를 두 번 적는다
  • 메서드는 불렸고, 우리가 걸러냈다
  • 잊으면 통과한다

BookRequest 의 표식

@Valid 가 검사한다

  • 규칙이 값 옆에 적힌다
  • 한 번 적으면 받는 곳마다 적용된다
  • 메서드가 불리기 전에 끝난다
  • 표식이 곧 문서다
19강이 문을 세웠고, 20강이 문지기를 세웠다.

400 이 두 종류다

11강에서도 400 을 봤다. price 를 아예 빠뜨렸을 때다. 지금도 그렇다.

터미널
  • $ curl -o /dev/null -w '%{http_code}' -X POST http://localhost:8080/books -d '{"title":"자바"}'
  • 400

같은 400 인데 다른 곳에서 나온 것이다. 서버 로그를 켜고 보면 예외 이름이 갈린다.

서버 로그
  • # {"title":"자바"} — price 를 빠뜨렸다
  • Resolved [org.springframework.http.converter.HttpMessageNotReadableException
  • # {"title":"","price":-5000} — 값이 규칙을 어겼다
  • Resolved [org.springframework.web.bind.MethodArgumentNotValidException

앞의 것은 JSON 을 객체로 못 만든 것이다. intnull 을 넣을 수 없어서 Jackson 이 포기했다. 11강에서 본 그대로다.

뒤의 것은 객체는 잘 만들었는데 값이 규칙에 안 맞는 것이다. @Valid 가 잡았다.

그런데 왜 틀렸는지 안 알려준다

400 은 받았다. 그런데 손님 입장에서 보자.

{"timestamp":"...","status":400,"error":"Bad Request","path":"/books"}

무엇이 틀렸는지 한 글자도 없다. 제목이 문제인지 가격이 문제인지 알 수 없다. 우리가 message 에 정성껏 적어둔 "제목은 비어 있을 수 없다"는 어디로 갔나.

서버 로그에는 있다.

서버 로그
  • Resolved [org.springframework.web.bind.MethodArgumentNotValidException:
  • Validation failed for argument [0] ... with 2 errors:
  • Field error in object 'bookRequest' on field 'price': rejected value [-5000];
  • default message [가격은 음수일 수 없다]
  • Field error in object 'bookRequest' on field 'title': rejected value [];
  • default message [제목은 비어 있을 수 없다]

두 개를 다 잡아냈고, 우리가 적은 문장도 그대로 들고 있다. 어긴 값이 무엇이었는지(rejected value [-5000])까지 안다.

이 모든 것이 예외 하나에 담겨 있는데, 응답에는 안 실린다. Spring 이 그 예외를 받아 400 만 만들고 나머지를 버렸기 때문이다.

12강에서 /books/99500 을 냈을 때도 이랬다. 17강에서 orElseThrow500 을 냈을 때도 그랬다. 예외는 있는데, 그것을 응답으로 옮겨줄 사람이 없다.

세 번째로 같은 자리에 왔다
  1. 12강IndexOutOfBounds 로 500. 구멍을 짚고 넘어갔다
  2. 17강orElseThrow 로 500. 21강에 넘겼다
  3. 20강400 은 맞는데 사유가 안 실린다
  4. 21강예외를 받아 응답으로 옮긴다
@ExceptionHandler 하나가 이 셋을 한꺼번에 갚는다.

정리하면

규칙은 값 옆에 적는다. 검사하라고 시키는 것은 @Valid 다.

다시 짚어보기
  1. 01

    if 로 막으면 규칙이 흩어진다

    POSTPUT 에 같은 검사를 두 번 적게 된다. 13강에서 null 을 손으로 붙잡던 자리로 되돌아간다.

  2. 02

    규칙을 DTO 에 적는다

    @NotBlanknull · 빈 글자 · 공백뿐인 글자를 막고, @Positive0 보다 커야 한다는 뜻이다. record 에도 붙는다.

    public record BookRequest(
            @NotBlank(message = "제목은 비어 있을 수 없다")
            String title,
    
            @Positive(message = "가격은 음수일 수 없다")
            int price
    ) { }
  3. 03

    표식만 붙이면 아무 일도 안 일어난다

    붙이고 그대로 보내면 201 이 나간다. 표식은 적어둔 사실일 뿐이다. 자바 편 21강의 자작 @Test 러너와 같다.

  4. 04

    @Valid 가 방아쇠다

    @RequestBody 앞에 한 단어를 붙이면 그때부터 표식이 읽힌다. 걸리면 컨트롤러 메서드는 아예 안 불린다.

    public ResponseEntity<BookResponse> create(@Valid @RequestBody BookRequest request) {
  5. 05

    400 은 두 종류다

    JSON 을 객체로 못 만든 것(HttpMessageNotReadableException)과, 객체는 만들었는데 값이 규칙에 안 맞는 것(MethodArgumentNotValidException).

  6. 06

    사유는 아직 안 실린다

    우리가 적은 message 는 예외 안에 있다. 응답 본문에는 없다. 21강의 일이다.

4xx 중 하나를 갚았다. 손님이 이상한 값을 보내면 이제 400 이 나간다.

그런데 아직 갚지 못한 것이 남아 있다. 없는 책을 달라고 하면 어떻게 되나.

17강에서 orElseThrow 를 써봤다가 되돌렸다. 그때 이렇게 적었다.

orElseThrow 가 틀린 게 아니다. 던진 예외를 받아 404 로 옮겨줄 사람이 아직 없을 뿐이다.

그 사람이 오면, 방금 본 400 의 사유도 함께 실을 수 있다. 같은 자리에서 하는 같은 일이기 때문이다.

21강에서 BookNotFoundException404 가 된다.

검증 — @Valid 가 400 을 돌려준다 — 디코드랩(DCODELAB)