Frontend/🔨 JS

자바스크립트 효율적으로 처리하기 (requestAnimationFrame, requestIdleCallback, Web Workers)

haeunkim.on 2026. 5. 11. 11:33

브라우저 성능 최적화의 핵심은 단순히 코드를 “빠르게” 만드는 것이 아니라,
언제 실행할지와 어디서 실행할지를 적절히 나누는 데 있다.

 

브라우저의 메인 스레드는 하나다.

 


개발자가 작성한 JavaScript 실행뿐 아니라 이벤트 처리, 스타일 계산, 레이아웃, 페인트, 컴포지팅까지 대부분의 주요 작업이 같은 스레드에서 처리된다.

따라서 JavaScript가 메인 스레드를 오래 점유하면 브라우저는 화면을 갱신하지 못한다. 사용자는 이를 스크롤 버벅임, 클릭 지연, 입력 랙, 애니메이션 끊김 같은 형태로 체감한다.

브라우저가 일반적으로 목표로 하는 60fps 환경에서는 한 프레임에 사용할 수 있는 시간은 약 16.6ms뿐이다.

이 짧은 시간 안에 JavaScript 실행, 스타일 계산, 레이아웃, 페인트까지 끝나야 부드러운 화면이 유지된다. 만약 JavaScript가 40ms, 100ms씩 실행되면 다음 프레임이 밀리고 화면은 끊기기 시작한다.

 

결국 성능 최적화의 핵심은 다음 질문으로 정리할 수 있다.

 

이 작업을 지금 실행해야 할까?
꼭 메인 스레드에서 실행해야 할까?

 

 

우리가 사용할 수 있는 도구는 다음과 같은 것들이 있다. (세 API는 서로 대체재가 아니다.)

도구 핵심 역할 적합한 작업
requestAnimationFrame 렌더링 직전에 실행 애니메이션, 캔버스 드로잉, 카메라 프리뷰
requestIdleCallback 브라우저가 한가할 때 실행 로그 전송, preload, 캐시 정리, 저우선순위 작업
Web Workers 메인 스레드 밖에서 실행 이미지 처리, 암호화, 대용량 파싱, ML 추론

 

각각 실행 타이밍을 맞추는 도구, 후순위 작업을 미루는 도구, 무거운 연산을 다른 스레드로 넘기는 도구라고 볼수있다.


1. requestAnimationFrame: 화면을 그리기 직전에 실행하기

왜 필요한가

초기 웹에서는 애니메이션을 setInterval() 로 구현하는 경우가 많았다.

setInterval(update, 16)

 

겉보기에는 60fps처럼 보인다. 하지만 이 방식에는 문제가 있다.

setInterval은 브라우저가 실제로 언제 화면을 그릴지 모른다. 렌더링 직전이 아니라 프레임 중간에 실행될 수도 있고, 브라우저가 바쁜 상황에서도 계속 콜백을 예약할 수 있다. 그 결과 애니메이션이 렌더링 타이밍과 어긋나거나, 백그라운드 탭에서도 불필요하게 CPU를 사용할 수 있다.

이 문제를 해결하기 위해 사용하는 API가 requestAnimationFrame이다.

개념

requestAnimationFrame(callback) 은 브라우저가 다음 화면을 그리기 직전에 콜백을 실행한다. 즉, 브라우저의 렌더링 루프와 동기화된 JavaScript 실행을 제공한다.

  • 60Hz 모니터에서는 약 16.6ms마다
  • 120Hz 모니터에서는 약 8.3ms마다

화면 주사율에 맞춰 콜백이 호출된다.

특징

requestAnimationFrame의 가장 큰 장점은 렌더링 타이밍과 맞춰진다는 점이다.

브라우저는 일반적으로 다음과 같은 흐름으로 프레임을 만든다.

JavaScript → Style → Layout → Paint → Composite

 

requestAnimationFrame 콜백은 다음 페인트가 일어나기 전에 실행된다. 그래서 DOM이나 Canvas 상태를 갱신한 뒤 브라우저가 바로 그 결과를 화면에 반영할 수 있다.

또한 비활성 탭에서는 호출 빈도가 줄어들거나 멈춘다. 사용자가 보고 있지 않은 화면에서 불필요한 애니메이션을 계속 실행하지 않기 때문에 CPU와 배터리 사용량을 줄일 수 있다.

