자바스크립트 Long Tasks 최적화 완벽 가이드: scheduler.yield로 INP 개선하기 (2026)

자바스크립트 Long Tasks를 scheduler.yield()와 LoAF API로 최적화하는 방법을 실전 코드와 함께 정리했다. 태스크 청킹, Web Worker 활용, React·Next.js 통합, INP 측정 전략까지 다룬다.

Long Tasks 최적화: scheduler.yield로 INP 개선

업데이트: 2026년 7월 25일

자바스크립트 Long Tasks는 메인 스레드를 50ms 이상 점유하는 작업으로, 사용자 입력 응답을 지연시켜 INP(Interaction to Next Paint)를 악화시키는 주범이다. 2026년 현재 크롬 129+에서 안정화된 scheduler.yield()와 크롬 123+의 Long Animation Frames(LoAF) API를 활용하면, 긴 작업을 프레임 단위로 쪼개어 상호작용 지연 없이 처리할 수 있다. 솔직히 이 가이드는 나사 하나 조이는 수준의 정밀함으로, 실제 트레이스와 함께 최적화 전략을 정리한다.

  • Long Task는 메인 스레드를 50ms 이상 점유하는 작업이며, 이는 60fps 렌더링 예산(16.67ms)의 3배에 해당해 사용자 입력 응답을 지연시킨다.
  • 2024년 W3C는 Long Tasks API를 폐지 예정(deprecated)으로 표시하고, 크롬 123+에서 LoAF(Long Animation Frames) API를 정식 대체 표준으로 지정했다.
  • scheduler.yield()는 크롬 129(2024년 9월)부터 안정 지원되며, setTimeout(0)과 달리 원래 태스크의 우선순위와 연속성을 유지한다.
  • scheduler.postTask()user-blocking, user-visible, background 세 가지 우선순위로 태스크를 명시적으로 분류할 수 있다.
  • 메인 스레드 최적화는 INP를 200ms 이하로 유지하는 가장 확실한 방법이며, 실제 프로덕션에서 500ms에서 150ms로 개선한 사례가 다수 보고되고 있다.

Long Task란 무엇이며 왜 50ms가 기준인가

Long Task는 브라우저 메인 스레드에서 50밀리초 이상 연속으로 실행되는 자바스크립트 작업을 의미한다. 이 기준은 W3C Web Performance Working Group이 2017년 Long Tasks API 스펙에서 확정한 값으로, 두 가지 근거에 기반한다. 첫째, 60fps 디스플레이의 프레임 예산은 16.67ms이며, 50ms는 이 예산을 3프레임 연속으로 초과하는 시점이다. 둘째, RAIL 모델은 사용자 입력에 100ms 이내로 응답할 것을 요구하는데, 브라우저가 이벤트를 감지하고 핸들러를 실행하는 오버헤드를 감안하면 실제 스크립트 예산은 약 50ms로 좁혀진다.

내가 프로파일러에서 처음 확인하는 지점은 항상 이것이다. 개발자 도구 → Performance 패널에서 트레이스를 녹화하면, 50ms를 초과한 태스크는 오른쪽 상단에 붉은 삼각형으로 표시된다. 이 표식이 상호작용 직전에 몰려 있다면 그 페이지는 INP가 나쁠 수밖에 없다. 지난 프로젝트 한 곳에서 검색 자동완성 로직이 단일 태스크로 320ms를 잡아먹고 있는 걸 본 적이 있다. 사용자는 키 한 번을 누른 뒤 다음 반응까지 세 프레임을 기다렸다.

Long Task가 발생하는 대표적인 원인은 다음과 같다.

  • 대규모 배열 순회: 수천 개의 아이템에 대해 동기적으로 map, filter, reduce를 체이닝하는 경우
  • 번들 파싱과 실행: 초기 로드에서 500KB 이상의 자바스크립트가 한 번에 컴파일·실행되는 경우
  • 동기적 DOM 조작: innerHTML로 대량의 마크업을 삽입하거나 강제 레이아웃(forced synchronous layout)이 반복되는 경우
  • 서드파티 스크립트: 광고·분석 태그가 document.write나 무거운 초기화 로직을 실행하는 경우

Long Task 감지하기: LoAF API와 PerformanceObserver

