외부 JSON 바인딩
애플리케이션은 원래부터 있던 두 메서드로 이루어진 이음새(seam)를 통해 자체 라이브러리로 JSON을 바인딩할 수 있다. 코어의 JSON은 의도적으로 손으로 작성한 것이지만, 그 결정은 프레임워크가 코어를 위해 내린 것이지 애플리케이션을 위해 내린 것이 아니다. DTO가 삼백 개인 애플리케이션이나, 소유하지 않은 스키마에서 와이어 포맷이 생성되는 애플리케이션은 라이브러리로 객체를 바인딩할 이유가 있다.
이음새
나가는 쪽에서는 `WebResponse.json(String rawJson)`이 이미 직렬화된 문서를 받는다. 들어오는 쪽에서는 `req.bodyStream()`과 `req.bodyReader()`가 본문을 읽지 않은 채로 넘겨준다.
public static WebResponse json(String rawJson) // whatever produced it
public InputStream bodyStream() // the bytes, unparsed
public BufferedReader bodyReader() // the characters, unparsed
이것이 통합의 전부다. 다른 라이브러리의 출력을 `JsonWriter<T>`로 흘려보내지 않는다: 그것은 `Json.JsonValue`를 반환하므로, 라이브러리가 방금 다 쓴 문서를 다시 파싱하게 된다. 라이브러리가 직렬화하고, `json(String)`이 그 결과를 보낸다.
본문은 한 가지 방식으로만 읽는다: body()`가 보관하는 텍스트로 읽거나, `bodyStream(), bodyReader(), bodyNdjson()`을 통해 읽지 않은 채로 넘겨받는다.
`body()`는 텍스트를 한 번 읽어 요청이 끝날 때까지 보관하므로, 어느 필터나 핸들러가 먼저 요청했든 `bodyJson()`과 두 번째 `body()`는 같은 본문을 답한다.
텍스트를 읽은 요청은 `bodyStream(), bodyReader(), bodyNdjson()`에서 `IllegalStateException`을 던지는데, 그 아래의 리더가 이미 끝에 도달했기 때문이다.
읽지 않은 채로 넘겨준 본문은 `body()`와 `bodyJson()`에서 `IllegalStateException`을 던지는데, 얼마나 소비되었는지 아무도 말할 수 없기 때문이다.
스트림과 리더도 서로 배타적인데, 이는 서블릿 API 자체의 규칙이다.
핸들러가 `raw()`를 통해 읽는 것은 이 기록을 거치지 않는다.
폼 인코딩된 POST도 첫 `param() 읽기로 소진되는데, 컨테이너가 본문을 끝까지 읽어 폼을 파싱하기 때문이다.
그 뒤 `bodyStream()`은 예외를 던지는 대신 빈 스트림을 답하고, 바이트로 먼저 읽은 본문은 `formParam()`에 아무 필드도 남기지 않는다.
Jackson
static final ObjectMapper MAPPER = new ObjectMapper();
app.get("/api/decks", req -> {
try {
String resBody = MAPPER.writeValueAsString(deckService.decks());
return WebResponse.json(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.json(resBody);
});
들어오는 쪽에서는 `body()`가 아니라 `bodyStream()`을 통해 읽는다. 그러면 Jackson이 소켓을 파싱하고, 요청이 하나의 String으로 존재하는 일은 없다.
핸들러가 둘 이상이 되면 두 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.json(resBody);
});
app.post("/api/decks", req -> {
NewDeck body = GSON.fromJson(req.bodyReader(), NewDeck.class);
String resBody = GSON.toJson(deckService.create(body.name()));
return WebResponse.json(resBody);
});
Gson은 문자를 읽으므로, `bodyReader()`가 이음새에서 Gson이 맡는 절반이다. `fromJson`은 빈 본문에는 null을 반환하고 잘못된 형식의 본문에는 `JsonSyntaxException`을 던진다. 둘 다 애플리케이션이 답할 몫인데, 코어는 이 본문이 JSON이라는 말을 들은 적이 없기 때문이다.
코드를 생성하는 바인더
프레임워크의 리플렉션 없음 규칙은 코어에 관한 것이고, 애플리케이션이 추가한 라이브러리는 스스로 책임진다. Jackson과 Gson은 둘 다 필드를 리플렉션으로 읽고 쓰는데, 이는 네이티브 이미지에서 실제 비용이 된다: 바인딩되는 모든 타입에 리플렉션 항목이 필요하다.
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.json(resBody);
});
그 애너테이션 프로세서는 컴파일 시점에 타입마다 Java 소스 어댑터를 생성하고 `ServiceLoader`를 통해 등록한다. 리플렉션 폴백은 없다. 따라서 런타임은 리플렉션 없이 유지되고, 네이티브 이미지에는 바인딩되는 타입의 항목이 필요 없는데, 이는 jte가 템플릿에서 하는 것과 같은 거래다.
지켜지지 않는 것은 리플렉션 없음 규칙 아래에 깔린 더 강한 약속이다: 와이어 포맷은 누군가 편집할 때만 바뀐다. 생성된 어댑터는 레코드의 컴포넌트에서 필드 이름을 끌어내므로, 컴포넌트 이름을 바꾸면 JSON 필드 이름도 바뀐다. 그것도 조용히, 컴파일 시점에, 여전히 성공하는 빌드 안에서 바뀐다. 각 이름을 `@Json.Property("…")`로 고정하면 그 보장이 돌아오는데, 타이핑 양은 대체한 손으로 작성한 라이터와 비슷하다. 그것이 거래다. 어느 쪽으로 결론이 나든 애플리케이션이 정할 일이고, 코어는 어느 쪽에도 관여하지 않는다.
DSL-JSON의 `@CompiledJson`도 같은 방식으로 동작하는데, 알아둘 만한 차이가 하나 있다. 프로세서가 보지 못한 타입에 대해 런타임 리플렉션 폴백을 유지하므로, 거기서 "리플렉션 없음"은 빌드의 속성이 아니라 지켜야 할 설정이다.