requestAnimationFrame를 사용한 애니메이션 구현

Song Haeun·2025년 9월 16일
0
post-thumbnail

랜덤! 뮤직스타 프로젝트의 보드판 모드에서 캐릭터 정지/이동 시에 자연스러운 애니메이션이 필요했다.

그래서 브라우저 렌더링 주기에 맞춰 애니메이션을 돌려주는 requestAnimationFrame을 사용했다.

이 포스트를 통해 requestAnimationFrame의 특징에 대해 알아보고 애니메이션 구현한 내용을 공유하려고 한다.

requestAnimationFrame이란?

requestAnimationFrame은 브라우저가 화면을 다시 그리기 직전에 우리가 등록한 콜백 함수를 실행해주는 메서드이다.

브라우저의 렌더링 파이프라인

여기서 1.JavaScript 실행 단계스크립트 단계라고 부르고,
2. 스타일 계산부터는 렌더링 단계라고 부른다.

requestAnimationFrame는 브라우저가 화면을 다시 그리기 직전 즉 렌더링 단계 직전에 우리가 등록한 콜백함수를 실행시켜주는 역할을 하는 것이다.

setTimeout & setInterval과의 차이점

애니메이션을 다음 방식처럼도 구현할 수 있다.

setInterval(() => {
  box.style.left = box.offsetLeft + 2 + 'px';
}, 16); // 60fps 가정

그렇다면 requestAnimationFrame과 차이점을 하나씩 살펴보자

1. 브라우저 렌더링 사이클

우선 requestAnimationFramesetTimeout/setInterval은 비동기 함수이다.

자바스크립트는 싱글 스레드로 동작하기 때문에 비동기 함수의 콜백은 바로 실행되지 않고 이벤트 루프에 의해 태스크 큐 등의 이벤트 큐에 저장된다. 이후 콜 스택이 비었을 때 이벤트 루프가 큐에서 콜백을 꺼내와 실행하게 된다.

브라우저의 이벤트 큐 종류에는 3가지가 있다.
비동기 함수의 종류에 따라 콜백함수는 3가지 중에 하나의 큐로 들어간다.

이 중에서 Animation Frame Callback Queue렌더링 전에 실행이 보장되고
Task QueueMicrotask Queue렌더링과 무관하게 실행된다.

따라서

requestAnimationFrame는 브라우저 렌더링 사이클과 동기화되어 렌더링 직전에 실행이 되고, setTimeout/setInterval는 브라우저 렌더링 사이클과 별개로 동작된다고 정리할 수 있다.

2. 탭 비활성화에 따른 최적화

requestAnimationFrame

탭이 비활성화되면 자동으로 중단되거나 최소한 1fps 수준으로 느려진다.

=> 불필요한 CPU/GPU 낭비 방지

setTimeout / setInterval

탭이 비활성화되어도 일정 주기로 계속 실행된다. (*최신 브라우저들 제외)

3. 프레임 속도 최적화

Hz: 모니터가 초당 몇 번 화면을 새로 그릴 수 있는지
fps: 애플리케이션에서 초당 몇 개의 프레임을 만들 것인지

requestAnimationFrame

모니터 주사율(60Hz, 120Hz 등)에 맞춰 브라우저가 자동으로 실행 타이밍을 조절

=> 초당 최적의 프레임 속도를 유지 (ex. 60Hz 모니터면 60fps)

setTimeout/setInterval

실행 간격에 대한 값을 직접 넣어야 하고, 모니터 주사율과 비교했을 때 손실 가능성이 있다.

예를 들어 모니터가 60Hz인데 8ms(≈125fps)로 돌리면, 절반정도의 프레임이 버려지는 것이다.

requestAnimationFrame 사용 방법

  • requestAnimationFrame은 기본적으로 콜백을 한 번만 실행한다.

  • 루프를 돌리려면 콜백 안에서 requestAnimationFrame을 재호출한다.

  • 중단하려면 cancelAnimationFrame(id) 사용한다.

let animationId: number;

function animate() {
  x += 2;
  box.style.transform = `translateX(${x}px)`;

  if (x < 100) {
    animationId = requestAnimationFrame(animate);
  }
}

animationId = requestAnimationFrame(animate);

// cancelAnimationFrame(animationId);

내 서비스에 어떻게 적용했는지

1. 구조

먼저 애니메이션 재귀 호출 구조를 만들고, 현재 시간을 timestamp로 넘긴다.

let animationId: number;

const animate = (timestamp: number) => {
  // 애니메이션 로직
  animationId = requestAnimationFrame(animate);
};
animationId = requestAnimationFrame(animate);

animationId
requestAnimationFrame의 리턴 값은 number값이다.
이는 예약된 애니메이션 프레임 요청의 식별자로 쓰인다.

timestamp
requestAnimationFrame의 콜백함수에 첫번째 인자DOMHighResTimeStamp가 자동 전달된다.

DOMHighResTimeStamp은 페이지가 로드된 이후 경과 시간을 나타낸다.

