구글과 연동하기 전이어도 상점은 미리 만들어 볼 수 있는 거 아닌가?

웹 빌드를 서버에 올려 이것저것 눌러 보던 중이었다. 팁 창을 고치고, 브라우저 포커스 문제를 묻고, 그다음에 던진 말이다. 계획이라기보다 떠오른 생각에 가까웠다.

Claude Code 의 답은 이렇게 시작했다.

맞습니다, 정확한 통찰이에요. CLAUDE.md 절대 규칙 5가 바로 그 설계입니다.

Google 결제와 광고 SDK 는 마지막에 끼우는 부품이니, 상점 화면도 보석도 광고 보상도 지금 만들 수 있다는 설명이 이어졌다. 틀린 말이 하나도 없었다. 그렇게 로드맵의 마지막 단계, 수익화가 시작됐다.

멈추자는 제안

첫 작업은 눈에 보이는 게 거의 없었다. 광고와 결제의 인터페이스, 가짜 구현, 그리고 복귀 정산에 [광고 보고 2배] 버튼 하나. 보고 끝에 Claude Code 는 이런 문장을 붙였다.

계속 M4를 진행할지, 아니면 여기서 끊고 지인 테스트를 먼저 돌릴지 말씀해 주세요. 개인적으로는 지금 버전으로 피드백을 한 바퀴 받는 것도 좋은 타이밍이라고 봅니다 — 수익화 지점은 게임이 재밌다는 확인 후에 다듬어도 늦지 않으니까요.

1편에서 쓴 "유일한 취소 지점"을 나는 이미 그냥 지나왔다. 이건 두 번째 기회였다. 그것도 AI 가 먼저 내민 기회였다. 내가 보낸 답은 이것이었다.

상점 기능이 어디 있는지 모르겠어

질문에 대한 답이 아니었다. 그런데 답하지 않고 다음 요청을 보내면, 그 요청이 곧 "계속"이라는 답이 된다. Claude Code 는 안내가 앞서갔다고 인정하고 상단 바에 [상점] 버튼과 상품 네 종을 만들었다. 광고 제거, 사장님 패키지, 보석 팩, 월간 VIP. 그다음 내가 "보석으론 뭘 할 수 있는 거지?"라고 물었고, 그제야 보석을 쓸 곳이 없다는 게 드러났다. 팔 물건부터 생기고 쓸 곳은 나중에 생긴 셈이다.

인앱 상점 패널 — 광고 제거 ₩5,500, 사장님 패키지 ₩11,000, 보석 팩 ₩1,200, 월간 VIP ₩7,900. 상단에 테스트 결제임을 표시했다

₩5,500, ₩7,900, 매출 1.25배. 이 숫자들에는 근거가 없었다. 게임이 재미있는지도 모르는 상태에서 붙인 가격이니까.

SDK 없이 끝까지 도는 상점

그런데 코드를 다시 열어 보면, 목적 없이 시작된 작업치고 구조가 놀라울 만큼 반듯하다. 이유는 규칙 파일의 그 5번 한 줄이다. "광고·결제는 인터페이스 뒤에 둔다. 에디터에서는 항상 Fake 구현으로 동작해야 하고, SDK 없이 전체 게임이 플레이 가능해야 한다."

public interface IAdService
{
    bool IsReady(AdPlacement placement);
    void ShowRewarded(AdPlacement placement, Action<bool> onFinished);
}

public sealed class FakeAdService : IAdService
{
    private const float FakeDelaySec = 1.5f;

    public bool IsReady(AdPlacement placement) => true;

    public void ShowRewarded(AdPlacement placement, Action<bool> onFinished)
        => _runner.StartCoroutine(Delay(onFinished));   // 1.5초 뒤 무조건 성공
}

게임 코드가 아는 건 두 가지뿐이다. 이 자리의 광고가 준비됐는가, 끝까지 봤는가. 광고 회사가 어디든 돌아오는 건 bool 하나다. 결제 쪽 IIapService 는 Purchase 메서드 하나뿐이고, 가짜 구현은 그 자리에서 성공을 돌려준다. 결제가 됐는지는 서비스가, 무엇을 줄지는 게임이 정한다. 조립은 부트스트랩의 두 줄이고, 진짜 SDK 를 붙이는 날 바꿀 곳도 그 두 줄이다.

가짜 광고가 즉시 성공하지 않고 1.5초를 기다리는 게 마음에 들었다. 진짜 광고처럼 "아직 결과가 안 온 상태"를 UI 가 겪어 봐야, 그 사이에 버튼을 또 누르거나 배너가 사라지는 경우를 에디터에서 미리 볼 수 있다.

나는 구조를 이렇게 잡아 달라고 말한 적이 없다. 규칙 파일에 경계를 적어 뒀을 뿐이다. 무엇을 만들지는 내가 정하지 못했지만, 어디에 어떻게 둘지는 미리 적혀 있었고, 그 부분은 끝까지 지켜졌다.

미뤄 둔 골드

광고와 실제로 연결된 곳은 복귀 정산 하나였다. 그 전까지는 앱을 다시 켜면 꺼 둔 동안 번 골드를 바로 줬다. 광고를 보고 두 배를 받게 하려면 지급을 잠깐 미뤄야 한다. 그래서 정산 결과는 PendingOffline 에 보관되고, 실제 지급은 ClaimOffline 한 곳으로 모였다.

미루는 순간 새 걱정이 생긴다. 배너를 보고도 아무것도 안 누르면? 광고를 보다가 앱을 꺼 버리면? 그 골드는 어디로 가나.

