JSON Toolkit

대용량 JSON 파싱, 렌더링, 스트리밍

핵심 요약: 파싱이 병목인 경우는 흔하지 않습니다. 렌더링과 복사가 병목입니다. "파싱 → 순회 → 렌더 → 전송" 네 계층을 따라 최적화해야 노력이 헛되지 않습니다.

중급약 13분 소요2026-10-30 발행2026-10-31 업데이트
대용량 JSON 파싱, 렌더링, 스트리밍 표지

핵심부터 말하면, 파싱, 순회, 렌더링, 전송을 따로 측정한 뒤 이 순서로 작업하세요. 복사 줄이기, 가상 스크롤로 온디맨드 확장, Worker로 작업 이동, 스트리밍 전환, 그다음에야 형식 변경. 스트리밍은 파일이 메모리에 맞지 않거나 필드의 작은 부분집합만 필요할 때만 값어치가 있습니다.

"JSON.parse가 느리다"는 결론은 열에 여덟 번은 틀립니다. 진짜 느린 것은 파싱 후에 하는 일입니다.

1. 먼저 측정, 그다음 최적화

같은 10MB JSON 파일이라도 단계별 시간이 수백 배 차이납니다:

단계 대표 소요 주요 비용
JSON.parse(네이티브) 수십에서 백수십 밀리초 단일 선형 스캔 + 객체 구성
딥 복사(structuredClone / 재귀) 파싱의 1~3배 가능 전체 객체 그래프 재구성
딥 비교 / diff 다시 두 배 가능 재귀 순회 + 문자열 비교
DOM 트리로 렌더 노드 수만 개에서 멈춤 레이아웃과 리플로우
문자열 연결 후 직렬화 출력 크기에 비례 메모리 두 배

그래서 행동 전에 측정하세요. 브라우저에서는 단계마다 performance.now()나 console.time으로 감싸고, Node에서는 process.memoryUsage()로 힙 증가를 봅니다. 기준선이 없으면 최적화도 없습니다.

2. 파싱 계층: 세 전략

2.1 전체 파싱(기본 선택)

const data = JSON.parse(text);

네이티브 구현이 스캔과 객체 구성을 C++로 하므로 순수 JS 라이브러리가 이기기 어렵습니다. 다음 세 조건이 성립하는 한 쓰세요:

  • 텍스트가 완전히 메모리에 맞음(대개 < 100MB)
  • 파싱 후 필드 대부분이 실제로 필요함
  • 데이터가 신뢰할 수 없는 정도가 관리 가능함

2.2 스트리밍 증분 파싱

stream-json 같은 SAX 스타일 파서로 완전한 객체 트리를 만들지 않고 대상 필드를 스캔해 낼 수 있습니다:

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) {
  // Process one element at a time; memory is independent of element count
  handleItem(value);
}

비용:

  • 코드 복잡도가 크게 오르고 오류 처리가 지저분해집니다.
  • 순차 접근만 가능하고 임의 접근이 없습니다.
  • 전역 집계가 필요한 요구에는 불친절합니다.

결정 규칙: 파일이 너무 커서 들어가지 않거나 필드의 아주 작은 부분집합만 필요할 때만 스트리밍이 값어치가 있습니다.

2.3 형식 변경

데이터가 "단일 문서"가 아니라 "레코드 묶음"이라면 처음부터 JSON Lines를 썼어야 합니다. 줄 구분 형식은 "스트리밍"을 "줄 단위 읽기"로 바꿔 구현 복잡도를 한 단계 낮춥니다.

3. 순회 계층: 복사 하나 줄이기가 절반

// Anti-pattern: deep copying first "for safety"
const safeCopy = structuredClone(bigData);   // Memory doubles immediately

// Good pattern: pass references directly in read-only scenarios
function renderSummary(data: OrderDocument): Summary {
  return { total: data.items.length, amount: data.total };
}

흔한 숨은 복사:

  • JSON.parse(JSON.stringify(x))를 "딥 복사"로 쓰는 것 — 가장 비싼 종류입니다.
  • { ...data }는 한 단계만 복사하지만 큰 객체에서는 상위 키 참조 배열 전체를 여전히 복사합니다.
  • Array.prototype.map은 새 배열을 만들어 요소가 많으면 비용도 큽니다.
  • 프레임워크 반응성 프록시(Vue의 reactive)는 큰 객체를 재귀적으로 프록시하며 오버헤드가 파싱 자체보다 클 수 있습니다.

실용 조언: "원본 데이터"를 평범한 불변 객체로 유지하고 반응성으로 감싸지 마세요. 표시에 필요한 파생 데이터를 별도로 계산하고 캐시하세요.

4. 렌더 계층: 전체 트리를 DOM에 쑤어 넣지 말 것

5MB JSON 파일은 대략 3만에서 10만 노드이고, 모두 DOM 요소로 렌더하면 브라우저가 응답을 멈춥니다.

세 가지 실행 가능한 접근:

4.1 온디맨드 확장

루트 노드의 직계 자식만 렌더하고 노드가 확장될 때만 다음 단계를 계산합니다. 트리 뷰어의 표준 접근입니다:

type TreeNode = { key: string; value: unknown; expanded: boolean };

/**
 * 전체 트리를 한 번에 만들지 않고 현재 보이는 노드만 반환합니다.
 * @param rootNode 루트 노드
 * @param expandedPaths 확장된 경로 집합
 * @returns 보이는 노드 목록
 */
