외부 JSON 바인딩

애플리케이션은 코어에 이미 있는 두 메서드로 이루어진 이음새(seam)를 통해 자체 라이브러리로 JSON을 바인딩할 수 있다. 코어의 JSON은 의도적으로 손으로 작성하거나 타입에서 생성한 것이지만, 그 결정은 프레임워크가 코어를 위해 내린 것이지 애플리케이션을 위해 내린 것이 아니다. DTO가 삼백 개인 애플리케이션이나, 소유하지 않은 스키마에서 와이어 포맷이 생성되는 애플리케이션은 라이브러리로 객체를 바인딩할 이유가 있다. Jackson에는 전용 모듈 spider-silk-jackson이 있으며, 이 모듈은 코어가 이미 받는 JsonWriter와 JsonReader를 통해 바인딩한다.

이음새

나가는 쪽에서는 WebResponse.rawJson(String json)이 이미 직렬화된 문서를 받는다. 들어오는 쪽에서는 req.bodyStream()과 req.bodyReader()가 본문을 읽지 않은 채로 넘겨준다.

public static WebResponse rawJson(String json) // whatever produced it
public InputStream bodyStream() // the bytes, unparsed
public BufferedReader bodyReader() // the characters, unparsed

이것이 통합의 전부다. 다른 라이브러리의 출력을 JsonWriter<T>로 흘려보내지 않는다: JsonWriter는 텍스트가 아니라 값을 쓰므로, 라이브러리가 방금 다 쓴 문서를 파싱했다가 다시 쓰게 된다. 라이브러리가 직렬화하고, rawJson(String)이 그 결과를 보낸다.

본문은 한 가지 방식으로만 읽는다: body()와 bodyJson()이 보관하는 바이트로 읽거나, bodyStream(), bodyReader(), bodyNdjson()을 통해 읽지 않은 채로 넘겨받는다. body()는 바이트를 한 번 읽어 요청이 끝날 때까지 보관하므로, 어느 필터나 핸들러가 먼저 요청했든 bodyJson()과 두 번째 body()는 같은 본문을 답한다. 텍스트를 읽은 요청은 bodyStream(), bodyReader(), bodyNdjson()에서 IllegalStateException을 던지는데, 그 아래의 리더가 이미 끝에 도달했기 때문이다. 읽지 않은 채로 넘겨준 본문은 body()와 bodyJson()에서 IllegalStateException을 던지는데, 얼마나 소비되었는지 아무도 말할 수 없기 때문이다. 스트림과 리더도 서로 배타적인데, 이는 서블릿 API 자체의 규칙이다. 이 JVM이 디코딩하지 못하는 charset을 지정한 Content-Type에는, 빈 charset=도 포함해, body(), bodyJson(), bodyReader(), bodyNdjson()이 본문을 한 바이트도 읽기 전에 415로 답한다. 핸들러가 raw()를 통해 읽는 것은 이 기록을 거치지 않는다. 폼 인코딩된 POST도 첫 param() 읽기로 소진되는데, 컨테이너가 본문을 끝까지 읽어 폼을 파싱하기 때문이다. 그 뒤 bodyStream()은 예외를 던지는 대신 빈 스트림을 답하고, 바이트로 먼저 읽은 본문은 formParam()에 아무 필드도 남기지 않는다.

Jackson

spider-silk-jackson은 코어가 이미 받는 JsonWriter와 JsonReader를 통해 Jackson 3로 바인딩한다.

dependencies {
    implementation 'net.benelog.spidersilk:spider-silk-jackson:1.1.0'
}

JacksonCodecs는 ObjectMapper를 생성자에서 한 번 받는다. 그것이 만드는 모든 라이터와 리더가 그 매퍼로 바인딩하므로, 핸들러는 타입만 지명하고 매퍼는 지명하지 않는다.

static final JacksonCodecs JACKSON = new JacksonCodecs(JsonMapper.builder().build());
static final JsonWriter<Deck> DECK = JACKSON.writer(Deck.class);
static final JsonReader<NewDeck> NEW_DECK = JACKSON.reader(NewDeck.class);

app.post("/api/decks", req -> {
    NewDeck body = req.bodyJson(NEW_DECK);
    return WebResponse.json(deckService.create(body.name()), DECK);
});

new JacksonCodecs()는 Jackson 기본값의 JsonMapper로 바인딩한다. 직접 만든 매퍼는 그 모듈, 이름 전략, 기능을 모든 라이터와 리더에 가져간다.

