브라우저에서 포커스가 잃어버리면 게임이 멈추는거 같아.

웹 빌드를 서버에 올려 직접 플레이하던 중이었다. 진열대 가격을 맞춰 두고 다른 창에서 할 일을 하다가 돌아왔는데, 골드가 그대로였다. 용사들은 문 앞에서 걸음을 멈춘 채 서 있었다. 앱을 꺼 둬도 돈이 쌓이는 게 이 장르의 약속인데, 창 하나 옮겼다고 가게가 문을 닫은 것이다.

Claude Code 의 진단은 짧았다. 빌드 설정에서 백그라운드 실행이 꺼져 있다는 것. 그런데 원인보다 그 뒤에 붙은 말이 더 오래 남았다. 이렇게 멈춘 시간은 "온라인도 오프라인도 아닌 죽은 시간"이 된다는 것이었다.

죽은 시간

방치형 게임에서 시간은 두 종류다. 화면이 돌아가는 동안의 온라인 시간은 매 프레임 시뮬레이션이 손님을 들이고 물건을 판다. 앱을 끈 동안의 오프라인 시간은 다시 켰을 때 한꺼번에 정산된다. 둘 다 돈이 된다.

포커스를 잃은 브라우저는 그 어디에도 속하지 않았다. 프레임이 돌지 않으니 온라인이 아니고, 앱이 꺼진 게 아니니 다시 켤 때의 정산도 일어나지 않는다. 시간은 흘렀는데 가게 장부에는 아무 일도 없었던 것으로 남는다. 버그라기보다는 내 게임이 "시간"을 어떻게 세는지에 난 구멍이었다.

이 구멍을 메우려면 먼저 오프라인 정산이 어떻게 돌아가는지 알아야 했다. 사실 나는 그때까지 그걸 제대로 들여다본 적이 없었다. 1편에서 말한 "M2 진행해줘" 한마디에 실려 들어간 코드였다.

8시간을 한 번에

가장 쉬운 정산은 자리 비운 시간만큼 시뮬레이션을 돌리는 것이다. 온라인 시뮬레이션은 1초 단위로 쪼개 도니, 8시간이면 28,800번이다. 게임을 켜자마자 화면이 멈칫한다.

Claude Code 는 이 길을 처음부터 피했다. 시뮬레이션을 만들 때 이미 "재고 소진 같은 경계 조건 때문에 폐쇄형으로는 Tick(3600) ≈ Tick(1)×3600 을 보장할 수 없다"고 하며, 온라인은 서브스텝으로 두고 오프라인은 공식으로 따로 풀겠다고 미뤄 뒀다. 그 미뤄 둔 공식이 이것이다.

진열대 하나를 본다. 초당 팔리는 개수를 s, 초당 채워지는 개수를 r, 지금 재고를 S₀ 라고 하자. 재고는 r − s 의 속도로 변한다. 채우는 쪽이 더 빠르면(s ≤ r) 재고는 마르지 않고, 경과 시간 t 동안 s·t 개가 팔린다. 끝.

흥미로운 건 파는 쪽이 더 빠를 때다. 재고가 s − r 의 속도로 줄다가 어느 순간 바닥난다. 그 시점이 t1 이다.

t1 = S₀ / (s − r)

판매량(t)   = s · min(t, t1) + r · max(0, t − t1)
재입고량(t) = r · t

t1 까지는 풀속도로 팔리고, 그 뒤로는 채워지는 만큼만 팔린다. 두 구간으로 나누면 미분방정식이고 뭐고 없이 덧셈 두 번으로 끝난다. 두 번째 항이 핵심이다. 이게 없으면 손님 앞에 빈 진열대만 있는데도 8시간 내내 매출이 쌓인다. 실제 코드는 도난과 명성 배수까지 얹은 모양이다.

double drainRate = salesRate - restockRate;
double t1 = shelf.stock / drainRate;   // 재고 소진 시점
if (t <= t1)
{
    soldUnits = salesRate * t;
    restockedUnits = restockRate * t;
    endStock = shelf.stock - drainRate * t;
}
else
{
    double phase2 = t - t1;
    soldUnits = salesRate * t1 + restockRate * phase2;
    restockedUnits = restockRate * t;
    endStock = 0.0;
}

gross += soldUnits * (1.0 - theftRate) * unitRevenue * goldMult
         - restockedUnits * unitCost;

진열대 수만큼 한 바퀴 돌면 끝이라 8시간이든 10초든 비용이 같다. theftRate 는 재고만 빠지고 돈은 안 들어오는 좀도둑 몫이다.

