JSON Toolkit

JSON Lines와 NDJSON - 스트리밍 데이터

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

중급약 10분 소요2026-10-30 발행2026-10-30 업데이트
JSON Lines와 NDJSON 스트리밍 데이터 표지

핵심부터 말하면, 한 배치를 한 번에 처리하면 JSON 배열을 쓰고, 스트리밍 append와 불량 줄 격리가 필요하면 JSON Lines를 쓰세요. 전체 구조를 여전히 검증해야 한다면 줄 구분 형식으로 바꾸지 마세요.

데이터가 메모리를 넘어서거나 계속 들어오기 시작하면, "하나의 거대한 배열" 구조는 적이 됩니다.

1. 형식 규칙

JSON Lines(.jsonl)와 NDJSON(.ndjson)은 사실상 동의어이고, 규칙은 둘 다 짧습니다:

  1. 인코딩은 UTF-8, BOM 없음
  2. 각 줄에는 완전하고 유효한 JSON 값 하나
  3. 줄은 \n으로 구분하며, 마지막 줄은 개행으로 끝날 수도 있고 아닐 수도 있음
  4. 한 줄 안에 이스케이프되지 않은 줄 바꿈이 있어서는 안 됨. 문자열 안의 줄 바꿈은 \n으로 써야 함
  5. 빈 줄은 대개 무시하지만, 엄격한 구현은 무효로 처리해야 함
{"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' });
}

두 가지를 주의하세요:

  1. JSON.stringify 출력에는 줄 바꿈이 없습니다(문자열의 \n이 두 문자로 이스케이프된 경우 제외). 따라서 한 줄은 언제나 정확히 한 레코드입니다.
  2. 여러 프로세스가 동시에 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을 배열로 전처리하면 형식의 장점을 모두 버리므로, 데이터가 메모리에 들어가는 일회성 전달에만 합리적입니다.