JSON Toolkit

큰 JSON 트리가 끊기는 이유: 가상 스크롤과 접기 전략

핵심 요약: 끊김은 스크롤할 때가 아니라 DOM 노드를 만들 때 시작됩니다. 접기 우선 렌더, 가상 목록, 검색 하이라이트, Worker 경계와 지표를 다룹니다.

고급약 10분 소요2026-10-31 발행2026-10-31 업데이트
JSON viewer의 가상 스크롤과 접기 전략

핵심부터 말하면, "느림"을 파싱, 노드 생성, 레이아웃과 페인트, 상호작용으로 나누고 각 단계를 측정하세요. 이 순서로 구현하세요. 기본 접기, 가상 목록 추가, 계산을 Worker로 이동, 하이라이팅을 보이는 행으로 제한. 접기 없이 가상 목록으로 바로 뛰면 보이는 집합만 줄이고, 전체 트리 재계산 비용은 그대로입니다.

끊김은 대개 5MB 자체가 아니라, 그 5MB를 위해 만든 수십만 개의 DOM 노드에서 옵니다.

1. "느림"을 네 단계로 나누기

사용자가 "열면 멈춘다"고 말할 때, 문제는 완전히 다른 네 단계 중 하나에 있고 각 단계의 해결책이 다릅니다:

단계 흔한 촉발 체감 임계 지배 비용
파싱과 객체 생성 파일 열기, 텍스트 붙여 넣기 50ms 초과 작업 문자열 스캔, 객체 그래프 생성
DOM 노드 생성 전체 트리를 한 번에 렌더 노드 수만 개 요소, 텍스트 노드, 스타일 객체 할당
레이아웃과 페인트 첫 표시, 스크롤 16.7ms 초과 프레임 기하, 리플로우, 리페인트
상호작용 응답 확장, 접기, 검색 100ms 초과는 지연으로 느껴짐 재평탄화와 재렌더

브라우저 메인 스레드는 한 번에 하나만 합니다. 어느 단계가 50ms를 넘게 점유하면 스크롤과 타이핑이 프레임을 놓칩니다. 그런 작업이 연속 몇 개면 페이지는 죽은 것처럼 보입니다. 따라서 첫 최적화는 requestAnimationFrame이 아니라 어느 단계가 실제로 시간을 쓰는지 측정하는 것입니다.

2. 노드 수가 진짜 적

트리 뷰어는 설계상 노드 수를 배수로 늘립니다. 한 JSON 줄이 대개 "들여쓰기 + 키 + 콜론 + 값 + 쉼표 + 화살표"가 되므로, 보수적 추정으로 줄당 3~6개의 DOM 노드입니다:

파일 크기 대략 줄 수 트리 뷰 DOM 노드(추정) 한 번 렌더의 모습
100 KB 2,500 10,000 대개 견딜 만함
1 MB 25,000 100,000 분명히 끊기고, 수 초 빈 화면일 수 있음
5 MB 125,000 500,000 응답 없음이 매우 높은 확률
20 MB 500,000 200만 초과 메모리와 레이아웃 모두 붕괴

각 요소는 수십 바이트 객체가 아닙니다. 스타일 규칙, 레이아웃 박스, 페인트 지시를 지닙니다. 노드는 생성 후에도 실제로 제거되지 않는 한, 나중에 접힌 부모로 숨겨져도 레이아웃과 스타일 작업 비용을 냅니다. "전부 렌더한 뒤 숨기기"는 최적화가 아니라 다음 리플로우로 비용을 미루는 것입니다.

3. 먼저 접기, 그다음 가상화

가상 스크롤은 "보이는 행이 너무 많다"를 해결합니다. 접기는 "이만큼 볼 필요가 없다"를 해결합니다. 순서가 중요합니다. 기본 확장된 100만 줄 문서에서 사용자가 "모두 접기"를 클릭하면 완벽한 가상 목록도 전체 트리를 재계산해야 합니다.

