클로드와 함께 방치형 게임 만들기: 2. 게임 루프와 상태 관리 구현
'배달왕 키우기' 개발 두 번째 편. requestAnimationFrame 기반 게임 루프와 DeltaTime 설계, Zustand 상태 관리 구조를 setInterval 등 대안과 비교하며 왜 그렇게 선택했는지 정리한다.
배달왕 키우기 개발 과정을 공유하는 두 번째 포스트입니다. 첫 번째에서는 Vite, React, TypeScript, Tailwind CSS, Zustand을 조합해 프로젝트 기초를 다졌습니다. 이번에는 방치형 게임의 핵심인 게임 루프(game loop)와 상태 관리를 어떻게 구현했는지 살펴보겠습니다.
방치형 게임은 일반 게임과 완전히 다른 요구사항을 가집니다. 3D 게임처럼 물리 엔진이나 충돌 검사가 필요 없습니다. 대신 “시간 × 수입률 = 돈”이라는 단순한 공식이 핵심입니다. 이 포스트에서는 이 공식을 효율적으로 구현하기 위해 어떤 선택을 했고, 왜 그런 선택을 했는지 기술적으로 깊이 있게 다루겠습니다.
방치형 게임의 게임 루프란?
일반 게임 루프 vs 방치형 게임 루프
일반 게임(e.g. 2D 플래터포머, 슈팅 게임)의 게임 루프는 보통 이렇습니다:
while (isRunning) {
input() // 입력 처리
update(dt) // 물리, 애니메이션, 로직 업데이트
render() // 화면에 그리기
}
매 프레임마다 엔티티의 위치, 속도, 충돌을 계산하고, 수백 개의 그래픽 객체를 렌더링합니다. 성능은 프레임 시간과 직접 연결됩니다. 방치형 게임은 훨씬 단순합니다:
while (isRunning) {
deltaTime = now - lastTime
money += incomePerSecond() * deltaTime
lastTime = now
render(money)
}
물리나 충돌이 없습니다. 핵심은 “시간만 지나면 돈이 는다”입니다. 화면은 초당 60번 새로고침될 필요가 없습니다. 브라우저 렌더링 사이클에 맞춰도 되고, 심지어 필요하면 1초에 한 번만 업데이트해도 됩니다.
DeltaTime 설계의 중요성
방치형 게임에서 deltaTime 기반 설계는 필수입니다. 그 이유는 다음과 같습니다:
- 프레임 독립적 계산: 어떤 기기에서 어떤 프레임 레이트로 실행되든, 시간 경과에만 의존합니다.
- 오프라인 보상: 앱을 종료했다가 2시간 후 다시 열 때,
tick(2 * 3600)으로 오프라인 동안 벌어야 할 돈을 계산할 수 있습니다. - 슬로우모션/고속 재생: 테스트할 때
tick(10)으로 10초분의 진행을 순간에 시뮬레이션할 수 있습니다.
반대로 “매 프레임마다 money += 5” 같은 고정 증가량 방식을 쓰면, 프레임 드롭이 생길 때마다 소득이 줄어듭니다. 이를 보정하려면 별도의 보정 로직을 추가해야 하고, 결국 복잡해집니다.
requestAnimationFrame vs 대안들
게임 루프를 구현할 때 가장 먼저 마주치는 선택지입니다. 각 방식의 장단점을 비교했습니다.
setInterval의 문제점
setInterval(() => {
const deltaTime = 1000 / 60; // 고정값
tick(deltaTime);
}, 1000 / 60);
이 방식은 간단해 보이지만, 문제가 많습니다:
- 탭 비활성화 시 throttle: 브라우저가 백그라운드 탭의 setInterval을 1초마다로 제한합니다. 즉, 10분 동안 딱 10번만 실행됩니다.
- 정확한 타이밍 보장 안 함: OS의 다른 작업으로 인해 16.67ms 간격이 보장되지 않습니다. 누적되면 시간이 뒤처집니다.
- 프레임과 동기화 안 됨: 브라우저가 60fps로 화면을 새로고침하는데, setInterval은 독립적으로 동작합니다. 화면 깜빡임(tearing)이 발생할 수 있습니다.
setTimeout의 재귀 호출
function loop() {
const deltaTime = now - lastTime;
tick(deltaTime);
lastTime = now;
setTimeout(loop, 1000 / 60);
}
setTimeout(loop, 1000 / 60);
setInterval보다 낫지만, 역시 문제가 있습니다:
- 정시 보장 안 함: 각 콜백 실행 시간 + 대기 시간의 합이 프레임 시간이 됩니다. 콜백이 3ms 걸리면, 실제 간격은 19.67ms가 됩니다.
- Drift 누적: 시간이 계속 뒤처집니다.
requestAnimationFrame 선택 이유
let lastTime: number | null = null;
function loop(timestamp: number) {
if (lastTime !== null) {
const deltaSec = (timestamp - lastTime) / 1000;
tick(deltaSec);
}
lastTime = timestamp;
rafRef.current = requestAnimationFrame(loop);
}
rafRef.current = requestAnimationFrame(loop);
rAF를 선택한 이유는 다음과 같습니다:
- 브라우저 렌더링 사이클과 동기화: 브라우저가 60fps(또는 144fps)로 화면을 새로고침할 때와 정확히 맞춰 실행됩니다.
- 정확한 timestamp: 인자로 받는
timestamp는 이전 프레임이 렌더링되던 정확한 시점입니다. 누적 오류가 없습니다. - 탭 비활성화 시 일시정지: 탭을 비활성화하면 rAF 콜백도 자동으로 일시정지됩니다. 일반 게임에서는 이게 문제가 될 수 있지만, 방치형 게임에서는 “오프라인 보상”으로 해결합니다.
- 성능: 브라우저 최적화의 우선순위가 높아서, 다른 방식보다 더 효율적입니다.
Web Worker를 안 쓴 이유
일부 게임 개발자는 Web Worker에서 게임 로직을 돌리고, 메인 스레드에서만 렌더링하는 방식을 씁니다:
// worker.js
setInterval(() => {
tick(1000 / 60);
postMessage({ money });
}, 1000 / 60);
이 방식의 장점:
- 무거운 계산이 UI를 블로킹하지 않음
- 탭 비활성화에서도 계속 실행됨
단점:
- 이 규모의 게임에서는 계산량이 극히 적습니다 (단순 사칙연산)
- 메시지 패싱 오버헤드가 더 큽니다
- 상태 동기화 복잡도가 올라갑니다
- 디버깅이 어렵습니다
따라서 “오버엔지니어링”이라고 판단했습니다.
Zustand Store 설계 결정
GameState 정의
interface GameState {
// 저장할 상태 (localStorage)
money: number;
currentBikeId: string;
bikeLevel: number;
prestigeCount: number;
lastSaveTime: number;
adBoostEndTime: number;
// 함수들
incomePerSecond: () => number;
tick: (deltaSec: number) => void;
// ... 기타 메서드
}
export const useGameStore = create<GameState>((set, get) => ({
// ...
}));
incomePerSecond를 왜 함수로 만들었는가?
처음 고려한 방식:
// 아이디어 1: 저장된 파생 상태
const gameStore = {
money: 0,
incomePerSecond: 0, // 매번 수동으로 계산해서 저장?
bikeLevel: 1,
// ...
};
function updateIncomePerSecond() {
const bike = getBike(state.currentBikeId);
const prestigeMultiplier = 1 + 0.5 * state.prestigeCount;
state.incomePerSecond = bike.baseIncome * (1 + 0.1 * state.bikeLevel) * prestigeMultiplier;
}
문제
- 매번 손으로 계산해서 동기화해야 합니다.
bikeLevel바뀌고incomePerSecond는 안 바뀌는 상황이 생깁니다.- 버그를 만들기 쉽습니다.
최종 선택: Computed 함수
incomePerSecond: () => {
const state = get();
const bike = getBike(state.currentBikeId);
const prestigeMultiplier = 1 + 0.5 * state.prestigeCount;
const adBoostMultiplier = Date.now() < state.adBoostEndTime ? 2 : 1;
return bike.baseIncome * (1 + 0.1 * state.bikeLevel) * prestigeMultiplier * adBoostMultiplier;
},
장점
- 단일 진실 공급원(Single Source of Truth): 계산 공식이 한 곳에만 있습니다.
- 자동 동기화: 상태가 바뀌면 이 함수는 자동으로 새 값을 반환합니다.
- 부수 효과 없음: 순수 함수처럼 동작합니다.
이건 React의 “파생 상태 피하기” 원칙과 같은 맥락입니다. 여러 소스에서 비롯된 데이터를 수동으로 동기화하지 말고, 필요할 때 계산하라는 것입니다.
tick()이 deltaSec를 받는 이유
tick: (deltaSec: number) => {
const income = get().incomePerSecond() * deltaSec;
set(state => ({ money: state.money + income }));
},
왜 store 내부에서 deltaTime을 계산하지 않고, 외부에서 받을까요?
- 관심사 분리: store는 “돈을 얼마나 증가시킬지”만 알면 되고, “얼마만큼의 시간이 지났는지”는 hook이 담당합니다.
- 테스트 용이:
store.tick(10)으로 10초분 진행을 시뮬레이션할 수 있습니다. - 오프라인 보상: 앱을 켤 때
tick(offlineSeconds)로 일괄 처리합니다. - 재사용성: 게임 루프뿐 아니라 다른 곳에서도
tick()을 호출할 수 있습니다.
bikeLevel 초기값이 1인 이유
bikeLevel: 1,
수입 공식이 이렇습니다:
baseIncome × (1 + 0.1 × bikeLevel)
level이 0이면: baseIncome × 1.0 = baseIncome (강화 없음)
level이 1이면: baseIncome × 1.1 (10% 증가)
level이 2이면: baseIncome × 1.2 (20% 증가)
초기값을 0으로 하면, “강화를 하나 해야 효과가 보인다”는 뉘앙스입니다. 하지만 처음 플레이하는 사용자는 혼동할 수 있습니다. “강화했는데 효과가 없네?”
따라서 초기값을 1로 하면, “강화 레벨 1 상태로 시작”이 되고, 강화하면 바로 수치가 올라가는 것을 느낄 수 있습니다. UX 관점에서 더 낫습니다.
수입 공식 상세 분석
const baseIncome = bike.baseIncome;
const levelMultiplier = 1 + 0.1 * bikeLevel;
const prestigeMultiplier = 1 + 0.5 * prestigeCount;
const adBoostMultiplier = Date.now() < adBoostEndTime ? 2 : 1;
const totalIncome = baseIncome * levelMultiplier * prestigeMultiplier * adBoostMultiplier;
곱셈을 쓴 이유
더하기로 했다면:
const totalIncome = baseIncome + (bikeLevel * 100) + (prestigeCount * 10000) + (adBoost ? 1000 : 0);
문제
- 초반 100만원짜리 탈것에서는 bikeLevel +100이 큰 효과지만,
- 후반 10억짜리 탈것에서는 무시할 수준이 됩니다.
- 밸런스가 붕괴됩니다.
곱셈이면
- 탈것 가격이 작든 크든, 항상 “10% 증가”입니다.
- 상대적 이득이 일정합니다.
- 그래프가 지수 곡선을 그려서 중후반이 재미있어집니다.
프레스티지 배율이 선형인 이유
프레스티지란 게임을 리셋하고 영구 보너스를 얻는 시스템입니다. 배율을 선형으로
1 + 0.5 * prestigeCount
prestigeCount 0: 1.0배
prestigeCount 1: 1.5배
prestigeCount 2: 2.0배
prestigeCount 3: 2.5배
만약 지수로 했다면 1.5 ^ prestigeCount
prestigeCount 0: 1.0배
prestigeCount 1: 1.5배
prestigeCount 2: 2.25배
prestigeCount 3: 3.375배
지수는 너무 빨리 커집니다. 후반 플레이에서 프레스티지 3회만 해도 3배 이상 수입이 되므로, 이후 진행이 너무 빠릅니다. 플레이 타임이 줄어듭니다. 방치형 게임은 오래 즐기는 것이 목표이므로, 선형이 맞습니다.
광고 부스트가 정확히 2배인 이유
광고를 보면 2배 수입을 2분간 줍니다:
adBoostMultiplier = Date.now() < adBoostEndTime ? 2 : 1;
게임 디자인 관점
- 1.5배라면? 광고를 볼 필요 없습니다. 프레스티지로 더 큰 이득을 얻습니다.
- 3배라면? 광고가 너무 강력해서, 광고 없이는 진행 속도가 답답합니다.
- 2배는? 중간값입니다. 광고를 볼 만한 가치가 있으면서도, 광고 없이도 충분히 진행 가능합니다.
또한 정수배인 것이 중요합니다. 사용자가 쉽게 이해합니다. “광고 보면 2배” vs “광고 보면 1.87배”는 심리적 임팩트가 다릅니다.
한국식 숫자 포맷팅
방치형 게임은 빠르게 큰 숫자를 다룹니다. 사용자 화면에 “1234567890”이라고 표시하면 답답합니다.
구현
export function formatMoney(amount: number): string {
if (amount === 0) return '0';
const units = [
{ name: '조', value: 1_000_000_000_000 },
{ name: '억', value: 100_000_000 },
{ name: '만', value: 10_000 },
];
for (const { name, value } of units) {
if (amount >= value) {
const divided = amount / value;
return `${divided.toFixed(1)}${name}`;
}
}
return amount.toFixed(0);
}
예
- 0 → “0”
- 1234 → “1234”
- 12340 → “1.2만”
- 123400000 → “1.2억”
- 1234000000000 → “1.2조”
다른 접근법들
Intl.NumberFormat
const formatter = new Intl.NumberFormat('ko-KR');
formatter.format(1234567890); // "1,234,567,890"
장점: 국가별 형식 자동 지원
단점: 만/억/조 축약이 아니라 쉼표만 붙습니다. 방치형 게임에는 적절치 않습니다.
과학적 표기법
(1234567890).toExponential(2); // "1.23e+9"
장점: 간결
단점: 게이머한테는 낯섭니다. “1.23e+9가 뭐야?” 같은 반응이 나옵니다.
게임 업계 표준: 약어 조합
일부 게임은 K, M, B, T를 씁니다 (영어권)
- 1000 → 1K
- 1000000 → 1M
- 1000000000 → 1B
- 1000000000000 → 1T
하지만 한국 게임은 전통적으로 만/억/조를 씁니다. 사용자 입장에서 더 직관적입니다.
소수점 처리
위 코드는 toFixed(1)로 소수점 첫째 자리까지 표시합니다.
1234567890 / 100_000_000 = 12.34567
toFixed(1) = "12.3" // 반올림
선택지
- toFixed(1): 간결하지만, 12.34→12.3 같은 반올림이 보기 흉할 수 있습니다.
- toFixed(2): 더 정확하지만, 1.23억이라고 하면 너무 깁니다.
- Math.floor: 항상 내림. 1.23억이 실제론 1.2억으로 표시됩니다. 게이머가 속한 기분이 들 수 있습니다.
현재는 toFixed(1)로 선택했습니다. 대부분의 방치형 게임 기준입니다.
React 렌더링 최적화 고려
rAF에서 매 프레임 set() 호출하면?
export function useGameTick() {
const tick = useGameStore(state => state.tick);
useEffect(() => {
const loop = (timestamp: number) => {
if (lastTimeRef.current !== null) {
const deltaSec = (timestamp - lastTimeRef.current) / 1000;
tick(deltaSec); // ← 매 프레임마다 Zustand state 변경
}
lastTimeRef.current = timestamp;
rafRef.current = requestAnimationFrame(loop);
};
rafRef.current = requestAnimationFrame(loop);
return () => { /* cleanup */ };
}, [tick]);
}
Zustand의 set() 호출은 상태 변경이고, 이는 구독하는 컴포넌트를 리렌더링합니다. 그러므로 매 프레임마다 리렌더링됩니다.
왜 문제가 아닌가?
- DOM 변경량이 적습니다: 숫자 하나(
money)만 업데이트합니다. 배열 재구성, 객체 생성, 복잡한 계산이 없습니다. - React의 최적화: React 18부터는 automatic batching이 있습니다. 같은 이벤트 루프 틱에서 여러
setState호출이 한 번의 리렌더링으로 묶입니다. - 자동 일시정지: rAF는 탭 비활성화하면 자동으로 멈춥니다. 배터리 낭비가 없습니다.
나중에 최적화할 여지
지금은 문제 없지만, 나중에 더 많은 UI를 추가하면 성능이 떨어질 수 있습니다. 그때 고려할 방법들입니다
1. Selector로 필요한 상태만 구독
// 전체 state를 쓰는 대신
const tick = useGameStore(state => state.tick);
// 필요한 것만
const money = useGameStore(state => state.money);
const tick = useGameStore(state => state.tick);
이렇게 하면, money만 업데이트하는 컴포넌트는 다른 상태 변경에 영향을 받지 않습니다.
2. React.memo로 자식 컴포넌트 보호
const MoneyDisplay = React.memo(({ money }: { money: number }) => (
<div>{formatMoney(money)}</div>
));
부모가 리렌더링되어도, money prop이 같으면 자식은 리렌더링되지 않습니다.
3. 더블 버퍼링
게임 루프는 계속 돌지만, UI 업데이트는 100ms마다만 하기
const [displayMoney, setDisplayMoney] = useState(0);
const loop = (timestamp: number) => {
// 게임 로직은 계속
if ((timestamp - lastUIUpdate) > 100) {
setDisplayMoney(get().money);
lastUIUpdate = timestamp;
}
rafRef.current = requestAnimationFrame(loop);
};
단점: 화면이 끊어져 보일 수 있습니다. 방치형 게임에는 오버엔지니어링일 가능성이 높습니다.
현재 프로젝트 규모에서는 selector 정도면 충분할 것 같습니다.
Claude AI와의 구현 과정
이 구현을 혼자 한 게 아니라, Claude와 함께 진행했습니다. 흥미로웠던 부분들을 소개합니다:
AI가 제안했던 것
- Web Worker 사용: “계산을 별도 스레드에서 하면 UI가 안 끊어진다”
- 거절 이유: 이 규모에서는 과도한 설계입니다. 계산량이 극히 적기 때문에, 메시지 패싱 오버헤드가 더 큽니다.
- Immer 미들웨어 사용: “상태 변경 불변성을 자동으로 보장한다”
- 선택 이유: Zustand은 기본적으로 Immer를 지원합니다. 나중에 복잡한 상태 업데이트가 생기면 도움이 될 것 같습니다.
- 분당 저축액(MPS) 계산: “게임의 모든 숫자는 시간 기반이어야 한다”
- 동의했습니다. 현재 구조가 정확히 이것입니다.
내가 거절했던 것
- Redux 사용: “상태 관리의 표준”
- 거절 이유: Zustand이 더 간단하고 번들 크기가 작습니다. 이 규모 프로젝트에는 과도합니다.
- GameEngine 클래스: “OOP 구조로 관리하면 확장성이 좋다”
- 거절 이유: React와 함께 쓰면서 보일러플레이트가 너무 많아집니다. Zustand의 함수형 접근이 React와 더 자연스럽습니다.
- requestIdleCallback 사용: “메인 스레드가 유휴 상태일 때만 업데이트”
- 거절 이유: rAF와 비교했을 때, 게임 루프 정시성이 떨어집니다. 방치형이라도 “이 시점에 정확히” 업데이트되어야 스마트해 보입니다.
마주쳤던 버그들
개발 과정에서 겪은 문제들과 해결책입니다:
1. deltaTime이 음수?
초기에 이런 실수를 했습니다.
const deltaSec = (timestamp - lastTimeRef.current); // ← 단위 변환 빼먹음
tick(deltaSec);
1000ms를 1초가 아니라 1000초로 계산해서, 갑자기 돈이 1000배 늘어났습니다.
해결: / 1000 추가.
2. cleanup에서 rafRef를 체크하지 않음
useEffect(() => {
rafRef.current = requestAnimationFrame(loop);
return () => {
cancelAnimationFrame(rafRef.current); // ← null이면?
};
}, []);
만약 컴포넌트가 아주 빨리 언마운트되면, rafRef.current가 null일 수 있습니다.
해결:
return () => {
if (rafRef.current !== null) {
cancelAnimationFrame(rafRef.current);
}
};
3. React 18의 StrictMode에서 hook이 두 번 실행됨
개발 환경에서 useGameTick이 두 번 호출되니까, 게임 루프가 두 개 돕니다. 처음엔 “왜 두 배가 빨리 느는 거지?”라고 생각했습니다.
해결: StrictMode는 버그를 찾기 위한 의도된 동작입니다. 배포 환경에서는 한 번만 실행되므로 무시해도 됩니다. 필요하면
const [isInitialized, setIsInitialized] = useState(false);
useEffect(() => {
if (isInitialized) return;
setIsInitialized(true);
// setup
}, [isInitialized]);
다음 편 예고
이제 기초가 다져졌습니다. 다음 포스트에서는
- 탈것 교체 시스템: 돈이 쌓이면 더 비싼 자전거를 사는 로직
- 강화 시스템: bikeLevel을 올려서 수입 증가
- UI 구현: 보유 탈것 목록, 구매 버튼, 강화 버튼
그 다음은 localStorage 저장, 오프라인 보상, 프레스티지 시스템으로 이어질 것입니다.
결론
방치형 게임의 게임 루프는 일반 게임보다 훨씬 단순합니다. 핵심은
- DeltaTime 기반 설계: 프레임 독립적이고, 오프라인 보상에 활용 가능
- requestAnimationFrame: 정확성과 성능의 최적 조합
- Zustand의 computed 함수: 파생 상태를 안전하게 관리
이런 선택들이 모여서, 향후 시스템 확장도 쉬워집니다. 광고 부스트, 프레스티지, 이벤트 같은 기능들을 추가할 때도, 핵심 로직은 손대지 않아도 됩니다. 그냥 incomePerSecond()에 새로운 승수를 곱하기만 하면 됩니다. 방치형 게임은 단순해 보이지만, 밸런스를 맞추는 과정은 까다롭습니다. 다음 편에서는 그 과정을 더 깊이 들어가겠습니다.