브라우저의 메인 스레드는 언제나 바쁘다

현대 웹 애플리케이션, 특히 복잡한 UI와 수많은 서드파티 스크립트(GTM, Ads, Maps 등)를 품은 페이지에서 메인 스레드 블로킹은 필연적입니다. 메인 스레드가 막히면 TBT(Total Blocking Time)가 치솟고, 사용자의 첫 입력 지연(FID)이 악화됩니다.

 

초기 렌더링에 당장 필요 없는 작업들을 어떻게 영리하게 뒤로 미룰 것인가?

그 해답으로 적용했던 requestIdleCallback에 대해서 정리하고자 합니다.

 


 

requestIdleCallback은 어떻게 동작하는가?

우리가 매일 사용하는 웹 브라우저는 겉보기에 단순해 보이지만, 사용자에게 끊김 없는 화면을 보여주기 위해 1초에 60번(60Hz 기준) 엄청난 속도로 화면을 다시 그립니다. 이 한 프레임을 그리는 데 주어진 시간은 단 16.6ms. 이 짧은 시간 동안 브라우저 내부에서 어떤 일이 일어나는지, 렌더링 파이프라인의 생명주기를 단계별로 핵심만 짚어보겠습니다.

 

1단계: JavaScript Execution (자바스크립트 실행)

모든 변화의 시작점입니다. 사용자의 클릭, 스크롤, 타이머 등 다양한 이벤트에 의해 자바스크립트 코드가 실행됩니다. 이 단계에서 개발자가 작성한 코드에 의해 DOM이나 CSSOM의 구조가 변경될 수 있습니다. (CPU가 담당합니다.)

 

2단계: Style (스타일 계산/Recalculate Style)

DOM의 변화가 생기면, 브라우저는 각 요소에 어떤 CSS 규칙이 적용되어야 하는지 다시 계산합니다.
예를 들어, 특정 클래스가 추가되었다면 그 클래스에 정의된 스타일을 요소에 매칭시키고, 최종적으로 각 요소가 가질 스타일 속성들을 결정합니다.

 

3단계: Layout (레이아웃/Reflow)

스타일 계산이 끝나면, 브라우저는 각 요소가 화면의 어디에, 어떤 크기로 배치될지 결정합니다. 이것을 '레이아웃' 또는 '리플로우'라고 합니다. 요소의 너비, 높이, 위치 등이 이 단계에서 확정됩니다. 블루프린트를 그리는 과정과 비슷합니다.

 

4단계: Paint (페인트/Rasterization)

이제 각 요소의 스타일과 위치를 알았으니, 실제 화면에 그릴 '명령'을 생성합니다. 배경색, 텍스트, 이미지 등을 그리는 작업으로, 이 과정을 통해 요소들을 픽셀로 변환하는 '래스터화' 명령이 준비됩니다.

 

5단계: Composite (합성/Compositing)

드디어 마지막 단계입니다! 브라우저는 여러 개의 레이어(layer)를 사용하여 화면을 효율적으로 그립니다. 페인트 단계에서 생성된 그리기 명령을 바탕으로, 각 레이어를 순서대로 겹쳐서 최종 화면 이미지를 완성합니다. 이 최종 이미지를 GPU에 전달하여 화면에 출력하게 됩니다.

 

사용자는 1초에 60번 화면이 바뀌어야 "끊김 없이 부드럽다"고 느낍니다. 따라서 이 5가지 단계는 모두 합쳐 단 16.6ms 안에 완료되어야 합니다.

 

앞서 살펴본 5단계의 과정(JavaScript → Style → Layout → Paint → Composite)이 모두 끝났다고 해서 16.6ms가 바로 다 소모되는 것은 아닙니다. 

 


 

유휴 시간(Idle Period)의 발견

브라우저가 레이아웃을 계산하고 화면을 그리는 작업을 예상보다 빨리, 예를 들어 10ms 만에 끝냈다고 가정해 봅시다.

그러면 다음 프레임이 시작되기 전까지 약 6.6ms라는 '빈 시간'이 남게 됩니다.

브라우저는 이 시간을 그냥 놀리지 않고, "지금 좀 한가한데, 미뤄뒀던 가벼운 일 좀 할까?"라고 판단합니다.

이때 호출되는 것이 바로 requestIdleCallback입니다.

 

 

 

requestIdleCallback의 동작 원리

메인 스레드에서 가장 중요한 렌더링 작업이 끝난 뒤에 실행되는 최하위 우선순위 콜백입니다.

브라우저는 콜백 함수에 deadline 객체를 전달합니다. deadline.timeRemaining()을 호출해 현재 남은 유휴 시간이 몇 ms인지 확인할 수도 있습니다. 만약 브라우저가 너무 바빠서 16.6ms를 꽉 채워 쓴다면, requestIdleCallback은 이번 프레임에서 실행되지 않고 다음 유휴 시간이 생길 때까지 뒤로 밀립니다.

 

 

 

우리가 setTimeout이나 일반적인 스크립트로 무거운 작업을 돌리면 렌더링 파이프라인을 방해해 프레임 드롭(버벅임)을 일으킬 수 있습니다. 하지만 requestIdleCallback을 사용하면:

  1. 사용자 경험 최적화: 화면이 그려지는 중요한 순간을 방해하지 않습니다.
  2. 효율적인 자원 활용: 브라우저가 쉬는 시간을 활용해 분석 데이터 전송, 컴포넌트 미리 로딩 같은 부수적인 작업을 처리합니다.

이중 지연 스케줄링 전략

스케줄링 전략 핵심:

setTimeout: 초기 렌더링 파이프라인에서 해당 Task를 물리적으로 완전히 격리 (수 초간 대기)

requestIdleCallback: 지정된 시간이 지난 후에도 메인 스레드를 방해하지 않도록 브라우저 유휴 시간에 실행