운용 가능한 접기 전략:

  • 기본으로 첫 두 단계만 확장합니다. 루트와 바로 아래 필드가 보이고, 더 깊은 단계는 items: Array(12000) 같은 요약을 보여 줍니다.
  • 요약에 타입과 개수를 넣으세요. "필드 이름이 맞나"를 확인하는 사용자는 1만 레코드를 드물게 필요로 합니다.
  • 하위 트리별로 행을 캐시하세요. 확장된 노드마다 평탄화된 행 배열을 소유하고, 접으면 버리며, 확장하면 그 하위 트리만 재계산합니다.
  • 토글마다 전체 트리를 절대 다시 스캔하지 마세요. 확장이 3,000번째 행에서 일어나면 그 범위만 교체하고 주변 행은 그대로 유지하세요.
/**
 * 전체 트리를 재평탄화하지 않고 하위 트리 행을 부모 뒤에 삽입합니다.
 * @param rows 현재 보이는 행 배열
 * @param parentIndex 부모 행의 인덱스
 * @param childRows 하위 트리의 평탄화된 행
 * @returns 새로 보이는 행 배열
 */
function replaceSubtreeRows(
  rows: TreeRow[],
  parentIndex: number,
  childRows: TreeRow[],
): TreeRow[] {
  const nextRows = rows.slice();
  nextRows.splice(parentIndex + 1, 0, ...childRows);
  return nextRows;
}

피해야 할 패턴은 "확장 하나, 전체 재레이아웃 하나"입니다. 클릭마다 전체 객체 그래프를 다시 순회하면 비용이 문서 크기에 선형으로 커집니다. 하위 트리 캐시가 있으면 단일 확장 비용은 그 하위 트리 크기만큼입니다.

4. 가상 목록: 뷰포트만 렌더

접기로 행 수가 줄었는데 남은 것이 여전히 수만 행일 수 있습니다. 가상 목록은 컨테이너 높이를 "총 행 수 x 고정 행 높이"로 설정하고 뷰포트와 위아래 작은 버퍼에만 요소를 만듭니다.

const ROW_HEIGHT = 24;
const OVERSCAN = 8;

/**
 * 뷰포트가 렌더해야 할 행 범위를 계산합니다.
 * @param rows 모든 보이는 행
 * @param scrollTop 컨테이너의 현재 스크롤 오프셋
 * @param viewportHeight 컨테이너의 보이는 높이
 * @returns 렌더할 행, 시작 인덱스, 세로 오프셋
 */
function getViewportRows(
  rows: TreeRow[],
  scrollTop: number,
  viewportHeight: number,
): { visibleRows: TreeRow[]; startIndex: number; offsetTop: number } {
  const start = Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - OVERSCAN);
  const end = Math.min(
    rows.length,
    Math.ceil((scrollTop + viewportHeight) / ROW_HEIGHT) + OVERSCAN,
  );

  const visibleNodes = rows.slice(start, end);
  return {
    visibleRows: visibleNodes,
    startIndex: start,
    offsetTop: start * ROW_HEIGHT,
  };
}

양보할 수 없는 제약:

  • 행 높이는 고정이어야 합니다. 그때만 scrollTop / ROW_HEIGHT이 인덱스를 바로 줍니다. 줄 바꿈 행을 허용하면 모든 높이를 측정하고 캐시해야 해서 복잡도가 한 단계 올라가고 흔들립니다.
  • transform이나 절대 위치로 오프셋을 주세요. 스페이서 요소로 내용을 밀어내면 스크롤마다 레이아웃이 변합니다.
  • 안정적 키를 쓰세요. 행을 배열 인덱스가 아니라 경로로 키잉하세요. 아니면 한 행을 확장할 때 이후 모든 행이 재마운트됩니다.
  • 스크롤 핸들러에서는 산술만 하세요. 렌더는 requestAnimationFrame으로 미루고 중복 예약을 막으세요.
  • 버퍼를 작게 유지하세요. 양쪽 5~10행이면 충분합니다. 버퍼를 두 배로 하면 DOM 노드와 스타일 작업도 두 배입니다.

