본문으로 건너뛰기
jsonbeautifiers
한국어

NDJSON과 JSON Lines - 한 줄에 레코드 하나

거대한 JSON 배열은 스트리밍도, 덧붙이기도, 부분 복구도 되지 않습니다. 한 줄에 JSON 값 하나가 셋 다 해결합니다.

이 페이지의 모든 주장은 직접 측정했거나 출처가 있습니다. 둘 다 아닌 경우에는 그렇다고 명시합니다.

내보내기 파일을 내려받았는데 확장자는 .json이고, 첫 두 줄은 이렇습니다.

{"ts":"2026-09-01T10:00:00Z","level":"info","msg":"started"}
{"ts":"2026-09-01T10:00:01Z","level":"warn","msg":"retry 1"}

각 줄은 유효한 JSON입니다. 파일은 아닙니다. 여는 대괄호도, 쉼표도, 닫는 대괄호도 없으므로 전체를 향해 들이대는 어떤 파서든 두 번째 레코드에서 실패합니다. 이것이 NDJSON이고, 고른 것은 여러분이 아닙니다. 파일을 만든 쪽이 골랐습니다. 대안은 그 파일이 도달할 크기에서 작동하지 않기 때문입니다.

형식, 정확히

  • 한 줄에 완결된 JSON 값 하나. 보통은 객체지만, 맨 숫자나 문자열도 합법입니다.
  • UTF-8, 바이트 순서 표식 없이.
  • 레코드는 줄바꿈으로 구분합니다. 대부분의 읽는 쪽은 CRLF도 참아 주지만, 여러분이 내보내서는 안 됩니다.
  • 빈 줄은 무시되므로 파일 끝의 줄바꿈은 문제없고, 오히려 관행입니다.
  • 감싸는 배열 없음. 레코드 사이의 쉼표 없음.
  • 확장자는 .ndjson.jsonl. 다만 세상의 많은 파일은 .json이나 .log라는 이름을 달고 있습니다.

이 형식이 무너지지 않는 이유는 JSON 문자열의 성질에 있습니다. JSON 문자열은 문자 그대로의 줄바꿈을 담을 수 없습니다. U+0020 미만의 제어 문자는 반드시 이스케이프해야 하므로, 제대로 직렬화된 레코드에는 날것의 \n이 들어갈 수 없습니다. 바로 그것이 “줄바꿈으로 자르기”를 추측이 아니라 안전한 토큰화로 만들어 줍니다.

NDJSON인가 JSON Lines인가

같은 것입니다. 작은 명세 둘이 따로 쓰였을 뿐, 파일이 파싱되는지를 결정하는 모든 지점에서 서로 일치합니다. 한 줄에 JSON 값 하나, UTF-8, 줄바꿈 구분. JSON Lines 쪽은 .jsonl 확장자를 선호하고 NDJSON 쪽은 .ndjson을 선호하며, 모든 읽는 쪽이 둘 다 받아들입니다. 두 문서 중 무엇도 여러분 코드를 한 줄도 바꾸지 않습니다. 어느 쪽을 만들고 있냐고 물으면, 정직한 답은 “둘 다”입니다.

단일 배열이 하지 못하는 세 가지

흘려보내기. JSON 배열은 값 하나이므로, 통상적인 파서는 전체를 붙들고 나서야 무언가를 건네줍니다. 문서 트리를 짓는 데는 입력 크기의 몇 배에 달하는 힙이 듭니다. 이 사이트의 파서에서는 10MB 문서에 대략 294MB. 브라우저에서는 그보다 앞서 더 단단한 벽에 부딪힙니다. JavaScript 엔진이 단일 문자열을 536,870,888자, 약 512MB로 제한하기 때문입니다. 그보다 큰 것은 텍스트로 메모리에 읽어 들이는 것조차 안 되고, 파싱은 그다음 이야기입니다. NDJSON에는 그 천장이 없습니다. 한 번에 붙들고 있는 것이 레코드 하나뿐이니까요. 파서 층위의 이야기는 대용량 JSON 파일 다루기에 있습니다.