여기에 economy.json 의 숫자가 붙는다. 오프라인 수익은 온라인의 55%, 기본 상한 2시간, 보너스를 다 더해도 절대 상한 8시간. 평판은 자리를 비운 동안 고정이다. 사장이 없다고 손님이 등을 돌리지는 않는다는, 일부러 둔 너그러움이다. 계산(Calculate)과 지급(Apply)도 나뉘어 있어서, 광고 2배는 사용자가 고른 뒤에야 곱해진다.

가짜 시계와 테스트 이름

공식은 그럴듯했다. 문제는 그걸 내가 믿어도 되느냐였다. 세이브가 날아가면 게임이 끝나고, 오프라인 보상이 틀리면 경제가 무너진다. 이 코드를 나는 한 줄도 쓰지 않았다.

그런데 이상하게 불안하지 않았다. 다시 들여다보니 이유가 CLAUDE.md 에 있었다. 기획 문서를 만들 때 적어 둔 절대 규칙 중 네 번째 줄이다.

  1. 시간은 ITimeService로만 읽는다. DateTime.UtcNow / Time.time 직접 호출 금지. 오프라인 보상 로직은 시간을 조작해서 테스트해야 합니다.

ITimeService 는 DateTime UtcNow { get; } 한 줄짜리 인터페이스다. 게임에서는 진짜 시계가, 테스트에서는 시각을 마음대로 정하는 가짜 시계가 꽂힌다. 이 한 줄 덕분에 "8시간 뒤에 켜면?", "시계를 한 시간 되돌리면?" 같은 질문을 기다리지 않고 바로 물어볼 수 있다.

같은 파일 테스트 절에는 이런 줄도 있었다.

  • OfflineRewardTests — 경계값(0초, cap 직전, cap 초과, 시계 되감기)

내가 고른 경계값 네 개는 하나도 빠지지 않고 테스트가 됐다. 경과 0초는 골드 0. 시계를 3,600초 되감으면 보상 0에 되감기 플래그가 서고, 그 결과를 지급 단계에 넘겨도 상태가 그대로다. 상한 1초 전에는 잘리지 않고, 상한의 세 배를 비워도 정확히 상한만큼만 준다. 재고가 바닥난 뒤엔 시간을 두 배로 늘려도 보상이 두 배가 안 되는지도 따로 확인한다.

가장 값진 건 마지막 테스트였다. 같은 가게 두 벌을 하나는 폐쇄형 공식으로, 하나는 진짜 온라인 Tick(1800) 으로 돌려 결과가 rateFactor 배와 2% 안에서 맞는지 본다. 나중에 직원이나 도난 같은 시스템이 들어오며 온라인 쪽만 고치고 오프라인 공식을 빠뜨리면 이 테스트가 깨진다. 같은 계산을 두 번 쓴 위험을 테스트 하나가 지키고 있었다.

AI 가 알아서 경계값을 잘 고른 게 아니었다. 내가 미리 고른 경계값을 AI 가 빠짐없이 코드로 바꿨다. 내가 이 코드를 믿은 건 코드를 읽어서가 아니라 테스트 이름을 내가 썼기 때문이었다.

15초의 문턱

그런데 규칙이 지킨 건 거기까지였다. 규칙 4는 시간을 "어떻게 읽느냐"를 정했지, 엔진이 멈춘 시간을 "어떻게 볼 것이냐"는 정하지 않았다. 죽은 시간은 그 빈자리에서 생겼다.

고친 방향은 둘이었다. 창이 보이는데 포커스만 잃은 경우는 계속 돌게 한다. 빌드 스크립트의 PlayerSettings.runInBackground = true; 한 줄이다. 그래도 멈추는 경우, 탭이 숨겨졌다 돌아오는 경우는 그 사이를 오프라인 정산으로 메운다. 포커스를 잃는 순간 저장해 lastSeen 을 찍어 두고, 돌아오면 이 함수를 부른다.

private void CatchUpAfterSuspend()
{
    const double minSuspendSec = 15.0;
    if (_saveData == null || Simulation == null || _saveData.lastSeenUtcTicks <= 0) return;

    double elapsedSec =
        (Time.UtcNow.Ticks - _saveData.lastSeenUtcTicks) / (double)TimeSpan.TicksPerSecond;
    if (elapsedSec < minSuspendSec) return;

    SettleOffline();
    SaveNow();   // lastSeen 갱신 — 이중 정산 방지
    if (OfflineReport != null)
        OfflineSettled?.Invoke(OfflineReport);
}