800픽셀 뷰포트와 24픽셀 행이면 약 34행이 보이고, 버퍼를 포함해도 50개 정도를 렌더합니다. 바탕 문서는 10만 행을 담을 수 있지만 DOM은 일관되게 수백 요소만 유지해 레이아웃 비용이 평평합니다.

5. 접기, 확장, 스크롤 고정

행 인덱스가 유일한 좌표가 되면 상호작용 디테일 몇 가지가 깨집니다:

  1. 뷰포트 위에서 접을 때 시각적 고정점을 보존하세요. 아래 행이 위로 밀리고, 같은 커밋에서 scrollTop을 조정하지 않으면 내용이 튀어 보입니다. 고정 행의 경로를 기록하고 새 인덱스로 스크롤 위치를 복원하세요.
  2. 점프 전에 조상을 확장하세요. 검색이 깊은 필드를 맞히면 모든 부모를 먼저 확장하고, 대상 행 인덱스를 계산한 뒤 스크롤하고 하이라이트하세요. 첫 단계를 건너뛰면 대상 행이 보이는 배열에 없습니다.
  3. 키보드 탐색은 보이는 행에서 구동하세요. 위아래는 행 인덱스를 움직이고, 왼쪽과 오른쪽은 확장과 접기입니다. 키 핸들러 안에서 전체 트리를 절대 순회하지 마세요.
  4. 업데이트를 한 프레임으로 배치하세요. 여러 단계를 연속 확장할 때 배열 변경을 합치고 한 번 렌더하세요. 아니면 각 단계가 전체 리플로우를 촉발합니다.

6. 검색과 하이라이팅: 한 번 계산, 조금만 표시

검색은 트리 뷰어에서 가장 흔히 간과되는 성능 함정입니다. 키 입력마다 전체 객체 그래프를 순회하고 결과 목록을 재구성하면 10만 노드 문서가 즉시 멈춥니다.

계층 접근:

  • 입력을 150~250ms 디바운스하고 각 검색에 시퀀스를 붙여 오래된 결과를 버리세요.
  • Worker에서 스캔하세요. 객체 그래프 순회는 DOM 의존이 없는 순수 계산이라 타이핑을 막으면 안 됩니다.
  • 경로와 행 인덱스만 저장하세요. 매칭된 노드를 모두 복사하거나 하이라이트 HTML을 미리 만들지 마세요.
  • 보이는 행만 하이라이트하세요. 뷰포트에는 수십 행이 있고, 렌더 시 매칭 경로로 스타일을 적용합니다.
  • 점프에는 "조상 확장 후 인덱스 찾기"를 재사용하세요. 화살표 클릭이 쓰는 것과 같은 경로입니다.

보안 디테일 하나: 트리 뷰어의 키와 값은 사용자 데이터입니다. 여기서 하이라이트 HTML을 만들려면 이스케이프가 필요합니다. 아니면 마크업이 든 문자열이 XSS가 됩니다. JSON 뷰어는 리치 텍스트가 필요 없으므로 텍스트 노드와 클래스 이름이 더 안전하고 빠릅니다.

7. 구문 하이라이팅: CodeMirror와 Monaco는 다른 도구

트리 뷰와 텍스트 에디터는 다른 렌더 모델이라 "어느 것이 더 빠른가"는 직접 비교가 아닙니다:

차원 CodeMirror 6 Monaco Editor
위치 모듈형 에디터 코어 완전한 IDE 에디터
렌더링 뷰포트 기반 뷰포트 기반이지만 모델과 데코레이션이 더 무거움
파싱 변경 범위 위의 Lezer 증분 파싱 Monarch 토크나이저, 줄 단위
대용량 파일 확장에 달려 있고 기본은 가벼움 기능 완전, 메모리와 시작 비용 높음
접기와 검색 별도 확장, 온디맨드 로딩 내장, 상시 오버헤드
흔한 함정 확장이 너무 많으면 비용이 복원됨 미니맵, 고정 스크롤, 쌓인 데코레이션

