대용량 JSON 스트리밍: 전체를 로드하지 않고 처리하기
표준 JSON 파싱은 전체 문서를 메모리에 로드하고 완전한 데이터 구조를 구축한 뒤 접근을 제공합니다. 10MB 파일이라면 문제없습니다. 하지만 10GB 파일이라면 프로세스가 메모리 부족으로 충돌합니다. 스트리밍 파서는 전체 문서를 메모리에 보관하지 않고 데이터가 도착하는 대로 증분 처리하여 이 문제를 해결합니다.
표준 파싱의 문제점
import json
# 이렇게 하면 전체 파일이 메모리에 로드됩니다
with open('huge.json') as f:
data = json.load(f) # 10 GB 파일 = 10+ GB RAM
# 전체 로드 후에야 처리가 시작됩니다
for item in data['records']:
process(item)
레코드 100만 개, 각 10KB인 파일의 경우:
- 파일 크기: ~10 GB
- 파싱 메모리: ~10 GB (원시 문자열)
- 데이터 구조 메모리: ~15-20 GB (Python 객체는 원시 JSON보다 큼)
- 합계: ~25-30 GB RAM
스트리밍은 이를 메가바이트 단위로 줄여줍니다.
스트리밍 접근 방식
SAX 스타일 (이벤트 기반)
파서가 JSON 토큰을 만날 때마다 이벤트를 발생시킵니다:
import ijson
# 한 번에 하나씩 처리 - 일정한 메모리 사용량
with open('huge.json', 'rb') as f:
for record in ijson.items(f, 'records.item'):
process(record) # 각 레코드가 개별적으로 파싱됨
# 이전 레코드는 가비지 컬렉션됨
이벤트 종류: start_map, map_key, end_map, start_array, end_array, string, number, boolean, null.
JSON Lines (JSONL / NDJSON)
더 간단한 접근: 한 줄에 하나의 JSON 객체. 각 줄이 완전하고 유효한 JSON 문서입니다:
{"id": 1, "name": "Alice", "email": "alice@example.com"}
{"id": 2, "name": "Bob", "email": "bob@example.com"}
{"id": 3, "name": "Charlie", "email": "charlie@example.com"}
처리가 간단합니다 — 줄 단위로 읽기만 하면 됩니다:
with open('data.jsonl') as f:
for line in f:
record = json.loads(line)
process(record)
JSON Lines의 장점:
- 각 줄이 독립적으로 파싱 가능 (병렬 처리)
- 추가가 용이 (새 줄만 추가)
- 표준 Unix 도구와 호환 (
grep,wc,head,tail) - 로그 파일과 스트리밍 데이터에 자연스러움
청크 처리
표준 JSON 배열의 경우, 처리를 청크로 분할:
import ijson
def process_in_chunks(filename, chunk_size=1000):
chunk = []
with open(filename, 'rb') as f:
for record in ijson.items(f, 'item'):
chunk.append(record)
if len(chunk) >= chunk_size:
process_batch(chunk)
chunk = []
if chunk:
process_batch(chunk)
언어별 구현
Python (ijson)
import ijson
# 파일에서 스트리밍
with open('large.json', 'rb') as f:
parser = ijson.parse(f)
for prefix, event, value in parser:
if prefix == 'records.item.name':
print(value)
# HTTP 응답에서 스트리밍
import urllib.request
response = urllib.request.urlopen('https://api.example.com/data')
for record in ijson.items(response, 'records.item'):
process(record)
JavaScript (Node.js)
const { createReadStream } = require('fs');
const { parser } = require('stream-json');
const { streamArray } = require('stream-json/streamers/StreamArray');
const pipeline = createReadStream('large.json')
.pipe(parser())
.pipe(streamArray());
pipeline.on('data', ({ value }) => {
process(value);
});
pipeline.on('end', () => {
console.log('Done processing');
});
커맨드 라인 (jq)
# 스트림 모드 - 객체를 개별 처리
jq --stream 'select(length == 2) | .[1]' large.json
# JSON Lines 처리
cat data.jsonl | jq -c 'select(.age > 30)'
# 배열을 JSON Lines로 변환
jq -c '.[]' large_array.json > data.jsonl
언제 스트리밍을 사용해야 할까
| 시나리오 | 표준 파싱 | 스트리밍 |
|---|---|---|
| 100 MB 미만 파일 | 권장 | 과도함 |
| 100 MB ~ 1 GB 파일 | RAM에 따라 다름 | 권장 |
| 1 GB 초과 파일 | 불가능 | 필수 |
| 대용량 HTTP 응답 | 타임아웃 위험 | 수신하면서 스트리밍 |
| 실시간 데이터 피드 | 해당 없음 | 필수 |
| 단순 일회성 읽기 | 권장 | 불필요 |
성능 비교
100만 레코드 처리 (1 GB 파일):
| 접근 방식 | 메모리 사용량 | 처리 시간 | 복잡도 |
|---|---|---|---|
| json.load() | 3-5 GB | 15초 | 단순 |
| ijson 스트리밍 | 50 MB | 45초 | 보통 |
| JSON Lines | 10 MB | 12초 | 단순 |
| 청크 (1000) | 100 MB | 20초 | 보통 |
스트리밍은 메모리를 훨씬 적게 사용하지만 이벤트 기반 오버헤드로 인해 SAX 스타일 파싱은 더 느립니다. JSON Lines는 각 줄이 독립적인 파싱 작업이므로 빠르면서도 메모리 효율적입니다.
모범 사례
- 가능하면 JSON Lines 사용: 가장 간단한 스트리밍 포맷이며 표준 도구와 호환됩니다.
- 버퍼 크기 설정: 최적 처리량을 위해 읽기 버퍼를 구성하세요 (보통 64 KB ~ 1 MB).
- 배치 처리: 레코드를 하나씩 처리하는 대신 데이터베이스 삽입과 API 호출을 배치로 수행하세요.
- 오류를 우아하게 처리: 스트리밍에서는 하나의 잘못된 레코드가 전체 파이프라인을 중단시키면 안 됩니다.
- 메모리 모니터링: 프로파일링을 사용하여 스트리밍이 실제로 메모리를 제한하고 있는지 확인하세요.
더 작은 JSON 파일의 포맷팅과 검증에는 JSON 포매터가 최대 100 MB까지 실시간 포맷팅을 지원합니다.
FAQ
스트리밍 파서에서 JSONPath를 사용할 수 있나요?
일부 스트리밍 라이브러리는 경로 기반 필터링을 지원합니다. Python ijson.items는 스트리밍 중 경로 기반 필터링을 지원합니다. 그러나 복잡한 JSONPath 쿼리(필터, 레벨 간 와일드카드)는 일반적으로 전체 문서가 메모리에 있어야 합니다. 경로 기반 쿼리에 대해서는 JSONPath 가이드를 참조하세요.
대용량 JSON 배열을 JSON Lines로 어떻게 변환하나요?
메모리에 들어가는 파일은 jq -c '.[]' input.json > output.jsonl을 사용하세요. 정말 큰 파일은 스트리밍 변환기를 사용하세요: 스트리밍 파서로 배열을 읽고 각 요소를 한 줄로 작성합니다.
관련 리소스
- JSON 포매터 — JSON 파일 포맷팅 및 검증
- JSONPath 쿼리 가이드 — JSON에서 효율적으로 데이터 추출
- JSON 에디터 팁 — 대용량 JSON 문서 다루기