JSON Toolkit

성능과 보안 FAQ: 느림, 끊김, 신뢰할 수 없는 데이터

핵심 요약: 수십 MB JSON 파일이 왜 열리지 않는지, 딥 복제가 얼마나 비싼지, __proto__ 오염을 어떻게 막는지, XSS가 어디서 들어오는지 — 증상별로 정리한 열 가지 응급 결론.

중급약 13분 소요2026-10-30 발행2026-10-30 업데이트
성능과 보안 FAQ 표지

핵심부터 말하면, 먼저 증상으로 진단하세요. 파싱, 렌더링, 메모리 중 하나입니다. 그다음 수정 순서를 따르세요. 데이터량을 줄이고, 딥 복제를 멈추고, 파싱을 Worker로 옮기며 가상 스크롤을 쓰고, 50MB를 넘으면 스트리밍합니다. 신뢰할 수 없는 입력은 __proto__, constructor, prototype을 필터하기 전에 크기와 중첩 깊이를 제한하세요.

"JSON이 느리다"는 열에 아홉 번은 부정확합니다. 진짜 느린 것은 대개 렌더링, 딥 복제, 또는 잘못된 작업 순서입니다.

1. 왜 느린가

큰 JSON 파일 파싱이 느립니다. 어떻게 최적화하나?

먼저 병목을 찾고, 최적화는 그다음에 말하세요. 단계별 차이가 수백 배입니다:

단계 10MB 데이터의 대략 시간 병목인 이유
네트워크 전송 대역폭에 따름 압축되지 않은 JSON은 대개 약 1/5로 압축됨
JSON.parse 20~80 ms 네이티브 구현이라 이미 빠름
재귀 순회 / 딥 복제 200~2000 ms 순수 JS 노드별 처리
DOM 렌더(트리 뷰) 수 초, 쉽게 멈춤 노드 수가 폭발

그래서 최적화 순서는 이래야 합니다:

  1. 데이터량 줄이기: API가 페이지네이션하고 필요한 필드만 반환하며 gzip/brotli를 켜게 합니다.
  2. 딥 복제 피하기: 다음 절, 가장 흔한 숨은 비용입니다.
  3. 지연 렌더링: 트리 뷰에서 확장된 단계만 렌더하고 수만 개 DOM 노드를 한 번에 만들지 않습니다.
  4. 스트리밍: 데이터가 10MB를 넘으면 한 번에 전부 파싱하지 않습니다.

병목이 진짜 JSON.parse라면 데이터가 이미 수십 MB 이상이고, 올바른 선택은 스트리밍 파서입니다:

import { parser } from 'stream-json';
import { streamArray } from 'stream-json/streamers/StreamArray';

// Handle array elements one by one; memory usage is independent of the total size
const pipeline = fs.createReadStream('big.json')
  .pipe(parser())
  .pipe(streamArray());

pipeline.on('data', ({ value }) => {
  processItem(value);
});

수십 MB JSON이 열리는 순간 브라우저가 죽습니다. 어떻게 하나?

먼저 "죽는" 것인지 "멈추는" 것인지 확인하세요. 둘은 완전히 다른 처리가 필요합니다:

  • 멈춤: 탭이 "Page unresponsive"를 보이고 수십 초 후 회복될 수 있음. 원인은 긴 작업이 메인 스레드를 포화
  • 크래시: 탭이 "Aw, Snap"이 됨, 대개 메모리 고갈(OOM)

어느 쪽이든 메인 스레드에서 처리하지 마세요. 파싱을 Web Worker로 옮기세요:

// worker.js
self.onmessage = (event) => {
  const parsed = JSON.parse(event.data);
  self.postMessage(parsed);
};

Worker는 "UI가 멈추지 않음"을 해결하지 메모리 상한을 해결하지 않습니다. 브라우저 탭은 대개 1~4GB를 사용할 수 있고, 200MB JSON을 객체로 파싱하면 1GB 이상을 차지할 수 있으며(모든 키-값 쌍이 객체 오버헤드를 지님), 렌더까지 더하면 쉽게 한도에 부딪힙니다.

진짜 해결은 절대 전부 올리지 않는 것입니다:

데이터 크기 접근
< 5 MB JSON.parse로 직접 파싱, 렌더를 주시
5~50 MB Worker 파싱 + 가상 스크롤
> 50 MB 스트리밍 파싱, 필요한 필드만 유지
> 500 MB 형식 변경(Parquet / SQLite / 샤딩된 JSON Lines)

JSON.parse(JSON.stringify(obj))는 딥 복제인가?

딥 복제이지만 여러 곳에서 "조용히 데이터를 바꾸므로" 범용 복제 도구로는 부적합합니다.

const source = {
  date: new Date(),
  fn: () => {},
  undef: undefined,
  nan: NaN,
  map: new Map(),
  nested: { a: 1 },
};
JSON.parse(JSON.stringify(source));
// {
//   date: "2026-10-30T03:00:00.000Z",  // Date becomes a string
//   nan: null,                          // NaN becomes null
//   nested: { a: 1 }                    // only this level is correct
// }
// fn and undef disappear, map becomes {}

