JSON — 객체를 텍스트로 적는 약속
Article
9강의 컨트롤러는 글자를 돌려줬다.
@GetMapping("/text")
public String text() {
return "책 목록";
}
쓸모가 없다. 진짜 API 는 책 한 권의 제목과 가격을 돌려줘야 한다.
자바 편 11강에서 만든 Book 을 그대로 가져오자. 다른 패키지에서 쓰려면 클래스와 생성자에 public 이 필요하니 거기까지만 손댄다. 필드는 11강 원문 그대로 둔다.
public class Book {
String title; // 11강 그대로 — 접근 제어자를 안 붙였다
int price;
public Book(String title, int price) {
this.title = title;
this.price = price;
}
}
그리고 String 자리에 Book 을 적는다.
@GetMapping("/book")
public Book book() {
return new Book("자바", 20000);
}
curl -i http://localhost:8080/book
- HTTP/1.1 200
- Content-Type: application/json
- {}
200 이다. 그런데 본문이 빈 중괄호다.
에러가 안 났다. 스택 트레이스도 없다. 로그도 조용하다. 그냥 아무것도 안 담겨 있다.
2강에서 Content-Length 를 실제보다 작게 적으면 조용히 잘린다고 했다. 지금 벌어진 일도 같은 종류다. 실패했는데 실패했다고 말해주지 않는다. 이번 강의는 이 {} 를 파고든다.
먼저, JSON 이 무엇인가
Content-Type: application/json. 우리가 시킨 적 없는데 바뀌어 있다. String 을 돌려줬을 때는 text/plain 이었다.
JSON 은 객체를 텍스트로 적는 약속이다. 2강에서 봤듯이 HTTP 로 나가는 것은 글자뿐이다. Book 이라는 타입은 그 경계를 넘지 못한다. 그러니 글자로 적어서 보내야 하고, 받는 쪽이 그 글자를 다시 객체로 되돌릴 수 있어야 한다. 그 적는 방법을 정해둔 것이 JSON 이다.
문법은 몇 줄이면 끝난다.
| 모양 | 뜻 | 예 |
|---|---|---|
{ } | 객체 — 이름과 값의 짝 | {"title":"자바"} |
[ ] | 배열 — 순서 있는 목록 | [1, 2, 3] |
"..." | 문자열. 키는 언제나 문자열이다 | "자바" |
20000 | 숫자. 따옴표 없다 | 20000 |
true false | 참·거짓 | true |
null | 값이 없음 | null |
2강에서 우리가 보냈던 그 글자를 기억하는지.
{"title":"자바","price":20000}
curl -d 로 서버에 보냈던 것이다. 그때는 뜻을 몰랐다. 지금 우리가 돌려주려는 것이 정확히 저 모양이다.
왜 빈 중괄호였나
Book 객체 안에 "자바" 와 20000 이 분명히 들어 있다. 그런데 왜 안 나올까.
JSON 으로 바꿔주는 도구는 우리 클래스의 사정을 모른다. 밖에서 꺼낼 수 있는 것만 꺼낸다.
자바 편 11강의 Book 을 다시 보자.
public class Book {
String title; // ← 접근 제어자가 없다
int price;
}
public 이 안 붙어 있다. 자바 편 11강은 아직 public 을 안 배운 시점이라 그냥 두었고, 같은 패키지 안에서만 보인다. JSON 변환기는 남의 패키지에 있는 남이다. 아무것도 안 보인다. 그래서 {} 다.
붙여보자.
public String title;
public int price;
- {"title":"자바","price":20000}
나온다. 필드 이름이 그대로 키가 됐다.
그런데 자바 편 12강은 private 하라고 했다
필드를 열어두는 것은 자바 편 12강이 하지 말라고 한 바로 그것이다. 캡슐화를 배웠으니 닫아야 한다.
public class Book {
private String title;
private int price;
...
}
- {}
다시 {} 다. 당연하다. 밖에서 안 보이니까.
그래서 자바 편 12강이 가르친 대로 getter 를 붙인다. 하나만 붙여봤다.
private String title;
private int price;
public String getTitle() { return title; } // title 만
- {"title":"자바"}
price 가 사라졌다. 꺼낼 방법이 없으니 없는 것 취급이다.
여기서 이 강의의 핵심이 나온다. 이름을 살짝 바꿔보자. get 을 뗀다.
public String title() { return title; } // getTitle -> title
- {}
메서드는 그대로 있는데 안 보인다. 하는 일도 똑같다. 이름만 바꿨을 뿐이다.
자바 편 12강에 이런 문장이 있었다.
값을 꺼내주는 메서드를 getter, 넣어주는 메서드를 setter 라고 부른다. 그냥 관례적인 이름이지 자바 문법은 아니다.
그 관례에 도구들이 기대고 있다. get 으로 시작하는 메서드를 찾아 그 뒤를 소문자로 바꿔 키로 쓴다. getTitle() → title. 자바 문법이 아니라 약속이지만, 지키지 않으면 아무 일도 일어나지 않는다. 에러도 없이.
record 는 왜 되는가
자바 편 12강 마지막에 배운 record 로 만들어보자.
public record BookRecord(String title, int price) { }
- {"title":"자바","price":20000}
된다. 그런데 이상하다. 자바 편 12강에서 record 를 소개하며 "값을 꺼내는 메서드가 getX() 가 아니라 x() 다" 라고 짚었다.
record 의 접근자는 title() 이다. get 이 안 붙는다. 방금 우리가 title() 로 이름을 바꿨더니 {} 가 나왔는데, record 는 왜 되는가.
record 가 특별하기 때문이다. 컴파일러가 만든 그 클래스에는 "이 필드가 이 클래스의 구성 요소다"라는 정보가 남아 있고, JSON 변환기가 그걸 읽는다. get 관례를 볼 필요가 없다.
| Book 을 이렇게 쓰면 | 응답 본문 |
|---|---|
String title; (자바 편 11강 원문) | {} |
public String title; | {"title":"자바","price":20000} |
private String title; + getter 없음 | {} |
private + getTitle() 만 | {"title":"자바"} |
private + title() (get 없음) | {} |
record BookRecord(String title, int price) | {"title":"자바","price":20000} |
앞으로 밖으로 내보낼 객체는 record 로 만든다. 필드를 열지 않아도 되고, getter 를 손으로 쓸 일도 없고, 이름을 틀릴 일도 없다.
19강에서 이 원칙을 DTO 라는 이름으로 다시 정리한다.
배열과 null
목록을 돌려주면 어떻게 되나.
@GetMapping("/books")
public List<Book> books() {
return List.of(new Book("자바", 20000), new Book("스프링", 32000));
}
- [{"title":"자바","price":20000},{"title":"스프링","price":32000}]
자바 편 16강의 List 가 JSON 배열 [ ] 이 된다. 우리가 아무것도 안 했다.
값이 없으면 이렇게 된다.
- {"title":null,"price":0}
null 은 JSON 에도 null 로 실린다. 키는 사라지지 않는다. 그리고 int 는 null 이 될 수 없으니 0 이다. 자바 편 4강의 기본형과 참조형이 여기서도 갈린다.
헤더를 다시 본다
String 을 돌려줬을 때
9강
Content-Type: text/plain;charset=UTF-8Content-Length: 10- 본문:
책 목록
Book 을 돌려줬을 때
이번 강의
Content-Type: application/jsonContent-Length: 32- 본문:
{"title":"자바","price":20000}
Content-Length: 32. 어디서 본 숫자다.
2강에서 이 JSON 을 curl -d 로 보낼 때 Content-Length: 32 였다. 글자는 28개인데 한글 자바 가 6바이트라 32바이트라고 했다. 같은 글자를 이번엔 돌려받는다. 우리는 세지 않았고, Spring 이 셌다.
누가 바꿨나
5강에서 상자를 열어봤을 때 이름이 146개 있었다. 그중에 이런 것들이 있다.
- jacksonJsonMapper
- jacksonJsonHttpMessageConvertersCustomizer
- org.springframework.boot.jackson.autoconfigure.JacksonAutoConfiguration
- org.springframework.boot.http.converter.autoconfigure.HttpMessageConvertersAutoConfiguration
Jackson 이라는 도구가 객체와 JSON 을 오가는 일을 한다. 우리가 만든 적 없다. 8강에서 자동설정 후보 목록을 셀 때 spring-boot-jackson jar 에서 한 줄이 딸려 왔던 것을 기억할 것이다. build.gradle 의 spring-boot-starter-webmvc 가 묶음으로 데려왔다.
- return new Book(...)우리가 쓴 코드는 여기까지
- @ResponseBody돌려준 값을 본문에 싣겠다는 표식 (9강)
- Jacksongetter 를 찾아 키와 값을 뽑는다
- {"title":"자바",...}글자가 되어 나간다
정리하면
JSON 은 객체를 텍스트로 적는 약속이고, Jackson 은 꺼낼 수 있는 것만 꺼낸다.
- 01
객체를 돌려주면
Content-Type이 바뀐다String이면text/plain, 객체면application/json. 우리가 정하지 않았다. - 02
빈 중괄호는 에러가 아니다
꺼낼 게 없으면
{}를 200 으로 내보낸다. 값이 안 온다면 getter 부터 확인한다. - 03
getter 는 자바 문법이 아니라 관례다
getTitle()은 되고title()은 안 된다. 자바 편 12강이 "그냥 관례적인 이름"이라고 했던 그 관례에 도구가 기대고 있다. - 04
그래서 내보낼 객체는
record로 만든다접근자가
title()인데도 동작한다. record 는 컴파일러가 구성 요소 정보를 남겨두기 때문이다.public record BookRecord(String title, int price) { } - 05
List는 배열이 되고null은null이 된다키는 사라지지 않는다. int 는 null 이 될 수 없어 0 으로 실린다.
지금까지는 나가는 방향만 봤다. 객체가 글자가 되어 밖으로 나갔다.
반대 방향이 남았다. 3강에서 POST 는 새로 만드는 동사이고 본문이 있다고 했다. 2강에서 그 본문을 서버가 readNBytes 로 바이트 단위로 읽는 것도 봤다.
이제 클라이언트가 이런 글자를 보내온다.
{"title":"자바","price":20000}
이 글자를 Book 객체로 되돌려야 한다. 누가, 어떻게 되돌리는가. 11강에서 본다.