대용량 JSON 파싱 및 포맷팅 웹 성능 최적화 기법
API 응답이나 로그 파일을 확인하려고 큰 JSON을 붙여 넣었더니 브라우저 탭이 몇 초간 멈춘 경험이 있을 것입니다. JSON은 사람이 읽기 쉬운 텍스트 형식이지만, 크기가 커지면 읽어서 객체로 바꾸는 작업(파싱)과 화면에 보기 좋게 그리는 작업(렌더링) 모두 부담이 됩니다. 이 글에서는 화면이 멈추는 이유를 먼저 설명하고, 브라우저 표준 기능인 Web Worker와 가상 스크롤로 이를 완화하는 방법, 그리고 JSON을 다룰 때 자주 하는 실수를 정리합니다.
1. 화면이 멈추는 두 가지 이유
메인 스레드를 오래 점유하는 동기 작업
브라우저에서 JavaScript 코드와 화면 갱신, 클릭 처리는 기본적으로 하나의 메인 스레드에서 차례로 실행됩니다. JSON.parse()와 JSON.stringify()는 동기 함수라서, 실행이 끝날 때까지 다른 일을 처리하지 못합니다. 데이터가 작을 때는 눈에 띄지 않지만, 크기가 커지면 그 시간 동안 스크롤과 입력이 반응하지 않습니다. 얼마나 커야 멈춤이 느껴지는지는 기기 성능과 데이터 구조에 따라 크게 다르므로, 직접 측정해 보는 것이 가장 정확합니다.
const start = performance.now();
const data = JSON.parse(jsonText);
const pretty = JSON.stringify(data, null, 2);
console.log(`처리 시간: ${(performance.now() - start).toFixed(1)}ms`);
한 번에 너무 많은 DOM 노드 만들기
포맷된 JSON을 줄마다, 또는 키와 값마다 <span>으로 감싸 색을 입히면 수만~수십만 개의 요소가 생길 수 있습니다. 브라우저는 요소마다 스타일을 계산하고 배치(레이아웃)한 뒤 그려야 하므로, 파싱이 끝난 뒤에도 화면이 한동안 버벅일 수 있습니다.
2. 해결책 1: Web Worker로 연산 분리하기
Web Worker는 메인 스레드와 별도의 백그라운드 스레드에서 스크립트를 실행하는 브라우저 표준 기능입니다. 무거운 파싱과 포맷팅을 워커로 넘기면, 그동안 메인 스레드는 스크롤과 입력에 계속 반응할 수 있습니다. 워커는 DOM에 직접 접근할 수 없으므로, 결과는 메시지로 돌려받아 메인 스레드에서 화면에 반영합니다.
// main.js (메인 스레드)
const worker = new Worker("json-worker.js");
worker.onmessage = (event) => {
if (event.data.ok) {
render(event.data.text); // 화면 반영은 메인 스레드에서
} else {
showError(event.data.message);
}
};
worker.postMessage(largeJsonText);
// json-worker.js (워커 스레드)
self.onmessage = (event) => {
try {
const parsed = JSON.parse(event.data);
self.postMessage({ ok: true, text: JSON.stringify(parsed, null, 2) });
} catch (error) {
self.postMessage({ ok: false, message: error.message });
}
};
주의할 점은 postMessage()로 주고받는 데이터가 기본적으로 복사된다는 것입니다(구조화된 복제). 파싱한 거대한 객체를 그대로 메인 스레드로 보내면 복사 비용이 다시 메인 스레드에 생길 수 있으므로, 위 예제처럼 화면에 필요한 결과(문자열 등)만 보내는 편이 유리한 경우가 많습니다.
3. 해결책 2: 가상 스크롤로 보이는 부분만 그리기
10만 줄짜리 결과를 모두 DOM으로 만드는 대신, 현재 화면에 보이는 줄과 그 위아래 약간만 실제 요소로 만들고 나머지는 전체 높이만큼의 빈 공간으로 대신하는 기법이 가상 스크롤(가상화)입니다. 스크롤 위치가 바뀌면 보이는 범위를 다시 계산해 요소를 교체합니다.
가상 스크롤의 기본 계산
- 줄 높이가 20px로 일정하고 보이는 영역이 600px이면, 한 번에 보이는 줄은 30줄입니다.
- 스크롤 위치가 40,000px이면 시작 줄은 40,000 ÷ 20 = 2,000번째 줄입니다.
- 2,000~2,030번째 줄(여유분 포함 약간 더)만 그리고, 컨테이너 전체 높이는 전체 줄 수 × 20px로 유지합니다.
이렇게 하면 DOM 요소 수가 데이터 크기와 거의 무관하게 일정한 수준으로 유지됩니다. 다만 줄 높이가 제각각이면 계산이 복잡해지므로, 직접 구현하기보다 검증된 라이브러리를 쓰는 경우가 많습니다. 또한 화면에 그려지지 않은 줄은 DOM에 없으므로 브라우저의 페이지 내 검색(Ctrl+F)으로 찾을 수 없다는 점도 알아 두어야 합니다. 검색이 필요하다면 데이터에서 직접 찾는 기능을 따로 제공합니다.
4. 자주 하는 실수
- 큰 정수의 정밀도 손실: JavaScript의 숫자는
Number.MAX_SAFE_INTEGER(9,007,199,254,740,991)를 넘는 정수를 정확히 표현하지 못합니다.JSON.parse("9007199254740993")의 결과는9007199254740992가 됩니다. 긴 ID는 서버에서 문자열로 내려주는 것이 안전합니다. - JSON 문법과 JavaScript 문법 혼동: JSON은 작은따옴표 문자열, 마지막 요소 뒤의 쉼표(trailing comma), 주석을 허용하지 않습니다. 코드에서 복사한 객체가 파싱되지 않는다면 이 세 가지를 먼저 확인합니다.
- JSON.stringify()의 조용한 변환: 객체의 값이
undefined나 함수인 속성은 결과에서 빠지고, 배열 안에서는null로 바뀝니다.Date객체는 ISO 형식 문자열이 되므로, 다시 파싱해도Date로 돌아오지 않습니다. - try/catch 없이 파싱하기: 잘못된 입력이면
SyntaxError가 발생해 이후 코드가 실행되지 않습니다. 사용자 입력은 항상 예외 처리를 합니다. - 워커를 쓰면 무조건 빨라진다고 생각하기: 워커는 화면 멈춤을 줄여 줄 뿐, 파싱 자체의 총 시간을 줄여 주지는 않습니다. 데이터 복사 비용까지 고려해야 합니다.
5. 정리
큰 JSON 때문에 화면이 멈추는 원인은 메인 스레드를 점유하는 동기 파싱과 과도한 DOM 생성입니다. 파싱과 포맷팅은 Web Worker로 옮기고, 결과 표시는 가상 스크롤로 보이는 부분만 그리는 것이 기본적인 대응입니다. 그리고 큰 정수, 문법 차이, 예외 처리를 함께 챙기면 데이터가 조용히 틀어지는 문제를 막을 수 있습니다.
anyTools의 JSON 포매터는 입력한 내용을 서버로 보내지 않고 브라우저에서 처리합니다. 따라서 처리 속도는 사용하는 기기의 성능과 메모리에 따라 달라지며, 아주 큰 데이터는 오래 걸릴 수 있습니다. JSON 안에 Base64 문자열이 들어 있다면 Base64 인코딩 이해하기도 함께 참고해 보세요.