2024년 W3C는 기존 Long Tasks API를 폐지 예정으로 표시하고, Long Animation Frames(LoAF) API를 후속 표준으로 지정했다. LoAF는 크롬 123부터 정식 지원되며, 단순히 "50ms를 넘겼다"는 신호만 주는 것이 아니라 렌더링·스타일·레이아웃·스크립트 실행 시간을 프레임 단위로 분해해 준다. 즉 어느 스크립트가 어느 소스에서 얼마나 오래 걸렸는지까지 알려준다.

// LoAF 감지 (크롬 123+)
const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.duration < 50) continue;

    console.group(`LoAF ${entry.duration.toFixed(1)}ms @ ${entry.startTime.toFixed(0)}ms`);
    console.log('렌더 시작:', entry.renderStart);
    console.log('스타일/레이아웃:', entry.styleAndLayoutStart);
    console.log('블로킹 지속시간:', entry.blockingDuration);

    // 어느 스크립트가 시간을 소모했는지 breakdown
    for (const script of entry.scripts) {
      console.log(
        `  ${script.invoker} (${script.sourceURL}): ` +
        `execution ${script.executionStart - script.startTime}ms, ` +
        `forced style/layout ${script.forcedStyleAndLayoutDuration}ms`
      );
    }
    console.groupEnd();
  }
});

observer.observe({ type: 'long-animation-frame', buffered: true });

이 코드를 프로덕션에 배포하면 각 프레임의 병목을 정확히 파악할 수 있다. 특히 script.invoker는 이벤트 핸들러 이름이나 setTimeout·promise-resolve 같은 태스크 소스를 알려주고, script.sourceURL은 서드파티 스크립트를 즉시 식별할 수 있게 해준다. 나는 이 API를 활용해 RUM 대시보드를 구성해 두는 것을 강력히 권장한다. 실험실 환경(Lighthouse)에서 잡히지 않는 실제 사용자 환경의 병목을 보려면, 이 방법이 사실상 유일하다.

참고로 기존 longtask 엔트리 타입도 여전히 동작한다. 다만 LoAF가 지원되는 환경에서는 LoAF를 우선 사용하고 폴백으로만 두는 것을 권장한다. INP 자체를 얼마나 개선해야 하는지에 대한 기준은 INP 완벽 가이드에서 자세히 다뤘다.

scheduler.yield()로 태스크 양보하기

scheduler.yield()현재 실행 중인 태스크를 잠시 양보한 뒤 원래 우선순위로 다시 재개하도록 브라우저에 요청하는 최신 API다. 크롬 129(2024년 9월)부터 플래그 없이 안정 지원되며, 2026년 현재 엣지·오페라를 포함한 크로미움 계열 브라우저 전반에서 사용 가능하다. 공식 크롬 문서에 따르면, setTimeout(0)이 새 태스크를 큐 끝에 추가해 다른 태스크에 밀릴 수 있는 반면, scheduler.yield()연속성 보장(continuation)이 있어 양보 후에도 원래 태스크가 우선적으로 재개된다.

// AS-IS: 5000개 아이템을 한 번에 처리 (300ms 이상 블록 가능)
function renderList(items) {
  for (const item of items) {
    processItem(item);
  }
}

// TO-BE: 매 50개마다 프레임에 양보 (각 청크는 < 10ms 유지)
async function renderList(items) {
  for (let i = 0; i < items.length; i++) {
    processItem(items[i]);

    // 50개마다 양보. yield는 Promise를 반환
    if (i % 50 === 0 && 'scheduler' in window && 'yield' in scheduler) {
      await scheduler.yield();
    }
  }
}

scheduler.yield()setTimeout(0)·queueMicrotask()의 결정적 차이는 다음과 같다.

특성scheduler.yield()setTimeout(0)queueMicrotask()
렌더링 프레임 양보예 (브라우저 페인트 및 입력 이벤트 처리 가능)아니오 (현재 태스크 내 실행)
우선순위 보존연속으로 재개 (continuation)큐 끝으로 이동 (기아 가능)해당 없음
입력 이벤트 우선순위양보 후 대기 중인 사용자 입력이 먼저 실행동일아니오
최소 지연0ms (프레임 정렬)4ms (HTML 스펙 최소 클램프)0ms
브라우저 지원Chrome/Edge 129+모든 브라우저모든 최신 브라우저