덧붙이기. JSON 배열에 레코드를 더한다는 것은, 닫는 대괄호까지 되돌아가 쉼표를 쓰고, 레코드를 쓰고, 대괄호를 다시 쓰는 일입니다. 두 쓰는 쪽이 동시에 그러면 쓰레기가 나옵니다. NDJSON에 덧붙이는 일은 파일 끝에 한 번 쓰는 것이고 앞서 읽을 것이 없습니다. 세상의 모든 로그 전송 도구가 이 위에 지어진 이유가 정확히 그것입니다.

손상을 견디기. JSON 배열을 어디서든 잘라 내면 문서 전체를 잃습니다. Unexpected end of JSON input이 나오고 건진 레코드는 없습니다. NDJSON을 잘라 내면 마지막 줄만 잃습니다. 망가진 레코드의 값은 레코드 하나이고, 줄 단위로 예외를 잡는 읽는 쪽은 그대로 나아갑니다.

이미 마주친 자리들

도커의 기본 로그 드라이버 json-file. 컨테이너마다 한 줄에 JSON 객체 하나를 씁니다. 엘라스틱서치의 _bulk API. 그 변종을 쓰는데, 액션 줄 다음에 문서 줄이 오고 끝의 줄바꿈을 요구합니다. BigQuery 적재 작업. 소스 형식 이름이 말 그대로 NEWLINE_DELIMITED_JSON입니다. ClickHouse의 JSONEachRow. jq -c가 쓰는 모든 것. LLM 완성을 포함한 스트리밍 API는 대개 같다기보다 이웃입니다. server-sent events는 data: 줄마다 JSON 값 하나를 나르지만 자기 나름의 틀을 덧씌우므로, SSE 스트림은 NDJSON 파일이 아닙니다. 안에 실린 페이로드가 그렇다 해도요.

틀렸을 때의 모습

위의 두 레코드 예시를 문서 하나로 파싱하면, 메시지가 즉시 알아볼 만큼 구체적입니다.

Node(V8):

Unexpected non-whitespace character after JSON at position 61 (line 2 column 1)

파이썬:

Extra data: line 2 column 1 (char 61)

둘 다 같은 뜻입니다. 완결된 JSON 값 하나를 성공적으로 파싱했는데 입력이 계속 이어졌다는 것. 그걸 쫓고 있다면 파이썬의 Extra data가 변종들을 다룹니다.

