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

메모리 누수 감지와 해결

JavaScript의 GC(Garbage Collection)가 메모리를 자동으로 관리하지만, 참조가 남아있으면 메모리가 해제되지 않습니다. 흔한 메모리 누수 원인과 해결책입니다.

  • 이벤트 리스너 누수 — DOM이 제거될 때 리스너를 해제하지 않으면 DOM은 물론 관련 클로저까지 메모리에 남습니다. removeEventListener 또는 AbortController를 사용하세요.
  • 전역 변수 축적 — 실수로 var 없이 선언한 변수나 전역 배열에 계속 추가만 하면 메모리가 증가합니다.
  • 클로저에서 큰 객체 참조 — 타이머나 이벤트 핸들러의 클로저가 큰 객체를 캡처하면 해당 객체가 해제되지 않습니다.
// ❌ 메모리 누수: 리스너를 제거하지 않음
class DataFetcher {
  start() {
    window.addEventListener('resize', this.handleResize.bind(this));
  }
  // stop()에서 removeEventListener 없음
}

// ✅ AbortController로 리스너 일괄 해제
class DataFetcher {
  constructor() {
    this.controller = new AbortController();
  }
  start() {
    window.addEventListener('resize', this.handleResize, {
      signal: this.controller.signal
    });
  }
  stop() {
    this.controller.abort(); // 모든 리스너 한 번에 해제
  }
}

성능 측정 API — PerformanceObserver

Chrome DevTools는 개발 환경에서만 사용하고, 실제 사용자의 성능 데이터를 수집하려면 PerformanceObserver를 사용하세요.

// Long Task 감지 (50ms 이상 걸리는 작업)
const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.duration > 50) {
      console.warn(`Long Task: ${entry.duration.toFixed(2)}ms`, entry);
      // 분석 서버로 전송
      sendToAnalytics('long_task', entry.duration);
    }
  }
});
observer.observe({ entryTypes: ['longtask'] });

// 성능 마킹으로 특정 코드 구간 측정
performance.mark('feature-start');
await loadHeavyFeature();
performance.mark('feature-end');

const measure = performance.measure('heavy-feature', 'feature-start', 'feature-end');
console.log(`소요 시간: ${measure.duration.toFixed(2)}ms`);

JavaScript 성능 최적화 체크리스트

  • 측정 먼저 — Chrome DevTools Performance 탭, Lighthouse로 실제 병목 파악 후 최적화
  • 번들 크기 — Webpack Bundle Analyzer로 큰 의존성 파악, 동적 import로 코드 분할
  • Long Tasks 분리 — 50ms 이상 걸리는 작업은 requestIdleCallback이나 Web Worker로 분리
  • DOM 조작 최소화 — DocumentFragment나 가상 DOM 패턴으로 리플로우 횟수 감소
  • 이미지 최적화 — WebP/AVIF 형식, 적절한 크기, 지연 로딩 적용
  • 메모리 누수 — DevTools Memory 탭의 Heap Snapshot으로 누수 감지

성능 최적화의 역설 — 과도한 최적화를 피하는 법

성능 최적화를 배우다 보면 모든 코드를 최적화하려는 욕구가 생깁니다. 하지만 실제로 사용자가 느끼지 못하는 수준의 최적화에 과도한 시간을 쓰는 것은 오히려 비생산적입니다. 도널드 크누스의 유명한 말처럼 "과도한 최적화는 모든 악의 근원"이라는 원칙이 여기에도 적용됩니다. 최적화는 항상 측정에서 시작해야 합니다. 실제로 느린 부분이 어디인지 Chrome DevTools나 Lighthouse로 확인하기 전에 직관으로 최적화하면 엉뚱한 곳에 시간을 낭비할 수 있습니다. 프로파일링 결과에서 실제 병목이 확인된 부분만 집중적으로 개선하는 것이 올바른 순서입니다.

코드 가독성과 성능은 때로 충돌합니다. 메모이제이션이나 웹 워커 분리처럼 복잡성을 높이는 최적화는, 그만한 성능 이득이 측정으로 확인될 때만 도입해야 합니다. 사용자가 실제로 체감할 수 있는 개선 — 페이지 초기 로딩 2초에서 1초로 단축, 스크롤 끊김 해소, 버튼 클릭 반응 개선 — 이런 것에 먼저 집중하는 것이 비즈니스와 사용자 경험 모두에 유리합니다. 성능 예산(Performance Budget)을 팀 차원에서 정의하고, 그 기준을 유지하는 것만으로도 대부분의 성능 문제를 예방할 수 있습니다.