메서드 돌려주는 것

writer(Class<T>), writer(TypeReference<T>)

선언된 타입에 대해 Jackson이 직렬화기를 한 번 찾아 두는 JsonWriter<T>

writer()

값을 런타임의 타입대로 쓰는 JsonWriter<Object>

reader(Class<T>), reader(TypeReference<T>)

JsonReader<T>

codec(Class<T>), codec(TypeReference<T>)

한 타입의 양방향인 JsonCodec<T>

라이터는 코어가 넘기는 JsonOutput 위의 생성기를 Jackson에 건넨다. 따라서 문서는 응답이 나가는 버퍼에 곧바로 한 번 직렬화되고, Content-Length는 다른 라이터처럼 계산된다. Jackson이 만든 텍스트를 다시 파싱하는 일은 없다.

리더는 본문의 바이트를 Jackson으로 파싱한다. Jackson이 거부하는 입력은 손으로 작성한 리더가 거부할 때처럼 400으로 답한다: 잘못된 문법, 타입이 틀린 값, 뒤따르는 내용, IllegalArgumentException을 던지거나 본문에 빠진 속성 때문에 NullPointerException을 던지는 생성자가 그렇다. JSON null인 본문도 400으로 답하므로, 핸들러는 값 대신 null을 받는 일이 없다. Jackson이 만들 수 없는 매핑은 클라이언트가 아니라 프로그램의 실수이며, 500으로 답한다.

둘 다 코어가 라이터나 리더를 받는 곳이면 어디서나 동작한다. req.bodyNdjson(NEW_DECK)은 각 줄을 Jackson으로 읽고, sink.write(deck, DECK)는 jsonArray`나 `ndjson 응답의 각 요소를 쓴다. JsonWriter.list`와 `JsonReader.list 안에서는 요소를 하나씩 쓰고 읽는다. 리스트는 JACKSON.reader(new TypeReference<List<NewDeck>>() { })로 Jackson에 통째로 맡기는 편이 빠르다. JsonReader.list 안의 요소 리더는 요소마다 Jackson 파서를 새로 시작하며, 레코드 100개의 리스트를 읽는 데 약 1.75배의 시간이 든다.

JsonOutput은 검사한 JSON만 담는다. 그래서 @JsonRawValue가 붙은 값은 파싱해 그 안의 값으로 쓰고, writeRaw로 쓰는 원시 조각은 거부한다. JsonOutput은 모든 문자열을 UTF-8로 쓰므로, new JacksonCodecs(mapper)는 JsonWriteFeature.ESCAPE_NON_ASCII가 켜진 mapper를 그 기능을 무시하는 대신 IllegalArgumentException으로 거부한다.

Jackson은 리플렉션으로 바인딩하며, 이 모듈은 애플리케이션이 그것을 택하는 자리다. 그러면 네이티브 이미지에는 바인딩되는 모든 타입에 리플렉션 항목이 필요하다.

이음새를 통해

이 모듈은 패키지가 tools.jackson인 Jackson 3를 담는다. 패키지가 com.fasterxml.jackson인 Jackson 2는 대신 이음새를 거친다:

static final ObjectMapper MAPPER = new ObjectMapper();

app.get("/api/decks", req -> {
    try {
        String resBody = MAPPER.writeValueAsString(deckService.decks());
        return WebResponse.rawJson(resBody);
    } catch (JsonProcessingException e) {
        throw new IllegalStateException(e);
    }
});

app.post("/api/decks", req -> {
    NewDeck body;
    try (InputStream in = req.bodyStream()) {
        body = MAPPER.readValue(in, NewDeck.class);
    } catch (IOException e) {
        throw new HttpException(HttpStatus.BAD_REQUEST, "Malformed JSON body");
    }
    String resBody = MAPPER.writeValueAsString(deckService.create(body.name()));
    return WebResponse.rawJson(resBody);
});

들어오는 쪽에서는 body()가 아니라 bodyStream()을 통해 읽는다. 그러면 Jackson이 요청 스트림을 곧바로 파싱하고, 본문이 하나의 String으로 존재하는 일은 없다. bodyStream()과 bodyReader()는 본문 크기 한도 바깥에도 있으며, 이 한도는 body()와 bodyNdjson()이 담는 것만 제한한다.

핸들러가 둘 이상이 되면 두 try 블록을 App의 exception(JsonProcessingException.class, …​) 핸들러로 옮긴다.

Gson

