
랜덤! 뮤직스타 프로젝트의 보드판 모드에서 캐릭터 정지/이동 시에 자연스러운 애니메이션이 필요했다.
그래서 브라우저 렌더링 주기에 맞춰 애니메이션을 돌려주는 requestAnimationFrame을 사용했다.
이 포스트를 통해 requestAnimationFrame의 특징에 대해 알아보고 애니메이션 구현한 내용을 공유하려고 한다.
requestAnimationFrame은 브라우저가 화면을 다시 그리기 직전에 우리가 등록한 콜백 함수를 실행해주는 메서드이다.

여기서 1.JavaScript 실행 단계는 스크립트 단계라고 부르고,
2. 스타일 계산부터는 렌더링 단계라고 부른다.
requestAnimationFrame는 브라우저가 화면을 다시 그리기 직전 즉 렌더링 단계 직전에 우리가 등록한 콜백함수를 실행시켜주는 역할을 하는 것이다.
애니메이션을 다음 방식처럼도 구현할 수 있다.
setInterval(() => {
box.style.left = box.offsetLeft + 2 + 'px';
}, 16); // 60fps 가정
그렇다면 requestAnimationFrame과 차이점을 하나씩 살펴보자
우선 requestAnimationFrame와 setTimeout/setInterval은 비동기 함수이다.
자바스크립트는 싱글 스레드로 동작하기 때문에 비동기 함수의 콜백은 바로 실행되지 않고 이벤트 루프에 의해 태스크 큐 등의 이벤트 큐에 저장된다. 이후 콜 스택이 비었을 때 이벤트 루프가 큐에서 콜백을 꺼내와 실행하게 된다.
브라우저의 이벤트 큐 종류에는 3가지가 있다.
비동기 함수의 종류에 따라 콜백함수는 3가지 중에 하나의 큐로 들어간다.

이 중에서 Animation Frame Callback Queue만 렌더링 전에 실행이 보장되고
Task Queue와 Microtask Queue는 렌더링과 무관하게 실행된다.
따라서
requestAnimationFrame는 브라우저 렌더링 사이클과 동기화되어 렌더링 직전에 실행이 되고, setTimeout/setInterval는 브라우저 렌더링 사이클과 별개로 동작된다고 정리할 수 있다.

탭이 비활성화되면 자동으로 중단되거나 최소한 1fps 수준으로 느려진다.
=> 불필요한 CPU/GPU 낭비 방지
탭이 비활성화되어도 일정 주기로 계속 실행된다. (*최신 브라우저들 제외)
Hz: 모니터가 초당 몇 번 화면을 새로 그릴 수 있는지
fps: 애플리케이션에서 초당 몇 개의 프레임을 만들 것인지
모니터 주사율(60Hz, 120Hz 등)에 맞춰 브라우저가 자동으로 실행 타이밍을 조절
=> 초당 최적의 프레임 속도를 유지 (ex. 60Hz 모니터면 60fps)
실행 간격에 대한 값을 직접 넣어야 하고, 모니터 주사율과 비교했을 때 손실 가능성이 있다.
예를 들어 모니터가 60Hz인데 8ms(≈125fps)로 돌리면, 절반정도의 프레임이 버려지는 것이다.
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);
먼저 애니메이션 재귀 호출 구조를 만들고, 현재 시간을 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가 바뀔 때 정리되도록 구현
=> 메모리 누수, 중복 실행 방지
캐릭터 바운스 애니메이션
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)`;
}
각 캐릭터 컨테이너 DOM을 Math.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으로 이동
timestamp와 moveStartTime을 비교해 경과 시간을 구하고 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