사용 예시

카메라 프리뷰, 게임 루프, 캔버스 렌더링처럼 매 프레임 갱신이 필요한 작업에 적합하다.

내가 만들었던 앱에서 카메라 영상을 캔버스에 계속 보정해서 그려야 했기에, 다음과 같은 구조를 사용했다.

useEffect(() => {
  let rafId: number

  const draw = () => {
    const ctx = previewCanvasRef.current?.getContext('2d')
    const video = videoRef.current

    if (ctx && video && video.videoWidth) {
      drawCorrectedFrame(ctx, video)
    }

    rafId = requestAnimationFrame(draw)
  }

  rafId = requestAnimationFrame(draw)

  return () => cancelAnimationFrame(rafId)
}, [])

 

여기서 중요한 점은 requestAnimationFrame이 단순히 <애니메이션용 API>라기보단 렌더링 타이밍에 맞춰 실행되어야 하는 작업에 적합한 API라는 점인 것 같다. 


2. requestIdleCallback: 급하지 않은 작업을 나중으로 미루기

왜 필요한가

모든 작업이 즉시 실행될 필요는 없으며, 다음 작업들은 사용자가 화면을 보는 순간 반드시 끝나야 하는 작업은 아니다.

  • 분석 로그 전송
  • 하단 컴포넌트 preload
  • 비가시 영역 데이터 준비
  • 캐시 정리
  • 저우선순위 상태 계산

하지만 이런 작업도 메인 스레드에서 실행된다. 그래서 아무 때나 실행하면 초기 렌더링이나 사용자 입력을 방해할 수 있다.

이때 사용할 수 있는 API가 requestIdleCallback이다.

개념

requestIdleCallback(callback, options) 은 브라우저가 한가한 시간에 콜백을 실행한다.
즉, 렌더링과 사용자 입력을 우선 처리하고 남는 시간에 후순위 작업을 실행하는 모델이다.

requestIdleCallback 콜백은 deadline 객체를 인자로 받는다.

requestIdleCallback((deadline) => {
  while (deadline.timeRemaining() > 0) {
    doWork()
  }
})

 

여기서 핵심은 deadline.timeRemaining()이다.
브라우저가 “지금 이 정도 시간은 더 써도 다음 프레임에 큰 영향을 주지 않을 것 같다”고 알려주는 값이다.

timeout 옵션

문제는 idle 시간이 항상 생기지는 않는다는 점이다.
애니메이션이 계속 실행되거나, 사용자의 입력이 많거나, CPU가 바쁜 상황에서는 idle 콜백이 오래 지연될 수 있다.

그래서 실제 서비스에서는 보통 timeout 옵션을 함께 사용한다.

requestIdleCallback(work, {
  timeout: 3000,
})

 

이렇게 하면 브라우저가 idle 상태가 되지 않더라도 3초 안에는 콜백이 실행된다.

 

Safari / iOS 지원 문제

주의할 점은 Safari와 iOS Safari에서 requestIdleCallback을 지원하지 않는다는 것이다.
따라서 실제 서비스에서는 polyfill이나 fallback 처리가 필요하다.

대표적으로 requestidlecallback-polyfill 같은 패키지를 사용할 수 있다.

import 'requestidlecallback-polyfill'

requestIdleCallback(() => {
  doLowPriorityWork()
}, { timeout: 3000 })

단, polyfill은 내부적으로 setTimeout을 사용해 비슷하게 동작하도록 만든 것이다. 브라우저의 실제 idle time을 완전히 동일하게 감지하는 것은 아니므로, “지원하지 않는 환경에서도 깨지지 않게 하는 fallback” 정도로 이해하는 것이 좋다.

 

Progressive Enhancement 관점에서 보기

이 지점에서 requestIdleCallbackProgressive Enhancement의 좋은 예시가 된다. (MDN 문서 보기)

Progressive Enhancement는 먼저 모든 브라우저에서 동작하는 기본 경험을 만들고, 더 최신 기능을 지원하는 환경에서는 그 위에 더 나은 경험을 점진적으로 얹는 접근 방식이다. 즉, 최신 API가 없어도 서비스가 깨지지 않아야 하고, 최신 API가 있으면 성능이나 사용성을 더 개선하는 식이다.