즉 사용자가 스크롤이나 클릭 이벤트를 발생시켰다면, scheduler.yield()는 해당 입력 처리를 먼저 진행한 뒤 다시 원래 반복문으로 돌아온다. 이것이 INP를 극적으로 개선하는 열쇠다.

브라우저 폴리필 패턴

// 크로스 브라우저 안전 폴리필
async function yieldToMain() {
  // Chrome/Edge 129+: 우선순위 유지
  if ('scheduler' in window && 'yield' in scheduler) {
    return scheduler.yield();
  }
  // Chrome 94+: postTask로 우선순위 명시
  if ('scheduler' in window && 'postTask' in scheduler) {
    return scheduler.postTask(() => {}, { priority: 'user-visible' });
  }
  // 레거시 폴백
  return new Promise(resolve => setTimeout(resolve, 0));
}

scheduler.postTask()와 우선순위 기반 스케줄링

scheduler.postTask()는 태스크에 명시적인 우선순위를 부여해 예약할 수 있는 API로, 크롬 94부터 지원된다. 세 가지 우선순위는 다음과 같다.

  • user-blocking: 사용자가 즉시 결과를 봐야 하는 작업. 예: 검색 자동완성 렌더링. 다른 마이크로태스크와 유사한 우선순위로 실행된다.
  • user-visible (기본값): 사용자가 곧 볼 작업. 예: 화면 밖 콘텐츠 프리렌더.
  • background: 급하지 않은 작업. 예: 로그 전송, 분석 데이터 배치 처리.
// 우선순위별 작업 분류 예시
async function loadDashboard() {
  // 즉시 화면에 보여야 할 카드
  const primaryData = await scheduler.postTask(
    () => fetchAndRenderTopMetrics(),
    { priority: 'user-blocking' }
  );

  // 스크롤해야 보이는 콘텐츠
  scheduler.postTask(
    () => prerenderBelowFold(),
    { priority: 'user-visible' }
  );

  // 사용자 인지 필요 없는 백그라운드 작업
  scheduler.postTask(
    () => sendAnalyticsBatch(),
    { priority: 'background' }
  );
}

AbortController와 결합하면 실행 전 태스크를 취소할 수도 있다. 예를 들어 라우트가 변경되어 이전 화면의 백그라운드 프리렌더가 불필요해진 경우, 컨트롤러의 abort()를 호출해 대기 큐에서 즉시 제거할 수 있다. SPA에서 특히 유용하다.

const controller = new AbortController();

scheduler.postTask(heavyRender, {
  priority: 'background',
  signal: controller.signal
});

// 라우트 변경 시 취소
router.on('change', () => controller.abort());

태스크 청킹(chunking) 패턴 실전 적용

실무에서 자주 마주치는 패턴을 세 가지로 정리한다. 각 패턴은 실제 프로덕션 코드에서 검증된 것이며, 5ms 예산 안에서 청크를 관리하는 원칙을 공유한다.

패턴 1: 시간 기반 청킹(time-slicing)

고정 개수가 아니라 경과 시간을 기준으로 양보 지점을 결정하는 방식이다. 아이템별 처리 시간이 불균일한 경우(예: 이미지 디코딩) 훨씬 안정적이다.

async function processTimeSliced(items, budgetMs = 5) {
  let deadline = performance.now() + budgetMs;

  for (const item of items) {
    processItem(item);

    if (performance.now() > deadline) {
      await yieldToMain();
      deadline = performance.now() + budgetMs;
    }
  }
}

패턴 2: isInputPending으로 사용자 입력 우선 반응

navigator.scheduling.isInputPending()는 대기 중인 사용자 입력이 있는지 확인해 준다. 평소에는 계속 실행하되, 입력이 감지되면 즉시 양보하는 하이브리드 전략이 가능하다.

async function processReactive(items) {
  for (const item of items) {
    processItem(item);

    if (navigator.scheduling?.isInputPending()) {
      await yieldToMain();
    }
  }
}

패턴 3: requestIdleCallback으로 비긴급 작업 처리

