에디터 닫았어
시뮬레이션 코어가 만들어지는 동안 내가 Claude Code 에 보낸 말 중 "진행해줘" 다음으로 많은 게 이거였다. 테스트를 돌리려는데 내가 Unity 에디터를 열어 둔 탓에 프로젝트가 잠겨 있었고, 나는 에디터를 닫고 저 한 줄을 보냈다. 그 사이 나머지 대화는 거의 "진행해줘"였다. 1편에서 고백한 그 말버릇이다.
이상한 건 그다음이다. 나는 어셈블리를 어떻게 나눌지, 숫자를 어디에 둘지, 시간을 어떻게 다룰지 한 번도 말하지 않았다. 그런데 경제 시스템이 직원, 원정, 환생, 명성 트리까지 불어나는 동안 코드 구조는 첫날 모습에서 한 치도 무너지지 않았다. 게임 로직은 끝까지 Unity 를 몰랐고, 밸런스 숫자는 끝까지 한 파일에 있었다. 지시하지 않은 품질이 왜 지켜졌을까.
한 줄이 컴파일러에게 넘어간 순간
답은 Claude Code 가 세션마다 읽는 규칙 파일 CLAUDE.md 의 첫 번째 절대 규칙에 있었다.
1. **`Simulation/` 하위 코드는 `UnityEngine`을 참조하지 않는다.**
순수 C#으로 유지해야 EditMode 테스트에서 "1년치 플레이"를 1초에 돌릴 수 있습니다.
이 프로젝트 생산성의 절반이 여기서 나옵니다. 예외 없음.
이 문장이 처음 힘을 쓴 건 Unity 를 설치하기도 전이었다. 설치 안내를 마친 Claude Code 가 덧붙였다.
CLAUDE.md 절대 규칙 1번이 "
Simulation/은 UnityEngine을 참조하지 않는다"라서, 핵심 로직(PricingModel, 경제 공식)은 순수 C#으로 Unity 없이도 먼저 작성 가능합니다.
그래서 게임 화면이 한 장도 없을 때 가장 먼저 생긴 파일이 시뮬레이션 어셈블리 정의였다.
{
"name": "Hogumart.Simulation",
"rootNamespace": "Hogumart.Simulation",
"references": [],
"noEngineReferences": true
}
마지막 줄이 이 편의 주인공이다. noEngineReferences 가 켜진 어셈블리 안에서 using UnityEngine; 을 쓰면 빌드가 깨진다. "참조하지 않는다"는 부탁이 "참조할 수 없다"는 사실로 바뀐 것이다. 이때부터 규칙 1을 지키는 건 Claude Code 의 주의력이 아니라 컴파일러였다. 내가 아무리 "진행해줘"만 보내도, 몇 번째 세션이든, 이 경계는 넘을 방법이 없었다.
경계가 생기자 판단이 따라왔다. Claude Code 는 "JSON 파싱은 Simulation에 넣지 않습니다"라고 정리했고, economy.json 을 읽는 로더는 Game 어셈블리에 두고 시뮬레이션은 완성된 설정 객체만 받게 했다. 시간도 같았다. 시뮬레이션 안에는 ITimeService 인터페이스와 테스트용 가짜 시계만 있고, 진짜 DateTime.UtcNow 를 부르는 곳은 Unity 쪽 한 군데뿐이다.
문장으로만 남은 규칙도 있었다. "밸런스 수치를 코드에 하드코딩하지 않는다." 이건 컴파일러가 막아 주지 않는다. 그런데 평판에 따른 손님 유입을 구현하던 중, 기준값 0.5 를 코드에 적으려던 Claude Code 가 스스로 멈췄다.
0.5상수가 코드에 박히면 절대 규칙 2 위반이라 config로 뺍니다
그렇게 trust.inflowBase: 0.5 가 생겼다. 다만 이건 읽는 쪽이 매번 기억해 줘야 지켜지는 규칙이라 규칙 1과는 무게가 달랐다. 그래서 가격 공식 테스트는 상수를 다시 적지 않고 실제 economy.json 을 읽어 검증했다. 문장 규칙을 반쯤 기계에 넘긴 셈이다.
3600초를 한 번에, 또는 3600번에
엔진을 모르는 시뮬레이션에도 시간은 흘러야 한다. Unity 쪽 GameLoop 는 매 프레임 ShopSimulation.Tick(dt) 하나를 부를 뿐이고, 나머지는 전부 시뮬레이션 안에서 일어난다.
private const double MaxSubStepSec = 1.0;
public void Tick(double dt)
{
if (dt <= 0.0 || double.IsNaN(dt)) return;
RefreshDerived(); // 외부에서 직원/명성/단골이 바뀌었어도 여기서 반영된다
while (dt > 0.0)
{
double step = dt < MaxSubStepSec ? dt : MaxSubStepSec;
SubTick(step);
dt -= step;
}
}
들어온 dt 가 아무리 커도 1초 이하 조각으로 썰어서 처리한다. 공식 하나로 한 시간을 단번에 계산하지 않은 이유가 있다. 진열대 재고가 바닥나는 순간, 발주할 돈이 모자라는 순간에 장사의 흐름이 꺾이는데, 그 꺾이는 지점을 닫힌 식으로 풀기보다 1초씩 걸어가며 부딪치는 편이 훨씬 단순했다.
경제 문서에는 "큰 dt 가 들어와도 결과가 같아야 한다"는 문장이 있었고, 이건 그대로 테스트 이름이 됐다. Tick_큰간격과_작은간격_결과가_같다. 한 가게는 Tick(3600) 을 한 번, 다른 가게는 Tick(1) 을 3600번 돌린 뒤 골드와 평판이 1% 안에서 같아야 통과한다. 반대 방향으로 1초보다 잘게 쪼개도 같아야 한다는 테스트도 있다. 60프레임 기기든 프레임이 출렁이는 저가 폰이든 같은 가게가 같은 돈을 번다는 약속이다.
다음 장의 30일 테스트도 "큰 dt 를 믿고 던져도 된다"는 이 전제 위에 서 있다.

