"작동은 하는데 느리다." 개발자라면 누구나 한 번쯤 들어봤을 불평입니다. 기능 개발이 끝난 뒤 성능 문제가 수면 위로 드러나는 경우가 많죠. 이 글에서는 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를 적극 활용하세요.