requestIdleCallback을 기준으로 보면 구조는 다음과 같다.

기본 경험:
- 페이지는 정상적으로 렌더링된다.
- 필수 기능은 즉시 동작한다.
- Safari처럼 requestIdleCallback이 없어도 서비스는 깨지지 않는다.

향상된 경험:
- requestIdleCallback을 지원하는 브라우저에서는
  급하지 않은 작업을 idle time으로 미룬다.
- 초기 렌더링과 사용자 입력을 덜 방해한다.

 

그래서 rIC를 사용할 때는 보통 기능 감지를 먼저 한다.

const runWhenIdle = (callback: () => void) => {
  if ('requestIdleCallback' in window) {
    window.requestIdleCallback(callback, { timeout: 3000 })
    return
  }

  setTimeout(callback, 1)
}

 

이렇게 하면 Chrome이나 Firefox처럼 requestIdleCallback을 지원하는 브라우저에서는 idle time을 활용하고, Safari처럼 지원하지 않는 브라우저에서는 setTimeout으로 최소한의 fallback을 제공할 수 있다.

중요한 건 “모든 브라우저에서 동일한 최적화를 제공하는 것”이 아니라,  사용자에게 필요한 기본 기능은 유지하면서, 가능한 환경에서만 더 좋은 성능을 제공하는 것이다.

이런 점에서 requestIdleCallback은 필수 기능을 담당하는 API라기보다는, 이미 동작하는 서비스를 더 부드럽게 만드는 성능 향상 레이어에 가깝다.

 

실제 사례 리서치 

LINE 증권 프런트엔드 팀은 초기 렌더링 성능 개선 과정에서 requestIdleCallback을 활용했다. (출처: Line 기술 블로그)

문제는 React.lazy()로 분리한 컴포넌트 청크 로딩이 초기 렌더링 중간에 끼어드는 것이었다. webpack은 동적으로 import된 청크를 로드할 때 내부적으로 <script> 태그를 삽입하는데, 이 작업이 초기 렌더링과 겹치며 메인 스레드를 점유했다.

이를 해결하기 위해 청크 로딩 자체를 idle time으로 미뤘다.

const lazyIdle: typeof lazy = (factory) =>
  lazy(
    () =>
      new Promise((resolve) => {
        requestIdleCallback(
          () => resolve(factory()),
          { timeout: 3000 }
        )
      })
  )

 

이렇게 하면 컴포넌트 코드를 즉시 불러오는 대신, 브라우저가 한가해졌을 때 import가 시작된다. 해당 사례에서는 몇 군데의 lazy()lazyIdle()로 교체해 초기 렌더링 시간을 줄일 수 있었다.

 


3. Web Workers: 무거운 연산을 메인 스레드 밖으로 보내기

왜 필요한가

requestAnimationFramerequestIdleCallback은 모두 메인 스레드 안에서 실행 시점을 조절하는 API다.
하지만 어떤 작업은 실행 타이밍의 문제가 아니라 연산량 자체가 너무 무거운 것이 문제다.

  • 이미지 처리
  • 영상 처리
  • 암호화 / 해시 계산
  • 대용량 JSON 파싱
  • 머신러닝 추론
  • 복잡한 물리 계산(게임)

이런 작업은 수십 ms에서 수백 ms 이상 걸릴 수 있다. 이 경우 실행 시점을 조금 미룬다고 해서 UI가 부드러워지지 않는다. 메인 스레드에서 실행되는 순간 화면은 멈출 수밖에 없다.

이 문제를 해결하기 위해 사용하는 것이 Web Worker다.

 

개념

Web Worker는 브라우저의 별도 스레드에서 JavaScript를 실행하는 API다.
메인 스레드와 완전히 분리된 실행 환경을 제공하기 때문에, 무거운 연산을 Worker로 넘기면 UI 렌더링과 사용자 입력 처리는 메인 스레드에서 계속 처리할 수 있다.

 

특징

Web Worker의 가장 큰 장점은 UI를 막지 않는다는 것이다.
메인 스레드는 렌더링, 입력 처리, 애니메이션을 계속 담당하고, Worker는 무거운 계산만 처리한다.

*다만 Worker는 DOM에 접근할 수 없다.

 

