대용량 JSON과 NDJSON
`WebResponse.jsonArray(…)`와 `WebResponse.ndjson(…)`은 한 번에 담기에 너무 큰 응답을 위해 요소를 만들어지는 대로 쓴다.
`WebResponse.json(list, JsonWriter.list(CARD))`는 모든 요소를 트리로 만들고, 그다음 그 전부를 담은 문자열 하나를 만들고, 그런 뒤에야 바이트를 쓴다. 따라서 클라이언트가 첫 카드를 보기 전에 십만 장의 카드가 메모리에 두 번 올라간다. 담을 수 있는 크기의 응답에는 그것이 맞는 거래다: 한 줄이면 되고, 길이를 알 수 있으므로 응답 크기를 지정할 수 있다. 담을 수 없는 응답에는 잘못된 거래다.
스트리밍되는 배열
`WebResponse.jsonArray(sink → …)`는 평범한 JSON 배열로 응답하되, 값을 하나씩 쓴다. 미디어 타입은 `application/json`이고, 문서는 통째로 담은 응답이 만들었을 것과 같다:
app.get("/api/decks/{deckId}/cards", req -> {
long deckId = req.pathParamLong("deckId");
return WebResponse.jsonArray(sink -> cardService.eachCard(deckId,
card -> sink.write(card, Codecs.CARD_ROW)));
});
대괄호와 쉼표는 응답의 몫이므로, 라이터는 다음 요소가 무엇인지만 말하고, 살아 있는 가장 큰 것은 요소 하나다.
아무것도 쓰지 않은 배열도 여전히 배열이다: [].
`sink.write(value, writer)`는 다른 모든 곳과 같은 손으로 작성한 `JsonWriter<T>`를 받는다. 이는 출력을 감싸는 다른 방식이지, 매핑하는 다른 방식이 아니다. 쓰기는 검사 예외인 `IOException`이 아니라 `UncheckedIOException`을 던지는데, 그 덕에 `card → sink.write(card, CARD)`가 평범한 `Consumer`가 되어 데이터베이스 커서를 도는 행 콜백 안에 들어갈 수 있다.
NDJSON
`WebResponse.ndjson(sink → …)`은 완전한 JSON 값을 한 줄에 하나씩 `application/x-ndjson`으로 쓴다:
app.get("/api/decks/{deckId}/cards.ndjson", req -> {
long deckId = req.pathParamLong("deckId");
return WebResponse.ndjson(sink -> cardService.eachCard(deckId,
card -> sink.write(card, Codecs.CARD_ROW)));
});
{"id":1,"text":"pomme","meaning":"apple","createdAt":"2026-03-01T09:00:00"}
{"id":2,"text":"poire","meaning":"pear","createdAt":"2026-03-01T09:01:00"}
대량 데이터에는 배열보다 NDJSON을 택하는데, 부분적인 응답으로 리더가 할 수 있는 일 때문이다. 각 줄이 독립적이므로 소비자는 십만 번째 레코드를 기다리지 않고 첫 번째 레코드를 처리하고, 중간에 끊긴 전송은 닫는 대괄호가 빠진 문서가 아니라 완전한 레코드들을 남긴다. 값은 결코 자체 개행을 포함할 수 없다: `Json`은 문자열 안의 개행을 `\n`으로 이스케이프하고 멤버 사이에 공백을 두지 않는다.
이것은 대량 전송이지 실시간 이벤트가 아니다. 데이터가 끝나면 끝나고, 컨테이너의 버퍼가 정하는 것 외에는 줄 사이에 아무것도 플러시하지 않는다. 나중에 더 일어날 일이 있어서 열린 채로 유지되는 스트림은 Server-Sent Events이며, 그쪽은 이벤트마다 플러시하고 재연결한다.
NDJSON 읽기
`req.bodyNdjson(reader)`는 NDJSON을 읽되, 지연 방식으로 읽는다. 본문은 스트림이 소비되는 대로 파싱되므로, 십만 개 레코드의 업로드가 리스트가 되는 일은 없다.
app.post("/api/decks/{deckId}/cards.ndjson", req -> {
long deckId = req.pathParamLong("deckId");
int imported = cardService.importCards(deckId, req.bodyNdjson(Codecs.CARD_DRAFT));
Json.JsonObject resBody = Json.obj().put("imported", imported);
return WebResponse.json(resBody);
});
빈 줄은 건너뛴다.
유효한 JSON이 아닌 줄이나, 리더가 IllegalArgumentException 또는 `DateTimeException`으로 거부한 줄은 그 줄을 지목하며 400으로 응답한다.
그것이 하나의 큰 배열 대신 NDJSON을 읽는 또 다른 이유다: 보고가 본문이 잘못되었다는 것만이 아니라 어디서 잘못되었는지를 말해 준다.
스트림이 지연 방식이므로, 그런 실패는 스트림이 소비되는 곳에서 일어난다.
응답을 반환하기 전에 스트림을 소비한다.
stream`이나 `ndjson 라이터 안에서는 핸들러 예외 처리가 이미 끝났으므로 거부가 더는 400을 만들지 않는다.
임포트 전체를 하나의 트랜잭션 안에서 읽는 것이 잘못된 줄을 안전하게 만든다: 예외가 서비스를 빠져나가고, 트랜잭션이 롤백되고, 클라이언트는 절반만 채워진 덱을 떠안는 대신 어느 줄인지 듣는다.
스트리밍 종류가 물려받는 것
둘 다 그 아래는 WebResponse.stream(…) 본문이다.
새로운 본문 종류가 없으므로, 서버, 필터, 압축 어느 것도 이들에 대해 새로 배울 필요가 없었다.
그 상속이 주의 사항 목록의 전부다:
-
라이터는 핸들러 예외 처리 바깥에서 실행된다. 커밋 전의 실패는 일반적인 500을 받는다. 커밋 후에는 상태를 바꿀 수 없고, 응답은 부분적일 수 있다. `RequestCompletion.failure()`는 상태와 무관하게 쓰기 실패를 보고한다. 없는 덱이나 권한 없는 호출자처럼 클라이언트가 들어야 할 방식으로 실패할 수 있는 것은 모두 응답을 반환하기 전에 둔다. 예제가 덱을 먼저 조회하는 것은 바로 이 이유 때문이다.
-
HEAD는 라이터를 실행한다. 본문을 만들어 봐야만 길이가 얼마였을지 알 수 있고, 라이터를 실행하는 것이 라이터가 연 것을 닫는 일이기도 하다.
-
gzip이 이들을 포함한다. `application/json`과 `application/x-ndjson`은 둘 다 `Gzip.DEFAULT_TYPES`에 있고, 스트리밍되는 본문은 `Content-Length`를 뺀 채 진행되는 대로 압축된다.
단일 대용량 문서 읽기
코어에는 스트리밍 *파서*가 없다. `req.bodyJson()`은 트리 전체를 만드는데, 손으로 작성한 `JsonReader`가 읽는 것이 트리이기 때문이다. 담기에 너무 큰 단일 JSON 문서에는 NDJSON을 쓴다: 한 줄에 값 하나면 하나로 담을 필요가 결코 없는 큰 본문이 된다. 포맷을 고를 수 없는 곳에서는 `req.bodyStream()`이 다른 라이브러리의 스트리밍 파서를 위해 바이트를 읽지 않은 채로 넘겨준다; 외부 JSON 바인딩을 참고한다.