사용자 인지 없이 처리 가능한 작업은 requestIdleCallback으로 유휴 시간에 실행한다. timeRemaining()으로 브라우저가 남긴 시간을 확인해 그 안에서만 처리한다.

function processIdle(items, index = 0) {
  requestIdleCallback((deadline) => {
    while (deadline.timeRemaining() > 0 && index < items.length) {
      processItem(items[index++]);
    }
    if (index < items.length) processIdle(items, index);
  }, { timeout: 2000 });
}

Web Workers로 CPU 집약 작업 오프로딩

청킹으로도 해결이 안 되는 진짜 무거운 계산, 이를테면 이미지 처리, 대용량 JSON 파싱, 암호화, 텍스트 인덱싱 같은 작업은 Web Worker로 별도 스레드에 격리해야 한다. Worker는 메인 스레드를 절대 블록하지 않으므로, 몇 초가 걸리는 작업도 UI 응답성에 영향을 주지 않는다. MDN의 Web Workers 사용 가이드는 스레드 간 메시지 프로토콜의 세부 사항을 잘 정리해 두었다.

// main.js
const worker = new Worker('/workers/heavy-compute.js', { type: 'module' });

worker.postMessage({ type: 'INDEX_DOCUMENTS', payload: documents });

worker.onmessage = ({ data }) => {
  if (data.type === 'INDEXED') {
    updateUI(data.payload);
  }
};

// workers/heavy-compute.js
self.onmessage = ({ data }) => {
  if (data.type === 'INDEX_DOCUMENTS') {
    const index = buildInvertedIndex(data.payload);
    self.postMessage({ type: 'INDEXED', payload: index });
  }
};

Comlink 같은 라이브러리를 사용하면 postMessage의 콜백 지옥을 피하고 Promise 기반 API로 간결하게 처리할 수 있다. 다만 Worker는 전송 비용(직렬화)이 있으므로, 처리 시간이 5ms 이하인 소규모 작업까지 무조건 Worker로 넘기면 오히려 손해가 된다. 50ms 이상 걸리는 순수 CPU 작업이 Worker의 스위트 스팟이다.

더불어 서버 응답 자체가 느려서 상호작용이 지연되는 경우가 있는데, 이는 별개의 문제다. TTFB 관련 튜닝은 TTFB 800ms 이하로 줄이는 서버 응답 최적화 가이드에서 다뤘다.

최적화 후 INP 개선 효과 측정

최적화를 적용했다면 반드시 개선 효과를 정량적으로 측정해야 한다. 실험실(Lab)과 실사용자(RUM), 두 축에서 접근한다.

Lab: Chrome DevTools + 커스텀 페인트 마킹

Performance 패널을 열고 CPU 4x slowdown 옵션으로 저사양 기기를 시뮬레이션한 뒤, 인터랙션(예: 검색어 입력)을 수행한다. 트레이스에서 다음을 확인한다.

  • Interactions 트랙에서 Duration이 200ms 미만인가
  • Main 트랙에서 붉은 삼각형(Long Task) 마커가 사라졌는가
  • Frames 트랙의 프레임 간격이 16.67ms 근처인가

RUM: web-vitals 라이브러리 + INP 어트리뷰션

import { onINP } from 'web-vitals/attribution';

onINP(({ value, attribution }) => {
  // 어트리뷰션은 어떤 이벤트/요소가 원인이었는지 알려준다
  navigator.sendBeacon('/rum', JSON.stringify({
    metric: 'INP',
    value,
    interactionTarget: attribution.interactionTarget,
    inputDelay: attribution.inputDelay,
    processingDuration: attribution.processingDuration,
    presentationDelay: attribution.presentationDelay,
    loafScripts: attribution.longAnimationFrameEntries
      ?.flatMap(e => e.scripts.map(s => s.sourceURL))
  }));
});

이 데이터를 대시보드로 시각화하면 75th percentile INP를 배포별로 추적할 수 있다. 나는 이걸 GA4 이벤트로 보내 배포 태그와 조인해서 회귀를 감지한다. 크롬 UX 리포트(CrUX)도 좋은 보조 지표지만, RUM만이 배포 즉시 반응을 준다.

React·Vue·Next.js에서의 적용

