Ray Book
프론트엔드 성능 최적화

JavaScript 실행, 메인 스레드를 해방하라

Long Task, time slicing, Web Worker, 메인 스레드 병목을 해소하는 방법을 시각화합니다

performancemain-threadlong-taskweb-workerscheduler

Long Task란

브라우저의 메인 스레드는 하나입니다. JavaScript 실행, 스타일 계산, 레이아웃, 페인트, 이벤트 핸들러, 모든 것이 이 단일 스레드에서 순차적으로 처리됩니다.

Long Task 는 메인 스레드를 50ms 이상 차단하는 작업입니다. Long Task가 실행되는 동안 브라우저는 사용자 입력에 반응할 수 없습니다. 버튼을 눌러도, 스크롤을 해도, 타이핑을 해도, 작업이 끝날 때까지 아무 일도 일어나지 않습니다.

Long Task의 주요 원인:

  • 대규모 데이터 처리 (정렬, 필터링, 변환)
  • 복잡한 DOM 조작 (수천 개 요소 삽입)
  • 서드파티 스크립트 (광고, 분석, 소셜 위젯)
  • 큰 JSON 파싱
  • 복잡한 컴포넌트 트리의 렌더링

INP와의 관계

INP (Interaction to Next Paint) 는 사용자 상호작용에서 다음 화면 업데이트까지의 시간을 측정하는 Core Web Vital 지표입니다. 2024년 3월부터 FID를 대체했습니다.

INP는 페이지 생애 주기 전체에서 가장 느린 상호작용의 지연 시간을 측정합니다. 사용자가 클릭하거나 키를 누른 시점에 Long Task가 메인 스레드를 차단하고 있다면, 이벤트 처리가 지연되어 INP 점수가 나빠집니다.

좋은 INP 점수는 200ms 이하 입니다. 500ms를 초과하면 "Poor"로 분류됩니다.

메인 스레드 최적화 시각화

Long Task, time slicing, Web Worker, scheduler.yield(), 메인 스레드 병목을 해소하는 네 가지 접근 방식을 비교합니다.

Step 1Long Task — 200ms 메인 스레드 차단
Main
Long Task200ms
idle
Event
click 대기 ⏳
처리5ms
200ms 동안 메인 스레드가 차단되어 사용자 클릭이 즉시 처리되지 못합니다. 50ms를 초과하는 작업은 Long Task로 분류되며, INP (Interaction to Next Paint) 점수에 직접적인 악영향을 미칩니다.

Time Slicing

200ms짜리 작업을 한 번에 실행하는 대신, 작은 청크로 분할하여 사이사이에 브라우저가 이벤트를 처리할 수 있게 합니다.

// 1만 개 항목을 처리하는 작업을 청크로 분할
async function processItems(items) {
  const CHUNK_SIZE = 100;

  for (let i = 0; i < items.length; i += CHUNK_SIZE) {
    const chunk = items.slice(i, i + CHUNK_SIZE);
    processChunk(chunk);

    // 브라우저에 제어권 반환
    await new Promise(resolve => setTimeout(resolve, 0));
  }
}

setTimeout(fn, 0)은 가장 기본적인 양보 방법이지만, 5단계 이상 중첩되면 최소 4ms의 지연이 발생하고 다른 태스크에 끼어들기가 가능합니다. 더 나은 대안이 있습니다.

MessageChannel , setTimeout의 4ms 제한 없이 즉시 양보합니다.

function yieldToMain() {
  return new Promise(resolve => {
    const channel = new MessageChannel();
    channel.port1.onmessage = resolve;
    channel.port2.postMessage(undefined);
  });
}

async function processItems(items) {
  const CHUNK_SIZE = 100;
  for (let i = 0; i < items.length; i += CHUNK_SIZE) {
    processChunk(items.slice(i, i + CHUNK_SIZE));
    await yieldToMain();
  }
}

scheduler.yield()

scheduler.yield()는 메인 스레드에 제어권을 양보하되, continuation을 우선 스케줄링 합니다. setTimeout이나 MessageChannel과 달리, 양보 후 재개될 때 다른 태스크보다 먼저 실행됩니다.

async function processItems(items) {
  const CHUNK_SIZE = 100;

  for (let i = 0; i < items.length; i += CHUNK_SIZE) {
    processChunk(items.slice(i, i + CHUNK_SIZE));

    // 브라우저에게 제어권을 반환하되, 재개가 우선됨
    if ('scheduler' in globalThis && 'yield' in scheduler) {
      await scheduler.yield();
    }
  }
}