실용 결론 두 가지:

  • 트리 뷰에는 완전한 토크나이저가 필요 없습니다. 키, 문자열, 숫자, 불리언에 각각 색을 하나씩 주면 평범한 텍스트 노드와 CSS 클래스로 충분하며, 구문 하이라이팅 실행보다 한 단계 저렴합니다.
  • 트리 뷰와 에디터를 동시에 마운트하지 마세요. 한 문서에 두 모델을 유지하면 메모리가 두 배입니다. 숨기는 대신 옛 뷰를 파괴하세요.

에디터가 진짜 뷰어 안에 속한다면, CodeMirror 6는 "읽기 전용, 대용량 파일, 로컬 하이라이팅"에, Monaco는 완성, 진단, 다중 커서 편집에 어울립니다. 기능 목록을 읽기 전에 첫 페인트 시간과 상주 메모리를 측정하세요. 에디터와 CLI 도구의 더 깊은 비교는 에디터, 명령행 도구, 자동화를 참고하세요.

8. Worker 경계: 파싱과 행 생성을 메인 스레드 밖으로

렌더 끊김의 나머지 절반은 메인 스레드에서의 파싱입니다. 1MB를 넘는 JSON은 파싱, 형식화, 평탄 행 구성을 Web Worker에 넣고, 메인 스레드는 행을 받아 뷰포트를 페인트합니다.

Worker는 공짜가 아닙니다. postMessage가 구조적 복제를 쓰므로 50MB 객체 그래프를 메인 스레드로 돌려보내면 두 번째 복사본이 만들어져 피크가 두 배입니다. 올바른 경계는 Worker 안에서 파싱하고 평탄화하며, 렌더러가 필요한 행만 반환하는 것입니다:

worker.onmessage = (event: MessageEvent<{ rows: TreeRow[] }>) => {
  rows = event.data.rows;
  requestAnimationFrame(() => renderViewport());
};

worker.postMessage({ text });

행 데이터가 여전히 크다면 행을 병렬 배열(경로, 타입, 값 오프셋)로 압축하고 ArrayBuffer에 넣어 소유권을 이전해 메인 스레드가 복사 비용을 내지 않게 하세요. 비용은 자체 직렬화기를 쓰는 일이며, 수백만 행에서만 값어치가 있습니다.

경계 규칙은 간단합니다. 메인 스레드는 DOM을 건드려야 하는 작업만 해야 합니다. 파싱, 검색, 정렬, 형식화, diff는 모두 옮길 수 있습니다. 렌더, 스크롤, 입력, 포커스는 메인 스레드에 남고, 매 프레임은 작업 예산 안에 들어야 합니다.

9. 지표와 구현 순서

최적화는 "더 빨라 보인다"에서 멈출 수 없습니다. 다음 세트는 수용 체크리스트에 바로 넣을 수 있습니다:

지표 목표 측정 방법
첫 가시 프레임 300ms 이하 열기부터 첫 페인트까지 performance.now()
긴 작업 50ms 초과 없음 longtask를 보는 PerformanceObserver
스크롤 프레임 시간 16.7ms 이하 유지 DevTools Performance 기록
뷰포트 DOM 요소 2,000 이하 document.querySelectorAll('*').length
확장 / 접기 응답 50ms 이하 상호작용 앞뒤 타임스탬프
10만 행 검색 300ms 이하, Worker 안 Worker 시간 측정 및 반환
상주 힙 텍스트 크기의 3배 이하 DevTools Memory 힙 스냅샷

균일하게 생성한 작은 객체보다 실제 파일로 측정하세요. 데이터 모양이 중요하며, 긴 문자열, 깊은 중첩, 매우 넓은 배열이 서로 다른 곡선을 냅니다. 객체 그래프 원장 자체는 JSON 메모리 원장을 참고하세요.