메인 스레드와 Worker는 메시지를 주고받는 방식으로 통신한다.

// main.js
const worker = new Worker('detection.worker.js')

worker.postMessage({ imageData })

worker.onmessage = (e) => {
  const { count, detections } = e.data
  updateUI(count, detections)
}
// detection.worker.js
self.onmessage = async (e) => {
  const { imageData } = e.data
  const result = await runYoloInference(imageData)

  self.postMessage(result)
}

데이터 전달 비용

Worker에 데이터를 넘길 때는 기본적으로 Structured Clone 알고리즘을 통해 데이터가 복사된다.
즉, 큰 객체나 이미지 데이터를 자주 주고받으면 그 자체로 비용이 될 수 있다.

그래서 성능이 중요한 경우에는 다음 방식도 함께 고려한다.

  • Transferable Objects
  • SharedArrayBuffer
  • 메시지 전송 횟수 줄이기
  • 큰 데이터 대신 필요한 최소 데이터만 전달하기

즉, Worker를 사용한다고 항상 빨라지는 것은 아니다.
핵심은 메인 스레드를 막는 무거운 작업을 분리하되, 통신 비용이 과도해지지 않도록 설계하는 것이다.

실제 적용 관점

예를 들어 YOLO 같은 객체 탐지를 브라우저에서 직접 수행한다고 해보자. TensorFlow.js나 ONNX Runtime Web을 사용해 클라이언트에서 추론을 돌리면 상당한 CPU/GPU 자원을 사용할 수 있다.

이 작업을 메인 스레드에서 실행하면 카메라 프리뷰가 끊기거나, 스크롤이 밀리거나, 버튼 클릭 반응이 늦어질 수 있다.

이럴 때는 다음처럼 역할을 나누는 것이 좋다.

메인 스레드:
- 카메라 표시
- 사용자 입력 처리
- UI 렌더링

Worker:
- 모델 로드
- 이미지 데이터 처리
- 추론 실행
- 결과 반환

 

정리하자면, Worker는 “무거운 계산을 백그라운드로 보내서 UI를 살리는” 데 가장 효과적이다.

다만 모든 YOLO 감지 파이프라인에 Web Worker가 필요한 것은 아니다.


내가 네이버랩스에서 개발했던 앱을 생각해보면, YOLO 감지 구조는 클라이언트에서 직접 추론하는 구조가 아니라, 약 700ms 주기로 캔버스 이미지를 만들어 서버 YOLO API를 호출하는 방식이다.

이 구조에서는 작업별로 Worker 적용 효과가 다르다.

작업 성격 Worker 적용 효과
YOLO API 호출 네트워크 I/O 낮음
Canvas 드로잉 렌더링 작업 낮음
canvas.toBlob() 이미지 인코딩 CPU 작업 있음

 

즉, Worker를 적용한다면 YOLO API 호출 자체보다는 canvas.toBlob() 같은 이미지 인코딩이나 전처리 작업이 더 현실적인 후보가 된다.

하지만 현재 병목이 주로 서버 응답 시간이고, UI 프레임 저하가 체감되지 않는다면 Worker 도입은 오버엔지니어링일 수 있다.
따라서 먼저 DevTools Performance 탭으로 toBlob()이 실제로 메인 스레드를 막는지 확인하고, 문제가 확인될 때 Worker 도입을 검토하는 것이 적절하다.


4. 정리: 언제 무엇을 써야 할까

 

핵심 차이는 다음처럼 정리할 수 있다.

requestAnimationFrame:
메인 스레드 안에서 "렌더링 직전"에 실행한다.

requestIdleCallback:
메인 스레드 안에서 "브라우저가 한가할 때" 실행한다.

Web Worker:
메인 스레드가 아닌 "다른 스레드"에서 실행한다.

 

브라우저 성능 최적화는 결국 메인 스레드를 얼마나 잘 비워두느냐의 문제다.
사용자가 보고 있는 화면과 상호작용을 가장 우선순위에 두고, 나머지 작업은 적절한 타이밍이나 다른 스레드로 분리해야 한다.

이 세 가지 도구를 상황에 맞게 조합하면 메인 스레드의 부담을 줄이고, 더 부드러운 사용자 경험을 만들 수 있다.


참고 자료