scheduler.yield()는 긴 자바스크립트 작업 중간에 "메인 스레드를 잠깐 브라우저에 돌려줬다가 다시 이어받는" 표준 API다. 긴 작업(long task)을 여러 조각으로 쪼개, 그 사이사이에 사용자 입력에 응답할 틈을 만들어 INP(Interaction to Next Paint)를 개선한다.
버튼을 눌렀는데 화면이 0.5초쯤 얼어붙는 경험, 다들 있을 것이다. 원인은 대개 하나다. 메인 스레드가 긴 작업에 붙잡혀 있어서 클릭에 반응할 짬이 없는 것.
문제 상황 — 메인 스레드는 한 줄
브라우저의 메인 스레드는 자바스크립트 실행, 레이아웃, 페인트를 전부 처리한다. 한 번에 하나씩. 그래서 50ms를 넘기는 작업(long task)이 실행 중이면 그동안 클릭·입력·스크롤은 큐에서 대기한다. 사용자 눈엔 "버벅임"으로 보인다.
예전엔 이걸 setTimeout(fn, 0)이나 await new Promise(r => setTimeout(r))로 우회했다. 동작은 하지만 두 가지가 아쉬웠다. 우선순위 제어가 안 되고, 타이머 클램핑 때문에 실제로는 몇 ms씩 지연이 붙었다.
원인을 눈으로 확인하기
추측하지 말고 재보자. web-vitals 라이브러리의 attribution 빌드를 쓰면 어떤 인터랙션이 느렸는지, 어느 long task 때문인지 바로 잡힌다.
import { onINP } from 'web-vitals/attribution'
onINP((metric) => {
const a = metric.attribution
console.log(metric.value, a.interactionTarget, a.longAnimationFrameEntries)
}, { reportAllChanges: true })
여기서 값이 200ms를 넘고, 원인 프레임에 내 계산 로직이 찍힌다면 후보가 확정된 거다.
해결 — 작업을 쪼개고 양보한다
핵심 아이디어는 단순하다. 큰 반복문을 돌다가 일정 시간마다 await scheduler.yield()로 제어를 양보한다. 그러면 브라우저가 대기 중인 클릭을 먼저 처리하고, 곧바로 내 작업의 다음 조각을 이어서 실행한다.
async function processAll(items) {
let last = performance.now()
for (const item of items) {
doHeavyWork(item)
// 50ms 넘게 붙잡았으면 양보
if (performance.now() - last > 50) {
await scheduler.yield()
last = performance.now()
}
}
}
scheduler.yield()가 setTimeout보다 나은 결정적 이유는 양보 후 우선 재개다. 양보하면서 새 태스크를 큐 맨 뒤에 던지는 게 아니라, 사용자 입력을 처리한 뒤 내 작업을 우선적으로 이어준다. 그래서 전체 작업이 뒤로 밀리며 늘어지지 않는다.
대안들과 비교
| 방법 | 우선 재개 | 비고 |
|---|---|---|
| setTimeout(0) | 아니오 | 클램핑 지연 있음 |
| requestIdleCallback | 아니오 | 한가할 때만, UI 작업엔 부적합 |
| scheduler.yield() | 예 | 양보 후 즉시 이어받음 |
| Web Worker | 해당 없음 | DOM 못 만짐, 순수 계산에 최적 |
언제 쓰고, 언제 쓰지 말지
양보는 공짜가 아니다. 조각을 나눌 때마다 약간의 오버헤드가 있고, 무엇보다 DOM을 건드리지 않는 순수 계산이라면 애초에 Web Worker로 통째로 넘기는 편이 낫다. scheduler.yield()가 빛나는 자리는 이렇다.
- 중간중간 DOM을 갱신해야 해서 워커로 못 보내는 작업
- 클릭 핸들러 안에서 목록을 대량 가공한 뒤 렌더하는 경우
- 초기 부팅 시 여러 초기화 루틴을 순차 실행하는 구간
반대로 이미 60ms짜리 짧은 작업이라면 굳이 쪼갤 필요 없다. 나는 프로파일러로 long task부터 찾고, 딱 그 지점만 손대는 걸 원칙으로 한다. 미지원 브라우저를 위해 if ('scheduler' in window && 'yield' in scheduler) 가드나 폴리필을 두면 안전하다.
자주 묻는 질문
scheduler.yield()와 startTransition은 뭐가 다른가요?
React의 startTransition은 상태 업데이트의 우선순위를 낮춰 렌더를 중단 가능하게 만듭니다. scheduler.yield()는 렌더와 무관한 일반 JS 루프를 쪼개는 저수준 API입니다. 둘은 층위가 다르며 함께 쓸 수도 있습니다.
양보를 자주 하면 전체 작업이 더 느려지지 않나요?
총 실행 시간은 약간 늘 수 있지만, 사용자 체감 응답성(INP)은 크게 좋아집니다. 트레이드오프를 이해하고 50ms 안팎을 기준으로 양보 빈도를 잡으면 균형이 맞습니다.
지원하지 않는 브라우저는 어떻게 하나요?
기능 감지로 분기하거나, 없을 때는 await new Promise(r => setTimeout(r))로 폴백하세요. 최근에는 공식 폴리필도 있어 그대로 얹어 쓸 수 있습니다.
INP 목표치는 얼마인가요?
75퍼센타일 기준 200ms 이하가 'good', 500ms를 넘으면 'poor'입니다. 실사용자 데이터(RUM)로 측정해야 실제 개선 여부를 알 수 있습니다.

댓글 0