@RequestBody — 텍스트가 객체가 되는 지점
Article
10강은 나가는 방향이었다. Book 객체가 글자가 되어 밖으로 나갔다.
이제 반대다. 클라이언트가 이런 글자를 보내온다.
{"title":"자바","price":20000}
2강에서 이 글자를 서버가 어떻게 읽었는지 기억하는지. Content-Length 를 헤더에서 뽑아 그 숫자만큼 바이트를 읽고, UTF-8 로 디코딩해 문자열로 만들었다. 거기까지가 전부였다. 문자열이었지 Book 이 아니었다.
byte[] body = in.readNBytes(len);
System.out.println("[본문 ] " + new String(body, "UTF-8"));
이번 강의에서 저 글자가 Book 이 된다.
받는다
3강에서 새로 만드는 동사는 POST 이고, 아직 번호가 없으니 경로는 /books 라고 했다.
@PostMapping("/books")
public Book create(@RequestBody Book book) {
System.out.println("### 받았다: title=" + book.title() + " price=" + book.price());
return book;
}
받은 것을 그대로 돌려준다. 보내보자.
curl -X POST http://localhost:8080/books \
-H 'Content-Type: application/json' \
-d '{"title":"자바","price":20000}'
- {"title":"자바","price":20000}
- ### 받았다: title=자바 price=20000
book.title() 이 불렸다. 글자가 객체가 됐다.
2강과 나란히 놓는다
2강 — 손으로
EchoServer.java
- 헤더를 한 줄씩 읽어
Content-Length를 찾는다 Integer.parseInt(line.substring(15).trim())in.readNBytes(len)— 바이트로 읽는다new String(body, "UTF-8")— 문자열로 만든다- 여기서 끝. 파싱은 시작도 안 했다
11강 — 표식 하나
BookController.java
@RequestBody Book book- —
- —
- —
- `book.title()` 이 바로 불린다
@ResponseBody(9강)가 돌려준 값을 본문에 싣는다는 뜻이었다면, @RequestBody 는 본문을 읽어 이 매개변수에 담는다는 뜻이다. 방향만 반대다.
record 는 어떻게 만들어지나
10강에서 Book 을 record 로 만들기로 했다.
public record Book(String title, int price) { }
record 에는 setter 가 없다. 자바 편 12강에서 "record 의 필드는 전부 private 이고 한 번 정해지면 바뀌지 않는다" 고 배웠다. 그럼 Jackson 은 값을 어떻게 넣는가.
생성자로 넣는다. record 는 컴파일러가 만들어준 생성자가 있고, 각 자리에 어떤 이름의 값이 들어가는지도 기록되어 있다. Jackson 은 그 정보를 읽어 new Book("자바", 20000) 을 대신 호출한다.
10강에서 record 를 고른 것이 양방향으로 옳았다. 나갈 때는 접근자로 꺼내고, 들어올 때는 생성자로 넣는다. 필드를 열 필요도, setter 를 만들 필요도 없다.
이상한 것을 보내본다
여섯 가지 본문을 던져봤다. 전부 실제 응답이다.
| 보낸 본문 | 상태 | 결과 |
|---|---|---|
{"title":"자바","price":20000} | 200 | 그대로 돌아온다 |
{"title":"자바","price":20000,"extra":1} | 200 | `extra` 는 조용히 무시된다 |
{"title":"자바"} | 400 | price 가 없다 |
{"title":"자바","price":"비쌈"} | 400 | price 가 숫자가 아니다 |
{title: 자바} | 400 | JSON 문법이 아니다 |
{} | 400 | price 가 없다 |
400 이 세 번 나왔다. 3강에서 배운 대로 4xx 는 요청한 쪽이 잘못했다는 뜻이다. 우리는 아무것도 안 했는데 Spring 이 판단해서 400 을 돌려줬다.
응답 본문은 4강에서 본 그 모양이다.
- {"timestamp":"2026-07-10T06:37:01.424Z","status":400,"error":"Bad Request","path":"/books"}
왜 400 인지는 안 알려준다. 서버 로그를 켜야 보인다.
- HttpMessageNotReadableException: JSON parse error:
- Cannot map `null` into type `int`
- Cannot deserialize value of type `int` from String "비쌈": not a valid `int` value
- Unexpected character ('t' (code 116)): was expecting double-quote to start property name
HttpMessageNotReadableException — 본문을 읽을 수 없다는 뜻이다. 읽을 수 없으니 메서드를 부를 수조차 없고, 그래서 create() 안의 System.out.println 도 안 찍힌다.
int 하나 때문에 400 이 났다
세 번째 줄을 다시 보자. {"title":"자바"} 를 보냈더니 400 이다.
price 를 안 보냈으니 0 이 들어올 줄 알았는데 아니다. 이유가 로그에 적혀 있다.
Cannot map `null` into type `int`
자바 편 4강에 이렇게 적혀 있었다.
기본형에는
null이 없다.int age = null;은 컴파일조차 안 된다. 값을 담는 상자에는 "주소 없음"이라는 개념 자체가 성립하지 않는다.
JSON 에 price 가 없다는 것은 값이 없다는 뜻이고, 그것을 자바로 옮기면 null 이다. 그런데 int 는 null 을 담을 수 없다. 그래서 읽기를 포기하고 400 을 돌려준다.
Integer 로 바꾸면 어떻게 될까. 참조형이니 null 을 담을 수 있다.
public record Book(String title, Integer price) { }
| 보낸 본문 | int price | Integer price |
|---|---|---|
{"title":"자바","price":20000} | 200 정상 | 200 정상 |
{"title":"자바"} | `400` | 200 · {"title":"자바","price":null} |
{} | `400` | 200 · {"title":null,"price":null} |
int 가 걸러준 것은 운이 좋았을 뿐이다. 타입에 기대어 검증하지 않는다.
Content-Type 을 빠뜨리면
curl 에서 -H 'Content-Type: application/json' 을 빼고 보내봤다.
- {"timestamp":"...","status":415,"error":"Unsupported Media Type","path":"/books"}
415. 3강에서 안 다룬 상태코드다. **"본문이 어떤 형식인지 모르겠다"**는 뜻이다.
2강에서 Content-Type 은 본문이 어떤 형식인지 알려주는 헤더라고 했다. 서버는 그것을 보고 JSON 변환기를 고른다. 안 적어주면 무엇으로 읽어야 할지 알 수 없으니 아예 거절한다.
@RequestBody 를 빠뜨리면
이게 가장 무섭다. 표식만 지워봤다.
@PostMapping("/books-nobody")
public Book create(Book book) { // @RequestBody 가 없다
return book;
}
- 상태 200
- {"title":null,"price":null}
200 이다. 그런데 전부 null 이다.
표식이 없으니 Spring 은 본문을 읽지 않는다. 대신 다른 곳에서 값을 찾는데, 거기에 아무것도 없으니 빈 Book 을 만들어 넘긴다. 본문은 읽히지도 않은 채 버려졌다.
표식 하나가 방향을 정한다
- @RequestBody본문(글자) → 매개변수(객체). 생성자로 넣는다
- 메서드가 실행된다book.title() 을 쓸 수 있다
- @ResponseBody반환값(객체) → 본문(글자). 접근자로 꺼낸다
읽을 수 없으면 메서드는 아예 실행되지 않는다. 경계에서 걸러진다. 우리 코드는 이미 Book 이 된 것만 본다.
이 성질이 앞으로 계속 쓰인다. 컨트롤러 메서드 안에서 "이게 JSON 이 맞나" 같은 걱정을 하지 않아도 된다.
정리하면
@RequestBody 는 본문을 읽어 객체로 만들고, 못 만들면 메서드를 부르지 않고 400 을 돌려준다.
- 01
@RequestBody는@ResponseBody의 반대다하나는 본문을 객체로, 하나는 객체를 본문으로. 둘 다 Jackson 이 한다.
public Book create(@RequestBody Book book) - 02
record는 생성자로 채워진다setter 가 없어도 된다. 10강에서 record 를 고른 것이 들어오는 방향에서도 옳았다.
- 03
읽을 수 없으면 메서드는 실행되지 않는다
HttpMessageNotReadableException→400. 컨트롤러 안은 이미 걸러진 값만 본다. - 04
int는null을 담을 수 없어서 400 이 난다자바 편 4강 그대로다. 하지만 이건 검증이 아니라 우연이다.
String이 빠진 것은 못 막는다. - 05
조용히 실패하는 두 가지를 기억한다
Content-Type을 빠뜨리면 415,@RequestBody를 빠뜨리면 200 에 전부 null.
이제 만들 수 있고 돌려줄 수 있다. 그런데 아직 한 권을 콕 집어 달라고 할 방법이 없다.
3강에서 이렇게 약속했다.
GET /books/1
DELETE /books/1
주소 한가운데 있는 1 을 어떻게 꺼낼 것인가. 그리고 GET /books?minPrice=20000 처럼 물음표 뒤에 붙는 값은 또 어떻게 꺼내는가.
12강에서 주소를 뜯는다.