"작동은 하는데 느리다." 개발자라면 누구나 한 번쯤 들어봤을 불평입니다. 기능 개발이 끝난 뒤 성능 문제가 수면 위로 드러나는 경우가 많죠. 이 글에서는 JavaScript 앱이 느려지는 진짜 원인을 파악하고, 측정 → 개선 → 검증 흐름으로 성능을 올리는 실전 기법을 정리합니다.
먼저 측정하라 — 추측하지 말고
무작정 코드를 최적화하기 전에 반드시 병목을 먼저 찾아야 합니다. Chrome DevTools의 Performance 탭과 console.time이 출발점입니다.
// 간단한 함수 실행 시간 측정
console.time('processData');
const result = processLargeArray(data);
console.timeEnd('processData'); // processData: 342ms
// Performance API — 고해상도 타이밍
const start = performance.now();
heavyOperation();
const duration = performance.now() - start;
console.log(`실행 시간: ${duration.toFixed(2)}ms`);
// 마커로 구간 표시 (DevTools Performance 탭에서 확인)
performance.mark('render-start');
renderDashboard();
performance.mark('render-end');
performance.measure('render', 'render-start', 'render-end');
메모이제이션 — 같은 계산 반복하지 않기
같은 입력에 항상 같은 결과를 돌려주는 순수 함수라면 결과를 캐싱해 중복 계산을 피할 수 있습니다.
// 직접 구현하는 메모이제이션
function memoize(fn) {
const cache = new Map();
return function(...args) {
const key = JSON.stringify(args);
if (cache.has(key)) return cache.get(key);
const result = fn.apply(this, args);
cache.set(key, result);
return result;
};
}
// 재귀 피보나치 — 메모이제이션 전: O(2^n), 후: O(n)
const fib = memoize(function(n) {
if (n <= 1) return n;
return fib(n - 1) + fib(n - 2);
});
console.log(fib(40)); // 메모이제이션 없으면 수 초, 있으면 즉시
배열 & 루프 최적화
대용량 데이터 처리에서 잘못된 루프 패턴은 큰 성능 차이를 만들어냅니다.
const bigArray = Array.from({ length: 1_000_000 }, (_, i) => i);
// ❌ 느림 — 매 반복마다 length를 참조하고 push는 재할당 발생
const result1 = [];
for (let i = 0; i < bigArray.length; i++) {
if (bigArray[i] % 2 === 0) result1.push(bigArray[i] * 2);
}
// ✅ 빠름 — length 캐싱, 사전 할당
const len = bigArray.length;
const result2 = new Array(len); // 최대 크기 사전 할당
let count = 0;
for (let i = 0; i < len; i++) {
if (bigArray[i] % 2 === 0) result2[count++] = bigArray[i] * 2;
}
result2.length = count;
// 체이닝 지양 — filter().map() 은 배열을 두 번 순회
// 하나의 reduce나 for 루프로 처리
const result3 = bigArray.reduce((acc, n) => {
if (n % 2 === 0) acc.push(n * 2);
return acc;
}, []);
DOM 조작 최소화 — Reflow 줄이기
DOM 접근과 스타일 변경은 브라우저의 Reflow(레이아웃 재계산)를 유발합니다. 특히 읽기와 쓰기를 번갈아 하면 강제 동기 레이아웃이 발생해 극도로 느려집니다.
// ❌ 강제 동기 레이아웃 (Forced Synchronous Layout)
const boxes = document.querySelectorAll('.box');
boxes.forEach(box => {
const width = box.offsetWidth; // 읽기 → 레이아웃 강제 실행
box.style.width = width * 2 + 'px'; // 쓰기 → dirty
// 다음 반복에서 다시 읽기 → 또 레이아웃 강제 실행 (n번 반복!)
});
// ✅ 읽기 먼저 일괄, 쓰기 나중 일괄
const widths = Array.from(boxes).map(box => box.offsetWidth); // 읽기 일괄
boxes.forEach((box, i) => {
box.style.width = widths[i] * 2 + 'px'; // 쓰기 일괄
});
// ✅ DocumentFragment — DOM 삽입 횟수 최소화
const fragment = document.createDocumentFragment();
items.forEach(item => {
const li = document.createElement('li');
li.textContent = item.name;
fragment.appendChild(li);
});
list.appendChild(fragment); // 단 한 번의 DOM 삽입
이벤트 위임 — 리스너 수 줄이기
수천 개의 아이템에 각각 이벤트 리스너를 붙이는 것은 메모리 낭비입니다. 공통 부모 하나에만 리스너를 달고 event.target으로 대상을 구별하세요.
// ❌ 아이템마다 리스너 — 메모리 낭비
document.querySelectorAll('.item').forEach(item => {
item.addEventListener('click', handleClick);
});
// ✅ 이벤트 위임 — 리스너 단 1개
document.querySelector('.item-list').addEventListener('click', (e) => {
const item = e.target.closest('.item');
if (!item) return;
handleClick(item);
});
웹 워커 — 메인 스레드 블로킹 해소
JavaScript는 싱글 스레드입니다. 무거운 연산을 메인 스레드에서 실행하면 UI가 멈춥니다. Web Worker로 별도 스레드에서 처리하면 UI를 부드럽게 유지할 수 있습니다.
// worker.js
self.onmessage = function(e) {
const { data, operation } = e.data;
let result;
if (operation === 'sort') {
result = [...data].sort((a, b) => a - b);
} else if (operation === 'filter') {
result = data.filter(n => n % 2 === 0);
}
self.postMessage(result);
};
// main.js
const worker = new Worker('./worker.js');
worker.postMessage({ data: hugeArray, operation: 'sort' });
worker.onmessage = (e) => {
console.log('정렬 완료:', e.data);
renderResults(e.data);
};
번들 최적화 — 코드 스플리팅과 트리 쉐이킹
초기 번들 크기는 Time To Interactive에 직결됩니다. 모든 코드를 하나의 파일로 보내지 말고 필요할 때 불러오세요.
// 동적 import — 지연 로딩
const handleExport = async () => {
// 버튼 클릭 시에만 무거운 라이브러리 로드
const { exportToPDF } = await import('./lib/pdf-exporter.js');
exportToPDF(document.body);
};
// React에서의 코드 스플리팅
import { lazy, Suspense } from 'react';
const HeavyChart = lazy(() => import('./HeavyChart'));
function Dashboard() {
return (
<Suspense fallback={<div>로딩 중...</div>}>
<HeavyChart />
</Suspense>
);
}
requestAnimationFrame — 애니메이션 최적화
애니메이션을 setTimeout으로 구현하면 프레임 타이밍이 맞지 않아 끊깁니다. requestAnimationFrame은 브라우저의 페인트 주기에 맞춰 실행됩니다.
// ❌ setTimeout — 프레임 타이밍 불일치
let pos = 0;
setInterval(() => {
element.style.transform = `translateX(${pos++}px)`;
}, 16);
// ✅ requestAnimationFrame — 브라우저 최적화 활용
let pos = 0;
let rafId;
function animate() {
pos += 2;
element.style.transform = `translateX(${pos}px)`;
if (pos < 500) {
rafId = requestAnimationFrame(animate);
}
}
rafId = requestAnimationFrame(animate);
// 정지
cancelAnimationFrame(rafId);
측정 → 원인 파악 → 개선 → 재측정. 직관에 의존해 무작정 최적화하면 오히려 코드가 복잡해지고 유지보수만 어려워집니다. Chrome DevTools의 Performance 탭, Lighthouse, WebPageTest를 적극 활용하세요.