블로그 목록
frontend

scheduler.yield()로 긴 작업 쪼개 UI 멈춤 풀기 — setTimeout(0) 꼼수 그만

버튼 하나 눌렀더니 화면이 몇백 밀리초 동안 얼어붙는 경험, 다들 있을 거다. 리스트 수천 개를 한 번에 가공하거나, 큰 JSON을 돌면서 변환하는 코드가 메인 스레드를 통째로 붙잡고 있으면 그 사이 클릭도 스크롤도 입력도 다 밀린다. INP(Interaction to Next Paint)가 Core Web Vital에 들어오면서 이 '긴 작업(long task)'이 더 눈에 띄는 지표가 됐다.

예전에 쓰던 방법은 setTimeout(fn, 0)로 작업을 잘게 나눠 매크로태스크 큐에 흘려보내는 꼼수였다. 동작은 하는데, 지연이 들쭉날쭉하고(브라우저마다 최소 4ms 클램핑) 어디까지 처리했는지 상태를 직접 들고 다녀야 해서 코드가 지저분해진다.

yield 한 줄이면 끝

scheduler.yield()는 지금 실행 흐름을 "여기서 한 번 끊고, 급한 거 먼저 처리한 뒤 이어서 와" 하고 브라우저에 양보하는 Promise다. await 한 줄이면 된다.

async function processAll(items) {
  for (const [i, item] of items.entries()) {
    heavyTransform(item); // 무거운 동기 작업

    // 50개마다 메인 스레드를 양보
    if (i % 50 === 0 && 'scheduler' in window && 'yield' in scheduler) {
      await scheduler.yield();
    }
  }
}

await scheduler.yield()를 만나면 거기서 루프가 잠깐 멈추고, 대기 중이던 클릭·입력 이벤트가 먼저 처리된다. 그게 끝나면 끊었던 바로 그 자리에서 이어서 돈다. setTimeout처럼 인덱스를 밖에 저장했다가 다음 콜백에서 복원할 필요가 없다. 그냥 평범한 for 루프에 await 한 줄 끼운 게 전부다.

setTimeout보다 나은 점

핵심은 우선순위다. setTimeout으로 넘긴 나머지 작업은 큐 맨 뒤로 가서 다른 태스크들과 똑같이 줄을 선다. 반면 scheduler.yield()로 이어지는 작업은 큐 앞쪽에 들어가서, 사용자 상호작용은 먼저 처리하되 내 작업은 다른 잡다한 태스크보다 빨리 재개된다. "양보는 하되 새치기당하진 않는" 셈이라 전체 처리 시간이 덜 늘어난다.

쓰기 전 체크할 것

아직 모든 브라우저가 지원하는 건 아니다(크로미움 계열 먼저 들어왔다). 위 예제처럼 'scheduler' in window && 'yield' in scheduler로 가드를 두거나, 폴리필을 쓰고 미지원 환경에선 그냥 쭉 돌게 두면 된다. 양보가 안 될 뿐 결과는 똑같다.

정리하면, UI가 버벅이는 원인이 긴 동기 루프라면 await scheduler.yield() 한 줄부터 넣어보자. 워커로 옮기는 큰 공사 없이도 INP 체감이 확 달라진다. 반대로 애초에 CPU를 오래 먹는 연산 자체라면 그건 Web Worker 쪽을 봐야 한다 — yield는 '쪼갤 수 있는' 작업에만 통한다.