학습 자료

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 이다.

문법은 몇 줄이면 끝난다.

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 관례를 볼 필요가 없다.

무엇이 JSON 에 실리나 (전부 직접 확인했다)
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}
여섯 줄 중 세 줄이 조용히 실패한다. 그래서 API 로 내보낼 객체는 record 로 만든다.

앞으로 밖으로 내보낼 객체는 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 일 때
  • {"title":null,"price":0}

null 은 JSON 에도 null 로 실린다. 키는 사라지지 않는다. 그리고 intnull 이 될 수 없으니 0 이다. 자바 편 4강의 기본형과 참조형이 여기서도 갈린다.

헤더를 다시 본다

무엇을 돌려주느냐에 따라 헤더가 바뀐다

String 을 돌려줬을 때

9강

  • Content-Type: text/plain;charset=UTF-8
  • Content-Length: 10
  • 본문: 책 목록

Book 을 돌려줬을 때

이번 강의

  • Content-Type: application/json
  • Content-Length: 32
  • 본문: {"title":"자바","price":20000}
charset 이 안 붙었다. JSON 은 UTF-8 로 쓰기로 이미 정해져 있어서 굳이 적지 않는다.

Content-Length: 32. 어디서 본 숫자다.

2강에서 이 JSON 을 curl -d보낼 때 Content-Length: 32 였다. 글자는 28개인데 한글 자바 가 6바이트라 32바이트라고 했다. 같은 글자를 이번엔 돌려받는다. 우리는 세지 않았고, Spring 이 셌다.

누가 바꿨나

5강에서 상자를 열어봤을 때 이름이 146개 있었다. 그중에 이런 것들이 있다.

JSON 관련 빈들
  • jacksonJsonMapper
  • jacksonJsonHttpMessageConvertersCustomizer
  • org.springframework.boot.jackson.autoconfigure.JacksonAutoConfiguration
  • org.springframework.boot.http.converter.autoconfigure.HttpMessageConvertersAutoConfiguration

Jackson 이라는 도구가 객체와 JSON 을 오가는 일을 한다. 우리가 만든 적 없다. 8강에서 자동설정 후보 목록을 셀 때 spring-boot-jackson jar 에서 한 줄이 딸려 왔던 것을 기억할 것이다. build.gradlespring-boot-starter-webmvc 가 묶음으로 데려왔다.

메서드가 돌려준 객체가 응답이 되기까지
  1. return new Book(...)우리가 쓴 코드는 여기까지
  2. @ResponseBody돌려준 값을 본문에 싣겠다는 표식 (9강)
  3. Jacksongetter 를 찾아 키와 값을 뽑는다
  4. {"title":"자바",...}글자가 되어 나간다
Jackson 이 getter 를 찾는 방식도 결국 리플렉션이다. 7강에서 본 그것이다.

정리하면

JSON 은 객체를 텍스트로 적는 약속이고, Jackson 은 꺼낼 수 있는 것만 꺼낸다.

다시 짚어보기
  1. 01

    객체를 돌려주면 Content-Type 이 바뀐다

    String 이면 text/plain, 객체면 application/json. 우리가 정하지 않았다.

  2. 02

    빈 중괄호는 에러가 아니다

    꺼낼 게 없으면 {} 를 200 으로 내보낸다. 값이 안 온다면 getter 부터 확인한다.

  3. 03

    getter 는 자바 문법이 아니라 관례다

    getTitle() 은 되고 title() 은 안 된다. 자바 편 12강이 "그냥 관례적인 이름"이라고 했던 그 관례에 도구가 기대고 있다.

  4. 04

    그래서 내보낼 객체는 record 로 만든다

    접근자가 title() 인데도 동작한다. record 는 컴파일러가 구성 요소 정보를 남겨두기 때문이다.

    public record BookRecord(String title, int price) { }
  5. 05

    List 는 배열이 되고 nullnull 이 된다

    키는 사라지지 않는다. int 는 null 이 될 수 없어 0 으로 실린다.

지금까지는 나가는 방향만 봤다. 객체가 글자가 되어 밖으로 나갔다.

반대 방향이 남았다. 3강에서 POST 는 새로 만드는 동사이고 본문이 있다고 했다. 2강에서 그 본문을 서버가 readNBytes 로 바이트 단위로 읽는 것도 봤다.

이제 클라이언트가 이런 글자를 보내온다.

{"title":"자바","price":20000}

이 글자를 Book 객체로 되돌려야 한다. 누가, 어떻게 되돌리는가. 11강에서 본다.

JSON — 객체를 텍스트로 적는 약속 — 디코드랩(DCODELAB)