JSON 파일이 커질 때 실제로 깨지는 것들
JSON 도구에서 말하는 크기 임계값은 모두 특정한 한계에서 나오고, 사람들이 경고하는 한계는 대개 엉뚱한 쪽입니다.
이 페이지의 모든 주장은 직접 측정했거나 출처가 있습니다. 둘 다 아닌 경우에는 그렇다고 명시합니다.
내보내기가 40MB짜리 .json 파일 하나로 도착합니다. 에디터의 구문 강조는 포기하고, 웹 포매터에 붙여 넣으면 탭이 몇 초 동안 하얗게 변합니다. 누군가는 스트리밍을 권하고, 다른 누군가는 재귀 때문에 스택이 터진다고 말하는데, 두 조언 모두 아직 여러분에게 없는 문제를 겨냥하고 있습니다.
쓸모 있는 질문은 “이 파일이 큰가”가 아니라 “지금 어떤 한계에 부딪히려 하는가”입니다. 한계는 네 개쯤뿐이고, 정해진 순서로 찾아옵니다.
크기의 계단
| 크기 | 무슨 일이 벌어지나 |
|---|---|
| 1MB | 아무 일도. 네이티브 파싱은 몇 밀리초, 객체 트리는 수십 MB. 못 만든 것까지 포함해 모든 도구가 동작합니다. |
| 10MB | 네이티브 JSON.parse가 약 158ms. 이 사이트처럼 JavaScript로 쓴 토크나이저는 같은 입력을 정렬하는 데 약 780ms가 걸립니다. 그것을 위한 탐색 가능한 노드 트리를 만들면 힙이 약 294MB 듭니다. 아직 다 동작하지만, 동기 파싱은 이제 멈춘 것처럼 보일 만큼 길어졌습니다. |
| 100MB | 같은 숫자를 외삽해 보세요. JS 토크나이저에서 몇 초의 파싱, 기가바이트 단위의 트리. 브라우저 탭이 시간이 아니라 메모리 때문에 죽기 시작하는 지점입니다. 서버 쪽에서는 여전히 일상입니다. |
| 500MB 이상 | V8은 단일 문자열을 536,870,888자, ASCII로 약 512MB로 제한합니다. 크롬과 Node에서는 파일을 문자열로 읽어 들이는 것 자체가 불가능하므로, 그 단계 위에 지은 도구는 어떻게 작성됐든 손댈 수 없습니다. |
앞의 세 줄은 이 사이트 자체 도구로 잰 값이고, 여러분의 파서는 다를 것입니다. 네 번째 줄은 성능 수치가 아니라 엔진의 단단한 천장입니다.
512MB의 벽
브라우저의 JSON 도구는 모두 같은 길을 갑니다. 파일을 문자열로 읽고, 그 문자열을 파서에 넘깁니다. 아주 큰 파일이 죽는 곳이 바로 이 첫 단계입니다. 엔진의 최대 문자열 길이는 고정된 상수이고, 그보다 큰 것에 대한 FileReader.readAsText나 Response.text()는 여러분의 코드가 돌기도 전에 예외를 던집니다.
이 상수는 엔진마다 다릅니다. V8은 536,870,888자(64비트 Node에서 require('buffer').constants.MAX_STRING_LENGTH)에서, SpiderMonkey는 1,073,741,822에서, JavaScriptCore는 2,147,483,647에서 멈춥니다. 따라서 파이어폭스와 사파리는 크롬이 거부하는 파일을 견디지만, 셋 다 천장이 있으므로 어디서나 동작해야 하는 도구는 V8 기준으로 설계됩니다.
ReadableStream 위에서 덩어리 단위로 읽는 파서는 조금 더 멀리 갑니다. 문서를 하나의 문자열로 실체화하지 않기 때문입니다. 그것은 문자열 상한을 해결하지만 다음 문제는 해결하지 못합니다. 파싱된 결과도 메모리에 들어가야 한다는 문제입니다. 수백 MB를 넘어가는 것은 브라우저의 문제가 아닙니다. 셸이나 언어 런타임, 데이터베이스로 옮기세요.
파싱된 트리가 왜 파일보다 그렇게 큰가
10MB의 텍스트가 약 294MB의 힙이 된다는 사실은 사람을 놀라게 하지만, 산수 자체는 신비롭지 않습니다. 디스크에서 18바이트인 {"id":1,"ok":true}를 봅시다. 메모리에서는 이렇습니다.
- 헤더와, 자신의 형태(프로퍼티 맵)를 가리키는 포인터를 가진 객체 하나.
- 값들 앞에 프로퍼티마다 포인터 폭의 슬롯 하나. 포인터 압축이 있는 64비트 빌드에서 4바이트, 없으면 8바이트.
- 각 문자열 값이 자기 헤더와 길이 필드, 문자 데이터를 지니고, 각 문자열 키도 엔진이 인터닝하지 않았다면 마찬가지.
- 작은 정수가 아닌 값은 자기 헤더를 가진 별도의 힙 셀에 저장되어, 포인터를 한 번 더 따라가야 닿습니다.
부가 비용은 바이트당이 아니라 노드당 붙으므로, 데이터가 구조화될수록 비율이 나빠집니다. 긴 문자열 하나로 된 10MB는 싸고, 키가 여덟 개인 작은 객체 8만 개로 된 10MB는 그렇지 않습니다. 평범한 JSON.parse 결과는 노드마다 메타데이터를 지니는 뷰어 트리보다 가볍지만, 그래도 원본의 몇 배입니다. 한 자릿수 크기 차이를 잡아 두고, 그다음 자기 데이터 형태로 직접 재세요.
재귀에 얽힌 통념, 바로잡기
흔한 경고는 깊게 중첩된 JSON이 파싱 때 스택을 터뜨린다는 것입니다. 브라우저에서, 요즘 엔진에서는 더 이상 사실이 아닙니다. V8은 v7.6에서 재귀 JSON 파서를 반복 방식으로 바꿨고, 100만 단계의 중첩도 군말 없이 읽습니다. Node v24.15.0(V8 13.6.233.17)에서 재 보니, 예전엔 치명적이던 깊이에서도 파싱은 멀쩡했습니다.
실패는 반대편으로 옮겨 갔습니다. JSON.stringify는 여전히 재귀하고, 몇천 단계 아래에서 RangeError를 던집니다. 같은 빌드에서 4,800 언저리였습니다.
const deepText = '{"a":'.repeat(1000000) + '1' + '}'.repeat(1000000);
const deep = JSON.parse(deepText); // 멀쩡하다, 100만 단계
JSON.stringify(deep); // RangeError: Maximum call stack size exceeded
이 숫자는 상수가 아닙니다. 런타임이 시작할 때의 스택 크기, 그리고 호출 시점에 스택에 얹혀 있는 다른 것들에 따라 움직이므로, 설계의 기준으로 삼을 값이 아닙니다.
그러니 어떤 서비스는 적대적인 페이로드를 받아들이고, 군말 없이 파싱하고, 저장한 다음, 같은 값을 로그에 쓰거나 다시 내보내려 할 때 쓰러질 수 있습니다. 깊이 제한은 여전히 입력 경계에 두어야 합니다. 정작 살아남는 쪽이 입력이라 해도 그렇습니다.
다른 런타임은 양쪽 모두에서 덜 관대합니다. CPython의 json 모듈은 디코딩에서도 인코딩에서도 재귀하므로, 깊게 중첩된 입력은 들어오는 길에 RecursionError를 냅니다. 그 전에 얼마나 깊이 갈 수 있는지는 빌드에 달렸는데, 여기서는 CPython 3.14의 C 스캐너가 14,000단계 근처에서 손을 들었습니다. V8이 받아들이는 깊이에 한참 못 미칩니다. 언어 경계를 넘나든다면, 서비스가 견디는 깊이는 경로에서 가장 빡빡한 구간의 깊이입니다.
스트리밍, 그리고 사람들이 틀리는 부분
스트리밍이란 문서 전체를 결코 들고 있지 않는 것입니다. 주요 언어에는 모두 이를 위한 풀 방식 파서가 있습니다.
파이썬의 ijson은 접두 경로에 맞는 값을 하나씩 내놓습니다. 접두사 records.item은 “최상위 키 records에 있는 배열의 각 원소”를 뜻하고, item은 필드 이름 자리에 넣는 자리표가 아니라 배열 원소를 가리키는 문자 그대로의 토큰입니다. 처음 쓰는 사람이 틀리는 지점이 이것입니다.
import ijson
total = 0
with open("events.json", "rb") as f: # 텍스트가 아니라 바이너리 모드
for record in ijson.items(f, "records.item"):
if record["status"] == "failed":
total += 1
print(total)
ijson은 임포트 시점에 쓸 수 있는 가장 빠른 백엔드를 고릅니다. C 백엔드는 순수 파이썬 대체본보다 훨씬 빠릅니다. 스트리밍이 느리다고 결론 내리기 전에 어느 쪽이 잡혔는지 확인하세요.
Go의 encoding/json은 Decoder로 같은 일을 하는데, 함정의 성격이 다릅니다. 최상위 배열에 대해 Decode를 한 번 부르면 배열 전체가 슬라이스 하나로 디코딩됩니다. 바로 피하려던 그 일이죠. 먼저 여는 대괄호를 토큰으로 읽어 낸 다음, 원소 단위로 디코딩해야 합니다.
f, err := os.Open("events.json")
if err != nil { log.Fatal(err) }
defer f.Close()
dec := json.NewDecoder(f)
if _, err := dec.Token(); err != nil { log.Fatal(err) } // '[' 를 읽는다
for dec.More() {
var r Record
if err := dec.Decode(&r); err != nil { log.Fatal(err) }
process(r)
}
Node에서는 stream-json(부분 트리를 고르는 Pick과 원소를 흘려보내는 StreamArray)이나 더 오래된 JSONStream이 같은 일을 하고, JVM에서는 Jackson의 JsonParser가 같은 토큰 루프를 줍니다. 모두 문서 전체가 한꺼번에 필요한 작업을 포기하는 대가로 일정한 메모리 사용량을 얻습니다.
문제는 형식이었다
거대한 JSON 배열을 스트리밍하는 일은, 그 파일이 애초에 배열 하나여서는 안 됐기 때문에 하고 있는 일입니다. NDJSON, 즉 한 줄에 완결된 JSON 값 하나를 두는 형식은 이 문제군 전체를 없앱니다. 한 줄 읽고, 한 줄 파싱하고, 버립니다. 메모리는 가장 큰 레코드 하나로 상한이 정해집니다. split으로 쪼개지고, 텍스트처럼 grep되고, 다시 쓰지 않고 덧붙일 수 있으며, 쓰다 만 파일에서도 잃는 것은 파일이 아니라 레코드 하나입니다.
오늘은 배열에 묶여 있고 내일은 줄 단위가 필요하다면, NDJSON → JSON 변환이 양방향으로 바꿔 주고, JSON 뷰어는 어느 쪽이든 엽니다.
780ms가 왜 망가진 탭인가
동기 파싱은 메인 스레드를 붙잡습니다. 아무것도 그려지지 않고 클릭도 먹지 않습니다. 100ms쯤을 넘기면 조작이 즉각적으로 느껴지지 않고, 1초를 넘기면 페이지는 얼어붙은 것으로 읽히며 사용자는 새로고침에 손을 뻗습니다. 새로고침은 파싱을 처음부터 다시 시작합니다.
해결책은 더 빠른 파서가 아니라, 화면을 그리는 스레드에서 작업을 빼내는 것입니다. 이 사이트의 정렬 도구는 웹 워커에서 파싱하고 정렬하므로 탭은 계속 그려지고, 진행 상태는 블로킹 호출 직전에 띄운 거짓말이 아니라 실제 상태가 됩니다. 이 구조적 선택이 토크나이저의 어떤 미세 최적화보다 크게 작용합니다.
답이 도구가 아닐 때
위의 어느 것에도 손대기 전에 해 볼 만한 일들이 있습니다.
먼저 jq로 걸러서, 읽어 들이는 것 자체를 작게 만드세요.
jq -c '.records[] | select(.status == "failed")' events.json > failed.ndjson
맨 jq는 문서 전체를 메모리에 올린다는 점에 주의하세요. RAM보다 큰 파일에는 그러지 않는 jq --stream 모드가 있지만, 훨씬 낯선 이벤트 기반 문법이라는 대가가 따릅니다.
그냥 들여다보고 싶은 일부라면 필터 도구가 브라우저 안에서 같은 선택을 해 주고, 압축 도구는 서식용 공백을 걷어냅니다. 들여쓰기된 내보내기 파일에서 그 공백은 실제로 바이트의 상당 부분을 차지합니다.
그리고 때로는 이게 파일 문제가 아니라는 것이 정직한 답입니다. 2GB짜리 내보내기 파일에 반복해서 grep을 걸고 있다면, 한 번 SQLite나 DuckDB에 써 넣고 거기서 질의하는 편이 이후의 모든 질문을 싸게 만듭니다. 계속 다시 파싱하고 있는 파일은 자기가 테이블이 되고 싶다고 이미 말한 것입니다.