프레임워크별로 Long Task를 유발하는 지점과 최적화 지점이 조금씩 다르다.

React 19

React 19의 useDeferredValuestartTransition은 내부적으로 MessageChannel과 유사한 스케줄러를 사용하지만, 프레임 예산을 감지해 자동으로 양보한다. 무거운 리스트 렌더링은 트랜지션으로 감싸고, 검색 결과 하이라이팅 같은 후속 계산은 useDeferredValue로 분리한다.

function SearchResults({ query }) {
  const deferredQuery = useDeferredValue(query);
  const results = useMemo(
    () => expensiveFilter(items, deferredQuery),
    [deferredQuery]
  );
  return <List items={results} />;
}

Vue 3

Vue는 defineAsyncComponent<Suspense>를 활용해 큰 컴포넌트를 지연 로드한다. 반복 렌더링이 많은 리스트에는 vue-virtual-scroller를 붙여, 실제 DOM에는 화면에 보이는 아이템만 유지한다.

Next.js 15

Next.js 15의 앱 라우터는 서버 컴포넌트로 CPU 부담을 서버 측으로 넘기는 것이 첫 번째 최적화다. 클라이언트 컴포넌트가 필수인 경우, next/dynamic으로 지연 로드하고 ssr: false 옵션을 신중히 사용해 초기 하이드레이션 부담을 줄인다.

// app/page.tsx
import dynamic from 'next/dynamic';

const HeavyChart = dynamic(() => import('./HeavyChart'), {
  ssr: false,
  loading: () => <ChartSkeleton />,
});

이미지 렌더링이 상호작용을 지연시키는 사례도 자주 본다. 이 경우 AVIF/WebP 이미지 최적화 전략과 병행하면, 렌더링 파이프라인 전체가 가벼워진다.

자주 묻는 질문

Long Task는 정확히 몇 밀리초부터 문제가 되나요?

W3C 스펙 기준 50ms가 임계값이지만, INP 관점에서는 단일 태스크가 50ms를 넘기지 않는 것이 목표다. INP는 이벤트에 반응하는 데 걸린 총 시간을 측정하므로, 각 태스크가 짧아도 여러 개가 연달아 붙으면 총합은 여전히 나쁠 수 있다. 실무에서는 각 태스크를 5~16ms 이내로 유지하는 것을 권장한다.

scheduler.yield()와 setTimeout(0)의 차이는 무엇인가요?

setTimeout(0)은 새 태스크를 큐 끝에 추가해 다른 태스크 뒤로 밀릴 수 있고, 4ms 최소 클램프가 있다. 반면 scheduler.yield()연속성(continuation)을 보장해 양보 후에도 원래 태스크가 우선적으로 재개되며, 사용자 입력 이벤트가 대기 중이면 그것을 먼저 처리한다.

Long Tasks API는 이제 사용하면 안 되나요?

여전히 동작하지만 W3C가 폐지 예정으로 표시했다. 크롬 123 이상에서는 Long Animation Frames(LoAF) API를 사용하는 것을 권장한다. LoAF는 프레임 단위로 세분화된 정보를 제공하며, 스크립트 소스와 실행 시간까지 알려준다.

모든 반복문에 scheduler.yield()를 넣어야 하나요?

아니다. 반복문의 총 실행 시간이 50ms를 훨씬 밑돈다면 yield 호출 자체가 오버헤드가 된다. Performance 패널로 실제로 Long Task가 발생하는 반복문을 먼저 식별한 뒤, 5ms 예산 기반 청킹을 적용하는 것이 정석이다.

Web Worker를 쓰면 Long Task 문제가 완전히 해결되나요?

Worker는 메인 스레드를 블록하지 않지만, 결과를 다시 메인 스레드로 postMessage로 전달할 때 직렬화·역직렬화 비용이 발생한다. 결과 데이터가 매우 크다면 이 메시지 처리 자체가 Long Task가 될 수 있다. 이 경우 Transferable 객체(예: ArrayBuffer)를 사용하거나 결과를 청킹해서 여러 번 전달한다.

Alex Petrov
저자 소개 Alex Petrov

Web performance engineer who treats every millisecond as a personal challenge. Has profiled more sites than he can count.