문제 목록:

타입 결과
Date ISO 문자열이 됨
NaN / Infinity null이 됨
undefined / 함수 / Symbol 객체 안에서는 사라지고 배열 안에서는 null이 됨
Map / Set {}가 됨
BigInt TypeError를 던짐
순환 참조 TypeError를 던짐

현대 브라우저에는 structuredClone이 있어 Date, Map, Set, 순환 참조를 지원하지만 함수는 지원하지 않습니다:

const copy = structuredClone(source);

얕은 설정 병합만이라면 스프레드 연산자가 가장 저렴합니다: { ...config, timeout: 3000 }.

트리 뷰 확장이 왜 그렇게 끊기나?

모든 노드가 DOM 요소이고 노드 수가 수만에 이르면 브라우저 레이아웃 비용이 폭발하기 때문입니다.

10MB JSON은 5만~10만 리프 노드를 담을 수 있습니다. 보이는 영역만 렌더해도 DOM을 한 번 구성하는 데 수백 밀리초에서 수 초가 걸리고, 이후 스크롤마다 리플로우가 촉발됩니다.

올바른 접근:

  1. 가상 스크롤: 뷰포트 안의 수십 행만 렌더하고 스크롤 중 DOM 노드를 재사용
  2. 기본 접기: 첫 두 단계만 확장하고 더 깊은 단계는 온디맨드로 렌더
  3. 렌더 규모 제한: 노드가 1만을 넘으면 강제 렌더 대신 "데이터가 너무 큽니다. 검색으로 찾으세요"를 표시

이 사이트의 에디터가 큰 파일에서 우아하게 저하하는 이유이기도 합니다. 기술적으로 렌더할 수 없어서가 아니라 렌더하면 쓸 수 없기 때문입니다.

2. 왜 공격받나

__proto__ 주입은 어떻게 동작하나?

프로토타입 오염입니다. 공격자가 __proto__ 키가 든 JSON을 만들고, 코드가 필터 없이 객체로 병합하면 Object.prototype을 덮어써 런타임의 모든 객체에 영향을 줍니다.

const payload = JSON.parse('{"__proto__": {"isAdmin": true}}');

// Dangerous code: recursive merge without checking key names
function merge(target, source) {
  for (const key in source) {
    if (typeof source[key] === 'object') {
      target[key] = target[key] || {};
      merge(target[key], source[key]);
    } else {
      target[key] = source[key];
    }
  }
  return target;
}

merge({}, payload);
({}).isAdmin;  // true — every object has been polluted

핵심 사실: JSON.parse 자체는 프로토타입을 오염시키지 않습니다. {"__proto__": ...}는 평범한 자체 속성으로 파싱됩니다. 위험한 것은 파싱 이후의 병합, 할당, Object.assign, lodash.merge 같은 연산입니다.

세 가지 방어:

  1. 키 이름 필터: 병합 시 __proto__, constructor, prototype을 건너뜀
  2. **Object.create(null)**을 컨테이너로 사용해 오염할 프로토타입 체인이 없게 함
  3. **Map**으로 신뢰할 수 없는 키-값 쌍을 저장
function safeMerge(target, source) {
  for (const key of Object.keys(source)) {
    if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
      continue;
    }
    const value = source[key];
    if (value && typeof value === 'object' && !Array.isArray(value)) {
      target[key] = safeMerge(target[key] ?? {}, value);
    } else {
      target[key] = value;
    }
  }
  return target;
}

for...in은 상속 속성도 순회하고 Object.keys()가 더 안전하다는 점도 기억하세요.

신뢰할 수 없는 JSON을 파싱할 때 무엇을 주의하나?

비용은 낮고 효과는 큰 순서로 하세요:

  1. 크기 제한: 임계를 넘으면 메모리에 읽기 전에 즉시 거부
  2. 깊이 제한: 수백 단계 중첩된 JSON은 재귀 파서의 스택을 넘칠 수 있음
  3. 키 수 제한: 해시 충돌 공격과 메모리 증폭을 막음
  4. 위험 키 필터: __proto__, constructor, prototype
  5. 구조 검증: Schema로 타입과 필수 필드 확인
  6. Content-Type을 신뢰하지 말 것: 선언 타입과 실제 내용이 다를 수 있음

깊이 검사는 저렴합니다. 파싱 전 대략적 정규식 필터나 파싱 후 순회를 쓸 수 있습니다:

function measureDepth(value, depth = 0) {
  if (value === null || typeof value !== 'object') {
    return depth;
  }
  let maxDepth = depth;
  for (const key of Object.keys(value)) {
    maxDepth = Math.max(maxDepth, measureDepth(value[key], depth + 1));
  }
  return maxDepth;
}

그러나 이 코드 자체도 재귀적이라 악의적으로 깊은 데이터에서 여전히 스택을 넘칠 수 있습니다. 안전한 접근은 파싱 중 한도를 강제하는 것이고, 깊이 옵션을 지원하는 파서(일부 라이브러리는 maxDepth 파라미터를 제공)를 씁니다.

