requestAnimationFrame 에 대한 이해

2025. 4. 17. 18:35·Develop/Frontend

requestAnimationFrame(이하 raf)는 웹 애니메이션을 최적화하는데 사용되는 webAPI 입니다.

웹 애니메이션을 사용하기 위한 방법으로 setInterval이나 반복적인 setTimeout을 사용할 수도 있지만, raf에 비해서는 성능이 좋지 않습니다. 오늘은 그 이유에 대해 알아보며, raf에 대한 이해를 깊게 가져가는 시간을 가져봅시다.

 

들어가기에 앞서

이번 아티클에서는 단순히 사용 방법 뿐만 아니라 원리도 다룰 것이기 때문에 여러가지 개념에 대한 지식이 필요합니다.

프레임, 이벤트 루프(+싱글 쓰레드 개념), CPU와 GPU의 차이, 브라우저 렌더링 과정(reflow&repaint) ... 전부 설명하기엔 너무 양이 많아지니, 해당 아티클에 필요에 따라 언급하는 방식으로 넘어갈게요. 그 중에서도 프레임에 대해서만 조금 알아봅시다.

 

프레임(Frame)이란 하나의 사진이라고 생각하면 됩니다. 이렇게 프레임들이 많이 모여서 빠른 속도로 보여주면 마치 영상처럼 느껴지죠.

어릴 때 다들 애니메이션 미니북 만들어보시진 않았나요? 하나 하나의 장면은 분명 멈춰있지만 빠르게 페이지를 넘기면 안의 그림들이 움직이는 것처럼 느껴져요. 최소 60장 이상은 그려야만 1초정도 지속되었던 것 같은데, 10초이상의 영상을 만들기 위해 600장을 그리면서 새빠지게 고생하던게 생각나요. 만들어놓고서는 별로 플레이해보지는 않고, 애지중지했던 기억은 납니다. 제 노력이 들어가있었으니까요.

결국 동적인 것은 정적인 것들로 구성되고 그 보잘것 없어보이던 정적인 것들, 즉 순간들이 가장 소중한 것들이었네요.

 

FPS 는 1초당 프레임의 개수를 의미해요. 사람의 눈에는 60FPS 면 충분히 부드럽다고 느껴지니까, 1초당 60개의 이미지이상만 보여주면 됩니다. 보통 기기마다 주사율(Hz)가 다른데, 대략 60FPS 이상이면 적당히 부드럽게 느껴진다라고 생각하시면 됩니다. rAF는 기기에 따라 성능이 다르게 동작해요. 60FPS 기기에서는 최소 16.6ms 마다 실행되는 것을 '보장'하려고 노력하고 120FPS 기기에서는 최소 0.8ms마다 실행되는 것을 보장하려고 노력합니다. (참고로, 16.6ms은 1000/60을 해서 나온 값입니다. 1초는 1000ms 이니까요)

 

rAF의 사용 방법 (with Code)

rAF 함수는 콜백함수를 인자로 받고, 이 콜백 함수에는 이전 프레임의 렌더링이 끝난 시간(timestamp)를 인자로 넘깁니다. 

또한, rAF 호출 자체는 (setInterval과 달리) 자동으로 반복적으로 호출되지 않습니다. 단 1회 호출해요. 따라서 보통 재귀적으로 구성하여 rAF를 호출하곤 합니다.

const box = document.getElementById('animatedBox');
let leftPosition = 0;

function animate() {
    leftPosition += 2; // 이동 거리
    box.style.left = leftPosition + 'px';

    // 프레임 요청
    requestAnimationFrame(animate);
}

// 애니메이션 시작
requestAnimationFrame(animate);

 

사용 방법 자체는 정말 단순하죠?

 

 

rAF의 동작 원리 (with 이벤트 루프)

rAF도 결국 webAPI 이기 때문에 이벤트 루프에 따라 실행됩니다. (webAPI라고 무조건 이벤트루프를 거치는 건 아닙니다.)

이벤트루프란 간단히 말해 자바스크립트 런타임 환경에서 비동기 작업을 처리하고, 비동기 콜백 및 이벤트 핸들러가 실행될 수 있도록 도와주는 메커니즘입니다. 

따라서 fetch, setTimeOut, setInterval, Promise 메서드의 콜백함수, rAF등은 이벤트 루프를 거치지만
동기적으로 동작하는 localStorage 메서드, document.getElementById 등은 이벤트 루프를 거치지 않습니다.

 

자바스크립트는 싱글 쓰레드 언어입니다. 더욱 포괄적으로 설명하면 자바스크립트 엔진이 한번에 하나의 동작 밖에 처리할 수 없습니다.

반면 브라우저 자체는 여러 쓰레드를 가져다가 씁니다. 사용자 기기에서 프로세스를 만들고 쓰레드를 만들어 생성해요. 당장 관리자도구에 들어가서(혹은 활성 상태) 크롬 브라우저만 살펴봐도 메모리나 쓰레드를 많이 차지하고 있는 것을 볼 수 있습니다. 

 