이 순서로 구현하세요:

  1. 기준선 측정: 첫 렌더 시간, 노드 수, 스크롤 프레임 시간
  2. 기본 접기: 두 단계 확장, 요약에 타입과 개수 표시
  3. 가상 목록: 고정 행 높이, 안정적 키, 뷰포트 + 작은 버퍼
  4. 계산 이동: 파싱, 검색, 평탄화를 Worker로
  5. 하이라이팅 제한: 보이는 행만 스타일링, 전체 트리 재페인트 피하기
  6. 가드레일 추가: 임계 초과 시 "첫 N행 표시"를 제공하거나 검색 필터를 요구

모든 단계는 하나의 목표를 공유합니다. 동시에 존재해야 하는 노드와 객체를 줄이는 것이지, 브라우저에 더 열심히 일하라고 요구하는 것이 아닙니다. 계층 전략의 전체 배경은 대용량 JSON 파싱, 렌더링, 스트리밍에 있습니다.

가상 스크롤이 파싱 시간 끊김을 고칠 수 있나?

아니요. 가상 스크롤은 DOM 노드만 줄입니다. 파싱이 아직 메인 스레드에서 일어나면 페이지는 여전히 끊깁니다. 1MB를 넘는 문서는 파싱을 Worker로 옮기고 두 문제를 따로 취급하세요.

전부 확장했는데 여전히 끊깁니다. 무엇이 문제인가?

가상 목록이 실제로 활성되지 않았거나, 확장마다 전체 트리를 다시 스캔하고 있을 것입니다. 화면 밖 행이 아직 DOM을 만드는지, 확장이 영향받은 하위 트리만 재계산하는지 확인하세요.

JSON 뷰어에는 CodeMirror가 나은가 Monaco가 나은가?

읽기 전용 대용량 파일과 로컬 하이라이팅에는 CodeMirror 6를 선호하세요. 모듈형 온디맨드 로딩이 더 가볍습니다. 완성, 진단, 다중 커서 편집이 필요하면 Monaco를 고르세요. 어느 쪽이든 같은 문서에 트리 뷰와 에디터를 동시에 마운트하지 마세요.

검색 점프 후 스크롤 위치가 왜 틀리나?

대개 확장 전에 뷰가 스크롤되어서입니다. 모든 조상을 먼저 확장하고 새 행 인덱스로 스크롤 위치를 설정한 뒤 마지막에 하이라이트하세요. 행을 인덱스가 아니라 경로로 키잉해 확장이 모든 인덱스를 밀지 않게 하세요.

FAQ

끊김이 어느 단계에서 오는지 어떻게 찾나?

"느림"을 파싱과 객체 생성, DOM 노드 생성, 레이아웃과 페인트, 상호작용 응답으로 나누고 각 단계를 열기부터 첫 페인트까지 performance.now()로 시간을 재세요. 그다음 PerformanceObserver로 longtask를 보세요. 50ms를 넘는 작업은 프레임을 놓치고, DevTools Performance 기록이 스크롤 프레임 시간이 16.7ms 이하로 유지되는지 보여 줍니다.

전체 트리를 한 번에 렌더하면 왜 화면이 빈가?

5MB 문서는 약 125,000줄이고, 트리 뷰는 대개 각 줄을 3~6개의 DOM 노드로 만들어 한 번에 약 500,000 요소가 됩니다. 각 요소는 스타일 규칙, 레이아웃 박스, 페인트 지시도 지니므로, 연속 50ms 초과 메인 스레드 작업이 페이지를 죽은 것처럼 보이게 합니다. 접힌 부모로 숨긴 노드도 레이아웃 비용을 계속 내므로, "전부 렌더한 뒤 숨기기"는 최적화가 아닙니다.

50MB 객체 그래프를 Worker에서 돌려보내야 하나?

아니요. postMessage는 구조적 복제를 써서 수신 측이 두 번째 복사본을 만들고 피크가 두 배입니다. 올바른 경계는 Worker 안에서 파싱하고 평탄화해 렌더러가 필요한 행만 반환하는 것입니다. 수백만 행에서는 병렬 배열(경로, 타입, 값 오프셋)을 ArrayBuffer에 넣어 소유권을 이전할 수 있습니다.