한 달을 1초 안에
CLAUDE.md 의 테스트 절에는 필수 테스트가 이름까지 적혀 있었다. 그중 하나가 LongRunSimulationTests 였고, 첫 테스트는 규칙 1의 존재 이유를 그대로 실패 메시지에 새겼다.
[Test]
public void 가상_30일_플레이가_1초_안에_돌고_상태가_건전하다()
{
var sim = NewStartingShop();
var sw = System.Diagnostics.Stopwatch.StartNew();
for (int day = 0; day < 30; day++)
sim.Tick(86400.0);
sw.Stop();
Assert.That(sw.ElapsedMilliseconds, Is.LessThan(1000),
"30일 시뮬이 1초를 넘으면 밸런싱 반복이 불가능해집니다.");
}
30일은 1초 서브스텝으로 259만 2천 번이다. 매 스텝마다 Math.Pow 로 원가를 다시 구하면 이 예산을 못 맞추니, 가격 모델에 티어별 원가 캐시가 들어갔다. 테스트가 성능 요구를 코드에 밀어 넣은 장면이다.
이 테스트가 진짜 쓸모를 보인 건 환생을 넣은 뒤였다. 최적 가격을 맞추고, 남는 골드로 업그레이드하고, 조건이 되면 바로 환생하는 순수 C# 자동 플레이어를 만들고, 하루를 "3분씩 네 번 접속, 그 사이는 오프라인 정산"으로 잡아 30일을 돌렸다. 목표는 무과금으로 환생 8~14회. 경제 문서의 공식대로 만든 첫 결과는 이랬다.
| 조건 | 30일 환생 횟수 |
|---|---|
| 환생 요구치 고정(1e9), 티어 제한 없음 | 1,521회 |
| 상품 티어 제한만 추가 | 1,248회 |
| 요구치가 회차마다 2.0배 | 23회 |
| 요구치가 회차마다 2.6배 | 9회 |
1,521회. 하루에 쉰 번씩 가게를 접고 다시 연 셈이다. 범인은 명성 배율 1 + 0.02 × 누적 명성 이었다. 환생할수록 돈을 더 빨리 벌고, 더 빨리 벌수록 더 빨리 환생했다. 결론은 환생 요구치를 1e9 × 2.6^n 으로 키우고, 팔 수 있는 상품 티어를 3 + 환생 횟수 까지로 묶는 것이었다. 둘 다 economy.json 에 숫자로 들어갔다. 코드에는 여전히 공식만 있다.
Unity 를 띄우지 않는 반복
그런데 1,521에서 9까지 조이는 길은 Unity 안에서는 너무 무거웠다. 배치 모드 테스트는 매번 에디터 한 벌을 띄웠다 내렸고, 내가 에디터를 열어 두면 잠금에 걸려 바로 실패했다. "에디터 닫았어"가 여러 번 나오는 이유다.
밸런스를 잡기 시작할 때 Claude Code 가 방향을 틀었다.
dotnet 10이 있으니 Unity 없이 초 단위로 밸런스 반복이 가능합니다.
규칙 1이 여기서 두 번째로 값을 했다. 시뮬레이션이 UnityEngine 을 한 글자도 모르니, 그 소스 파일을 평범한 .NET 콘솔 프로젝트에 그대로 끌어와 컴파일할 수 있었다.
<!-- csproj: Unity 프로젝트의 Simulation 소스를 직접 참조 -->
<Compile Include="...\Assets\_Project\Scripts\Simulation\*.cs" />
설정 파일은 System.Text.Json 으로 읽었다. 설정 객체가 public 필드만 가진 단순한 클래스라 IncludeFields = true 하나만 켜면 Unity 의 JsonUtility 가 만드는 것과 같은 객체가 나왔다. Unity 를 아예 띄우지 않으니 숫자를 바꾸고 30일을 돌려 보는 한 바퀴가 눈 깜짝할 새에 끝났다. 23회에서 9회로 조이는 과정은 전부 이 작은 하니스 위에서 돌았고, Unity 의 LongRunSimulationTests 는 마지막에 결과를 못 박는 데만 썼다.
그리고 그 하니스는 지금 없다. 저장소가 아니라 작업용 임시 폴더에 만들어졌고, 세션이 끝나면서 같이 사라졌다. 남은 건 프로젝트 메모에 적힌 csproj 한 줄과 밸런스 커밋 본문의 숫자들뿐이다. 그 숫자들이 남은 것도 우연이 아니다. CLAUDE.md 의 커밋 규칙에 "balance(...) 커밋에는 변경 전후 30일 시뮬 결과를 본문에 남긴다"는 줄이 있었다. 규칙에 적힌 건 남았고, 적히지 않은 도구는 사라졌다.
초록불이 말해 주지 않은 것
여기까지 쓰면 성공담 같다. 실제로 다음 게임에서도 이 구조는 그대로 쓸 생각이다.
그런데 이 게임은 출시되지 못했다. 시스템을 넣을 때마다 StaffModelTests, ExpeditionModelTests, PrestigeTreeTests 가 생겼고 보고는 매번 "전부 통과"였다. 나는 그 초록불을 게임이 나아지고 있다는 신호로 읽었다. 실제 뜻은 훨씬 좁았다. 이 공식이 문서대로 계산된다는 것. 테스트는 숫자가 맞는지를 증명했지, 재미있는지는 한 번도 증명하지 않았다.
돌아보면 신호는 테스트 안에 이미 있었다. 가격 테스트는 슬라이더 전 구간을 0.001 간격으로 훑어 해석해가 전역 최적임을 확인했다. 1편에서 펜으로 푼 그 "정답이 하나인 슬라이더"를 테스트가 보증까지 해 준 것이다. 자동 플레이어가 규칙 몇 줄로 목표 페이싱을 통과했다는 건, 사람이 내릴 판단도 그만큼 단순하다는 뜻이었다. 그리고 경제 문서의 검증 항목에는 환생 횟수 말고 하나가 더 있었다. "어느 시점에도 20분 이상 아무 선택지도 없는 구간이 없는가." 플레이 감각에 가장 가까운 이 항목만 끝내 테스트가 되지 않았다. 무엇을 선택지로 볼지는 사람이 정해야 했고, CLAUDE.md 에 테스트 이름으로 적힌 적이 없었다.
규칙 한 줄을 컴파일러에게 넘기자 구조는 내가 손을 놓아도 버텼다. 하지만 재미는 어떤 컴파일러에게도 넘길 수 없었다. 그 판정만은 처음부터 내 몫이었고, 나는 거기서도 "진행해줘"라고 말했다.
다음 편은 바로 그 "진행해줘"가 무엇을 불러왔는지다. 첫 단계가 끝난 자리에서 판단 대신 다음 단계를 누르자, 직원과 도난과 원정과 명성 트리가 한꺼번에 가게 안으로 들어왔다.