브라우저의 이벤트 루프는 멀티 쓰레드인 브라우저를 적극 활용하여, 비동기 처리를 진행합니다.

비동기 처리가 끝난 이후에 콜백함수에 필요한 인자들을 넘겨준 상태로 큐(Queue)에 저장해둡니다.

큐(Queue)는 크게 2가지 종류가 있다고 많이들 알고 있지만, 정확히는 3가지입니다. 

 

우선 순위가 높은 순서대로 아래와 같습니다.

1. Micro Task Queue - Promise 메서드의 콜백함수

fetch(url)
  .then(response => {
    // 네트워크 응답 처리
    return response.json(); // JSON 형식으로 변환
  })
  
  //아래의 부분이 콜백함수고, 응답이 오면 response가 채워진 상태로 microTaskQueue에 들어가있습니다.
  response => {
    // 네트워크 응답 처리
    return response.json(); // JSON 형식으로 변환
  }

 

2. Animation frames Queue - rAF

3. Task Queue - setTimeOut, setInterval 등 나머지가 들어갑니다.

 

 

이벤트 루프는 계속 call stack(실행 컨텍스트 스택)을 들여다보며 콜스택이 비어있는지 확인합니다.

결국 큐에 존재하는 콜백 함수를 '실행'하기 위해서는 자바스크립트 엔진(메인 쓰레드)에서 필요하기 때문이에요.

만약 콜스택에 매우 많은 수의 실행 컨텍스트(잘 모르시면 단순히 함수라고 생각하시면 됩니다)가 존재하면 큐에 있는 콜백 함수의 실행은 무기한 연장될 수도 있습니다. 또 만약 다른 경우로, 콜 스택도 비어있고 큐도 전부 비어져 있으면 이를 Idle Time 이라고 칭합니다. 

 

결국 rAF의 동작원리는 이벤트루프를 알고 있으면 이미 반은 알고 있는 것이나 마찬가지에요.

rAF의 콜백함수가 단지 animation frame 큐에 들어간다는 사실만 인지하고 있으면 됩니다. 

 

 

rAF가 성능을 보장한다고 하는 이유는? (완전 보장이라는 것은 없다)

setInterval과 달리 rAF는 애니메이션을 사용할 때는 무조건 유리하다고 하는데 그 이유는 도대체 무엇일까요?

setInterval이나 rAF가 서로 우선 순위가 다른 큐에 담길 수 있다는 것을 이해하더라도, 콜 스택이 꽉꽉 차있는 경우에는 결국 실행이 밀릴텐데요. 그 이유는 계산을 생략하는가 아닌가의 여부에 따라 다릅니다.

 

단순히 setInterval이 브라우저의 최적화 기법(같은 값을 몇 초안에 호출하면 4ms의 딜레이를 주는 스펙)때문에 setInterval이 성능이 안 좋다고 하기에는 핵심을 벗어났습니다. 핵심은 '언제 실행되느냐' 에요.

 

setInterval 이든 rAF든 당연히 호출이 16.6ms내에 일어나지 않을 수도 있지만,
1. rAF는 항상 repaint를 할 준비가 완료된 상태에서 rAF의 콜백 함수를 실행하고 난뒤 repaint를 진행한다는 점

2. rAF는 1프레임에 최대 1번만 호출한다는 점 (거의 정확히 1회 호출을 보장하려고 노력)

이 2가지 특성으로 애니메이션 실행에 있어 rAF가 버벅임을 거의 없앱니다.

 

1,2번에 대해서 설명하기 위해 cpu와 gpu에 대한 이해가 있어야해서 아주 간단히만 설명해보자면, cpu는 많은 작업은 할 수 없지만 한가지 작업을 굉장히 빠르게 진행하는 하드웨어이고, gpu는 여러가지 작업을 한번에 할 수 있지만 빠르지는 않은 하드웨어입니다. 그래서 점,선,면을 그리고 색을 칠해야하는 영역은 보통 gpu가 도맡아서 하게 되는데 이게 reflow, repaint의 작업 영역입니다. 

 

여기서 가장 비용이 많이 드는 단일 작업은 repaint인데 (물론 reflow가 일어나면 repaint도 일어납니다), setInterval은 repaint 도중이든 이전이든 끝난 후든 상관없이 실행할 수 있으면 (timeout만 넘으면) 실행하는데 이게 화면에 제대로 보이지도 않을 repaint 동작을 촉발할 수 있습니다. rAF는 rePaint가 일어나기 전에 콜백함수를 실행하고 이를 반영하여 repaint로 인한 비용을 줄입니다.

 

또한, 아무리 여러번 호출해도 1프레임당 최대 1번만 호출하도록 제한해두어서, 오히려 성능이 좋아집니다.

아까 60FPS 정도만 되어도 우리 눈엔 부드럽게 보인다고 했습니다. rAF는 이걸 이용한건데, 1프레임 간격에 여러번 호출이 되면 이걸 하나로 뭉쳐서 최종 결과 1번만 우리 눈에 보이도록 실행합니다(그립니다). 예시를 들어볼게요.

 