Chrome 129+, Firefox 142+에서 지원됩니다. Safari는 아직 미지원이므로 setTimeout 폴백을 함께 사용합니다.

async function yieldToMain() {
  if (globalThis.scheduler?.yield) {
    return scheduler.yield();
  }
  return new Promise(resolve => setTimeout(resolve, 0));
}

requestIdleCallback

requestIdleCallback은 브라우저가 유휴 상태 일 때 콜백을 실행합니다. 우선순위가 낮은 작업에 적합합니다.

// 분석 데이터 전송, 긴급하지 않은 작업
requestIdleCallback((deadline) => {
  while (deadline.timeRemaining() > 0 && queue.length > 0) {
    const task = queue.shift();
    processAnalytics(task);
  }

  // 남은 작업이 있으면 다음 유휴 시간에 계속
  if (queue.length > 0) {
    requestIdleCallback(processQueue);
  }
}, { timeout: 2000 }); // 2초 내에 반드시 실행

deadline.timeRemaining()으로 남은 유휴 시간을 확인하면서 작업을 처리합니다. timeout 옵션으로 최대 대기 시간을 설정할 수 있습니다.

주의: requestIdleCallback에서 DOM을 변경하면 안 됩니다. 유휴 콜백은 프레임이 끝난 후 실행되므로, DOM 변경이 추가 레이아웃을 유발합니다. DOM 변경은 requestAnimationFrame에서 해야 합니다.

Web Worker

Web Worker는 메인 스레드와 완전히 별도의 스레드에서 JavaScript를 실행합니다. CPU 집약적인 작업을 Worker로 위임하면 메인 스레드가 전혀 차단되지 않습니다.

// worker.js
self.onmessage = (event) => {
  const { data } = event;
  const result = heavyComputation(data);
  self.postMessage(result);
};

// main.js
const worker = new Worker('worker.js');

worker.onmessage = (event) => {
  updateUI(event.data);
};

worker.postMessage(largeDataset);

Worker의 제약 사항:

  • DOM에 접근할 수 없습니다 , document, window 객체가 없습니다
  • postMessage로만 통신합니다 , 데이터는 구조화된 복제 알고리즘으로 복사됩니다
  • 대용량 데이터 전송은 Transferable을 사용합니다 , 복사 대신 소유권을 이전합니다
// ArrayBuffer를 복사 없이 전송
const buffer = new ArrayBuffer(1024 * 1024);
worker.postMessage(buffer, [buffer]); // 두 번째 인자로 transfer 목록
// buffer.byteLength === 0, 소유권이 이전됨

적합한 사용 사례: 이미지/비디오 처리, 대규모 데이터 정렬, 암호화/해시, 텍스트 파싱, WebAssembly 연산.

React startTransition

React 18의 startTransition은 상태 업데이트를 낮은 우선순위 로 표시합니다. 긴급한 업데이트 (입력, 클릭) 가 전환 업데이트보다 우선 처리됩니다.

import { useState, startTransition } from 'react';

function SearchPage() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);

  function handleInput(e) {
    // 긴급: 입력 필드 즉시 반영
    setQuery(e.target.value);

    // 비긴급: 검색 결과는 나중에
    startTransition(() => {
      setResults(filterResults(e.target.value));
    });
  }

  return (
    <>
      <input value={query} onChange={handleInput} />
      <ResultList results={results} />
    </>
  );
}

startTransition 안의 상태 업데이트는 중단 가능합니다. 사용자가 계속 타이핑하면 이전 전환이 취소되고 새 전환이 시작됩니다. 이는 메인 스레드를 차단하지 않으면서 무거운 렌더링을 처리하는 React만의 방식입니다.

체크리스트

  • DevTools Performance 패널에서 50ms 이상 Long Task가 없는가
  • 대규모 데이터 처리를 청크로 분할하고 있는가
  • CPU 집약적 작업을 Web Worker로 위임하고 있는가
  • scheduler.yield()로 메인 스레드에 양보하고 있는가 (폴백 포함)
  • 우선순위가 낮은 작업에 requestIdleCallback을 사용하고 있는가
  • React에서 무거운 상태 업데이트에 startTransition을 사용하고 있는가
  • INP 점수가 200ms 이하인가