학습 자료

@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()` 이 바로 불린다
10강에서 나가는 방향을 맡은 Jackson 이, 들어오는 방향도 맡는다. 표식만 바뀐다.

@ResponseBody(9강)가 돌려준 값을 본문에 싣는다는 뜻이었다면, @RequestBody본문을 읽어 이 매개변수에 담는다는 뜻이다. 방향만 반대다.

record 는 어떻게 만들어지나

10강에서 Bookrecord 로 만들기로 했다.

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":"자바"}400price 가 없다
{"title":"자바","price":"비쌈"}400price 가 숫자가 아니다
{title: 자바}400JSON 문법이 아니다
{}400price 가 없다
모르는 필드를 보내도 에러가 아니다. 서버가 아는 것만 챙기고 나머지는 버린다.

400 이 세 번 나왔다. 3강에서 배운 대로 4xx 는 요청한 쪽이 잘못했다는 뜻이다. 우리는 아무것도 안 했는데 Spring 이 판단해서 400 을 돌려줬다.

응답 본문은 4강에서 본 그 모양이다.

400 의 본문
  • {"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 이다. 그런데 intnull 을 담을 수 없다. 그래서 읽기를 포기하고 400 을 돌려준다.

Integer 로 바꾸면 어떻게 될까. 참조형이니 null 을 담을 수 있다.

public record Book(String title, Integer price) { }
같은 요청, 다른 타입
보낸 본문int priceInteger price
{"title":"자바","price":20000}200 정상200 정상
{"title":"자바"}`400`200 · {"title":"자바","price":null}
{}`400`200 · {"title":null,"price":null}
자바 편 4강의 기본형과 참조형이 HTTP 경계에서 이렇게 갈린다.

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;
}
같은 JSON 을 보냈는데
  • 상태 200
  • {"title":null,"price":null}

200 이다. 그런데 전부 null 이다.

표식이 없으니 Spring 은 본문을 읽지 않는다. 대신 다른 곳에서 값을 찾는데, 거기에 아무것도 없으니 빈 Book 을 만들어 넘긴다. 본문은 읽히지도 않은 채 버려졌다.

표식 하나가 방향을 정한다

같은 Jackson, 반대 방향
  1. @RequestBody본문(글자) → 매개변수(객체). 생성자로 넣는다
  2. 메서드가 실행된다book.title() 을 쓸 수 있다
  3. @ResponseBody반환값(객체) → 본문(글자). 접근자로 꺼낸다
@RestController 에는 @ResponseBody 가 이미 들어 있다(9강). 그래서 나가는 쪽은 안 적어도 된다.

읽을 수 없으면 메서드는 아예 실행되지 않는다. 경계에서 걸러진다. 우리 코드는 이미 Book 이 된 것만 본다.

이 성질이 앞으로 계속 쓰인다. 컨트롤러 메서드 안에서 "이게 JSON 이 맞나" 같은 걱정을 하지 않아도 된다.

정리하면

@RequestBody 는 본문을 읽어 객체로 만들고, 못 만들면 메서드를 부르지 않고 400 을 돌려준다.

다시 짚어보기
  1. 01

    @RequestBody@ResponseBody 의 반대다

    하나는 본문을 객체로, 하나는 객체를 본문으로. 둘 다 Jackson 이 한다.

    public Book create(@RequestBody Book book)
  2. 02

    record 는 생성자로 채워진다

    setter 가 없어도 된다. 10강에서 record 를 고른 것이 들어오는 방향에서도 옳았다.

  3. 03

    읽을 수 없으면 메서드는 실행되지 않는다

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

  4. 04

    intnull 을 담을 수 없어서 400 이 난다

    자바 편 4강 그대로다. 하지만 이건 검증이 아니라 우연이다. String 이 빠진 것은 못 막는다.

  5. 05

    조용히 실패하는 두 가지를 기억한다

    Content-Type 을 빠뜨리면 415, @RequestBody 를 빠뜨리면 200 에 전부 null.

이제 만들 수 있고 돌려줄 수 있다. 그런데 아직 한 권을 콕 집어 달라고 할 방법이 없다.

3강에서 이렇게 약속했다.

GET    /books/1
DELETE /books/1

주소 한가운데 있는 1 을 어떻게 꺼낼 것인가. 그리고 GET /books?minPrice=20000 처럼 물음표 뒤에 붙는 값은 또 어떻게 꺼내는가.

12강에서 주소를 뜯는다.

@RequestBody — 텍스트가 객체가 되는 지점 — 디코드랩(DCODELAB)