JSON 메모리 원장: 문자열, 객체, 큰 배열의 실제 비용
핵심 요약: 10MB JSON 파일이 V8 힙에서 30~50MB가 될 수 있습니다. 문자열, 객체, 숫자의 실제 비용, process.memoryUsage로 기준선을 잡는 방법, 스트리밍이 도움이 되는 때를 배웁니다.

핵심부터 말하면, 먼저 process.memoryUsage로 기준선을 잡고 텍스트와 객체 그래프를 두 개의 별도 증가분으로 기록한 뒤, 다른 무엇보다 먼저 전체 복사 하나를 제거하세요. JSON.parse(JSON.stringify(x))도, 원본 문서의 반응성 래핑도 없이. 그다음 장수명 참조를 줄이고, 스트리밍은 마지막에 고려하세요. 평평한 메모리 곡선은 파일이 맞지 않거나 소수 필드만 필요할 때만 임의 접근을 포기할 만합니다.
10MB JSON 문서가
JSON.parse후 힙에서 30MB에서 50MB가 될 수 있고, 일시 피크는 더 높습니다. 이 산수를 하지 않으면 "최적화"는 추측입니다.
1. 텍스트와 객체 그래프는 다른 원장
디스크와 요청 본문에서 JSON은 바이트 시퀀스입니다. 메모리에 들어오면 두 가지가 됩니다. 문자열, 그리고 객체, 배열, 숫자, 불리언으로 만든 객체 그래프. 두 원장을 분리하는 것만이 10MB 파일이 프로세스를 80MB까지 소비하는 이유를 설명합니다.
| 데이터 모양 | 객체 그래프 대 텍스트 크기 | 지배 비용 |
|---|---|---|
| 평평한 객체 배열(각 8필드 정도) | 대략 2.5~4배 | 요소마다 객체 헤더와 속성 슬롯 |
| 깊이 중첩된 설정 | 대략 3~5배 | 많은 작은 객체, 각자 헤더 보유 |
| 아주 긴 문자열 배열 | 대략 1.2~2배 | 문자열 헤더 + 문자 데이터 |
| 소수가 있는 큰 숫자 | 대략 2~3배 | 값마다 HeapNumber 하나 |
| 반복 키가 많은 문서 | 키 수에 비례 | 파싱 중 키 해싱과 속성 설정 |
이 승수는 V8 버전, 포인터 압축, 필드 수, 문자열 내용에 의존하는 규모 참조입니다. 표의 요점은 하나입니다. 객체 그래프는 텍스트 바이트 수가 아니라 "노드 수 x 노드당 오버헤드"로 크기가 정해집니다.
2. 문자열: 가장 과소평가되는 항목
V8 문자열은 그냥 문자 배열이 아닙니다. 객체 헤더(맵 포인터, 해시 필드, 길이)를 지니며, 포인터 압축을 쓰는 대표적 64비트 빌드에서 문자 이전에 대략 십여 바이트가 듭니다. 문자는 두 방식 중 하나로 저장됩니다:
- 1바이트 문자열: Latin-1 범위 문자, 문자당 1바이트
- 2바이트 문자열: 내용에 CJK 문자, 이모지 또는 Latin-1 밖의 것이 포함될 때, 문자당 2바이트
중국어 문자는 UTF-8 파일에서 3바이트를 차지하지만 2바이트 문자열에서는 문자당 2바이트에 헤더를 더합니다. 텍스트 크기와 힙 크기는 같은 측정이 아니고, 항상 같은 방향으로 움직이지도 않습니다.
JSON 작업에 직접 중요한 세 디테일:
- 슬라이스는 부모를 살려 둡니다. V8은 긴 부분 문자열을
SlicedString으로 표현하며 부모 참조 + 오프셋 + 길이를 저장합니다. 80MB 로그에서 200문자를 슬라이스해 저장하면 그 슬라이스가 해제될 때까지 80MB 전체가 도달 가능합니다. - 연결은
ConsString을 만듭니다. 큰 문자열에 반복+=를 하면 조각 트리가 만들어지고, 문자를 실제로 읽을 때만 연속 메모리로 평탄화됩니다. 1만 번 연결한 뒤 한 번 읽으면 그 순간 큰 할당 급증이 생깁니다. - 반복 값이 자동으로 공유되지 않습니다.
"status": "paid"가 든 1만 레코드는 대개 1만 개의 독립 문자열 객체를 얻습니다. 파서가 하나의 상수로 가리키지 않습니다. 그 비용을 아끼려면 빈번한 문자열을 스스로 열거나 숫자로 매핑해야 합니다.
긴 시간 동안 짧은 발췌를 유지해야 할 때는 독립 문자열로 만들고 원본을 해제하세요:
// Bad: the slice references the whole document, so logText cannot be collected
const summary = logText.slice(0, 200);
// Good: create an independent string, then release logText
const summaryCopy = Buffer.from(summary, 'utf8').toString('utf8');
3. 객체: 모든 키가 슬롯을 차지
평범한 객체는 최소 세 가지를 담습니다. 숨은 클래스(맵) 포인터, 속성 저장소 포인터, 요소 저장소 포인터. 포인터 압축에서는 포인터가 4바이트이고 힙 객체 헤더가 더해져 빈 객체도 대략 16~20몇 바이트가 듭니다. 포인터 압축이 없는 64비트 빌드에서는 두 배입니다.
명명 속성은 두 곳 중 하나에 떨어집니다:
- 인오브젝트 속성: 객체 헤더 바로 뒤에 배치된 슬롯, 가장 빠른 접근
PropertyArray: 인오브젝트 슬롯이 소진되면 속성이 별도 배열로 이동하고 객체가 그 포인터를 지님
각 슬롯은 4 또는 8바이트이고, 키 문자열은 별도로 계산됩니다. 그래서 긴 필드 이름이 수십만 레코드에서 미학 문제가 아니라 실제 비용이 됩니다.
좋은 소식은 숨은 클래스가 공유된다는 것입니다. 같은 키를 같은 순서로 가진 100만 객체는 구조 정보를 복사하지 않고 하나의 맵을 공유합니다. 나쁜 소식은 다음 패턴이 공유를 깨거나 객체를 더 비싼 표현으로 밀어낸다는 것입니다:
- 일부 레코드에는
remark필드가 있고 다른 것에는 없어 두 모양이 만들어지고 접근마다 추가 모양 검사가 발생 - 다른 삽입 순서가 다른 모양을 만듦
- 속성 수가 매우 많거나(대략 1천 초과) 빈번한
delete가 객체를 딕셔너리 모드로 밀어내고, 모든 속성이 해시 테이블 항목도 지님 Object.create(null)은 태생부터 딕셔너리 모드입니다. 좋은 조회 표이지만 핫 루프에서 접근하는 고정 스키마 레코드에는 나쁜 선택입니다.
흔한 오해 하나를 바로잡습니다. JSON은 중복 키를 허용하고 {"a":1,"a":2}는 결과가 a === 2인 유효한 문서입니다. 최종 객체는 하나의 값을 유지하지만 파서는 매 발생을 스캔하고 할당합니다. 중복 키는 메모리를 아끼지 않고 파싱 시간만 듭니다.
4. 배열과 숫자: 요소 종류가 폭을 결정
V8은 배열에 여러 "요소 종류"를 추적하고 각각 슬롯당 폭이 다릅니다:
| 배열 내용 | 요소 종류 | 슬롯당 대략 비용 |
|---|---|---|
| 작은 정수(Smi 범위 내) | PACKED_SMI_ELEMENTS |
4바이트 |
| 소수 또는 매우 큰 정수 | PACKED_DOUBLE_ELEMENTS |
8바이트 |
| 문자열 또는 객체 | PACKED_ELEMENTS |
4바이트 포인터 + 값 자체 |
delete로 구멍이 생긴 배열 |
HOLEY_* |
같음 + 구멍 검사 |
| 매우 희소한 배열 | DICTIONARY_ELEMENTS |
해시 항목, 대개 20+ 바이트 |
두 규칙을 기억하세요:
첫째, 요소 종류는 나빠지기만 합니다. 작은 정수로 시작한 배열이 0.5를 삽입하는 순간 더블 배열이 되고, 문자열을 삽입하면 제네릭 요소 배열이 됩니다. 자동으로 복구되지 않으며 새 배열을 만들어야 합니다.
둘째, JSON 숫자가 떨어지는 곳은 값에 달려 있습니다. Smi 범위(대략 ±10억 7천만) 안의 정수는 Smi로 직접 저장되어 하나의 포인터 슬롯을 차지합니다. 소수나 지수가 있거나 범위 밖의 값은 약 16바이트의 HeapNumber를 할당하고 배열에는 그 포인터가 저장됩니다. 그래서 [1, 2, 3]과 [0.1, 0.2, 0.3]은 값뿐 아니라 "4바이트 슬롯 하나" 대 "8바이트 슬롯 + 힙 객체"로도 다릅니다.
이것은 큰 정수 ID 정밀도 문제의 근원이기도 합니다. 안전 정수 범위를 넘으면 JSON 숫자는 더블로 취급되고 낮은 자릿수가 그냥 사라집니다. JSON 배열은 packed이고 구멍 없이 도착하므로 파싱 후 요소를 delete하지 마세요. 배열 전체를 더 비싼 표현으로 밀어냅니다.
5. 파싱과 직렬화 피크: 프로세스가 실제로 죽는 순간
정상 상태 메모리는 하나의 원장이고, 피크는 또 다른 원장입니다. JSON.parse(text)가 실행되는 동안 프로세스는 세 가지를 동시에 지닙니다:
- 입력 문자열
text - 아직 완성되지 않은, 만들고 있는 객체 그래프
- 파서 자체의 임시 구조
파싱 피크는 대략 "텍스트 + 객체 그래프 + 임시 오버헤드"입니다. 파싱 후 text가 변수, 클로저, 모듈 수준 캐시, 반응성 저장소에 여전히 참조되면 그 텍스트 메모리는 절대 반환되지 않습니다.
직렬화에도 피크가 있습니다. JSON.stringify(data)는 출력 문자열을 만드는 동안 전체 객체 그래프를 유지하고 그 사이 연결 버퍼가 있습니다. 출력이 V8 문자열 길이 한도(약 5억 문자)에 접근하면 RangeError를 던집니다. 이 실패는 대개 전체 데이터베이스를 하나의 JSON 문서로 내보내는 스크립트에서 나타납니다.
가장 비싼 흔한 패턴은 직렬화를 딥 복제로 쓰는 것입니다:
// Peak is roughly: original object graph + temporary text + new object graph
const safeCopy = JSON.parse(JSON.stringify(bigData));
이 한 줄은 같은 데이터를 세 번 저장합니다. 읽기 전용 작업에는 참조를 넘기세요. 격리가 진실로 필요하면 structuredClone을 쓰고 그것도 전체 객체 그래프를 재구성한다는 점을 받아들이세요.
6. process.memoryUsage로 측정
Node에서 process.memoryUsage()는 다섯 수치를 한 번에 반환합니다. rss(프로세스가 쓴 물리 메모리), heapTotal, heapUsed(사용된 JS 힙), external(C++ 객체가 쥔 메모리), arrayBuffers(ArrayBuffer와 Buffer 메모리). JSON 처리를 연구할 때 객체 그래프에는 heapUsed가 맞는 렌즈이고, rss는 전체 프로세스가 통제를 벗어났는지 알려 줍니다.
측정 스크립트에는 세 가지 규율이 필요합니다. 먼저 global.gc()를 호출하고, 단계로 나눠 표본을 잡으며, 한 번에 하나의 변수만 바꾸세요. 이 스크립트는 "텍스트 읽기"와 "객체로 파싱"의 증가분을 따로 기록합니다:
// Run: node --expose-gc measure-json-memory.mjs
import { readFileSync } from 'node:fs';
global.gc();
const baseline = process.memoryUsage().heapUsed;
const text = readFileSync('big.json', 'utf8');
const afterRead = process.memoryUsage().heapUsed;
const data = JSON.parse(text);
const afterParse = process.memoryUsage().heapUsed;
console.log({
textOnHeap: afterRead - baseline,
graphOnHeap: afterParse - afterRead,
parsePeak: afterParse - baseline,
});
함정 몇 가지:
--expose-gc를 명시적으로 켜야 합니다. 아니면global.gc가 없습니다.heapUsed는 JS 힙만 덮습니다. 파일 읽기 Buffer는external과arrayBuffers아래에 살므로 하나의 수치로 모든 것을 설명하지 마세요.- 표본 사이에 큰 객체를
console.log하지 마세요. 임시 참조를 살려 둡니다. - 첫 실행은 JIT 워밍업과 지연 컴파일을 포함합니다. 최소 세 라운드를 돌리고 중앙값을 취하세요.
- 브라우저에서 비교하려면 DevTools Memory 패널에서 힙 스냅샷을 찍고
Retained Size를 읽으세요. Allocation Timeline의 톱니를 들여다보지 마세요.
이 기준선이 없으면 "스트리밍으로 바꾸면 고쳐진다"나 "캐시가 더 빠르게 한다" 같은 주장은 느낌일 뿐입니다.
7. 스트리밍은 피크 메모리를 사는 것이지 총 시간을 사는 것이 아님
스트리밍 파싱의 거래는 명시적입니다. 임의 접근을 포기하고 파일 크기에 비례하지 않는 메모리를 얻습니다.
| 접근 | 피크 메모리 | 임의 접근 | 적합 |
|---|---|---|---|
전체 JSON.parse |
텍스트 + 완전한 객체 그래프 | 가능 | 대략 100MB 이하, 필드 대부분 필요 |
stream-json 같은 SAX 파서 |
청크 하나 + 현재 요소 | 불가 | 거대한 파일, 또는 소수 필드만 필요 |
| 줄 단위 JSON Lines | 한 줄의 객체 | 불가 | 자연스럽게 레코드 묶음인 데이터 |
| 외부 인덱스나 데이터베이스 | 전부 적재하지 않음 | 가능 | 같은 데이터의 반복 질의 |
import { createReadStream } from 'node:fs';
import { parser } from 'stream-json';
import { pick } from 'stream-json/filters/Pick';
import { streamArray } from 'stream-json/streamers/StreamArray';
const pipeline = createReadStream('huge.json')
.pipe(parser())
.pipe(pick({ filter: 'items' }))
.pipe(streamArray());
for await (const { value } of pipeline) {
handleItem(value);
}
이 코드는 전체 트리를 절대 만들지 않아 메모리 곡선이 평평합니다. 비용도 실재합니다. 건너뛴 필드를 돌아가 얻을 수 없고, 전역 집계가 어려워지며, 오류 복구가 당신의 책임이고, 순수 JS 문자별 스캔은 대개 네이티브 JSON.parse보다 한 단계 느립니다.
결정은 "파일이 10MB를 넘었나"가 아닙니다. 두 질문을 하세요. 파싱 후 필드 대부분이 필요한가? 순차 접근으로 살 수 있는가? 둘 다 예라면 전체 파싱이 올바르고 노력은 복사와 렌더링 줄이기에 속합니다. 어느 쪽이라도 아니면 스트리밍을 고려하세요. 완전한 계층 전략은 대용량 JSON 파싱, 렌더링, 스트리밍을 참고하세요.
8. 복사 줄이기, 더 일찍 해제
메모리 작업에서 전체 복사 하나를 제거하는 것이 파서를 바꾸는 것보다 값어치가 큰 경우가 많습니다:
- 읽기 전용 작업에는 참조를 넘기세요. "안전을 위해" 딥 복제하지 마세요. 특히
JSON.parse(JSON.stringify(x))로. - 원본 문서를 반응성 시스템으로 감싸지 마세요. Vue의
reactive는 전체 객체 그래프를 재귀 프록시하고 프록시 계층 자체가 메모리 비용을 지닙니다. - 파생 데이터를 별도로 계산하고 캐시하세요. 목록에 id, title, status만 필요하면 전체 레코드를 저장소에 넣지 마세요.
- 고빈도 문자열을 정규화하세요.
"paid"와"pending"같은 값을 숫자 열거로 매핑해 문자열 객체와 비교 비용을 모두 아끼세요. - 장수명 참조를 자르세요. 작업 후 큰 객체를 null로, 캐시를 지우고, 콜백을 등록 해제해 클로저가 다음 페이지로 끌고 가지 않게 하세요.
- Worker 복사 비용을 고려하세요.
postMessage는 기본적으로 구조적 복제를 씁니다. 50MB 객체 그래프를 메인 스레드로 돌려보내면 두 번째 복사본이 생깁니다. 가능하면 요약을 반환하세요.
크기 원장의 나머지 절반은 JSON 압축을 잘못된 순서로 하지 마세요에 있습니다. 디스크 바이트와 전송로 바이트는 힙 안 객체 그래프와 다른 계정이고, 어떤 단일 퍼센트도 둘을 모두 설명하지 않습니다. 에디터와 CLI 도구가 메모리에서 어떻게 동작하는지는 에디터, 명령행 도구, 자동화를 참고하세요.
9. 체크리스트
| 증상 | 더 가능한 원인 | 첫 행동 |
|---|---|---|
| 10MB 파일이 프로세스를 50MB로 밀어냄 | 객체 그래프와 텍스트가 모두 살아 있음 | 단계로 측정, 텍스트가 유지되는지 확인 |
| 파싱 시 OOM, 정상 상태는 정상 | 파싱 피크가 옛 데이터와 겹침 | 새 파일 파싱 전에 옛 객체 해제 |
| 메모리가 천천히 오르고 절대 떨어지지 않음 | 슬라이스, 캐시, 클로저가 참조를 쥠 | 힙 스냅샷을 찍고 retained size로 정렬 |
| 배열이 예상보다 큼 | 소수가 요소 종류를 나쁘게 함 | 숫자 타입을 통일, delete 피하기 |
| 캐시를 더했는데 느려짐 | 키와 값이 메모리를 소비해 GC 압력 상승 | 캐시를 제한하고 요약만 저장 |
JSON.parse 후 메모리가 왜 3~4배 커지나?
텍스트는 입력일 뿐이고 파싱 산물은 객체 그래프입니다. 모든 객체에 헤더, 모든 속성에 슬롯, 모든 문자열에 자체 헤더가 있습니다. 노드가 충분히 많으면 총 오버헤드가 텍스트 바이트 수를 넘습니다. 승수는 데이터 모양에 달려 있고, 평평한 객체 배열은 깊이 중첩된 문서와 크게 다릅니다.
10MB JSON 파일은 전체 파싱해야 하나 스트리밍해야 하나?
먼저 목적을 보세요. 임의 접근이 필요하고 필드 대부분이 필요하며 파일이 메모리에 맞으면 전체 JSON.parse를 쓰세요. 소수 필드만 필요하거나 파일이 맞지 않으면 스트리밍하세요. 10MB는 경계가 아니고, 스트리밍은 대개 100MB를 넘어서야 필수가 됩니다.
process.memoryUsage().heapUsed가 왜 시스템 모니터링과 다른가?
heapUsed는 JS 힙의 사용 부분만 덮고 external, arrayBuffers, 코드 공간을 제외합니다. 시스템 모니터링은 rss를 보고하며 다른 측정입니다. heapUsed와 rss를 함께 추적하세요. 큰 격차는 Buffer와 네이티브 객체를 가리킵니다.
JSON.parse(JSON.stringify(x))는 안전한 딥 복제인가?
아니요. 원본 그래프, 임시 텍스트, 새 그래프를 동시에 지녀 피크가 정상 상태의 약 3배이고, undefined, 함수, Date 의미를 버립니다. 읽기 전용 작업에는 참조를 넘기세요. 격리가 필요하면 structuredClone을 쓰고 같은 재구성 비용을 받아들이세요.