HTML5 게임의 게임 루프: requestAnimationFrame vs 타이머, 프레임 드롭을 막는 방법
HTML5 게임의 부드러운 애니메이션을 위해 requestAnimationFrame과 타이머의 차이를 비교하고, 프레임 드롭을 방지하는 게임 루프 구현 방법을 설명합니다.
브라우저 렌더링과 게임 루프의 불일치 문제
게임을 만들 때 가장 기본이 되는 것이 바로 게임 루프(game loop)다. 루프가 얼마나 자주 실행되느냐에 따라 게임이 부드럽게 느껴질지, 끊기는 듯이 느껴질지가 결정된다.
초기 웹 게임 개발자들은 setInterval() 이나 setTimeout() 으로 루프를 돌렸다. 예를 들어 16ms 간격(약 60fps)으로 화면을 갱신하도록 설정했다. 그런데 문제가 있다. 브라우저가 화면을 실제로 그리는 시점(리페인트)과 자바스크립트 루프가 실행되는 시점이 맞지 않을 수 있다는 것이다. 결과적으로 어떤 프레임은 두 번 그려지고, 어떤 프레임은 건너뛰어진다. 사용자 눈에는 애니메이션이 끊기거나 버벅거리는 것처럼 보인다.
requestAnimationFrame (이하 rAF)은 이 문제를 해결하기 위해 만들어졌다. 브라우저가 다음 리페인트를 준비할 때 콜백을 실행하므로, 자바스크립트 코드와 브라우저 렌더링이 동기화된다.
requestAnimationFrame: 브라우저와 동기화된 방식
requestAnimationFrame 은 브라우저의 리프레시 레이트에 맞춰 콜백을 호출한다. 60Hz 모니터에서는 초당 60번 호출되고, 144Hz 모니터에서는 144번 호출된다. 이는 자동으로 디바이스 최적화가 이루어진다는 뜻이다.
기본적인 게임 루프 구조는 다음과 같다:
let lastTime = performance.now();
function gameLoop(currentTime) {
const deltaTime = (currentTime - lastTime) / 1000; // 초 단위로 변환
lastTime = currentTime;
update(deltaTime); // 게임 로직 업데이트
render(); // 화면 그리기
requestAnimationFrame(gameLoop);
}
requestAnimationFrame(gameLoop);
핵심은 델타 타임(이전 프레임과의 시간 차이)을 계산해서 모든 움직임에 곱하는 것이다. 이렇게 하면 프레임 속도가 다른 디바이스에서도 게임 속도가 일정하게 유지된다.
타이머 방식의 한계와 프레임 드롭 발생 원인
setInterval(gameLoop, 16) 방식은 얼핏 60fps처럼 보이지만 실제로는 다르다. 브라우저가 바쁠 때는 콜백이 정해진 시각보다 늦게 실행되거나 일부 호출이 건너뛰어질 수 있고, 타이머 주기가 리페인트 주기와 어긋나 프레임 간격이 들쭉날쭉해진다. 특히 다음 상황에서 문제가 심하다:
- 복잡한 DOM 조작이 동시에 실행될 때
- 백그라운드 탭에서 실행될 때 (브라우저가 의도적으로 속도를 낮춤)
- 가비지 컬렉션이 발생할 때
예를 들어, 16ms마다 실행하도록 설정했더라도 타이머는 화면 갱신 시점과 무관하게 돌기 때문에, 어떤 구간에서는 두 번의 콜백이 한 번의 리페인트에 몰리고 어떤 구간에서는 리페인트 사이에 콜백이 없어 같은 화면이 반복된다. 사용자는 그 순간 움직임이 끊기는 것을 느낀다.
반면 requestAnimationFrame 은 브라우저가 리페인트를 준비할 때만 호출되므로, 타이머처럼 리페인트 주기와 어긋나지 않는다. 다만 한 프레임의 자바스크립트 작업이 길어지면 rAF를 써도 프레임은 드롭된다.
프레임 드롭을 방지하는 실전 기법
1. 고정 타임스텝 + 가변 렌더링
게임 로직을 일정한 시간 간격으로 업데이트하되, 렌더링은 가능한 만큼 자주 한다:
const FIXED_TIMESTEP = 1 / 60; // 16.67ms
let accumulator = 0;
let lastTime = performance.now();
function gameLoop(currentTime) {
const deltaTime = Math.min((currentTime - lastTime) / 1000, 0.25); // 탭 복귀 등으로 큰 값이 들어와도 루프가 폭주하지 않도록 상한을 둔다
lastTime = currentTime;
accumulator += deltaTime;
// 고정 시간 간격으로 게임 로직만 업데이트
while (accumulator >= FIXED_TIMESTEP) {
update(FIXED_TIMESTEP);
accumulator -= FIXED_TIMESTEP;
}
// 렌더링은 매 프레임마다
render();
requestAnimationFrame(gameLoop);
}
requestAnimationFrame(gameLoop);
이 방식은 물리 엔진이 필요한 게임에서 특히 유용하다. 로직과 렌더링이 분리되므로 디바이스가 달라도 일관된 게임 동작을 유지하기 쉽다.
2. 프레임 스킵 감지
만약 한 프레임에서 예상보다 오래 걸렸다면, 이를 감지해 기록하고 게임 로직이 한 번에 너무 크게 움직이지 않도록 제한할 수 있다:
function gameLoop(currentTime) {
const rawDelta = (currentTime - lastTime) / 1000;
const deltaTime = Math.min(rawDelta, 0.1); // 게임 로직에는 상한을 둔 값을 사용
lastTime = currentTime;
// 실제 프레임 간격이 너무 크면 (0.1초 이상) 프레임 드롭으로 기록
if (rawDelta > 0.1) {
console.warn('프레임 드롭 감지:', rawDelta);
}
update(deltaTime);
render();
requestAnimationFrame(gameLoop);
}
Math.min() 으로 델타 타임에 상한을 두면, 갑작스러운 지연이 게임 로직에 미치는 영향을 줄일 수 있다.
3. 성능 모니터링
실제 게임에서는 fps를 측정해서 문제를 파악해야 한다:
let frameCount = 0;
let lastFpsTime = performance.now();
function gameLoop(currentTime) {
const deltaTime = (currentTime - lastTime) / 1000;
lastTime = currentTime;
update(deltaTime);
render();
frameCount++;
if (currentTime - lastFpsTime >= 1000) {
console.log('FPS:', frameCount);
frameCount = 0;
lastFpsTime = currentTime;
}
requestAnimationFrame(gameLoop);
}
언제 타이머를 써야 할까?
setInterval 이 완전히 쓸모없는 것은 아니다. 게임 루프가 아닌 다른 용도에서는 충분하다:
- UI 업데이트 (버튼 상태 갱신 등)
- 네트워크 요청 재시도
- 일시적인 알림 표시
하지만 애니메이션이나 게임 화면 갱신에는 쓰지 않는 것이 좋다.
requestAnimationFrame을 쓸 수 없는 경우는?
구형 브라우저(IE9 이하)를 지원해야 한다면 폴리필을 사용한다:
window.requestAnimationFrame = window.requestAnimationFrame
|| function(cb) { return setTimeout(cb, 16); };
하지만 현재는 모든 주요 브라우저가 지원하므로 걱정할 필요가 없다.
초보자가 자주 하는 실수는?
가장 흔한 실수는 델타 타임을 무시하는 것이다. 고정값으로 움직임을 설정하면 프레임 속도가 다른 디바이스에서 게임 속도가 달라진다. 예를 들어 x += 5 라고 하면, 60fps 디바이스와 120fps 디바이스에서 움직임 속도가 다르다. 반드시 x += speed * deltaTime 형태로 작성해야 한다.
두 번째 실수는 렌더링 코드를 게임 루프 밖에 두는 것이다. 렌더링은 항상 루프 안에서 호출해야 한다.
프레임 드롭이 보이는 이유는 뭔가요?
브라우저가 자바스크립트를 실행하는 동안 화면을 새로 그릴 수 없다. 만약 한 프레임에서 자바스크립트 작업이 16ms 이상 걸리면, 그 프레임은 렌더링되지 못하고 건너뛰어진다. 이를 프레임 드롭이라고 한다. requestAnimationFrame 을 쓰면 이런 상황을 감지할 수 있고, 델타 타임으로 보정할 수 있다.
모바일에서도 requestAnimationFrame이 최적일까요?
모바일 브라우저도 requestAnimationFrame 을 지원한다. 다만 배터리 절약을 위해 백그라운드 탭에서는 콜백이 멈춘다. 이는 오히려 좋은 동작이다. 게임을 하지 않는 탭의 배터리를 낭비할 이유가 없기 때문이다. 포그라운드로 돌아올 때 lastTime 을 갱신하면 문제없다.
게임 엔진을 써도 이 원리를 알아야 할까요?
Babylon.js나 Three.js 같은 엔진을 쓰더라도 내부적으로는 requestAnimationFrame 을 사용한다. 엔진이 게임 루프를 대신 관리해주지만, 성능 문제가 생겼을 때 원인을 파악하려면 이 원리를 이해해야 한다. 또한 간단한 프로젝트는 직접 게임 루프를 작성하는 것이 더 효율적이다.
다음 단계: 게임 프로젝트 공개하기
이제 부드러운 게임 루프를 구현했다면, 만든 HTML5 게임을 다른 개발자들과 공유해보자. Vibeollio에 프로젝트 등록하면 다른 사용자들이 직접 게임을 플레이해 보고 댓글로 피드백을 남길 수 있다. 게임플레이에 대한 의견이나 발견한 버그 같은 이야기를 댓글로 받을 수 있고, 다양한 디바이스에서 프레임 드롭이 느껴졌는지도 물어볼 수 있다.