function collectVisibleNodes(rootNode: TreeNode, expandedPaths: Set<string>): TreeNode[] {
  const visibleNodes: TreeNode[] = [];

  /**
   * 확장된 분기의 자식 노드를 재귀적으로 수집합니다.
   * @param currentNode 현재 노드
   * @param currentPath 현재 경로
   */
  const walk = (currentNode: TreeNode, currentPath: string): void => {
    visibleNodes.push(currentNode);
    if (!expandedPaths.has(currentPath) || typeof currentNode.value !== 'object' || currentNode.value === null) {
      return;
    }
    for (const [childKey, childValue] of Object.entries(currentNode.value)) {
      walk({ key: childKey, value: childValue, expanded: false }, `${currentPath}/${childKey}`);
    }
  };

  walk(rootNode, '');
  return visibleNodes;
}

4.2 가상 스크롤

확장된 노드가 수백 개를 넘으면 뷰포트 안의 수십 개만 렌더하고 나머지는 자리표시자 높이로 공간을 예약합니다. 목록형 뷰(테이블, 로그)에서 가장 효과적입니다.

4.3 요약만 표시

먼저 "구조 + 타입 + 개수"(예: items: Array(12000))를 제시하고, 사용자가 항목을 열 때만 구체 값을 가져옵니다. 이 요령은 "필드가 맞는지만 확인하고 싶다" 시나리오에서 가장 잘 동작합니다.

5. 메인 스레드: 필요할 때 Worker로 작업 이동

1MB를 넘는 파일의 파싱, 형식화, diff는 Web Worker로 보내야 합니다:

// json.worker.ts
self.onmessage = (event: MessageEvent<{ text: string }>) => {
  try {
    const parsed = JSON.parse(event.data.text);
    self.postMessage({ ok: true, data: parsed });
  } catch (parseError) {
    self.postMessage({ ok: false, message: (parseError as Error).message });
  }
};
/**
 * 메인 스레드를 막지 않도록 Worker 안에서 JSON을 파싱합니다.
 * @param text JSON 텍스트
 * @returns 파싱 결과
 */
function parseInWorker(text: string): Promise<unknown> {
  return new Promise((resolvePromise, rejectPromise) => {
    const worker = new Worker(new URL('./json.worker.ts', import.meta.url), { type: 'module' });
    worker.onmessage = (event) => {
      worker.terminate();
      if (event.data.ok) {
        resolvePromise(event.data.data);
      } else {
        rejectPromise(new Error(event.data.message));
      }
    };
    worker.postMessage({ text });
  });
}

Worker의 통신 비용에 주의하세요. postMessage는 기본적으로 구조적 복제를 쓰고, 10MB 객체를 한 번 복제하는 것도 수십 밀리초가 걸립니다. "파싱해서 메인 스레드에 넘기는" 것만 필요하다면 무거운 작업(파싱 + 변환 + 검증)을 Worker 안에서 하고 결과 요약을 돌려보내세요.

6. 전송 계층: 크기 최적화

기법 대표 이득 비용
gzip / brotli 압축 출력이 대개 원본의 10%~25% 서버 지원 필요
들여쓰기와 공백 제거 20%~40% 감소 사람이 못 읽음
필드 이름 축약 깊은 중첩에서 이득이 큼 가독성 저하, 매핑 표 필요
열거 문자열 대신 숫자 작음 사전 필요
페이지네이션 / 필드 온디맨드 최대 API 재설계

사람을 위한 페이지에서 올바른 조합: API는 압축되고 압축된 JSON을 보내고 UI는 표시를 위해서만 예쁘게 만듭니다. 많은 도구 사이트가 기본으로 예쁜 텍스트를 프런트엔드에 다시 보내 공짜로 30% 더 많은 바이트를 보냅니다.

7. 벤치마크 방법

/**
 * 한 번의 흔들리는 실행이 결과를 왜곡하지 않도록 동기 루틴의 중앙값 시간을 측정합니다.
 * @param runOnce 테스트 대상 함수
 * @param repeatCount 반복 횟수
 * @returns 밀리초 단위 중앙값
 */
function measureMedian(runOnce: () => void, repeatCount = 9): number {
  const durations: number[] = [];
  for (let roundIndex = 0; roundIndex < repeatCount; roundIndex += 1) {
    const startedAt = performance.now();
    runOnce();
    durations.push(performance.now() - startedAt);
  }
  durations.sort((firstValue, secondValue) => firstValue - secondValue);
  return durations[Math.floor(durations.length / 2)];
}

세 가지 규율:

  1. 고정 샘플을 쓰고 데이터를 무작위로 생성하지 마세요. 아니면 수치를 비교할 수 없습니다.
  2. 최소 9번 실행하고 중앙값을 취하세요. 첫 실행은 JIT 워밍업을 포함해 버려야 합니다.
  3. 런타임 환경(Node 버전, CPU)을 기록하세요. 아니면 기계 간 비교가 무의미합니다.

8. 체크리스트

  • [ ] 전후를 비교할 기준선 수치가 있습니다
  • [ ] 1MB를 넘는 파싱이 Worker에서 실행됩니다
  • [ ] 트리 뷰는 온디맨드로 확장하고 목록은 가상 스크롤을 씁니다
  • [ ] 무의미한 딥 복사나 반응성 래핑이 없습니다
  • [ ] 데이터가 "레코드 묶음"일 때 JSON Lines를 씁니다
  • [ ] API가 압축 JSON을 보내고 형식화는 표시용으로만 합니다