JSON Lines와 NDJSON - 스트리밍 데이터
핵심 요약: 한 줄에 JSON 값 하나를 두면 이어 붙이기, 스트리밍, 한 줄 단위의 피해 격리가 가능해집니다. 대가는 전체 구조의 부재이며, 검증도 줄 단위로 해야 합니다.

핵심부터 말하면, 한 배치를 한 번에 처리하면 JSON 배열을 쓰고, 스트리밍 append와 불량 줄 격리가 필요하면 JSON Lines를 쓰세요. 전체 구조를 여전히 검증해야 한다면 줄 구분 형식으로 바꾸지 마세요.
데이터가 메모리를 넘어서거나 계속 들어오기 시작하면, "하나의 거대한 배열" 구조는 적이 됩니다.
1. 형식 규칙
JSON Lines(.jsonl)와 NDJSON(.ndjson)은 사실상 동의어이고, 규칙은 둘 다 짧습니다:
- 인코딩은 UTF-8, BOM 없음
- 각 줄에는 완전하고 유효한 JSON 값 하나
- 줄은
\n으로 구분하며, 마지막 줄은 개행으로 끝날 수도 있고 아닐 수도 있음 - 한 줄 안에 이스케이프되지 않은 줄 바꿈이 있어서는 안 됨. 문자열 안의 줄 바꿈은
\n으로 써야 함 - 빈 줄은 대개 무시하지만, 엄격한 구현은 무효로 처리해야 함
{"time":"2026-10-30T10:41:45+08:00","level":"info","message":"boot"}
{"time":"2026-10-30T10:41:46+08:00","level":"warn","message":"slow query","costMs":812}
{"time":"2026-10-30T10:41:47+08:00","level":"error","message":"timeout","traceId":"a1b2"}
두 형식의 미묘한 차이: JSON Lines 웹사이트는 "한 줄에 값 하나, 값은 어떤 JSON이든 가능(스칼라 포함)"을 강조하고, NDJSON 명세는 "빈 줄 금지, 최상위 스칼라 금지" 같은 더 엄격한 구현 규칙을 더합니다. 엔지니어링에서는 더 엄격한 쪽을 따르면 문제가 없습니다.
2. JSON 배열과의 비교
| 관점 | JSON 배열 | JSON Lines |
|---|---|---|
| 레코드 하나 추가 | 파일 전체를 다시 씀(닫는 ] 변경) |
한 줄만 append |
| 스트리밍 읽기 | 괄호 깊이를 추적하는 스트리밍 파서 필요 | 줄 단위로 분리 |
| 손상된 항목 하나 | 파일 전체가 파싱 불가 | 해당 줄만 영향 |
| 파일 전체 검증 | 한 번에 전부 검증 | 줄 단위로만 |
| 압축 | gzip 후 크기 비슷 | 동일하며 블록 단위 병렬 압축 가능 |
| 중첩 표현 | 기본 지원 | 각 줄 내부는 중첩 가능. 줄끼리는 무관 |
| 대표 소비자 | 모든 JSON 도구 | 로그 시스템, 빅데이터 파이프라인, LLM 학습 데이터셋 |
결론은 분명합니다. 데이터가 "레코드 묶음"인지 "하나의 문서"인지가 형식을 결정합니다.
3. 읽기: 줄 단위 스트리밍
Node에서 가장 간단한 방법은 readline입니다:
import { createReadStream } from 'node:fs';
import { createInterface } from 'node:readline';
/**
* Reads a JSON Lines file line by line and invokes a callback for each record.
* @param filePath Path to the file
* @param onRecord Handler for a single record
* @returns Statistics containing the number of successful and failed lines
*/
async function readJsonLines(
filePath: string,
onRecord: (record: unknown, lineNumber: number) => void,
): Promise<{ okCount: number; badCount: number }> {
const lineReader = createInterface({
input: createReadStream(filePath, { encoding: 'utf8' }),
crlfDelay: Infinity,
});
let okCount = 0;
let badCount = 0;
for await (const rawLine of lineReader) {
const line = rawLine.trim();
if (!line) {
continue;
}
try {
onRecord(JSON.parse(line), okCount + badCount + 1);
okCount += 1;
} catch {
badCount += 1;
}
}
return { okCount, badCount };
}
이 코드의 메모리 사용량은 전체 파일 크기가 아니라 단일 줄 크기에만 의존합니다. 줄 구분 형식의 최대 가치입니다.
3.1 필드 몇 개만 필요하다면 먼저 JSON.parse를 하지 말 것
레코드마다 50개의 필드가 있고 3개만 필요하다면, JSON.parse는 50개 전부에 대한 객체를 만듭니다.
스트리밍 파서(stream-json, clarinet)는 대상 필드만 추출할 수 있지만 구현 복잡도가 높아집니다.
경험 법칙: 단일 레코드가 수백 KB 미만이면 JSON.parse를 직접 호출하는 쪽이 대개 더 이득입니다.
3.2 중국어 텍스트와 BOM
Windows에서 생성한 .jsonl 파일에는 BOM이 붙는 경우가 많고, 첫 줄의 JSON.parse가 반드시 실패합니다. 읽기 전에 BOM을 제거하거나, 첫 청크를 스트리밍할 때 앞 세 바이트를 검사하세요.
4. 쓰기: append와 원자성
import { appendFile } from 'node:fs/promises';
/**
* Appends a single JSON Lines record.
* @param filePath Target file
* @param record The record object to write
* @returns A promise that resolves when the write completes
*/
async function appendRecord(filePath: string, record: unknown): Promise<void> {
const line = `${JSON.stringify(record)}\n`;
await appendFile(filePath, line, { encoding: 'utf8' });
}
두 가지를 주의하세요:
JSON.stringify출력에는 줄 바꿈이 없습니다(문자열의\n이 두 문자로 이스케이프된 경우 제외). 따라서 한 줄은 언제나 정확히 한 레코드입니다.- 여러 프로세스가 동시에 append할 때 PIPE_BUF(리눅스에서는 대개 4096바이트)보다 작은 단일
write는 원자적이지만, 더 큰 것은 보장되지 않습니다. 고동시성 시나리오에서는 "프로세스마다 파일 하나, 디스크 반영 후 병합"으로 바꾸거나 트랜잭션을 지원하는 스토리지를 쓰세요.
5. 불량 줄 처리
줄 구분 형식이 미리 결정하게 만드는 선택입니다:
| 전략 | 적합한 데이터 | 비용 |
|---|---|---|
| 건너뛰고 집계 | 로그, 분석 이벤트처럼 공백을 견디는 데이터 | 데이터가 조용히 손실되므로 알림이 필수 |
| 즉시 실패 | 결제, 청구서처럼 손실되어서는 안 되는 데이터 | 불량 줄 하나가 전체 배치를 막음 |
| 데드레터 파일로 보내기 | 나중에 백필이 필요한 경우 | 추가 스토리지와 재처리 절차 |
어느 쪽을 골라도 줄 번호와 원본 내용의 일부를 기록하세요. 그렇지 않으면 사후에 문제를 찾을 수 없습니다. JSON.parse 오류 메시지에는 줄 번호가 없으므로 직접 유지해야 합니다.
6. 압축과 샤딩
- gzip은
.jsonl을 일관되게 높은 비율로 압축합니다(필드 이름이 반복되므로)..jsonl.gz를 권장합니다. - gzip은 블록 단위로 압축하고 스트리밍 해제를 기본 지원하므로 파일 전체를 해제할 필요가 없습니다.
- 큰 파일은 대략
1GB단위로 나누고 이름에 시퀀스 번호를 넣으면 병렬 처리와 이어받기 전송이 쉬워집니다. .jsonl을 "배열로 병합"하는 전처리는 절대 하지 마세요. 이점을 모두 버리는 일입니다.
7. 다른 빅데이터 형식과의 장단점
| 형식 | 구조 | 스키마 포함 | 적합한 용도 |
|---|---|---|---|
| JSON Lines | 줄마다 독립 | 없음(외부 계약 필요) | 로그, 가져오기/내보내기, 학습 데이터 |
| CSV | 표 형식 | 없음 | 리포트, Excel 교환 |
| Parquet | 컬럼 지향 | 있음 | 분석 쿼리, 초대형 오프라인 데이터셋 |
| Avro | 행 지향 | 있음 | Kafka 메시지, 스키마 진화 |
JSON Lines의 장점은 어떤 언어, 어떤 도구라도 즉시 읽고 쓸 수 있다는 것이고, 단점은 스키마와 컬럼 지향 압축의 부재입니다. 데이터가 한 대의 머신에 맞지 않고 컬럼 지향 쿼리가 빈번히 필요해지면 Parquet를 고려할 때입니다.
8. 체크리스트
- [ ] 파일 확장자가 일관되며(
.jsonl또는.ndjson) 섞이지 않습니다 - [ ] 작성기가 "한 줄에 레코드 하나"를 보장하고, 문자열 내부 줄 바꿈이 이미 이스케이프되어 있습니다
- [ ] 읽기가 BOM을 제거하고, 빈 줄을 건너뛰며, 줄 번호를 유지합니다
- [ ] 불량 줄 전략이 명시되고 모니터링됩니다(불량 줄 / 전체 줄)
- [ ] 큰 파일은 크기 단위로 나누고 gzip과 병렬 읽기를 함께 사용합니다
- [ ] 필드 계약이 있는 곳에서는 추가 스키마를 제공하고 줄 단위로 검증합니다
FAQ
JSON 배열과 JSON Lines 중 어떻게 고르나?
데이터가 레코드 묶음인지 하나의 문서인지 보세요. 레코드가 추가되고 스트림으로 읽힌다면 JSON Lines를 고르세요. append는 파일 끝에 한 줄을 쓰는 일이고, 읽기는 줄 단위 분리만 하면 됩니다. 전체 구조를 한 번에 검증해야 한다면 JSON 배열을 유지하세요. JSON 배열은 전부를 한 번에 검증하지만 JSON Lines는 줄 단위로만 검증할 수 있습니다.
손상된 한 줄이 왜 파일 전체를 깨뜨리지 않나?
JSON Lines의 각 줄은 독립적이고 완전한 JSON 값이며 파싱이 자연스럽게 줄 단위로 이루지므로, 손상된 데이터는 해당 줄에만 영향을 줍니다. JSON 배열에서는 문법 오류 하나가 파일 전체를 파싱 불가로 만들며, 줄 구분 데이터가 로그와 분석 이벤트에 맞는 이유가 바로 여기에 있습니다.
JSON Lines를 JSON 배열로 변환해야 하나?
가능합니다. 줄 단위로 읽고 각 레코드에 JSON.parse를 호출해 배열에 모으면 됩니다. 그러려면 전체 내용이 메모리에 들어가야 하고 스트리밍 읽기와 한 줄 append를 포기해야 합니다. .jsonl을 배열로 전처리하면 형식의 장점을 모두 버리므로, 데이터가 메모리에 들어가는 일회성 전달에만 합리적입니다.