const deltaTime = timestamp - prevTimestampRef.current;
prevTimestampRef.current = timestamp;
animationTimeRef.current += deltaTime;

timestamp를 사용해 프레임과의 시간 차이를 계산해 시간 기반의 애니메이션 구현
=> 주사율 차이나 렌더링 지연에도 일정한 속도 유지가 가능

이후 useEffect의 반환값으로 cleanup 함수에서 cancelAnimationFrame을 호출

return () => cancelAnimationFrame(animationId);

컴포넌트가 언마운트되거나 layoutSize가 바뀔 때 정리되도록 구현

=> 메모리 누수, 중복 실행 방지

2. 애니메이션 구현

캐릭터 바운스 애니메이션

const phaseOffset = index * 0.5;
const newOffset =
  Math.sin(
    (animationTimeRef.current / 1000) * animationConfig.speed + phaseOffset,
  ) * animationConfig.range;

char.animationOffset = newOffset;

if (container) {
  container.style.transform = `translateY(${newOffset}px)`;
}

각 캐릭터 컨테이너 DOMMath.sin 함수로 위아래로 흔들리게 만듦

phaseOffset을 캐릭터마다 다르게 줘서 동시에 같은 모션이 반복되지 않게 처리

캐릭터 포물선 이동 애니메이션

if (char.isMoving) {
  const moveElapsed = timestamp - char.moveStartTime;
  const progress = Math.min(
    moveElapsed / animationConfig.moveDuration,
    1,
  );

  char.moveProgress = progress;

  const position = calculateMovingPosition(char, layoutSize, leftSectionWidth);
  x = position?.x || 0;
  y = position?.y || 0;

  if (progress >= 1) {
    char.isMoving = false;
    char.position = char.toPosition;
    char.fromPosition = char.toPosition;
  }
} else {
  const position = calculateStaticPosition(char, layoutSize, leftSectionWidth);
  x = position?.x;
  y = position?.y;
}

점수가 바뀌면 fromPosition → toPosition으로 이동

timestampmoveStartTime을 비교해 경과 시간을 구하고 progress (0 ~ 1)로 변환

calculateMovingPosition 함수로 위치를 보정

이동이 끝나면 isMoving = false정지 상태 전환

전체 코드

// 애니메이션 (DOM 위치 갱신 및 상태 업데이트)
  useEffect(() => {
    let animationId: number;

    const animate = (timestamp: number) => {
      const deltaTime = timestamp - prevTimestampRef.current;
      prevTimestampRef.current = timestamp;
      animationTimeRef.current += deltaTime;

      let isEndMoving = false;
      charactersRef.current.forEach((char, index) => {
        const el = characterDOMRefs.current[char.name];
        if (!el) return;

        const container = el.container;
        const name = el.name;
        const image = el.image;

        const phaseOffset = index * 0.5;
        const newOffset =
          Math.sin(
            (animationTimeRef.current / 1000) * animationConfig.speed +
              phaseOffset,
          ) * animationConfig.range;
        char.animationOffset = newOffset;
        if (container) container.style.transform = `translateY(${newOffset}px)`;

        let x = 0,
          y = 0;

        if (char.isMoving) {
          const moveElapsed = timestamp - char.moveStartTime;
          const progress = Math.min(
            moveElapsed / animationConfig.moveDuration,
            1,
          );

          char.moveProgress = progress;

          const position = calculateMovingPosition(
            char,
            layoutSize,
            leftSectionWidth,
          );
          x = position?.x || 0;
          y = position?.y || 0;

          if (progress >= 1) {
            char.isMoving = false;
            char.position = char.toPosition;
            char.fromPosition = char.toPosition;

            isEndMoving = true;
          }
        } else {
          const position = calculateStaticPosition(
            char,
            layoutSize,
            leftSectionWidth,
          );

          x = position?.x;
          y = position?.y;
        }

        // // 캐릭터 위치
        const characterX = x - charWidth / 2 - 55;
        const characterY = y - charHeight - BOARD_CONFIG.yOffset;

        // // 캐릭터 닉네임 위치
        const nameX = x - 10;
        const nameY = y - charHeight - 30 - BOARD_CONFIG.yOffset;

        char.x = characterX;
        char.y = characterY;
        char.nameX = nameX;
        char.nameY = nameY;

        if (name) {
          name.style.left = `${nameX}px`;
          name.style.top = `${nameY}px`;
        }

        if (image) {
          image.style.left = `${characterX}px`;
          image.style.top = `${characterY}px`;
        }
      });

      if (isEndMoving) forceUpdate(n => n + 1);
      animationId = requestAnimationFrame(animate);
    };
    animationId = requestAnimationFrame(animate);
    return () => cancelAnimationFrame(animationId);
  }, [layoutSize]);

참고

https://inpa.tistory.com/entry/%F0%9F%8C%90-requestAnimationFrame-%EA%B0%80%EC%9D%B4%EB%93%9C

profile
프론트엔드 개발하는 송하은입니다🐣

0개의 댓글