대용량 JSON과 NDJSON
WebResponse.jsonArray(…)와 WebResponse.ndjson(…)은 한 번에 담기에 너무 큰 응답을 위해 요소를 만들어지는 대로 쓴다.
WebResponse.json(list, JsonWriter.list(CARD))는 모든 요소를 버퍼 하나에 쓰고, 그런 뒤에야 바이트를 보낸다.
따라서 클라이언트가 첫 카드를 보기 전에 십만 장의 카드가 문서 하나로 메모리에 올라간다.
담을 수 있는 크기의 응답에는 이 방식이 알맞다: 한 줄이면 되고, 길이를 알 수 있으므로 응답에 Content-Length가 붙는다.
담을 수 없는 응답에는 알맞지 않다.
스트리밍되는 배열
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으로 이스케이프하고 멤버 사이에 공백을 두지 않는다.
여기서 NDJSON은 대량 전송이지 실시간 이벤트가 아니다. 응답은 데이터가 끝나면 끝나고, 컨테이너의 버퍼가 정하는 것 외에는 줄 사이에 아무것도 플러시하지 않는다. 나중에 더 일어날 일이 있어서 열린 채로 유지되는 스트림은 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));
JsonObject resBody = Json.object().put("imported", imported);
return WebResponse.json(resBody);
});
빈 줄은 건너뛴다: 비어 있는 줄, 그리고 JSON이 허용하는 공백인 스페이스, 탭, CR로만 된 줄이다.
폼 피드처럼 그 밖의 공백으로 된 줄은 유효한 JSON이 아닌 줄이다.
유효한 JSON이 아닌 줄이나, 리더가 IllegalArgumentException 또는 DateTimeException으로 거부한 줄은 그 줄을 지목하며 400으로 응답한다.
이것이 하나의 큰 배열 대신 NDJSON을 읽는 또 다른 이유다: 오류 보고가 본문이 잘못되었다는 사실만이 아니라 어디서 잘못되었는지를 말해 준다.
1MB를 넘는 줄은 그 줄을 지목하며 413으로 응답하고, 한도를 넘어서는 즉시 거부된다.
줄 수에는 한도가 없으며, 줄 한도를 바꾸는 방법은 본문 크기 한도에서 설명한다.
스트림이 지연 방식이므로, 그런 실패는 스트림이 소비되는 곳에서 일어난다.
응답을 반환하기 전에 스트림을 소비한다.
stream이나 ndjson 라이터 안에서는 핸들러 예외 처리가 이미 끝났으므로 거부가 더는 400을 만들지 않는다.
임포트 전체를 하나의 트랜잭션 안에서 읽으면 잘못된 줄이 안전해진다: 예외가 서비스를 빠져나가고, 트랜잭션이 롤백되고, 클라이언트는 절반만 채워진 덱을 떠안는 대신 어느 줄이 잘못되었는지 듣는다.
스트리밍 종류가 물려받는 것
둘 다 그 아래는 WebResponse.stream(…) 본문이다.
새로운 본문 종류가 없으므로, 서버, 필터, 압축 어느 것도 이들을 위해 바뀔 필요가 없었다.
주의 사항은 모두 이 상속에서 나온다:
-
라이터는 핸들러 예외 처리 바깥에서 실행된다. 커밋 전의 실패는 일반적인 500을 받는다. 커밋 후에는 상태를 바꿀 수 없으므로, 대신 전송이 중단된다. 클라이언트는 완전해 보이는 부분 응답이 아니라 불완전한 응답을 받는다.
RequestCompletion.writeFailure()는 상태와 무관하게 쓰기 실패를 보고한다. 없는 덱이나 권한 없는 호출자처럼 클라이언트가 들어야 할 방식으로 실패할 수 있는 것은 모두 응답을 반환하기 전에 둔다. 예제가 덱을 먼저 조회하는 것은 바로 이 이유 때문이다. -
HEAD는 라이터를 실행한다. 본문을 만들어 봐야만 길이를 알 수 있고, 라이터가 연 것도 라이터를 실행해야 닫힌다.
-
gzip이 이들을 포함한다.
application/json과application/x-ndjson은 둘 다Gzip.DEFAULT_TYPES에 있고, 스트리밍되는 본문은Content-Length를 뺀 채 진행되는 대로 압축된다.
단일 대용량 문서 읽기
req.bodyJson(reader)는 본문을 1MB 본문 한도 안에서 바이트로 담으므로, 더 큰 문서는 bodyLimits로 한도를 올리지 않는 한 413으로 응답한다.
JsonInput을 상대로 작성하거나 타입에서 생성한 리더는 그 바이트를 한 번에 값 하나씩 읽으며 트리를 만들지 않고, req.bodyJson()과 JsonReader.object는 손으로 작성한 리더가 읽는 트리를 만든다.
담기에 너무 큰 단일 JSON 문서에는 NDJSON을 쓴다: 한 줄에 값 하나씩인 본문은 아무리 커도 스트리밍 파서가 필요 없다.
포맷을 고를 수 없는 곳에서는 외부 JSON 바인딩에서 설명하듯 req.bodyStream()이 읽지 않은 바이트를 다른 라이브러리의 스트리밍 파서에 넘겨준다.