{ timeout: ms }: 유휴 시간이 영원히 오지 않을 경우를 대비한 강제 실행 데드라인 설정

 


Use-cases 분석

Case A: 마이크로 인터랙션 및 API 호출 지연

메인 페이지 렌더링 시 다수의 팔로우 버튼이 마운트되며 발생하는 동시다발적인 API 호출 문제가 있었습니다.

사용자가 페이지에 진입하자마자 팔로우 버튼을 누르는 경우는 드물고, 모든 버튼의 상태를 알 필요는 없습니다.

가장 큰 문제는 우선순위가 높은 API 요청, 우선적으로 요청해야하는 API가 뒤로 밀리게 되는 문제를 해결하고자 했습니다.

 

메인 페이지에 한하여 setTimeout(2500)으로 초기 로딩 경합을 피하고, requestIdleCallback({ timeout: 1000 })로 안전하게 팔로우 상태를 fetch하도록 했습니다.

 

 

Case B: 무거운 서드파티 스크립트 로드 지연 (GTM, Maps, Ads)

지금 당장 로드 해야하는가를 우선적으로 판단했을 때, 아래의 서드파티 스크립트들은 모두 상황에 따라 지연로드를 해도 된다고 결정했습니다. 하지만, 이들은 모두 확실하게 로드가 되어야 하는 스크립트들이기 때문에 timeout을 모두 설정했습니다.

  1. Google Maps
    • ~200KB에 달하는 무거운 페이로드
    • /locations 등 즉시 필요한 페이지가 아니라면 과감히 setTimeout(8000) -> requestIdleCallback({ timeout: 2000 }) 적용 최대 10초까지 지연시켜 초기 렌더링 성능 확보
    • 필요한 페이지에서는 즉시 로드
  2. Google Tag Manager
    • 분석 도구는 사용자 경험보다 우선될 수 없다고 생각
    • 프로필 로딩 완료를 기준으로 삼고, 추가 3초 대기 후 requestIdleCallback 실행
  3. Google Ads
    • 수익과 직결되지만 콘텐츠 소비가 우선
    • 6초 지연 -> requestIdleCallback 실행
const useIdleTask = (task: () => void, delayMs: number, idleTimeoutMs: number) => {
  useEffect(() => {
    let idleCallbackId: number;
    
    // 1단계: 메인 스레드 점유 피하기: 초기 렌더링 파이프라인이 몰리는 시점을 의도적으로 회피합니다.
    const timeoutId = setTimeout(() => {
      // 2단계: 브라우저 유휴 시간 활용: 1프레임(16.6ms) 내에 남는 시간이 있을 때만 조심스럽게 실행
      idleCallbackId = requestIdleCallback(
        () => task(),
        { timeout: idleTimeoutMs } // 유휴 시간이 끝내 오지 않을 경우를 대비한 안전장치
      );
    }, delayMs);

    // cleanup: 메모리 누수 방지 및 언마운트 시 안전성 확보
    return () => {
      clearTimeout(timeoutId);
      if (idleCallbackId) cancelIdleCallback(idleCallbackId);
    };
  }, [task, delayMs, idleTimeoutMs]);
};

 

 

성과

1. FCP (3.0s → 0.4s): 핵심 콘텐츠 우선 렌더링

무거운 서드파티 스크립트를 setTimeout으로 격리하여 브라우저가 초기 렌더링에만 집중하게 했습니다. 그 결과, 스크립트 평가에 밀려있던 메인 콘텐츠가 8배나 빨리 화면에 나타나게 되었습니다.

 

2. TBT (380ms → 180ms): 메인 스레드 자유 시간 확보

requestIdleCallback을 통해 거대한 작업들을 잘게 쪼개 유휴 시간에만 실행되도록 분산했습니다. 덕분에 메인 스레드가 50ms 이상 꽉 막히는 '차단 시간'이 절반 이하로 줄어들며 사용자 반응성이 대폭 향상되었습니다.

 

지표 개선 전 개선 후  개선율
FCP (First Contentful Paint) 3.0 s 0.4 s 86% 감소
TBT (Total Blocking Time) 380 ms 180 ms 52% 감소

 

 


주의사항 및 한계점

cleanup의 중요성:

  • clearTimeout과 cancelIdleCallback을 누락했을 때 발생할 수 있는 메모리 누수나, 언마운트된 컴포넌트에서 상태를 업데이트하려는 에러 방지해야합니다.

브라우저 호환성:

  • 안타깝게도 requestIdleCallback은 모든 브라우저에서 사용할 수 있는 표준이 아닙니다.
  • 특히 Safari는 아직 이 API를 네이티브로 지원하지 않습니다. requestIdleCallback이 없는 환경에서는 즉시 실행되는 setTimeout을 사용하여 로직이 끊기지 않도록 fallback을 구성해야 합니다.

 

성능 최적화는 JavaScript를 제대로 이해하고, 브라우저를 제대로 이해하는 것에 온다는 것을 다시금 배울 수 있었습니다. 기술적인 구현보다 중요한 것은 사용자가 느끼는 '부드러움'의 본질이 무엇인지 끊임없이 고민하는 자세라는 점을 다시 한번 가슴에 새기게 되었습니다. 브라우저와 더 깊게 소통하며, 1프레임의 틈새까지도 사용자에게 더 나은 가치로 돌려줄 수 있는 개발자가 되도록 더 노력해야겠습니다.

 

 

 

 

참고자료
https://developer.chrome.com/blog/using-requestidlecallback?hl=ko

https://developer.mozilla.org/ko/docs/Web/API/Window/requestIdleCallback

https://www.w3.org/TR/requestidlecallback/

https://engineering.linecorp.com/ko/blog/line-securities-frontend-4