이 글은 누구를 위한 것인가
- 웹 서비스 안에 canvas 기반 미니게임이나 인터랙션을 넣으려는 팀
- 라우트를 옮겼는데 이전 화면의 애니메이션이 계속 도는 문제를 겪은 개발자
- 탭을 바꿨다 돌아오면 게임이 순간이동하는 현상의 원인을 찾는 중인 경우
들어가며
canvas로 게임을 만드는 것과, 이미 있는 SPA 안에 canvas 게임을 얹는 것은 다른 문제다. 후자에서 생기는 버그는 게임 로직이 아니라 대부분 수명 주기에서 나온다.
전형적인 증상은 이렇다.
· 게임 페이지를 떠났는데 CPU 사용률이 안 떨어진다
· 뒤로 갔다 돌아오면 애니메이션이 두 배 빨라진다
· 다른 탭에 갔다 오면 캐릭터가 화면 밖으로 순간이동해 있다
· 새로고침하면 진행이 통째로 날아간다
넷 다 게임 코드가 아니라 루프 정리, 시간 처리, 상태 보존의 문제다. 이 글은 그 세 가지를 다룬다.
1. 루프를 확실히 멈춘다
requestAnimationFrame은 한 번 예약하면 콜백 안에서 다시 예약되는 재귀 구조로 쓰는 게 보통이다. 그래서 멈추는 코드를 빼먹으면 영원히 돈다.
// ❌ 컴포넌트가 사라져도 루프는 계속 돈다
function useGameLoop(draw: (dt: number) => void) {
useEffect(() => {
let last = performance.now();
const tick = (now: number) => {
draw(now - last);
last = now;
requestAnimationFrame(tick); // 정리할 핸들을 안 들고 있음
};
requestAnimationFrame(tick);
}, [draw]);
}
// ✅ 핸들을 보관하고 정리 함수에서 취소
function useGameLoop(step: (dt: number) => void) {
const stepRef = useRef(step);
stepRef.current = step; // 최신 콜백을 참조만 갱신 (루프는 재시작 안 함)
useEffect(() => {
let rafId = 0;
let last = performance.now();
let running = true;
const tick = (now: number) => {
if (!running) return;
const dt = now - last;
last = now;
stepRef.current(dt);
rafId = requestAnimationFrame(tick);
};
rafId = requestAnimationFrame(tick);
return () => {
running = false; // 이미 예약된 콜백도 즉시 빠져나가게
cancelAnimationFrame(rafId);
};
}, []); // 의존성 비움 — 루프는 마운트당 하나
}
두 가지가 중요하다.
running플래그 —cancelAnimationFrame만으로는 이미 실행 중인 콜백을 막지 못한다- 의존성 배열을 비우고
stepRef로 최신 함수 참조 —draw가 매 렌더 새 함수면 루프가 매번 재시작된다
두 번째를 놓치면 루프가 중첩된다. 애니메이션이 두 배 빨라지는 증상의 원인이 대개 이것이다. 정리는 됐는데 그보다 자주 새로 시작된 것이다.
[루프 중첩 진단]
콘솔에 프레임 카운터를 찍어본다.
정상: 초당 약 60
중첩: 초당 120, 180 …
또는 tick 진입 시점에 전역 카운터를 올리고
언마운트 후에도 증가하는지 확인한다.
requestAnimationFrame의 콜백 인자와 호출 시점 규칙은 MDN 레퍼런스에 정리돼 있다.
2. 탭이 안 보일 때 — dt 폭탄
브라우저는 비활성 탭에서 requestAnimationFrame을 호출하지 않는다. 좋은 동작이지만, 돌아왔을 때 문제가 생긴다.
탭 이탈 t = 10.0초
탭 복귀 t = 130.0초
첫 tick의 dt = 120,000ms
→ position += velocity * dt 가 한 프레임에 적용
→ 캐릭터가 화면 밖으로 순간이동
두 가지를 같이 해야 한다.
// (1) dt에 상한을 둔다
const MAX_DT = 50; // 20fps 상당
const dt = Math.min(now - last, MAX_DT);
// (2) 보이지 않을 때는 아예 멈추고, 복귀 시 시간 기준을 리셋
useEffect(() => {
const onVisibility = () => {
if (document.hidden) {
pause();
} else {
last = performance.now(); // ★ 누락 시간 버리기
resume();
}
};
document.addEventListener('visibilitychange', onVisibility);
return () => document.removeEventListener('visibilitychange', onVisibility);
}, []);
last를 복귀 시점으로 다시 잡는 한 줄이 핵심이다. 이게 없으면 상한을 걸어도 첫 프레임이 MAX_DT만큼 튄다. Page Visibility API는 모바일 브라우저에서 백그라운드 전환에도 발화하므로, 데스크톱만 보고 테스트하면 놓치기 쉽다.
게임에 따라 "일시정지"의 의미가 다르다는 점도 정해야 한다.
퍼즐·턴제 → 완전 정지. 돌아오면 그대로 이어감
타이머 기반 → 실제 경과 시간을 반영해야 함 (정지하면 치트)
실시간 대전 → 서버 시간이 기준. 클라이언트 정지는 표시만
3. 고정 타임스텝 — 프레임 레이트에 로직이 흔들리지 않게
dt를 그대로 물리 계산에 넣으면 기기마다 결과가 달라진다. 120Hz 기기와 60Hz 기기에서 점프 높이가 다르게 나오는 식이다.
로직은 고정 간격으로, 렌더는 매 프레임으로 분리한다.
const FIXED_DT = 1000 / 60; // 로직 스텝 16.67ms
let accumulator = 0;
function tick(now: number) {
const frame = Math.min(now - last, MAX_DT);
last = now;
accumulator += frame;
// 밀린 만큼 로직을 여러 번 돌린다 (단, 상한을 둠)
let steps = 0;
while (accumulator >= FIXED_DT && steps < 5) {
world.update(FIXED_DT);
accumulator -= FIXED_DT;
steps++;
}
if (steps === 5) accumulator = 0; // 따라잡기 포기 (죽음의 나선 방지)
// 남은 시간 비율로 보간해서 그린다
render(world, accumulator / FIXED_DT);
rafId = requestAnimationFrame(tick);
}
steps < 5 상한이 없으면 **죽음의 나선(spiral of death)**에 빠진다. 로직이 느려서 밀리면 → 다음 프레임에 더 많이 돌려야 하고 → 더 느려지고 → 더 밀린다. 어느 시점에서는 따라잡기를 포기하고 시간을 버려야 한다.
4. 라우트 전환 — 언마운트인가 숨김인가
SPA에서 게임 화면을 떠날 때 선택지가 둘이다.
[A. 언마운트]
컴포넌트 제거, canvas 폐기
장점: 메모리 확실히 회수
단점: 돌아오면 처음부터. 상태를 밖에 저장해야 함
[B. 화면에서 숨기고 유지]
display:none 또는 화면 밖으로, 루프는 일시정지
장점: 즉시 복귀
단점: 메모리 계속 점유. 게임이 여러 개면 누적
게임이 하나뿐이고 짧은 이탈이 잦다면 B, 그 외에는 A가 기본이다. B를 쓰더라도 루프는 반드시 멈춰야 한다 — display:none이 requestAnimationFrame을 멈춰주지는 않는다.
A를 택하면 상태를 컴포넌트 밖에 둬야 한다.
// 게임 상태를 라우터 바깥의 스토어에 보관
const gameStore = {
snapshot: null as GameSnapshot | null,
save(s: GameSnapshot) { this.snapshot = s; },
load() { return this.snapshot; },
};
function GameScreen() {
const worldRef = useRef<World>();
useEffect(() => {
worldRef.current = World.from(gameStore.load() ?? World.initial());
return () => {
// 언마운트 시점에 스냅샷 저장
gameStore.save(worldRef.current!.toSnapshot());
};
}, []);
// ...
}
5. 진행 상태 저장 — 무엇을, 언제, 어디에
새로고침이나 앱 종료까지 버티려면 영속 저장이 필요하다. 세 가지를 각각 정해야 한다.
무엇을 저장하는가
렌더 상태가 아니라 논리 상태만 저장한다.
✓ 저장: 점수, 레벨, 보유 아이템, 진행 단계, 랜덤 시드
✗ 제외: 파티클 위치, 애니메이션 프레임, 카메라 흔들림 잔여값
→ 저장 크기가 수십 배 차이 나고,
렌더 상태를 복원하려다 버전 호환 문제만 생긴다
랜덤 시드를 저장하는 이유는 재현성 때문이다. 시드를 안 남기면 복구 후 다른 결과가 나온다.
언제 저장하는가
매 프레임 ✗ 과도. 저장 자체가 프레임을 먹는다
일정 주기(10초) △ 최대 10초 손실
의미 있는 시점 ✓ 레벨 클리어, 아이템 획득, 점수 갱신
이탈 시점 ✓ visibilitychange(hidden), pagehide
beforeunload가 아니라 pagehide / visibilitychange를 써야 한다. 모바일 브라우저는 beforeunload를 신뢰할 수 없게 발화한다.
useEffect(() => {
const persist = () => {
const snap = worldRef.current?.toSnapshot();
if (snap) localStorage.setItem(KEY, JSON.stringify({ v: 2, snap }));
};
const onHide = () => { if (document.hidden) persist(); };
document.addEventListener('visibilitychange', onHide);
window.addEventListener('pagehide', persist);
return () => {
document.removeEventListener('visibilitychange', onHide);
window.removeEventListener('pagehide', persist);
persist(); // 언마운트 시에도 한 번
};
}, []);
어디에 저장하는가
| localStorage | IndexedDB | 서버 | |
|---|---|---|---|
| 용량 | 약 5MB | 대용량 | 무제한 |
| API | 동기 | 비동기 | 비동기 |
| 기기 간 공유 | ✗ | ✗ | ✓ |
| 적합 | 소규모 진행 상태 | 리플레이·에셋 | 계정 연동 게임 |
간단한 미니게임이면 localStorage로 충분하다. 다만 동기 API라 큰 객체를 매번 쓰면 프레임이 끊긴다 — 저장 시점을 5절처럼 제한하는 이유다.
버전 필드(v: 2)를 반드시 넣는다. 게임을 업데이트하면 스냅샷 구조가 바뀌는데, 버전이 없으면 옛 데이터로 복구하다 크래시한다.
function loadSnapshot(): GameSnapshot | null {
try {
const raw = localStorage.getItem(KEY);
if (!raw) return null;
const parsed = JSON.parse(raw);
if (parsed.v !== CURRENT_VERSION) {
localStorage.removeItem(KEY); // 마이그레이션이 없으면 폐기
return null;
}
return parsed.snap;
} catch {
localStorage.removeItem(KEY); // 손상된 데이터
return null;
}
}
여러 종류의 미니게임을 한 사이트에 모아두는 구조라면 이 문제가 게임 수만큼 반복된다. 소셜슬롯플레이의 케노처럼 개별 게임이 각자 진행 상태를 갖는 포털에서는 저장 키 네임스페이스와 버전 정책을 게임별로 따로 두는 편이, 나중에 한 게임만 업데이트할 때 훨씬 안전하다.
6. 렌더 비용 — 프레임 예산 지키기
60fps는 프레임당 16.7ms다. 실제로 쓸 수 있는 건 브라우저 오버헤드를 빼고 약 10ms 정도다.
[canvas 렌더 비용 줄이기]
1. 변경된 영역만 다시 그리기 (더티 렉트)
— 전체 clearRect가 항상 최선은 아니다
2. 정적 배경은 별도 레이어 canvas에 한 번만 그리기
— 매 프레임 배경을 다시 그리지 않는다
3. 반복 도형은 오프스크린 canvas에 미리 렌더 후 drawImage
— 경로 연산이 매 프레임 반복되지 않게
4. 상태 변경(fillStyle, font 등) 횟수 줄이기
— 같은 스타일끼리 묶어서 그리기
5. 좌표를 정수로 맞추기
— 소수 좌표는 안티에일리어싱 비용 발생
구체적인 기법은 MDN의 canvas 최적화 문서에 정리돼 있다.
측정 없이 최적화하지 말 것. performance.now()로 update와 render 시간을 나눠 재고, 어느 쪽이 예산을 넘는지 확인한 뒤 손댄다. 대부분은 렌더가 아니라 매 프레임 생성되는 객체(GC 압박)가 원인이다.
7. 정리
1. 루프 핸들 보관 + running 플래그로 확실히 정지
2. 의존성 배열을 비우고 ref로 최신 콜백 참조 (루프 중첩 방지)
3. dt 상한 + visibilitychange 복귀 시 시간 기준 리셋
4. 고정 타임스텝으로 로직/렌더 분리 (따라잡기 상한 필수)
5. 논리 상태만, 의미 있는 시점에, 버전 필드와 함께 저장
6. pagehide/visibilitychange 사용 (beforeunload 아님)
이 중 실제로 버그를 가장 많이 만드는 건 2번과 3번이다. 둘 다 게임 로직과 무관한 곳에서 발생하고, 재현이 불규칙해서 원인을 찾는 데 시간이 오래 걸린다.