7편에서는 동기화 엔진이 요청을 받아 로그를 처리기에 넘기기까지를 코드로 따라갔다. 이번 글에서는 그 처리기 중 내가 맡은 결산(
SETTLEMENT) 처리기가 결산 로그의 값을 어떻게 검증하는지를 다룬다.
클라이언트는 전투가 끝나면 해당 전투의 결산 로그를 만들어 서버에 보낸다. 서버는 결산에 담긴 값들이 서로 일관적인지를 확인한다. 결산 로그는 진행도를 바꾸는 핵심 로그이다. (노드 구매는 규칙을 그대로 적용하면 되고, 판 시작은 진행도를 바꾸지 않는다.)
서버는 전투 도중에 일어난 일은 모르고, 전투의 결과인 결산 값만 받는다. 따라서 결산 로그가 주장하는 값이 실제 전투를 통해 산출된 것인지 정확히 검증하는 것은 불가능하다. 핵심 상한인 '처치 수'와 '시간'이 전투 도중 발생하는 확률 기반 사건에 의존하기 때문에 상한을 잡는 방식도 어렵다. 대신, 조작에는 "말이 되는" 로그를 만들어야 한다는 비용이 들어간다. 단순히 획득 골드만 뻥튀기하는 것은 우리 처리기가 잡을 수 있다.
검증 수준에 대한 고민과 결정은 3편 참조.
결산 payload는 다음과 같이 구성된다.
{
"runId": "6f1c2b0a-3d4e-4f5a-8b6c-7d8e9f0a1b2c",
"playedSeconds": 12.0,
"kills": [ { "enemyId": "asteroid", "tier": 0, "size": 1, "golden": false, "count": 20 } ],
"goldenCritCount": 0,
"goldenCritBonusGold": 0,
"earnedGold": 20,
"exp": 20,
"reachedLevel": 0,
"reachedMilestone": false,
"timeBonusCount": 0,
"settledGold": 20,
"nextGrowthStage": 0
}
kills: 처치한 적의 장부. (종류, 색 등급, 크기, 황금 여부)별 처치 수earnedGold, exp: 이번 판에서 번 골드와 경험치reachedLevel, reachedMilestone: 도달한 레벨과 이정표 달성 여부playedSeconds, timeBonusCount: 플레이한 시간과 시간 추가 횟수settledGold, nextGrowthStage: 클라이언트가 계산한 결산 금액과 결산 후 성장도결산 처리기의 일은 두 가지이다.
BattleRules: 이번 판 설정 계산하기결산 값을 다시 계산하려면 서버가 이 판에서의 설정값들을 알아야 한다. 예를 들어 이번 판의 초기 제한 시간이 몇 초인지, 소행성 하나를 파괴하면 골드를 얼마나 주는지, 이 판에서 나올 수 있는 색 등급이 무엇인지 같은 것이다. 이것을 알려 주는 것이 BattleRules이다.
BattleRules 구성public interface BattleRules {
/** 산 노드 Rank(nodeId → rank, 산 노드만)로 수치 값을 계산한다. */
UpgradeValues upgradesFor(Map<String, Integer> nodeRanks);
/** 성장도와 수치 값으로 이번 판의 설정을 계산한다. */
RunSetup setupFor(int growthStage, UpgradeValues upgrades);
}
구매한 노드와 현재 성장도를 넣으면 이번 판의 설정(RunSetup) 을 돌려준다. 클라이언트가 판을 시작할 때 계산하는 것과 같은 계산을 서버가 해 주는 것이다. 이 규칙이 하는 일의 예는 다음과 같다.
| 규칙 | 설명 | 인터페이스 |
|---|---|---|
| 노드 → 수치 | 산 노드의 효과를 기본값에 더하고 최소·최대로 자른다 | UpgradeValues.valueOf |
| 제한 시간 | 기본 제한 시간 + 시간 업그레이드(timer)로 늘어난 양 | RunSetup.timeLimitSeconds |
| 처치 하나의 골드와 경험치 | 색 등급별 기준값 * 크기 배율, 황금이면 황금 배율을 한 번 더 | KindSetup.goldOf, expOf |
| 나올 수 있는 색 등급과 크기 | 이번 판의 질량이 정하는 색 비율이 0보다 큰 등급만 가능, 크기에도 상한 | KindSetup.tierPossible, maxSize |
| 경험치 → 레벨 | 누적 경험치로 도달한 레벨. 목표 레벨(이정표)을 넘지 않는다 | RunSetup.levelForExp |
ContentBattleRules이다. 클라이언트의 C# 코드(NodePurchase.StatsFor, GameSessionFactory 등)를 Java로 옮기는 작업이고, 서버 팀원이 담당했다.BattleRules, RunSetup, KindSetup)를 먼저 확정해 두었기 때문에, 두 작업이 충돌 없이 나란히 진행되었다.ContentBattleRules)을 샘플 콘텐츠 묶음으로 만들어 쓴다.SettlementValidator가 하는 검사를 표로 먼저 정리한다. 번호(S1~S9)는 계획서의 검사 번호이다.
| # | 무엇을 검사하나 | 방식 | 거부 코드 |
|---|---|---|---|
| S1 | 열린 판이 있고 runId가 같다 | 정확 | RUN_NOT_OPEN |
| S2 | 처치 장부의 각 칸이 이번 판에서 가능한 조합이다 (종류, 색 등급, 크기, 황금) | 정확 | SETTLEMENT_INVALID_KILLS |
| S3 | exp = 시작 경험치 + Σ(칸 처치 수 × 칸 경험치) | 정확 | SETTLEMENT_EXP_MISMATCH |
| S4 | reachedLevel, reachedMilestone이 경험치와 레벨 표에서 계산한 값과 같다 | 정확 | SETTLEMENT_EXP_MISMATCH |
| S5 | 황금 치명 횟수와 보너스가 가능한 범위 안이다 | 범위 | SETTLEMENT_GOLD_MISMATCH |
| S6 | earnedGold = Σ(칸 처치 수 × 칸 골드) + 치명 보너스 | 정확 | SETTLEMENT_GOLD_MISMATCH |
| S7 | 시간 추가 횟수가 가능한 처치 수 이하이다 | 범위 | SETTLEMENT_TIME_EXCEEDED |
| S8 | playedSeconds가 가능한 시간의 상한 이내이다 | 범위 (오차 허용) | SETTLEMENT_TIME_EXCEEDED |
| S9 | settledGold, nextGrowthStage가 서버가 다시 계산한 값과 같다 | 정확 | SETTLEMENT_RESULT_MISMATCH |
SettlementHandler7편에서 본 LogHandler 인터페이스를 구현한 처리기이다.
@Component
@RequiredArgsConstructor
public final class SettlementHandler implements LogHandler<SettlementPayload> {
private final BattleRules rules;
private final SettlementValidator validator = new SettlementValidator();
@Override
public LogType type() {
return LogType.SETTLEMENT;
}
@Override
public Class<SettlementPayload> payloadType() {
return SettlementPayload.class;
}
@Override
public HandleResult handle(SettlementPayload payload, SyncContext context) {
// S1을 먼저 확인해야 열린 판의 성장도로 판 설정을 만들 수 있다
Optional<SettlementValidator.Violation> violation = validator.checkRunOpen(payload, context);
if (violation.isPresent()) {
return rejected(violation.get());
}
OpenRun openRun = context.openRun().orElseThrow();
RunSetup setup = rules.setupFor(openRun.startGrowthStage(), rules.upgradesFor(context.nodeRanks()));
violation = validator.validate(payload, setup, context.gold());
if (violation.isPresent()) {
return rejected(violation.get());
}
// 검사를 모두 통과했다. 서버가 계산한 값을 적용한다
long settledGold = SettlementValidator.expectedSettledGold(payload, setup, context.gold());
int nextGrowthStage = SettlementValidator.expectedNextGrowthStage(payload, setup);
context.earnGold(settledGold);
context.setGrowthStage(nextGrowthStage);
context.settleOpenRun(settledGold, nextGrowthStage);
return HandleResult.applied();
}
}
runId가 다르면 RUN_NOT_OPEN으로 거부한다.BattleRules에서 RunSetup을 얻는다.SettlementValidator가 결산의 값들을 RunSetup과 대조한다. 위반이 있으면 거부한다.earnGold, setGrowthStage), 판을 결산 완료로 닫는다(settleOpenRun).① S1을 나눠 먼저 부른다. 판 설정을 만들려면 열린 판의 시작 성장도가 필요하다. 열린 판이 없으면 판 설정 자체를 만들 수 없으므로, S1만 따로 먼저 확인하고 나머지 검사(validate)는 S1을 통과한 결산만 받도록 했다.
② 거부할 때는 아무것도 바꾸지 않는다. 5편에서 본 규칙이다. 이 처리기는 모든 검사가 끝난 뒤에만 earnGold 같은 변경 메서드를 부른다. SettlementValidator는 상태를 갖지 않고 진행도 저장소도 모르는 읽기 전용 검사기라서, 검사 중에 진행도가 바뀔 수 없다.
③ 판 설정을 "지금 산 노드"로 계산해도 되는 이유. 판 설정은 노드에 따라 달라진다(제한 시간, 공급량, 황금 확률 등). 원래는 판을 시작할 때의 노드로 계산해야 맞다. 그런데 판 도중에 구매가 끼어들면 더 강해진 노드 기준으로 검증이 느슨해지는 틈이 생긴다. 그래서 열린 판이 있을 때 노드 구매가 오면, 구매 처리기가 그 판을 포기로 닫도록 정했다(계약 C31). 결산이 통과하는 판은 시작 뒤에 노드가 바뀌지 않았으므로 지금의 노드가 곧 시작 때의 노드이다.
RUN_NOT_OPEN으로 거부된다. (테스트 settlementAfterAPurchaseThatClosedTheRunIsRejected)SettlementValidatorvalidate())public Optional<Violation> validate(SettlementPayload payload, RunSetup setup, long currentGold) {
Optional<Violation> found;
found = checkKills(payload, setup);
if (found.isPresent()) {
return found;
}
// 여기부터 장부의 모든 칸이 이번 판에서 가능한 조합이다
found = checkExpAndLevel(payload, setup);
if (found.isPresent()) {
return found;
}
found = checkGold(payload, setup);
if (found.isPresent()) {
return found;
}
found = checkTime(payload, setup);
if (found.isPresent()) {
return found;
}
return checkResult(payload, setup, currentGold);
}
Violation: 거부 코드와 메시지)을 돌려준다. 위반이 없으면 Optional.empty()이다.S2 → S3·S4 → S5·S6 → S7·S8 → S9 순서로, 계약의 거부 코드 순서와 같다. 앞 검사를 통과해야 뒤 검사의 계산이 안전하다. 예를 들어 S2가 "없는 종류"를 걸러 내야 이후 setup.kind(enemyId)가 예외 없이 동작한다.if로 이어지는 단순한 구조이지만, 검사마다 다른 거부 코드를 돌려주어야 하고 각 검사가 앞 검사에 기대므로 이 방식이 가장 읽기 쉬웠다.private static Optional<Violation> checkKills(SettlementPayload payload, RunSetup setup) {
for (KillItem kill : payload.kills()) {
Optional<KindSetup> found = setup.findKind(kill.enemyId());
if (found.isEmpty()) {
return violation(RejectCode.SETTLEMENT_INVALID_KILLS, "없는 종류다: " + kill.enemyId());
}
KindSetup kind = found.get();
if (kind.pickup() && (kill.tier() != 0 || kill.size() != 1 || kill.golden())) {
return violation(RejectCode.SETTLEMENT_INVALID_KILLS, "수거형은 tier 0, size 1, 황금 아님만 가능하다: " + describe(kill));
}
if (kill.size() > kind.maxSize()) {
return violation(RejectCode.SETTLEMENT_INVALID_KILLS, "이번 판의 최대 크기(" + kind.maxSize() + ")를 넘는다: " + describe(kill));
}
if (!tierPossible(kind, kill.tier())) {
return violation(RejectCode.SETTLEMENT_INVALID_KILLS, "이번 판에서 나올 수 없는 색 등급이다: " + describe(kill));
}
if (kill.golden() && !kind.goldenPossible()) {
return violation(RejectCode.SETTLEMENT_INVALID_KILLS, "이번 판에서 황금이 나올 수 없다: " + describe(kill));
}
}
return Optional.empty();
}
장부의 칸마다 다섯 가지를 본다.
BattleRules에서 온다(maxSize, tierPossible, goldenPossible). 어떤 색 등급이 나올 수 있는지는 노드(질량 업그레이드)에 따라 달라지는 값이다.tierPossible은 없는 등급 번호에 IllegalArgumentException을 던지는 규칙이므로, 검사기가 try-catch로 받아 "불가능한 색 등급"으로 처리한다. 클라이언트가 보낸 이상한 값 때문에 서버가 500을 내지 않게 하려는 것이다.// S3, S4: exp는 시작 EXP + 칸별 합이고, Level과 이정표는 그 EXP에서 정해진다
private static Optional<Violation> checkExpAndLevel(SettlementPayload payload, RunSetup setup) {
long expected;
try {
expected = setup.startExp();
for (KillItem kill : payload.kills()) {
long perKill = setup.kind(kill.enemyId()).expOf(kill.tier(), kill.size());
expected = Math.addExact(expected, Math.multiplyExact(perKill, (long) kill.count()));
}
} catch (ArithmeticException e) {
return violation(RejectCode.SETTLEMENT_EXP_MISMATCH, "EXP 합이 long 범위를 넘는다.");
}
if (payload.exp() != expected) {
return violation(RejectCode.SETTLEMENT_EXP_MISMATCH, "EXP가 장부와 다르다: 장부 " + expected + ", 받은 값 " + payload.exp());
}
int level = setup.levelForExp(expected);
if (payload.reachedLevel() != level) {
return violation(RejectCode.SETTLEMENT_EXP_MISMATCH, "Level이 EXP와 다르다: 계산 " + level + ", 받은 값 " + payload.reachedLevel());
}
boolean milestone = reachedMilestone(setup, level);
if (payload.reachedMilestone() != milestone) {
return violation(RejectCode.SETTLEMENT_EXP_MISMATCH, "이정표 도달 여부가 다르다: 계산 " + milestone + ", 받은 값 " + payload.reachedMilestone());
}
return Optional.empty();
}
시작 경험치 + Σ(칸 처치 수 × 칸 경험치)가 정확히 같아야 한다. 1이 다르면 거부한다. (테스트 expOffByOneIsRejected)Math.addExact, Math.multiplyExact를 쓴 것도 이유가 있다. 처치 수나 경험치가 long 범위를 넘으면 조용히 값이 뒤집히는 오버플로 대신 ArithmeticException이 나므로, 이를 잡아서 거부로 돌려준다. 오버플로로 우연히 맞아떨어지는 장부를 만들거나, 서버가 500을 내는 것을 막는다. (테스트 goldSumOverflowIsRejected)황금 적은 확률적으로 치명이 터지고, 치명이 터지면 보너스 골드를 더 준다. 어느 처치에서 치명이 터졌는지는 클라이언트가 보낸 goldenCritCount(횟수)와 goldenCritBonusGold(보너스 합)로만 알 수 있다. 확률이라 서버가 정확한 값을 알 수는 없다. 그래서 가능한 범위 안에 있는지만 본다.
핵심은 이렇다.
c번의 보너스 합은 "치명이 가능한 처치들 중 c개를 고른 합"이어야 한다. 이 합의 최솟값은 보너스가 가장 작은 c개, 최댓값은 가장 큰 c개를 고른 것이다.예를 들어 (예시) 치명이 가능한 황금 처치가 보너스 10짜리 3마리, 보너스 50짜리 2마리이고 치명이 2번 났다고 하자.
// 치명이 날 수 있는 황금 처치: (보너스 한 번의 Gold, 처치 수)
List<long[]> candidates = new ArrayList<>();
long eligibleKills = 0;
for (KillItem kill : payload.kills()) {
if (!kill.golden()) {
continue;
}
KindSetup kind = setup.kind(kill.enemyId());
Optional<GoldenTrait> golden = kind.golden();
if (golden.isEmpty() || golden.get().critChance() <= 0) {
continue;
}
candidates.add(new long[] { kind.goldenCritBonusOf(kill.tier(), kill.size()), kill.count() });
eligibleKills += kill.count();
}
if (crits > eligibleKills) {
return violation(RejectCode.SETTLEMENT_GOLD_MISMATCH, "치명 횟수가 치명이 가능한 황금 처치 수를 넘는다: ...");
}
candidates.sort(Comparator.comparingLong(candidate -> candidate[0]));
min = sumOfFirst(candidates, crits);
candidates.sort(Comparator.comparingLong((long[] candidate) -> candidate[0]).reversed());
max = sumOfFirst(candidates, crits);
c개를 더하면 최솟값, 내림차순으로 정렬해 앞에서 c개를 더하면 최댓값이다. (sumOfFirst는 같은 칸의 여러 마리를 한 번에 세도록 (보너스, 처치 수) 묶음 단위로 센다.)goldenCritCount == 0) 보너스도 0이어야 한다. (bonusWithoutCritIsRejected)critWithoutCritChanceIsRejected)// S8: 플레이한 시간은 시작 제한 시간 + Level업마다의 성장 시간 + 시간 추가 시간을 넘을 수 없다
double bound = (double) setup.timeLimitSeconds()
+ (double) (payload.reachedLevel() - setup.startLevel()) * setup.growthTimeSeconds()
+ (double) payload.timeBonusCount() * setup.killTimeBonusSeconds();
double allowed = bound + TIME_TOLERANCE_SECONDS + bound * TIME_TOLERANCE_RATIO;
if (payload.playedSeconds() > allowed) {
return violation(RejectCode.SETTLEMENT_TIME_EXCEEDED, "플레이 시간이 가능한 범위를 넘는다: 상한 " + bound + "초, 받은 값 " + payload.playedSeconds() + "초");
}
시작 제한 시간 + (오른 레벨 수 × 레벨당 성장 시간) + (시간 추가 횟수 × 시간 추가 1회의 초)이다. 레벨이 오르거나 시간 추가가 터지면 제한 시간이 늘어나기 때문에, 장부의 값(reachedLevel, timeBonusCount)이 상한에 영향을 준다.상한 + 0.01초 + 상한의 0.01%이다.float를 여러 번 더해서 만들어지기 때문에 서버가 정확히 계산한 합과 조금 달라질 수 있다.float 누적 오차 수준으로만 작게 둔 것이다./** 서버가 계산한 결산 금액: 이정표에 닿았으면 max(0, 목표 Gold − 지금 Gold), 아니면 earnedGold. 검사를 통과한 결산에만 쓴다. */
static long expectedSettledGold(SettlementPayload payload, RunSetup setup, long currentGold) {
if (payload.reachedMilestone()) {
return setup.nextMilestone().orElseThrow().rewardFor(currentGold);
}
return payload.earnedGold();
}
/** 서버가 계산한 결산 후 성장도: 이정표에 닿았으면 +1. 검사를 통과한 결산에만 쓴다. */
static int expectedNextGrowthStage(SettlementPayload payload, RunSetup setup) {
return payload.reachedMilestone() ? setup.growthStage() + 1 : setup.growthStage();
}
earnedGold)를 그대로 받는다.max(0, 목표 골드 − 지금 골드)를 받는다. 이정표 보상은 "목표 골드까지 채워 주는" 방식이기 때문이다.settledGold, nextGrowthStage와의 일치를 확인할 때이고, 다른 하나는 처리기가 진행도에 반영할 때이다.context.gold()). 클라이언트가 보낸 판 시작 골드가 아니다.테스트로 확인한 조작과, 설계상 막지 못하는 것을 나눠 둔다.
| 조작 | 결과 |
|---|---|
결산의 earnedGold만 부풀린다 | SETTLEMENT_GOLD_MISMATCH로 거부 (테스트 inflatedGoldIsRejected) |
| 계약의 조작 샘플(정상 결산의 골드를 99999로 부풀림) | SETTLEMENT_GOLD_MISMATCH (tamperedSampleSettlementIsRejected) |
| 경험치만 1 올린다 | SETTLEMENT_EXP_MISMATCH (expOffByOneIsRejected) |
| 이정표 도달 여부를 위조한다 | SETTLEMENT_EXP_MISMATCH (wrongMilestoneFlagIsRejected) |
| 이 판에서 나올 수 없는 색 등급·크기·황금을 적는다 | SETTLEMENT_INVALID_KILLS |
| 치명이 날 수 없는 상황에서 보너스를 적는다 | SETTLEMENT_GOLD_MISMATCH |
| 가능한 시간보다 오래 플레이했다고 적는다 | SETTLEMENT_TIME_EXCEEDED |
| 결산 금액·성장도를 서버 계산과 다르게 적는다 | SETTLEMENT_RESULT_MISMATCH |
| 열린 판 없이, 또는 다른 판의 결산을 보낸다 | RUN_NOT_OPEN |
| 판 도중에 노드를 사서 검증을 느슨하게 만든다 | 구매가 판을 닫으므로 결산은 RUN_NOT_OPEN |
막지 못하는 것도 있다. 3편에서 이 검증은 "의도적으로 약한 검증"이라고 밝혔고, 그 한계가 이 코드에 그대로 드러난다.
결산 처리기는 DB 없이 단위 테스트한다. 5편에서 본 SyncContext 덕분이다.
SyncContext를 넘긴다. 변경 메서드가 불리면 mutated가 true가 되도록 해서, 거부한 로그는 진행도를 바꾸지 않았는지를 모든 거부 테스트에서 함께 확인한다.ContentBattleRules)을 샘플 콘텐츠 묶음으로 만들어 쓴다. 샘플은 값이 고정되어 있어서 실제 콘텐츠가 바뀌어도 테스트가 깨지지 않는다.// 샘플 로그(log-settlement.json)와 같은 결산: 소행성 20마리, Gold 20. 반영되고 판이 닫힌다
@Test
void validSettlementIsAppliedAndClosesTheRun() {
Ctx ctx = new Ctx(0, 5, Map.of());
Settlement s = Settlement.of(ctx, kill("asteroid", 0, 1, false, 20));
HandleResult result = handler.handle(s.build(), ctx);
assertThat(result).isInstanceOf(HandleResult.Applied.class);
assertThat(ctx.gold).isEqualTo(25);
assertThat(ctx.growthStage).isZero();
assertThat(ctx.settledGold).isEqualTo(20);
assertThat(ctx.runOpen()).isFalse();
}
// 계약 3.8의 조작 샘플과 같은 경우: 정상 결산의 Gold만 부풀리면 GOLD_MISMATCH
@Test
void inflatedGoldIsRejected() {
Ctx ctx = new Ctx(0, 0, Map.of());
Settlement s = Settlement.of(ctx, kill("asteroid", 0, 1, false, 20));
s.earnedGold = 99_999;
assertRejected(handler.handle(s.build(), ctx), RejectCode.SETTLEMENT_GOLD_MISMATCH, ctx);
}
firstFailingCheckWins).| 구성 | 역할 |
|---|---|
SettlementHandler | 흐름을 맡는다: S1 → 판 설정 → 값 검증 → 서버 값으로 반영·판 닫기 |
SettlementValidator | 상태 없는 읽기 전용 검사기. S2~S9를 순서대로 검사하고 첫 위반 하나를 돌려준다 |
BattleRules (서버 팀원) | 산 노드와 성장도로 "이 판에서 가능한 값"을 계산한다. 인터페이스로만 호출한다 |
BattleRules 모두와 인터페이스로만 이어져 있어서, 엔진도 규칙 구현도 이 검사기를 고치지 않고 바뀔 수 있다. 계약을 먼저 정하고 병행 작업한 이득이 코드 구조에 남아 있다.