자바스크립트 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의 스위트 스팟이다.
이 데이터를 대시보드로 시각화하면 75th percentile INP를 배포별로 추적할 수 있다. 나는 이걸 GA4 이벤트로 보내 배포 태그와 조인해서 회귀를 감지한다. 크롬 UX 리포트(CrUX)도 좋은 보조 지표지만, RUM만이 배포 즉시 반응을 준다.
React·Vue·Next.js에서의 적용
프레임워크별로 Long Task를 유발하는 지점과 최적화 지점이 조금씩 다르다.
React 19
React 19의 useDeferredValue와 startTransition은 내부적으로 MessageChannel과 유사한 스케줄러를 사용하지만, 프레임 예산을 감지해 자동으로 양보한다. 무거운 리스트 렌더링은 트랜지션으로 감싸고, 검색 결과 하이라이팅 같은 후속 계산은 useDeferredValue로 분리한다.
이미지 렌더링이 상호작용을 지연시키는 사례도 자주 본다. 이 경우 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)를 사용하거나 결과를 청킹해서 여러 번 전달한다.