JSON을 통한 XSS는 어떻게 일어나나?

JSON 자체는 HTML이 아니고 실행되지 않습니다. XSS는 "JSON 값을 HTML에 잇는" 지점에서 일어납니다.

흔한 세 경로:

경로 예 방어
서버 측 템플릿 연결 res.send('<div>' + data.name + '</div>') 출력 시 HTML 이스케이프
프런트엔드 innerHTML el.innerHTML = data.html textContent나 정화 사용
인라인 스크립트 내장 <script>var d = ${JSON.stringify(data)}</script> <, >, & 이스케이프

세 번째가 가장 미묘합니다. JSON.stringify는 <를 이스케이프하지 않아, </script><script>alert(1)</script>가 든 데이터가 스크립트 태그를 조기에 닫을 수 있습니다:

const data = { name: '</script><script>alert(1)</script>' };
// Output: <script>var d = {"name":"</script><script>alert(1)</script>"}</script>

수정은 위험 문자를 Unicode 이스케이프로 변환하는 것입니다:

function safeJsonForHtml(data) {
  return JSON.stringify(data)
    .replace(/</g, '\\u003c')
    .replace(/>/g, '\\u003e')
    .replace(/&/g, '\\u0026')
    .replace(/\u2028/g, '\\u2028')
    .replace(/\u2029/g, '\\u2029');
}

중첩 깊이를 제한해야 하나?

예. 비용 대비 효과가 가장 좋은 방어 중 하나입니다.

제한하지 않으면: 재귀 함수가 스택을 넘치고, 깊이가 너무 크면 JSON.parse가 일부 구현에서 크래시하며, 직렬화도 같은 방식으로 스택이 터집니다. 1KB의 [[[[...]]]]만으로 보호되지 않은 파서를 쓰러뜨릴 수 있습니다.

합리적인 임계는 업무에 달려 있습니다:

데이터 유형 권장 한도
일반 업무 객체 10~20 단계
트리 구조(조직도, 디렉터리) 50~100 단계
범용 API 게이트웨이 20 단계

JSON 하이재킹이란?

역사적 취약점입니다. 초기 브라우저는 교차 출처 <script src>가 JSON을 로드하고 실행하게 했고, 공격자는 Array 생성자나 Object.prototype.__defineSetter__를 재정의해 데이터를 훔쳤습니다.

현대 보호는 상당히 완전합니다:

  • 브라우저는 이제 __defineSetter__로 배열 리터럴을 하이재킹하지 못합니다.
  • 올바른 Content-Type: application/json이 스크립트 실행을 막습니다.
  • X-Content-Type-Options: nosniff가 MIME 스니핑을 막습니다.

옛 시스템에는 잔여 위험이 남아 있습니다. 실용 조언: 최상위에 맨 배열이 아니라 객체를 쓰세요. {"data": [...]} 같은 래퍼는 응답이 유효한 JavaScript 문이 되지 않게 해({가 코드 블록으로 취급됨) 문법 수준에서 하이재킹을 막습니다.

JSON 로깅의 위험은?

세 가지:

  1. 민감 정보가 디스크에 기록됨: 전화번호, 주민등록번호, 토큰, 평문 비밀번호가 로깅 시스템에 남고, 대개 권한이 느슨하고 보존이 깁니다.
  2. 로그 주입: 줄 바꿈과 위조 로그 접두사가 든 JSON 값이 한 레코드를 여러 개로 나누고 감사 추적을 오염
  3. 크기 폭발: 10MB 응답 전체를 로그에 쓰면 로깅 시스템이 빠르게 찹니다.

접근:

// Redact and truncate before logging
function safeLog(data) {
  const cloned = structuredClone(data);
  for (const key of ['password', 'token', 'idCard', 'phone']) {
    if (key in cloned) cloned[key] = '***';
  }
  const text = JSON.stringify(cloned);
  return text.length > 2000 ? text.slice(0, 2000) + '...(truncated)' : text;
}

구조화 로깅 시스템(예: pino)은 줄 바꿈을 스스로 이스케이프하지만, 마스킹과 절단은 여전히 당신의 일입니다.

3. 빠른 참조

증상 먼저 확인 수정
느린 파싱 데이터량, 딥 복제 포함 여부 청킹/스트리밍, JSON.parse(JSON.stringify()) 제거
페이지 멈춤 메인 스레드 긴 작업, 렌더 노드 수 Worker + 가상 스크롤
탭 크래시 메모리 사용량 전부 올리지 말고 스트리밍으로
딥 복제가 데이터를 잃음 Date, NaN, Map structuredClone 사용
객체에 갑자기 여분 속성 프로토타입 오염 __proto__ 필터, Map 사용
스크립트가 팝업 문자열을 HTML에 연결 < 이스케이프, textContent 사용
스택 넘침 중첩 깊이 10~20 단계로 제한
로그가 가득 참 응답 전체를 로깅 절단 + 마스킹