JSONL 파일 실전 가이드: 열기, 변환, 깨진 줄 찾기
핵심 요약: .jsonl 파일 전체를 JSON.parse에 넣고 에러가 난다면 파일이 깨진 게 아니라 읽는 방식이 틀렸을 가능성이 높습니다. 이 글은 확인과 한 줄씩 읽기의 표준 패턴, JSON 배열과의 상호 변환 명령, 여섯 가지 깨진 줄의 진단 표, 스트리밍 API에서 NDJSON을 소비하는 올바른 방법을 다룹니다.

결론: JSONL의 각 줄은 독립적으로 유효한 JSON이지만, 파일 전체는 하나의 JSON가 아닙니다. 파일 전체를 JSON.parse나 json.load에 넣어 실패한다면 십중팔구 파일이 깨진 게 아니라 읽는 방식이 틀렸습니다.
한 줄에 한 레코드, 줄 사이 쉼표 없음, 바깥 배열 없음. JSONL은 "전체 구조를 포기하는" 대신 추가 쓰기, 스트리밍 읽기, 한 줄 단위 장애 격리라는 세 가지 이점을 얻습니다. 이 이점들은 모두 "줄 단위로 처리할 때" 비로소 실현되고, "파일 전체를 한 번에 파싱하는 순간" 일제히 에러로 바뀝니다.
1. 세 가지 이름, 하나의 형식: JSONL, NDJSON, JSON Lines
입문자는 이름 차이를 문법 차이로 오해하기 쉽지만 그렇지 않습니다.
| 이름 | 주로 쓰이는 곳 | 확장자 / 미디어 타입 |
|---|---|---|
| JSON Lines | 데이터 엔지니어링, ML 데이터셋(pandas, Spark, Hugging Face) | .jsonl |
| NDJSON | HTTP 스트리밍 API, 실시간 데이터 | .ndjson, application/x-ndjson |
| JSONL | 어느 쪽의 줄임말로도 쓰이는 통칭 | — |
한쪽을 읽을 수 있는 도구는 다른 쪽도 읽습니다. 이름을 고르는 기준은 하위 생태계의 관습뿐입니다. Hugging Face 데이터셋에 넣을 거면 .jsonl, 스트리밍 API를 제공한다면 application/x-ndjson을 지정하세요. 이식성 좋은 JSONL 파일은 세 가지 규칙을 충족합니다. UTF-8 인코딩(BOM 없음), 한 줄에 하나의 완전한 JSON 값, 줄은 \n으로 구분. 파일 끝의 개행은 강하게 권장되며 빈 줄은 유효한 레코드가 아닙니다. 프로토콜 측면의 세부와 설계 의도는 JSON Lines와 NDJSON 개요에 맡기고, 이 글은 "파일을 손에 넣은 다음 무엇을 할까"에만 집중합니다.
2. 확인하는 법: 먼저 head, 다음 grep, 에디터는 마지막에
500 MB짜리 .jsonl을 더블클릭으로 에디터에 여는 것이 가장 흔한 실수입니다. 300만 줄 텍스트에 문법 강조와 Undo 버퍼가 얹히면 거의 모든 에디터가 멈춥니다. 이건 JSON의 탓이 아닙니다. 45 MB 문서의 파싱 자체는 Node 22 기준 약 208밀리초입니다. 멈추는 건 렌더링이죠. 레코드와 물리적 줄이 일치하는 줄 지향 형식은 줄 도구를 위해 존재합니다.
# 먼저 규모 파악: 내용을 읽지 않고 레코드 수와 파일 크기 확인
wc -l events.jsonl && du -h events.jsonl
# 레코드의 모양 보기(필드와 구조 확인)
head -n 1 events.jsonl | jq .
# 원하는 레코드 바로 찾기: 줄 도구가 그대로 동작하는 것은 JSONL의 특권
grep '"level":"error"' events.jsonl | head -n 5
tail -n 100 events.jsonl | jq 'select(.latency_ms > 500)'
마지막 명령에 주목하세요. tail에서 나오는 각 줄은 여전히 독립적인 JSON 값이라 배열을 다시 만들 필요 없이 그대로 jq에 흘릴 수 있습니다. 이 방식은 수 GB 파일에서도 메모리 부담 거의 없이 동일하게 동작합니다. 이는 큰 JSON이 페이지를 느리게 만드는 이유에서 설명한 원리와 같습니다.
3. 한 줄씩 읽는 세 가지 표준 패턴
줄 단위 읽기가 처리해야 할 것은 세 가지입니다. 빈 줄 건너뛰기(특히 파일 끝 개행), 줄 번호를 붙인 에러 보고, 그리고 한 줄의 파손이 전체 작업을 죽이지 않도록 하기. 세 언어의 표준 패턴은 모두 이 세 점을 중심으로 구성됩니다.
Python, 표준 라이브러리만 사용:
import json
with open("events.jsonl", encoding="utf-8") as f:
for line_number, line in enumerate(f, start=1):
if not line.strip(): # 빈 줄 건너뛰기
continue
try:
record = json.loads(line) # 각 줄을 독립적으로 파싱
except json.JSONDecodeError as e:
print(f"{line_number}행 실패: {e}") # 보고는 하되 중단하지 않음
continue # 깨진 줄 격리
handle(record)
Node는 내장 readline 모듈로, 파일 크기와 무관하게 일정한 메모리를 유지합니다.
import fs from "node:fs";
import readline from "node:readline";
const input = fs.createReadStream("events.jsonl", "utf8");
const lines = readline.createInterface({ input, crlfDelay: Infinity });
let lineNumber = 0;
for await (const line of lines) {
lineNumber += 1;
if (!line.trim()) continue;
try {
handle(JSON.parse(line));
} catch (e) {
console.error(`${lineNumber}행: ${e.message}`);
}
}
Go 1.27에서는 encoding/json/v2가 안정 기본이 되었고 값 단위 스트림 디코딩이 JSONL에 그대로 맞습니다. jq 표현식을 내장하고 싶으면 gojq를 쓰세요. 라이브러리 선택에서 성급히 패키지를 늘리지 마세요. Python의 jsonlines는 실패한 줄의 줄 번호와 원문을 보고하고, orjsonl은 gzip/bzip2/zstd 압축 파일을 직접 읽고 씁니다. Node의 ndjson 패키지는 2020년 이후 릴리스가 없으므로 내장 readline이면 충분합니다.
4. JSON 배열과의 상호 변환
방향을 거꾸로 잡으면 모든 명령이 틀립니다. 기억할 규칙은 하나입니다. **배열 → JSONL은 map(레코드마다 한 줄로 압축), JSONL → 배열은 collect(줄을 하나의 리스트로 모음)**입니다.
# JSON 배열 → JSONL: -c로 각 요소를 한 줄로 압축
jq -c '.[]' records.json > records.jsonl
# JSONL → JSON 배열: -s로 전체 입력을 하나의 배열로 모음
jq -s '.' events.jsonl > records.json
# JSONL을 줄 단위로 필터: -s 불필요, jq는 입력을 스트림으로 처리
jq -c 'select(.active == true)' events.jsonl > active.jsonl
Python 원라이너도 동일합니다. 배열 → JSONL은 레코드마다 json.dumps(r, ensure_ascii=False) + "\n"을 쓰고, 반대 방향은 줄마다 json.loads해서 리스트에 모읍니다. 각 방향에는 함정이 하나씩 있습니다. 가는 방향(배열 → JSONL)에서는 레코드 안의 실제 개행이 json.dumps로 반드시 이스케이프되어야 합니다. 그렇지 않으면 한 레코드가 여러 줄로 찢어집니다. 돌아오는 방향(JSONL → 배열)에서는 줄들을 거대한 하나의 문자열로 이어 붙인 뒤 파싱하지 마세요. 수백만 줄이면 불필요한 전체 메모리 복사가 한 번 더 생깁니다. 집계(합계, 그룹화, 평균)에는 전체를 읽는 -s가 필요하고, 수 GB급에서는 awk나 Python의 스트리밍 집계로 전환하세요.
5. 여섯 가지 깨진 줄과 진단 절차
JSONL 에러는 거의 모두 줄 수준에서 발생합니다. 줄 단위로 검증하고 줄 번호를 보고하는 것이 유일하게 확실한 진단입니다. 빈도순으로 정리합니다.
| 증상 | 근본 원인 | 수정 |
|---|---|---|
줄 끝에서 Extra data |
줄 사이에 쉼표를 넣음 | 삭제. 각 줄은 독립적으로 완결되어야 함 |
| 한 레코드가 여러 줄로 분열 | 레코드 안의 실제 개행이 이스케이프되지 않음(손으로 포맷함) | json.dumps / jq -c로 재압축 |
첫 줄만 Unexpected token ï로 실패 |
파일에 BOM 있음 | BOM 없는 UTF-8로 다시 저장 |
| 마지막 줄만 에러, 내용은 정상 | 끝 개행 누락 또는 섞인 공백 | \n 추가. 관습상 빈 줄은 건너뜀 |
| 문자열 안 개행으로 에러 | 문자열 리터럴 안의 생개행(\n 아님) |
생성 측을 수정. JSON 문자열을 손으로 이어붙이지 말 것 |
| 큰 정수의 값이 변함 | 배정도 부동소수로 파싱(2^53 절삭) | 정밀도 보존 파서 사용. BigInt 정밀도 참고 |
진단 절차는 언제나 세 단계입니다. 먼저 head -n 1로 인코딩과 첫 줄 구조를 확인하고, 다음으로 첫 파손에서 멈추지 않고 모든 파손 줄 번호를 보고하는 줄 단위 스크립트를 실행하고, 마지막으로 파손 줄을 한 줄씩 꺼내 개별 조사합니다. 한 줄은 하나의 완전한 레코드이므로 단독 조사 비용은 거의 0입니다. 일괄 임포트에서는 "중단이 아니라 격리"를 권합니다. 파손 줄과 줄 번호를 errors.jsonl에 기록하고, 정상 줄은 평소처럼 임포트한 뒤 파손 비율을 보고 재임포트 여부를 결정합니다.
6. HTTP 위의 JSONL: 줄 단위 소비와 백프레셔
HTTP 위의 NDJSON 전형 구조는 서버가 계속 쓰고 클라이언트가 계속 읽는 형태입니다. 클라이언트에서 response.json()을 호출하지 마세요. 이는 바디 전체를 버퍼링한 뒤 파싱하므로 스트리밍의 의미를 지워버립니다. 바디 스트림을 직접 읽고 \n으로 나누며, 끝의 미완성 조각을 다음 반복으로 넘깁니다.
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
let buffer = "";
for (;;) {
const { value, done } = await reader.read();
if (done) break;
buffer += value;
const lines = buffer.split("\n");
buffer = lines.pop(); // 마지막 청크는 줄의 중간일 수 있음
for (const line of lines) {
if (line.trim()) handle(JSON.parse(line));
}
}
서버 측에서 필수인 두 가지. 첫째, res.write()가 false를 반환하면 drain 이벤트를 기다립니다(백프레셔). 이를 빼먹으면 소비자가 생산자보다 느릴 때 메모리가 끝없이 늘어납니다. 둘째, 스트리밍 경로에서는 gzip 미들웨어를 끕니다. gzip은 압축 전에 응답 전체를 버퍼해야 하므로 NDJSON 스트리밍을 완전히 무력화합니다. 두 동작 모두 로컬 검증과 여러 독립적인 영어 자료로 교차 확인된 것이며 추측이 아닙니다.
7. JSONL을 선택하면 안 되는 경우
형식 선택의 부정적 공간도 중요합니다. 다음 세 가지는 JSON 배열에 남아 있어야 합니다.
| 요구 사항 | JSONL이 맞지 않는 이유 | 대신 사용할 것 |
|---|---|---|
| 문서 전체 구조 검증 | 전체 구조가 없어 필드 이탈을 알아채지 못함 | JSON 배열 + Schema |
| N번째 레코드 무작위 접근 | 줄 길이가 가변적이라 이진 탐색 불가 | SQLite / Parquet |
| 깊은 중첩과 레코드 간 참조 | 줄이 독립적이라 참조가 자리를 가질 수 없음 | 표준 JSON 문서 |
한 문장 경험칙: 레코드가 시간에 걸쳐 도착하거나 줄 단위로 소비된다면(로그, 이벤트, 내보내기, 학습 데이터) JSONL. 작은 데이터를 한 번 만들고 한 번 소비하며 문서 전체 의미론이 필요하다면 JSON 배열. RS 구분자 기반 JSON Text Sequences 같은 파생 형식은 별개 주제이므로 RFC 7464 문서를 참고하세요.
FAQ
JSONL과 NDJSON에 실질적 차이가 있나요?
공학적으로는 없습니다. 둘 다 "한 줄에 하나의 완전한 JSON 값, 바깥 래퍼 없음, 줄 사이 쉼표 없음"을 의미하며 한쪽을 지원하는 도구는 다른 쪽도 지원합니다. 차이는 명칭과 생태계 관습입니다. 데이터 엔지니어링과 ML 데이터셋에서는 .jsonl이, 스트리밍 HTTP API에서는 .ndjson과 application/x-ndjson이 지배적입니다. 외부 시스템과 통합할 때는 그 문서가 지정한 확장자와 미디어 타입을 따릅니다.
.jsonl을 JSON.parse로 바로 읽을 수 있나요?
전체로는 안 됩니다. JSON.parse는 정확히 하나의 JSON 텍스트를 받지만 JSONL은 줄에 나열된 수많은 독립 값이므로 전체 파싱은 반드시 "Unexpected token"이나 "Extra data"로 실패합니다. 줄로 나누고 빈 줄을 제외한 각 줄을 개별 파싱하며 실패 시 줄 번호를 보고하세요.
JSONL에서 한 줄이 깨졌다면 파일 전체가 망가진 건가요?
아니요. 줄 단위 장애 격리가 이 형식의 핵심 설계입니다. 깨진 줄은 자기 자신에게만 영향을 주며, 건너뛰고 줄 번호를 기록한 채 읽기를 계속할 수 있습니다. 일괄 임포트에서는 깨진 줄을 별도 파일로 격리하고 정상 레코드는 평소처럼 임포트한 뒤 파손 비율에 따라 수정과 재임포트를 판단합니다.
JSONL을 Excel이나 CSV로 변환하려면?
먼저 배열로 바꾸거나 줄별로 필요한 필드를 추출한 뒤 테이블 변환에 넘깁니다. jq -c '.[]' data.jsonl로 압축한 결과를 컨버터에 주거나, 각 줄을 json.loads로 pandas에 흘려보내면 됩니다. 주의점: 중첩 필드를 펼치면 열 이름에 점이 붙고, 배열 필드는 셀 안에서 텍스트화할 수밖에 없으며, 긴 ID 열은 정밀도를 지키기 위해 미리 문자열로 선언해야 합니다.