딱 1초안에 1 -> 10,000 으로 값이 증가하는 애니메이션을 생각해봅시다.

 

setInterval의 경우에는 1 -> 2 -> 3 -> 4 -> ... 9999 -> 10,000 으로 총 대략 10,000번을 호출하려고 시도합니다. 이 과정에서 콜스택이 생기거나 하면 해당 프로세스가 전부 밀리고, 결국 버벅이면서 꾸역꾸역 1,2,3,4,.... 10000 의 숫자를 눈에 다 보여줍니다. 

 

하지만 rAF의 경우에는 1 -> 60 -> 300 -> 520 -> 1000 -> ... -> 10,000 과 같이 프레임 단위로 끊어서 60번만 호출하여 숫자를 보여줍니다. 이렇게 보여줘도 저희 눈은 부드럽게 숫자가 전환되었다고 느껴요. 

 

정리하면, 실제 계산(in CPU)은 setInterval 이나 rAF나 마찬가지로 10,000번을 하는 것은 맞습니다만 GPU를 사용하는 횟수가 다른겁니다. setInterval은 GPU를 10,000번 사용 , rAF는 GPU를 10,000/ 60 (1초당 프레임의 개수) = 대략 166번 사용하여 화면을 그려요.

 

 

 

rAF의 최적화(with setInterval)

이미 알아서 rAF가 최적화를 참 잘해주고 있지만, CPU의 호출마저 아끼는 방법이 존재합니다.  

rAF 자체를 setInterval로 감싸서, 16.6ms마다 호출되도록 만드는 거에요. 

// 상태 변수들
let isAnimating = false; // 애니메이션 진행 상태
let rafId = null; // requestAnimationFrame ID
let intervalId = null; // setInterval ID
const INTERVAL_DELAY = 1000 / 60; // 약 16.6ms (60fps에 해당)

// 애니메이션 루프 함수
function animationLoop(timestamp) {
  // 애니메이션 로직 실행
  updateAnimation(timestamp);
  renderScene();
  
  // 애니메이션 상태가 true인 경우에만 다음 프레임 요청
  if (isAnimating) {
    rafId = requestAnimationFrame(animationLoop);
  }
}

// 애니메이션 시작 함수
function startOptimizedAnimation() {
  if (isAnimating) return; // 이미 실행 중이면 중복 실행 방지
  
  isAnimating = true;
  
  // setInterval을 사용하여 16.6ms마다 rAF 호출
  intervalId = setInterval(() => {
    // 이전 rAF 취소 (중복 호출 방지)
    if (rafId) {
      cancelAnimationFrame(rafId);
    }
    
    // 새로운 rAF 요청
    rafId = requestAnimationFrame(animationLoop);
  }, INTERVAL_DELAY);
}

// 애니메이션 중지 함수
function stopAnimation() {
  isAnimating = false;
  
  // setInterval 정리
  if (intervalId) {
    clearInterval(intervalId);
    intervalId = null;
  }
  
  // requestAnimationFrame 정리
  if (rafId) {
    cancelAnimationFrame(rafId);
    rafId = null;
  }
}



// 사용 예시
// startOptimizedAnimation(); // 애니메이션 시작
// stopAnimation(); // 애니메이션 정지

 

물론, 기기마다 주사율이 다를 수 있다는 거니까 완전한 최적화는 아닐 수 있습니다만 손해볼 것은 크지 않습니다. 

144Hz 기기라고 판단하고 더 짧은 인터벌을 주어도, 어차피 CPU 계산은 조금 늘어나도 GPU의 호출은 여전히 동일할테니까요.

 

 

 

 

참고) https://developer.mozilla.org/en-US/docs/Web/API/Window/requestAnimationFrame

'Develop > Frontend' 카테고리의 다른 글

React Reconciliation 심화 이해를 바탕으로 리액트 잘 활용하기  (2) 2025.04.18
개발자도구 Element 탭 강조 표시의 기준  (1) 2025.04.18
Zustand 리렌더링의 조건 (트러블슈팅)  (0) 2025.04.12
zod 유효성 검사에서 겪은 트러블 슈팅  (0) 2025.02.04
배포를 위한 PAT  (0) 2025.01.18
'Develop/Frontend' 카테고리의 다른 글
  • React Reconciliation 심화 이해를 바탕으로 리액트 잘 활용하기
  • 개발자도구 Element 탭 강조 표시의 기준
  • Zustand 리렌더링의 조건 (트러블슈팅)
  • zod 유효성 검사에서 겪은 트러블 슈팅
ocahs
ocahs
개발 내용을 담습니다.
  • ocahs
    ocahs 개발 블로그
    ocahs
  • 전체
    오늘
    어제
    • 분류 전체보기 (47)
      • Develop (47)
        • Frontend (25)
        • Javascript (7)
        • Algorithm (14)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    Promise
    vite
    비트 연산자 활용 예시
    재귀타입
    promise reject
    요청의 역사
    Working Set Model
    JS
    번들러
    line sweeping
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
ocahs
requestAnimationFrame 에 대한 이해
상단으로

티스토리툴바