15초보다 짧게 비웠으면 아무 일도 하지 않는다. 탭을 잠깐 오갈 때마다 배너가 뜨면 성가시다. 정산한 직후에는 바로 저장해 lastSeen 을 지금으로 당긴다. 포커스 복귀와 일시정지 해제가 연달아 들어와도 두 번째 호출은 15초 문턱에 걸린다. 같은 시간을 두 번 정산해 돈을 두 번 주는 일은 없다.

여기서 Time 은 UnityEngine.Time 이 아니라 ITimeService 다. 돈이 걸린 시계는 이번에도 바꿔 끼울 수 있는 쪽으로 읽었다. 규칙 4가 내가 모르는 사이에도 일하고 있었다.

나는 한 번 더 물었다.

브라우저가 선택이 안된 상태면 게임이 멈추는건 어쩔 수 없는건가?

답은 경우를 나눴다. 포커스만 잃은 경우는 이제 계속 돈다. 탭 전환이나 최소화는 브라우저가 정책으로 멈추니 막을 수 없지만, 돌아오는 순간 정산이 돌아 손해가 없다. 앱을 꺼도 손님이 오는 게임이니, 탭을 숨긴 것도 같은 규칙을 따르는 게 맞다는 설명이었다. 죽은 시간이 오프라인 시간으로 편입된 것이다.

재미있는 건 Claude Code 가 이 수정을 끝까지 확인하지 못했다는 점이다. 자기 쪽 브라우저 패널이 화면에 보이지 않아, 그 자체가 숨겨진 탭처럼 프레임이 돌지 않았다. 버그를 고치는 도구가 같은 버그에 갇힌 셈이다. 그래서 확인 목록을 넘겨받았고, 다른 창을 클릭하고 탭을 오가 보는 마지막 확인은 내가 했다.

'원자적'이라는 이름

정산이 맞아도 저장이 깨지면 소용없다. lastSeen 도 골드도 전부 save.json 한 파일에 들어 있다. 이 파일을 쓰는 코드를 그제야 처음으로 끝까지 읽었다.

// 직전 세이브를 백업으로 보존한 뒤 임시 파일 → 원자적 교체
string tmp = SavePath + ".tmp";
File.WriteAllText(tmp, payload);
if (File.Exists(SavePath))
    File.Copy(SavePath, BackupPath, overwrite: true);
if (File.Exists(SavePath))
    File.Delete(SavePath);
File.Move(tmp, SavePath);

교과서 같은 순서다. 새 내용을 임시 파일에 다 쓰고, 기존 파일을 백업으로 복사하고, 교체한다. 쓰다가 죽어도 원본은 멀쩡하다. 내용은 AES 로 암호화되지만, 키가 클라이언트에 있으니 메모장으로 골드를 고치는 정도만 막는다는 건 Claude Code 가 먼저 짚었다.

그런데 주석의 "원자적 교체"에서 걸렸다. Delete 와 Move 사이에는 save.json 이 아예 없는 순간이 있다. 그 찰나에 게임이 죽으면 본 파일은 없고 .tmp 만 남는다. 원자적이라면 이런 순간이 없어야 한다.

그래도 세이브가 날아가지는 않는다. 바로 앞 줄에서 백업을 떠 뒀고, 로드는 본 파일이 없거나 깨지면 백업으로 간다. 잃는 건 마지막 저장 한 번이다. 결과적으로는 안전했다. 다만 그 안전은 주석이 말한 "원자적"에서 오는 게 아니라, 그 위의 백업 한 줄에서 온다. 주석만 믿었다면 나는 이 코드의 보장 범위를 실제보다 넓게 알았을 것이다.

AI 가 붙인 이름은 대개 맞다. 그래서 더 그대로 믿게 된다. 세이브처럼 날아가면 끝인 곳은 이름이 아니라 순서를 한 번은 직접 읽어야 했다. 테스트 이름은 내가 썼기에 믿을 수 있었고, 주석은 내가 쓰지 않았기에 확인이 필요했다.

정산은 이제 정확했다. 탭을 숨겼다 돌아와도, 하룻밤을 비워도 장부는 맞았다. 그런데 돌아온 사장 앞에 뜨는 건 숫자가 적힌 배너 한 장뿐이었다. 돈이 맞게 들어오는 것과 돌아와 보고 싶은 가게가 있는 것은 다른 문제였고, 다음 편은 그 화면에 그림을 입히던 이야기다.