public void ClaimOffline(bool doubled)
{
    if (PendingOffline == null || Simulation == null) return;
    _offline.Apply(Simulation.State, PendingOffline, doubled || IsVipActive);
    PendingOffline = null;
}

private void SaveNow()
{
    if (_saveData == null || _save == null) return;
    // 미지급 정산이 남아 있으면 기본 배율로라도 지급하고 저장 (유실 방지)
    if (PendingOffline != null) ClaimOffline(false);
    _saveData.lastSeenUtcTicks = Time.UtcNow.Ticks;
    _save.Save(_saveData);
}

광고를 끝까지 보면 ClaimOffline(true), 배너를 10초 그대로 두면 ClaimOffline(false), 지급 전에 저장 시점이 오면 저장이 먼저 기본 지급을 하고 나서 기록한다. 어느 길로 가든 결국 같은 문 하나를 지난다. 그리고 그 문은 한 번 지나면 PendingOffline 을 비우기 때문에, 두 경로가 겹쳐도 두 번 지급되지 않는다. 유실도 중복도 같은 한 줄이 막는다.

광고 버튼을 누르면 자동 지급을 30초 뒤로 미루고, 재생 중에는 타임아웃 지급을 건너뛰는 처리도 들어 있었다. 광고를 보는 동안 기본 지급이 먼저 끼어들면 두 배 받을 기회가 사라지니까. 이런 유실 시나리오는 내가 먼저 떠올린 게 아니다.

월간 VIP 도 비슷하게 단정했다. 저장하는 건 만료 시각 하나이고, 구매하면 Math.Max(now, vipUntil) 에 30일을 더한다. 기간이 남았을 때 다시 사면 남은 기간 뒤로 이어 붙고, 끝났으면 지금부터 30일이다. VIP 가 켜져 있으면 위의 ClaimOffline 이 광고 없이 두 배로 준다.

세 번째 경로

이 글을 쓰려고 코드를 다시 읽다가 하나를 찾았다. 사장님 패키지의 "영구 매출 +25%"는 어디서 곱해질까.

// ShopSimulation.RefreshDerived — 게임이 켜져 있는 동안의 시뮬레이션
_goldMult = _prestige.GoldMultiplier(State.prestige.fame);
if (State.iap != null && State.iap.ownerPack && _config.monetization != null)
    _goldMult *= _config.monetization.ownerPackRevenueMult;   // 사장님 패키지

켜져 있을 때는 곱해진다. 그런데 이 게임에서 골드가 들어오는 길은 셋이다.

골드가 들어오는 길 goldMult 로 넘기는 값 1.25배
켜져 있는 동안 (RefreshDerived) 명성 배율 × 1.25 적용
복귀 정산 (SettleOffline) 명성 배율만 빠짐
보석 즉시 생산 (TryInstantProduce) 명성 배율만 빠짐

복귀 정산은 패키지의 다른 혜택인 오프라인 상한 +2시간은 챙기면서 매출 배율은 빠뜨렸다. 보석 20개로 2시간치 매출을 받는 즉시 생산도 같다. 더 얄궂은 건 오프라인 계산 함수의 주석이다. goldMult 같은 값은 "온라인 SubTick 과 같은 값을 넘겨야 온·오프라인 수익률이 일관된다"고 적혀 있다. 함수는 조건을 말해 뒀는데 부르는 쪽 두 곳이 지키지 않았다.

상점을 만든 직후의 보고에는 효과가 "전부 진짜로 작동한다"고 되어 있었다. 화면에서는 맞는 말이었다. 패키지를 사면 그 순간부터 골드 팝업 숫자가 커진다. 눈으로 확인할 수 있는 길은 작동했고, 앱을 껐다 켰을 때나 보석을 쓸 때의 길은 아무도 확인하지 않았다. 수익화 쪽에는 테스트가 하나도 추가되지 않았다.

여러 경로에 같은 효과를 거는 코드는 경로를 다 세어 봐야 한다. 효과를 넣은 자리가 아니라 골드가 들어오는 자리를 기준으로 세야 빠진 곳이 보인다. 고치는 건 어렵지 않다. 명성 배율과 패키지 배율을 곱하는 함수 하나를 두고 세 길이 모두 그것만 부르게 하면 된다. 그리고 "패키지가 있으면 2시간 복귀 정산이 정확히 1.25배"라는 테스트를 길마다 하나씩 두면, 네 번째 길이 생겨도 거기서 걸린다.

답하지 않은 질문

돌아보면 이 장면에는 묘한 대칭이 있다. 내 질문은 "만들 수 있나?"였고, 답은 당연히 "맞습니다"였다. 규칙 5번이 정확히 그걸 위한 설계였으니까. 정작 "지금 필요한가?"라는 질문은 AI 가 꺼냈다. 그 질문에 답할 수 있는 사람은 나 하나였는데, 나는 상점이 어디 있냐고 물었다.

그래서 이 편의 코드는 잘 만든 것과 필요 없던 것이 한 몸이다. 인터페이스 뒤에 숨은 광고, 한 문으로만 지나가는 정산, 만료 시각 하나로 끝나는 VIP는 다음 게임에도 그대로 가져갈 수 있다. 근거 없는 가격표와 두 길에서 빠진 1.25배는 아무도 묻지 않은 질문의 흔적이다.

규칙 파일이 지킨 건 수익화 코드만이 아니었다. 1번 규칙, "Simulation 아래 코드는 UnityEngine 을 참조하지 않는다" 덕분에 이 게임은 엔진을 켜지 않고도 30일을 1초 안에 돌려 볼 수 있었다. 다음 편은 그 시뮬레이션 이야기다.