반대 방향의 실수도 그만큼 흔합니다. 들여쓴 문서를 NDJSON 읽는 쪽에 건네면, 첫 줄인 {만 따로 파싱하려 듭니다.

# Node:   Expected property name or '}' in JSON at position 1 (line 1 column 2)
# 파이썬: Expecting property name enclosed in double quotes: line 1 column 2 (char 1)

줄 단위로 읽을 때마다 1행 2열에서 나는 오류는, 쓰기 전에 들여쓰기가 된 문서의 서명입니다.

읽고 쓰기

파이썬. 파일 핸들을 순회하세요. 메모리로 읽어 들이지 마세요.

import json

with open("events.ndjson", encoding="utf-8") as f:
    for n, line in enumerate(f, 1):
        line = line.strip()
        if not line:
            continue
        try:
            record = json.loads(line)
        except json.JSONDecodeError as e:
            print(f"line {n}: {e}")

형식을 망가뜨리는 쪽은 쓰기이고, 결국 인자 하나의 문제입니다.

with open("out.ndjson", "w", encoding="utf-8") as f:
    for record in records:
        f.write(json.dumps(record, separators=(",", ":"), ensure_ascii=False) + "\n")

separators=(",", ":")json.dumps가 기본으로 넣는 공백을 없앱니다. indent=는 절대 넘기지 마세요. 레코드 안쪽에 줄바꿈을 뱉어 파일을 부숩니다. ensure_ascii=False는 선택 사항으로, 비ASCII 문자를 \uXXXX 이스케이프 대신 그대로 남깁니다. 기본값은 True이고, 유효하지만 더 큽니다.

Node. readline이 버퍼 경계를 처리하고, crlfDelay: Infinity는 두 청크에 걸쳐 쪼개진 \r\n이 줄바꿈 둘로 읽히는 것을 막습니다.

import { createReadStream } from "node:fs";
import { createInterface } from "node:readline";

const rl = createInterface({
  input: createReadStream("events.ndjson", "utf8"),
  crlfDelay: Infinity,
});

let n = 0;
for await (const line of rl) {
  n++;
  if (!line.trim()) continue;
  try {
    handle(JSON.parse(line));
  } catch (e) {
    console.error(`line ${n}: ${e.message}`);
  }
}

Go는 기본값이 이미 옳습니다. json.NewEncoder(w).Encode(v)는 압축된 레코드를 쓰고 줄바꿈을 덧붙입니다. <, >, &가 이스케이프로 바뀌는 걸 원치 않으면 SetEscapeHTML(false)를 부르세요.

jq는 공백으로 구분된 값들의 스트림을 그대로 읽습니다. -c는 한 줄에 압축된 값 하나를 내보내고, -s는 스트림을 배열 하나로 빨아들입니다.

jq -c '.[]' big-array.json > events.ndjson   # 배열에서 NDJSON으로
jq -s '.'   events.ndjson  > big-array.json  # NDJSON에서 배열로
jq -c 'select(.level == "warn")' events.ndjson

pandas는 양쪽에서 lines=True를 받고, chunksize는 읽기를 프레임의 이터레이터로 바꿔 파일을 통째로 실체화하지 않게 해 줍니다.

import pandas as pd

df = pd.read_json("events.ndjson", lines=True)
df.to_json("out.ndjson", orient="records", lines=True)

for chunk in pd.read_json("events.ndjson", lines=True, chunksize=50_000):
    ...

사람들이 깨는 규칙

단단한 규칙은 하나뿐입니다. 레코드 하나가 정확히 한 줄을 차지한다는 것, 곧 모든 레코드가 압축되어 있어야 한다는 것입니다. 포매터에서 NDJSON을 만든다면 파일이 아니라 레코드마다 압축하세요. 파일은 줄바꿈으로 끝내세요. 읽는 쪽은 빈 줄을 건너뛰고, 어떤 소비자는 종결자를 요구하며, cat a.ndjson b.ndjson은 두 파일 모두에 줄바꿈이 있어야 제대로 동작합니다.

줄 규율이 주는, 과소평가된 이득이 하나 더 있습니다. 이제 그 파일은 여러분이 이미 쓰는 도구들이 이해하는 텍스트입니다. wc -l이 레코드를 세고, grep이 걸러 내고, sortdiff가 동작하고, split이 파서 없이 파일을 쪼갭니다. 들여쓴 배열은 그중 무엇도 주지 않고, 그래서 내보내기 두 개를 비교하는 일이 보통 둘 다 구조 비교에 올리는 작업이 됩니다.

쓰지 말아야 할 때

브라우저가 한 덩어리로 소비하는 모든 것. fetch(...).then(r => r.json())은 NDJSON을 읽지 못하고, <script type="application/json"> 블록도 마찬가지입니다. 하나의 유효한 문서여야 하는 모든 것. 설정 파일, API 응답 본문, 스키마로 검증하는 페이로드, 뷰어에 넘겨 살펴볼 파일 같은 것들이죠. NDJSON은 레코드 흐름을 위한 전송·저장 형식이지 문서 형식이 아닙니다.

그 선을 넘어야 할 때는 손으로 고치지 말고 변환하세요. NDJSON → JSON 도구는 브라우저 안에서 양방향으로 변환하고, 어떤 레코드가 실패하면 파일 전체를 실패시키는 대신 그것이 몇 번째 줄이었는지 알려 줍니다.