static final Gson GSON = new Gson();

app.get("/api/decks", req -> {
    String resBody = GSON.toJson(deckService.decks());
    return WebResponse.rawJson(resBody);
});

app.post("/api/decks", req -> {
    NewDeck body = GSON.fromJson(req.bodyReader(), NewDeck.class);
    String resBody = GSON.toJson(deckService.create(body.name()));
    return WebResponse.rawJson(resBody);
});

Gson은 문자를 읽으므로, bodyReader()가 이음새에서 Gson이 맡는 절반이다. fromJson은 빈 본문에는 null을 반환하고 잘못된 형식의 본문에는 JsonSyntaxException을 던진다. 둘 다 애플리케이션이 답할 몫인데, 코어는 이 본문이 JSON이라는 것을 모르기 때문이다.

코드를 생성하는 바인더

프레임워크의 리플렉션 없음 규칙은 코어에 관한 것이고, 애플리케이션이 추가한 라이브러리는 스스로 책임진다. Jackson과 Gson은 둘 다 필드를 리플렉션으로 읽고 쓰는데, 이는 네이티브 이미지에서 실제 비용이 된다: 바인딩되는 모든 타입에 리플렉션 항목이 필요하다.

스스로를 바인딩해야 하는 타입에 대한 코어 자신의 답은 생성되는 코덱이다: 타입에 @JsonBound를 붙이면 빌드가 JsonCodec을 써내고, 핸들러는 그것을 다른 코덱처럼 지명한다. 코드를 생성하는 라이브러리는 프레임워크 바깥에서 같은 트레이드오프를 택한다.

avaje-jsonb는 그런 바인더이며, 런타임에 리플렉션을 쓰지 않는다:

@Json
public record Deck(long id, String name) { }
static final Jsonb JSONB = Jsonb.builder().build();
static final JsonType<Deck> DECK = JSONB.type(Deck.class);

app.get("/api/decks/{deckId}", req -> {
    String resBody = DECK.toJson(deckService.deck(req.pathParamLong("deckId")));
    return WebResponse.rawJson(resBody);
});

avaje-jsonb의 애너테이션 프로세서는 컴파일 시점에 타입마다 Java 소스 어댑터를 생성하고 ServiceLoader를 통해 등록한다. 리플렉션 폴백은 없다. 따라서 런타임은 리플렉션 없이 유지되고, 네이티브 이미지에는 바인딩되는 타입의 항목이 필요 없는데, 이는 jte가 템플릿에서 택하는 것과 같은 트레이드오프다.

지켜지지 않는 것은 리플렉션 없음 규칙 아래에 깔린 더 강한 약속이다: 와이어 포맷은 누군가 편집할 때만 바뀐다. 생성된 어댑터는 레코드의 컴포넌트에서 필드 이름을 끌어내므로, 컴포넌트 이름을 바꾸면 JSON 필드 이름도 바뀐다. 이 변경은 조용히, 컴파일 시점에, 여전히 성공하는 빌드 안에서 일어난다. 각 이름을 @Json.Property("…​")로 고정하면 그 보장이 돌아오는데, 타이핑 양은 대체한 손으로 작성한 라이터와 비슷하다. 이것이 트레이드오프이며, silk-json의 생성되는 코덱도 같은 조건으로 이를 택하되, 이름이 없으면 컴파일 에러로 만드는 옵션을 둔다. 어느 쪽을 택할지는 애플리케이션이 정한다.

DSL-JSON의 @CompiledJson도 같은 방식으로 동작하는데, 알아둘 만한 차이가 하나 있다. 프로세서가 보지 못한 타입에 대해 런타임 리플렉션 폴백을 유지하므로, 거기서 "리플렉션 없음"은 빌드의 속성이 아니라 지켜야 할 설정이다.

코어가 여전히 하는 일

라우팅, 파라미터 추출, 요청과 응답 봉투는 JSON 라이브러리와 무관하게 그대로다. 애플리케이션은 한 엔드포인트를 라이브러리로 바인딩하고 다음 엔드포인트를 손으로 작성할 수 있다. WebResponse.rawJson은 String을 받을 뿐 그것이 어디서 왔는지 묻지 않는다.

이 페이지의 스니펫은 spider-silk-jackson의 것을 빼면 예시이며 빌드의 일부가 아니다. Jackson에는 spider-silk-jackson만 의존하고, 이 프로젝트의 어느 모듈도 Gson이나 avaje-jsonb에 의존하지 않는다.