12편에서는 서버를 게임 전체의 흐름으로 검증해야 하는 이유와 두 가지 통합 테스트의 구조를 정리하고, 그중 규칙의 값이 클라이언트와 같은지 확인하는 W4-1(기대값 표)을 다뤘다. 이번 편은 W4-2다. 클라이언트가 실제로 판을 수백 번 돌려 만든 로그 전체를 서버에 그대로 넣어, 모두 통과하는지, 서버가 계산한 상태가 클라이언트와 같은지, 일부러 망가뜨린 로그가 정해진 곳에서 막히는지를 확인한다.
W4-1은 규칙이 내는 값이 같다는 것을 보여 준다. 하지만 서버가 실제로 하는 일은 값 하나를 계산하는 게 아니라 로그를 순서대로 적용하는 것이다.
값이 맞아도 이 규칙들이 맞물리는 순서가 어긋나면 정상 플레이가 거부된다. 그리고 한쪽에서는 반대 방향도 확인해야 한다. 서버가 막아야 하는 로그를 정말 막는지다. 이건 정상 플레이를 아무리 해도 알 수 없다.
[클라이언트 에디터 도구] [서버]
봇이 판을 돌림 + 싼 노드부터 구매
│ 게임이 쓰는 로그 생성기(SyncLogFactory)로 로그 생성
▼
golden-campaign.json ────────────▶ GoldenCampaignTests (실제 서버 + MySQL + 실제 콘텐츠 묶음)
logs 서버에 보내는 로그 그대로 1. 골든 통과 : 전부 반영되고 상태가 같은가
checkpoints 로그마다 적용 뒤 Gold·성장도 2. 조작 테스트: 망가뜨린 로그가 정해진 곳에서 막히는가
final 최종 진행도
campaign 범위(판 수, 클리어 여부 ...)
margins 시간 검사의 여유
이 테스트를 설계할 때 가장 먼저 정한 원칙이다. 클라이언트 팀이 마지막 이정표까지 도달할 수 있도록 밸런스를 조절할 계획이었기 때문이다. 이 테스트가 노드 id, 종류 id, 숫자, seq, "성장도 3까지" 같은 값을 코드에 쓰면 밸런스가 바뀔 때마다 테스트를 고쳐야 한다. 그러면 테스트가 실패해도 "서버가 틀렸다"인지 "테스트가 낡았다"인지 알 수 없다.
그래서 이렇게 정했다.
ContentBattleRules, NodeTree)에서 읽는다.에디터 메뉴(Export Campaign Log...)가 빈 진행도에서 시작해 [판 → 결산 → 구매]를 반복한다.
NearestEnemyBot)이 판을 끝까지 돌린다. 가장 가까운 적을 겨냥하는 최소한의 봇이다. 잘할 필요는 없고 Gold를 벌어 노드를 사며 진행만 되면 된다.SyncLogFactory)가 만든다. 서버는 이 글자를 그대로 받는다.RESET, 이어서 판마다 RUN_START → SETTLEMENT → 구매들이다. 구매는 반드시 결산 뒤에 둔다. 판 도중에 구매가 끼면 서버가 열린 판을 포기로 닫기 때문이다.{
"formatVersion": 1,
"contentVersion": "adc0e51625c400d8",
"campaign": {
"battles": 396, "maxGrowthStage": 12, "reachedGrowthStage": 12,
"cleared": true, "postClearBattles": 3, "stopReason": "cleared"
},
"logs": [
{"seq":1,"type":"RESET", ... ,"payload":{}},
{"seq":2,"type":"RUN_START", ... ,"payload":{"runId":"00000000-0000-4000-8000-000000000001","seed":100004,"startGold":0,"timeLimitSeconds":12.0,"startSupply":[{"enemyId":"asteroid","count":12}], ...}},
{"seq":3,"type":"SETTLEMENT", ... ,"payload":{"kills":[{"enemyId":"asteroid","tier":0,"size":1,"golden":false,"count":5}],"earnedGold":5,"exp":5,"settledGold":5, ...}},
{"seq":4,"type":"NODE_PURCHASE", ... ,"payload":{"nodeId":"timer-01","rank":1,"cost":2}}
],
"checkpoints": [ [1, 0, 0], [2, 0, 0], [3, 5, 0], [4, 3, 0] ],
"final": { "gold": 70200969538960000, "growthStage": 12, "nodes": [ {"nodeId": "timer-01", "rank": 1}, ... ] },
"margins": { "minTimeSlack": {"seq": 3, "seconds": 0} }
}
| 필드 | 역할 |
|---|---|
logs | 서버에 보내는 로그 그대로. 현재 콘텐츠로 1,144개(RUN_START 396, SETTLEMENT 396, NODE_PURCHASE 351, RESET 1), 약 800KB |
checkpoints | 로그마다 [seq, 그 로그를 적용한 뒤의 Gold, 성장도]. 서버를 거치지 않고 클라이언트의 진행도에서 읽은 값이라 정답지 역할을 한다 |
final | 캠페인이 끝났을 때의 진행도(Gold, 성장도, 산 노드 순서) |
campaign | 범위. 몇 판을 돌렸고 어디까지 갔는지, 클리어했는지, 왜 멈췄는지 |
margins | 결산마다 (서버 시간 상한 − 실제 playedSeconds)의 최솟값. 서버의 시간 허용 오차가 적절한지 보는 실측값 |
checkpoints가 있어서 서버가 어긋났을 때 처음 어긋나는 seq를 바로 알 수 있다. 최종 상태만 비교하면 "어딘가에서 틀렸다"까지만 안다.
파일을 저장소에 커밋하므로, 내보낼 때마다 내용이 바뀌면 diff로 콘텐츠 변화의 영향을 볼 수 없다. 그래서 흔들리는 값을 모두 고정했다.
| 값 | 고정 방법 |
|---|---|
runId | 판 번호로 만든 UUID (00000000-0000-4000-8000-{판 번호 16진수 12자리}). 실제 게임은 Guid.NewGuid()를 쓰지만 내보내기는 생성기에 직접 넘긴다 |
occurredAtUtc | 고정 시각 + (seq − 1)초. 시계를 바깥에서 주입한다 |
buildVersion | golden |
| 시드 | 판 번호에서 정한다 |
SyncLogFactory가 runId와 시계를 바깥에서 받도록 만들어져 있어서 코드를 바꾸지 않고 해결됐다. 같은 콘텐츠로 두 번 내보내 바이트가 같은지도 확인했다.
CampaignPolicy캠페인을 언제까지 돌릴지를 정하는 규칙은 판을 돌리는 코드와 분리해 순수 클래스로 만들고 단위 테스트를 붙였다.
| 멈추는 조건 | stopReason |
|---|---|
| 마지막 이정표에 닿은 뒤 3판을 더 돌렸다 | cleared |
| 이정표에 닿지 못한 채 300판이 이어졌다 | stalled |
| 판 수 상한 | battleLimit |
다음 판의 결산에서 Gold가 long을 넘는다 | overflow |
| 다음 판이 안전 상한까지 끝나지 않는다 | capped |
클리어 뒤 3판을 더 돌리는 이유는 마지막 이정표 이후의 서버 분기(다음 이정표가 없고 목표 레벨이 0인 판) 때문이다. 처음 파일에는 이 분기가 없었다(봇이 마지막 이정표에 닿지 못했다). 닿는 콘텐츠가 되면 자동으로 이 분기가 파일에 들어오도록 했다.
overflow와 capped는 판을 로그에 넣지 않고 그 앞에서 멈춘다. 끝나지 않은 판은 결산 로그를 만들 수 없고, long을 넘는 Gold는 서버가 거부할 로그가 되기 때문이다. 칸별 처치 장부가 판의 결과와 어긋나는 판이 나오면 로그를 조용히 만들지 않고 내보내기 자체를 실패시킨다. 골든 로그에 이미 틀린 값이 들어 있으면 서버 테스트가 무엇을 증명하는지 알 수 없기 때문이다.
GoldenCampaignTests새 계정에 로그를 보내고, 응답마다 서버 진행도가 그 시점의 체크포인트와 같은지 확인한다. 끝에서는 final과 완전히 같아야 한다(산 노드의 순서까지). 의심 표시는 하나도 없어야 하고, 같은 요청을 다시 보내도(재전송) 아무것도 바뀌지 않아야 한다.
보내는 방식은 실제 클라이언트가 쓰는 두 가지를 다 확인한다.
| 방식 | 의미 |
|---|---|
| 20개씩 자주 보내기 | 온라인과 오프라인이 자주 바뀌어 조금씩 보내는 경우 |
| 요청 한도(500개, 1MB)까지 채워 보내기 | 오프라인으로 쌓인 로그를 한꺼번에 보내는 경우. 한도에 가까운 큰 요청을 서버가 한 번에 처리하는 경로를 실제 HTTP와 DB로 확인한다 |
어긋나면 새 계정에서 로그를 하나씩 보내 처음 어긋나는 seq를 찾아 알린다.
(예시) 진단: seq 412(SETTLEMENT)에서 처음 어긋난다. 서버 State[gold=..., ...], 클라이언트 State[gold=..., ...]
골든 로그의 한 군데를 망가뜨려 보내면 정해진 seq에서 정해진 코드로 거부되고, 거부된 로그 뒤의 로그는 반영되지 않으며, 진행도는 거부 직전과 같아야 한다. 이 세 가지를 조작마다 똑같이 확인한다. 거부는 상태를 바꾸지 않으므로, 같은 위치의 조작은 접두 구간(seq 1부터 k−1까지)을 한 번만 보내고 연달아 시도한다.
| 조작 | 기대 코드 |
|---|---|
earnedGold +1 | SETTLEMENT_GOLD_MISMATCH |
settledGold만 +1 | SETTLEMENT_RESULT_MISMATCH |
exp +1, reachedMilestone 뒤집기, reachedLevel +1 | SETTLEMENT_EXP_MISMATCH |
kills에 불가능한 항목(크기 초과, 없는 종류, 황금 불가능, 색 불가능) | SETTLEMENT_INVALID_KILLS |
goldenCritCount를 가능한 수보다 크게 | SETTLEMENT_GOLD_MISMATCH |
timeBonusCount를 가능한 수보다 크게, playedSeconds를 상한 + 1초로 | SETTLEMENT_TIME_EXCEEDED |
다른 runId로 결산, 같은 결산을 한 번 더 | RUN_NOT_OPEN |
구매 cost −1 / rank +1 | COST_MISMATCH / RANK_MISMATCH |
| 숨은 노드 / Gold가 모자란 노드 / 최대 Rank 노드 / 없는 노드 구매 | NODE_HIDDEN / NOT_ENOUGH_GOLD / MAX_RANK_REACHED / UNKNOWN_NODE |
| 로그 하나를 빼고 다음 로그 | SEQ_GAP |
| 한 요청 안에서 인접한 두 로그의 순서 바꿈 | HTTP 400 INVALID_REQUEST |
contentVersion 변경 | CONTENT_VERSION_MISMATCH |
이미 쓴 runId로 RUN_START | DUPLICATE_RUN_ID |
(클리어한 파일만) 마지막 단계 결산의 settledGold ≠ earnedGold, reachedMilestone: true | SETTLEMENT_RESULT_MISMATCH, SETTLEMENT_EXP_MISMATCH |
기대 코드는 서버의 검사 순서에서 처음 걸리는 것 하나다. 여러 검사에 동시에 걸리는 조작은 어느 코드가 먼저인지까지 정확히 비교한다. 예를 들어 처치 하나를 더 센 척하면서 exp만 안 올리면 EXP 검사가 Gold 검사보다 먼저이므로 SETTLEMENT_EXP_MISMATCH다.
"seq 600번 결산의 Gold를 올린다"처럼 쓰면 콘텐츠가 바뀔 때 깨진다. 그래서 위치를 역할로 고른다.
| 역할 | 조건 |
|---|---|
| 초반 | 처음 나오는 SETTLEMENT |
| 이정표 | reachedMilestone이 true인 첫 SETTLEMENT |
| 중반 | 결산 목록의 가운데 번째 |
| 후반 | 마지막 SETTLEMENT |
| 황금 | kills에 golden: true가 있는 결산 중 중반에 가장 가까운 것 |
| 구매 | 조건을 만족하는 구매 중 중반에 가장 가까운 것 |
조작에 쓰는 값도 코드에 쓰지 않고 서버 콘텐츠로 계산한다. 예를 들어 "크기 초과"는 maxSize + 1을, "숨은 노드"는 그 시점의 산 노드 기준으로 아직 드러나지 않은 노드를 NodeTree에서 찾는다.
// 중반 결산에 가장 가까운 구매 중 condition을 만족하는 것
private int middleCandidate(Predicate<State> condition) {
int middle = middleSettlementIndex();
int best = -1;
for (int index : campaign.costlyPurchases()) {
if (condition.test(campaign.stateAfter(index - 1)) && (best < 0 || Math.abs(index - middle) < Math.abs(best - middle))) {
best = index;
}
}
if (best < 0) {
throw new NotApplicable("조건을 만족하는 구매가 없다");
}
return best;
}
콘텐츠에 따라 조작의 조건을 만족하는 로그나 노드가 없을 수 있다. 이때 조용히 건너뛰면 커버리지가 말없이 줄어든다. 밸런스가 바뀌었는데 테스트는 계속 초록색이고, 실제로는 절반이 아무것도 확인하지 않는 상태가 된다.
그래서 적용할 수 없는 조작은 실패로 모으고, "표의 모든 조작이 실행되었는가"도 확인한다. 이 가드는 아래 첫 실행에서 바로 쓸모를 보였다.
세 가지는 거부를 기대하지 않는다.
RUN_NOT_OPEN이다. 열린 판을 포기로 닫는 규칙이다.첫 실행의 결과는 6개 중 4개 통과, 2개 실패였다. 서버가 틀린 것이 아니라 내 조작표가 콘텐츠와 맞지 않았다.
| 조작 | 실패 이유 | 고친 방법 |
|---|---|---|
| 숨은 노드, Gold 부족 구매 | 중반 구매 시점에는 그 조건의 노드가 없었다. 중반이면 대부분의 노드가 이미 드러났고 Gold도 충분했다 | 조건을 만족하는 구매 중 중반에 가장 가까운 것을 고른다 |
| 구매를 판 중간에 끼우기 | 구매 정책이 "살 수 있는 건 전부 산다"라서, 판 시작 때는 살 수 있는 노드가 하나도 없다 | 새 구매를 만드는 대신 판 시작 바로 앞의 구매 하나를 판 시작 뒤로 옮긴다 |
두 번째가 재미있다. 판 시작 직후에 유효한 구매를 만들려고 했는데, 골든 로그는 구매 정책상 구매가 끝난 뒤에 다음 판이 시작되므로 그런 구매가 존재하지 않는다. 그래서 [구매, RUN_START, SETTLEMENT]를 [RUN_START, 구매, SETTLEMENT]로 순서만 바꿨다. 구매는 그대로 유효하고(판 시작은 진행도를 바꾸지 않는다) 결산만 RUN_NOT_OPEN이 된다.
이 두 실패는 가드가 없었다면 "적용 안 된 채 통과"로 지나갔을 것이다.
12편과 같은 방법으로, 서버의 검증을 일부러 느슨하게 만들고 이 테스트가 잡는지 확인했다(별도 worktree).
| 변이 | 잡은 조작 | 실패 방식 |
|---|---|---|
| Gold 비교(S6)에 ±1 허용 | earnedGold +1 (초반, 중반, 후반) | 기대 SETTLEMENT_GOLD_MISMATCH, 실제 SETTLEMENT_RESULT_MISMATCH |
| 시간 허용 오차(S8)를 5초로 | playedSeconds 초과 | 거부되지 않고 반영됨 |
| 구매 때 열린 판을 닫는 코드 제거 | 구매를 판 중간에 끼우기 | 결산이 RUN_NOT_OPEN이 아니라 반영됨 |
세 변이가 모두 잡혔다. 눈여겨볼 점이 둘 있다.
earnedGold는 이제 Gold 검사를 통과하고, 뒤의 결산 금액 검사(S9)에서 RESULT_MISMATCH로 걸린다. 방어선이 한 칸 뒤로 밀린 것이다. 테스트가 거부 코드까지 정확히 비교하기 때문에 약해진 검사를 잡았다. "거부됐는가"만 봤다면 놓쳤을 변이다.rejected.seq를 읽다가 예외로 중단되어 어느 조작인지 알 수 없었다. 조작 번호 라벨이 붙은 실패로 모으도록 고쳤다.이 테스트를 만든 뒤 실제로 밸런스가 바뀌었다. 클라이언트 팀이 마지막 이정표까지 도달할 수 있도록 콘텐츠를 조절했고, 서버로 새 콘텐츠를 받아왔다. 앞서 세운 원칙을 시험하는 순간이었다.
새 콘텐츠로 골든 로그를 다시 내보내 서버에 넣자, 정상 로그가 거부됐다. 은하(galaxy) 처치(tier 1)가 SETTLEMENT_INVALID_KILLS로 거부된 것이다.
클라이언트에서 은하의 규칙이 바뀌어 있었다. 은하의 색 등급이 질량이 아니라 성장도(성장도 − galaxyStage)로 정해지고, 은하가 나오는 판은 시작 공급에 은하 1마리가 더해진다. 서버는 이 규칙을 몰랐다. 단위 테스트는 서버가 아는 규칙을 기준으로 하므로 통과했고, 직접 플레이하지 않으면 알 수 없는 종류의 차이였다. 클라이언트가 실제로 낸 로그를 서버에 넣는 테스트가 아니었다면 배포 뒤에 후반 플레이어의 정상 결산이 거부되는 것으로 드러났을 것이다.
서버에 galaxyStage 규칙(HqGrowthDefinition, KindComposition, StartSupply)을 반영하고, 콘텐츠를 읽는 로더와 검증기에 이 값을 읽고 범위를 검사하는 것을 더했다.
이 변경에서 GoldenCampaignTests는 한 줄도 수정하지 않았다. 바뀐 것은 콘텐츠와 서버 규칙, 그리고 다시 내보낸 두 파일이다. 같은 변경에서 다른 테스트는 달랐다.
| 테스트 | 변경 | 이유 |
|---|---|---|
GoldenCampaignTests | 수정 없음 | 값을 코드에 쓰지 않는다 |
RulesTableTests | 18줄 수정 | 표의 형식에 galaxyTier가 새로 생겨 그 비교를 더해야 했다 |
RealNodeContentTests | 24줄 수정 | 하드코딩된 값에 의존해서 콘텐츠가 바뀌면 직접 고쳐야 한다 |
RealNodeContentTests는 커밋 메시지에도 "이 테스트는 하드코딩된 값에 의존하므로, 콘텐츠가 다시 변경될 경우 재수정이 필요함"이라고 적어 두었다. 같은 콘텐츠 변경에서 두 방식의 차이가 그대로 드러났다.
| 이전 | 이후 | |
|---|---|---|
| 콘텐츠 버전 | 4ab481c6a60d6e49 | adc0e51625c400d8 |
| 최대 성장도 | 4 | 12 |
| 도달한 성장도 | 3 (막힘) | 12 (클리어) |
| 판 | 470 | 396 (클리어 뒤 3판 포함) |
| 로그 | 1,276개 | 1,144개 |
| 크기 | 1.07MB | 약 800KB |
콘텐츠가 조절되어 봇이 마지막 이정표까지 닿았다. cleared: true가 되자 코드를 건드리지 않았는데도 마지막 이정표 이후 분기를 확인하는 조작 두 개가 자동으로 켜졌다. 앞에서 "닿는 콘텐츠가 되면 자동으로 포함된다"고 설계한 부분이다. 그동안은 이 분기를 서버의 단위 테스트만 보고 있었는데, 이제 실제 흐름으로도 확인된다. 새 콘텐츠에서 GoldenCampaignTests 7개, RulesTableTests 4개가 모두 통과한다.
서버의 검증에는 한계가 있다. 서버는 전투 도중에 일어난 일은 모르고 결산 값만 받는다. 그래서 값을 서로 맞춰서 조작한 로그는 막지 못한다. 예를 들어 처치를 하나 더 센 것으로 하고(kills의 한 항목 count +1), 그 처치의 exp와 earnedGold와 settledGold를 정확히 맞춰 올리면 모든 일관성 검사를 통과한다. 레벨이 바뀌지 않는 결산을 고르면 이정표 검사도 비켜 간다. 통계적 이상을 보는 검사(S10)는 기준을 정할 실측 데이터가 없어서 아직 넣지 않았다.
이 한계는 문서에 적어 두는 것에서 끝내지 않고 테스트로 고정했다. "일관되게 부풀린 결산은 반영된다"는 테스트다.
// 조작 24(한계 고정): 장부를 일관되게 부풀리면 서버는 막지 못한다.
assertThat(response.<Object>read("$.rejected")).as("한계 고정: 일관되게 부풀린 결산은 반영된다").isNull();
이 테스트가 통과하는 것은 나쁜 소식이다. 하지만 나중에 통계 검사를 넣어서 이 조작이 막히게 되면 이 테스트가 실패한다. 그때 "의도한 변경인가"를 확인하고 기대를 뒤집으면 된다. 한계를 모르고 지나치는 일이 없다.
한 가지 더, 시간 검사(S8)의 허용 오차를 실측하려고 margins에 (상한 − 실제 시간)의 최솟값을 남겼다. 이 콘텐츠에서 최솟값은 0이었고 음수는 없었다. 서버의 허용 오차(0.01초 + 상한의 0.01%)가 필요했던 판이 하나도 없었다는 뜻이다.
| 구성 | 핵심 |
|---|---|
| 클라이언트 | 봇으로 판을 돌리고 게임의 로그 생성기로 로그를 만든다. 정답지(checkpoints, final)도 클라이언트의 실제 진행에서 읽는다 |
| 결정성 | runId, 시각, 시드를 고정해 같은 콘텐츠면 같은 바이트 |
| 골든 통과 | 두 가지 전송 방식으로 보내며 모든 체크포인트와 비교. 어긋나면 처음 어긋나는 seq를 찾는다 |
| 조작 테스트 | 위치는 역할로, 값은 서버 콘텐츠로. 거부 seq·코드·뒤 로그 미반영·진행도 불변을 같은 방식으로 확인 |
| 적용 가능성 가드 | 적용할 수 없는 조작은 건너뛰지 않고 실패 |
| 한계 고정 | 막지 못하는 조작을 "통과해야 하는 테스트"로 남긴다 |
이번 작업에서 얻은 교훈이다.
이로써 서버는 단위 테스트(부품), 기대값 표(값의 일치), 골든 캠페인(전체 흐름과 조작) 세 겹으로 검증된다. 다음으로는 이 서버를 실제로 배포하는 일이 남아 있다.