검증 — @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
) { }
@NotBlank 는 null 도, 빈 글자도, 공백뿐인 글자도 막는다. @Positive 는 0 보다 커야 한다는 뜻이다.
message 는 규칙이 깨졌을 때 남길 말이다. 자바 편 18강에서 우리가 손으로 던지던 그 문장이다.
public void setPrice(int price) {
if (price < 0) {
throw new IllegalArgumentException("가격은 음수일 수 없다: " + price);
}
this.price = price;
}
같은 규칙이 if 와 throw 에서 표식 하나로 옮겨왔다.
표식만으로는 아무 일도 안 일어난다
표식을 붙였으니 이제 막히는가. 그대로 던져본다.
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) {
- @NotBlank · @Positive규칙을 적어둔다. 스스로는 아무 일도 안 한다
- @Valid저 표식들을 읽고 검사하라고 시킨다
- 400규칙이 깨지면 컨트롤러 메서드는 아예 안 불린다
이제 막힌다
같은 요청을 다시 보낸다.
- 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 | @Positive 는 0 보다 커야 한다 |
{"title":"정상","price":10000} | 201 | 통과 |
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 을 못 읽었을 때와 같다. 그 강의는 이렇게 정리했다.
HttpMessageNotReadableException→400. 컨트롤러 안은 이미 걸러진 값만 본다.
이제 걸러지는 것이 하나 늘었다.
컨트롤러의 if
우리가 검사한다
- 규칙이 컨트롤러 안에 흩어진다
POST와PUT에 같은 검사를 두 번 적는다- 메서드는 불렸고, 우리가 걸러냈다
- 잊으면 통과한다
BookRequest 의 표식
@Valid 가 검사한다
- 규칙이 값 옆에 적힌다
- 한 번 적으면 받는 곳마다 적용된다
- 메서드가 불리기 전에 끝난다
- 표식이 곧 문서다
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 을 객체로 못 만든 것이다. int 에 null 을 넣을 수 없어서 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/99 가 500 을 냈을 때도 이랬다. 17강에서 orElseThrow 가 500 을 냈을 때도 그랬다. 예외는 있는데, 그것을 응답으로 옮겨줄 사람이 없다.
- 12강IndexOutOfBounds 로 500. 구멍을 짚고 넘어갔다
- 17강orElseThrow 로 500. 21강에 넘겼다
- 20강400 은 맞는데 사유가 안 실린다
- 21강예외를 받아 응답으로 옮긴다
정리하면
규칙은 값 옆에 적는다. 검사하라고 시키는 것은 @Valid 다.
- 01
if로 막으면 규칙이 흩어진다POST와PUT에 같은 검사를 두 번 적게 된다. 13강에서null을 손으로 붙잡던 자리로 되돌아간다. - 02
규칙을 DTO 에 적는다
@NotBlank는null· 빈 글자 · 공백뿐인 글자를 막고,@Positive는0보다 커야 한다는 뜻이다.record에도 붙는다.public record BookRequest( @NotBlank(message = "제목은 비어 있을 수 없다") String title, @Positive(message = "가격은 음수일 수 없다") int price ) { } - 03
표식만 붙이면 아무 일도 안 일어난다
붙이고 그대로 보내면
201이 나간다. 표식은 적어둔 사실일 뿐이다. 자바 편 21강의 자작@Test러너와 같다. - 04
@Valid가 방아쇠다@RequestBody앞에 한 단어를 붙이면 그때부터 표식이 읽힌다. 걸리면 컨트롤러 메서드는 아예 안 불린다.public ResponseEntity<BookResponse> create(@Valid @RequestBody BookRequest request) { - 05
400은 두 종류다JSON 을 객체로 못 만든 것(
HttpMessageNotReadableException)과, 객체는 만들었는데 값이 규칙에 안 맞는 것(MethodArgumentNotValidException). - 06
사유는 아직 안 실린다
우리가 적은
message는 예외 안에 있다. 응답 본문에는 없다. 21강의 일이다.
4xx 중 하나를 갚았다. 손님이 이상한 값을 보내면 이제 400 이 나간다.
그런데 아직 갚지 못한 것이 남아 있다. 없는 책을 달라고 하면 어떻게 되나.
17강에서 orElseThrow 를 써봤다가 되돌렸다. 그때 이렇게 적었다.
orElseThrow가 틀린 게 아니다. 던진 예외를 받아404로 옮겨줄 사람이 아직 없을 뿐이다.
그 사람이 오면, 방금 본 400 의 사유도 함께 실을 수 있다. 같은 자리에서 하는 같은 일이기 때문이다.
21강에서 BookNotFoundException 이 404 가 된다.