[서버 개발 일지 #8] 결산 로그 처리기

KimGwangmin·2026년 10월 18일

7편에서는 동기화 엔진이 요청을 받아 로그를 처리기에 넘기기까지를 코드로 따라갔다. 이번 글에서는 그 처리기 중 내가 맡은 결산(SETTLEMENT) 처리기가 결산 로그의 값을 어떻게 검증하는지를 다룬다.

1. 동작 방식

클라이언트는 전투가 끝나면 해당 전투의 결산 로그를 만들어 서버에 보낸다. 서버는 결산에 담긴 값들이 서로 일관적인지를 확인한다. 결산 로그는 진행도를 바꾸는 핵심 로그이다. (노드 구매는 규칙을 그대로 적용하면 되고, 판 시작은 진행도를 바꾸지 않는다.)

서버는 전투 도중에 일어난 일은 모르고, 전투의 결과인 결산 값만 받는다. 따라서 결산 로그가 주장하는 값이 실제 전투를 통해 산출된 것인지 정확히 검증하는 것은 불가능하다. 핵심 상한인 '처치 수'와 '시간'이 전투 도중 발생하는 확률 기반 사건에 의존하기 때문에 상한을 잡는 방식도 어렵다. 대신, 조작에는 "말이 되는" 로그를 만들어야 한다는 비용이 들어간다. 단순히 획득 골드만 뻥튀기하는 것은 우리 처리기가 잡을 수 있다.
검증 수준에 대한 고민과 결정은 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: 클라이언트가 계산한 결산 금액과 결산 후 성장도

결산 처리기의 일은 두 가지이다.

  1. 위 값들을 서버가 다시 계산해서 서로 맞는지 확인한다.
  2. 통과하면 서버가 계산한 값(클라이언트가 보낸 값이 아니다)으로 진행도에 반영하고 판을 닫는다.

2. 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)을 샘플 콘텐츠 묶음으로 만들어 쓴다.
  • 두 구현이 어긋나면 정상 사용자의 결산이 거부된다. 그래서 서버의 계산은 클라이언트와 같은 타입과 순서로 하도록 계약(1.7)에 적어 두었다. 정확한 등식 검사가 1만 달라도 실패하기 때문이다.

3. 검사 목록

SettlementValidator가 하는 검사를 표로 먼저 정리한다. 번호(S1~S9)는 계획서의 검사 번호이다.

#무엇을 검사하나방식거부 코드
S1열린 판이 있고 runId가 같다정확RUN_NOT_OPEN
S2처치 장부의 각 칸이 이번 판에서 가능한 조합이다 (종류, 색 등급, 크기, 황금)정확SETTLEMENT_INVALID_KILLS
S3exp = 시작 경험치 + Σ(칸 처치 수 × 칸 경험치)정확SETTLEMENT_EXP_MISMATCH
S4reachedLevel, reachedMilestone이 경험치와 레벨 표에서 계산한 값과 같다정확SETTLEMENT_EXP_MISMATCH
S5황금 치명 횟수와 보너스가 가능한 범위 안이다범위SETTLEMENT_GOLD_MISMATCH
S6earnedGold = Σ(칸 처치 수 × 칸 골드) + 치명 보너스정확SETTLEMENT_GOLD_MISMATCH
S7시간 추가 횟수가 가능한 처치 수 이하이다범위SETTLEMENT_TIME_EXCEEDED
S8playedSeconds가 가능한 시간의 상한 이내이다범위 (오차 허용)SETTLEMENT_TIME_EXCEEDED
S9settledGold, nextGrowthStage가 서버가 다시 계산한 값과 같다정확SETTLEMENT_RESULT_MISMATCH

4. 처리기: SettlementHandler

7편에서 본 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();
    }

}
  1. 열린 판 확인(S1): 이 결산이 가리키는 판이 열려 있는지 확인한다. 열린 판이 없거나 runId가 다르면 RUN_NOT_OPEN으로 거부한다.
  2. 판 설정 만들기: 열린 판의 시작 성장도와 지금 산 노드로 BattleRules에서 RunSetup을 얻는다.
  3. 값 검증(S2~S9): SettlementValidator가 결산의 값들을 RunSetup과 대조한다. 위반이 있으면 거부한다.
  4. 서버 값으로 반영: 모두 통과하면 서버가 다시 계산한 결산 금액과 성장도로 진행도를 바꾸고(earnGold, setGrowthStage), 판을 결산 완료로 닫는다(settleOpenRun).

설계 지점 세 가지

① S1을 나눠 먼저 부른다. 판 설정을 만들려면 열린 판의 시작 성장도가 필요하다. 열린 판이 없으면 판 설정 자체를 만들 수 없으므로, S1만 따로 먼저 확인하고 나머지 검사(validate)는 S1을 통과한 결산만 받도록 했다.

② 거부할 때는 아무것도 바꾸지 않는다. 5편에서 본 규칙이다. 이 처리기는 모든 검사가 끝난 뒤에만 earnGold 같은 변경 메서드를 부른다. SettlementValidator는 상태를 갖지 않고 진행도 저장소도 모르는 읽기 전용 검사기라서, 검사 중에 진행도가 바뀔 수 없다.

③ 판 설정을 "지금 산 노드"로 계산해도 되는 이유. 판 설정은 노드에 따라 달라진다(제한 시간, 공급량, 황금 확률 등). 원래는 판을 시작할 때의 노드로 계산해야 맞다. 그런데 판 도중에 구매가 끼어들면 더 강해진 노드 기준으로 검증이 느슨해지는 틈이 생긴다. 그래서 열린 판이 있을 때 노드 구매가 오면, 구매 처리기가 그 판을 포기로 닫도록 정했다(계약 C31). 결산이 통과하는 판은 시작 뒤에 노드가 바뀌지 않았으므로 지금의 노드가 곧 시작 때의 노드이다.

  • 정상 클라이언트는 결산 로그가 구매 로그보다 먼저 만들어지므로 영향이 없다.
  • 판 도중에 구매 로그를 끼워 넣은 비정상 로그는 결산이 RUN_NOT_OPEN으로 거부된다. (테스트 settlementAfterAPurchaseThatClosedTheRunIsRejected)

5. 검사기: SettlementValidator

검사 흐름(validate())

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로 이어지는 단순한 구조이지만, 검사마다 다른 거부 코드를 돌려주어야 하고 각 검사가 앞 검사에 기대므로 이 방식이 가장 읽기 쉬웠다.

주요 검사 항목 자세히 살펴보기

S2. 칸의 유효성: "이 판에서 나올 수 없는 적"을 걸러 낸다

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();
}

장부의 칸마다 다섯 가지를 본다.

  1. 서버가 아는 적 종류인가
  2. 픽업(혜성)이면 색 등급 0, 크기 1, 황금 아님만 가능한가
  3. 크기가 이번 판의 최대 크기 이하인가
  4. 색 등급이 이번 판의 질량에서 나올 수 있는 등급인가
  5. 황금이라면, 이번 판에 황금이 나올 수 있는가
  • 왜 필요한가: 이 검사가 없으면 "가장 비싼 색 등급의 적을 처치했다"고 적기만 해도 이후 계산(S3, S6)이 그 값으로 통과하는 장부를 만들 수 있다. 가능하지 않은 칸을 먼저 걸러 내서, 이후의 계산이 믿을 수 있는 칸만 가지고 이루어지게 한다.
  • 가능 여부는 BattleRules에서 온다(maxSize, tierPossible, goldenPossible). 어떤 색 등급이 나올 수 있는지는 노드(질량 업그레이드)에 따라 달라지는 값이다.
  • tierPossible은 없는 등급 번호에 IllegalArgumentException을 던지는 규칙이므로, 검사기가 try-catch로 받아 "불가능한 색 등급"으로 처리한다. 클라이언트가 보낸 이상한 값 때문에 서버가 500을 내지 않게 하려는 것이다.

S3·S4. 경험치와 레벨: 장부에서 모든 것이 이어진다

// 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)
  • 레벨과 이정표는 경험치에서 정해진다. 서버가 같은 레벨 표로 경험치에서 레벨을 구하고, 보낸 값과 비교한다. 이정표 도달 여부는 "목표 레벨이 있고 거기에 닿았는가"이다.
  • 이렇게 장부 → 경험치 → 레벨 → 이정표가 한 줄로 이어져 있어서, 이정표를 위조하려면 경험치를, 경험치를 위조하려면 장부를 같이 조작해야 한다. 이것이 3편에서 말한 "값들이 서로 묶여 있다"의 구체적인 모습이다.
  • Math.addExact, Math.multiplyExact를 쓴 것도 이유가 있다. 처치 수나 경험치가 long 범위를 넘으면 조용히 값이 뒤집히는 오버플로 대신 ArithmeticException이 나므로, 이를 잡아서 거부로 돌려준다. 오버플로로 우연히 맞아떨어지는 장부를 만들거나, 서버가 500을 내는 것을 막는다. (테스트 goldSumOverflowIsRejected)

S5. 치명 보너스: 정확한 값을 알 수 없을 때는 "가능한 범위"로 본다

황금 적은 확률적으로 치명이 터지고, 치명이 터지면 보너스 골드를 더 준다. 어느 처치에서 치명이 터졌는지는 클라이언트가 보낸 goldenCritCount(횟수)와 goldenCritBonusGold(보너스 합)로만 알 수 있다. 확률이라 서버가 정확한 값을 알 수는 없다. 그래서 가능한 범위 안에 있는지만 본다.

핵심은 이렇다.

  • 치명은 치명 확률이 0보다 큰 종류의 황금 처치에서만 날 수 있다.
  • 치명 c번의 보너스 합은 "치명이 가능한 처치들 중 c개를 고른 합"이어야 한다. 이 합의 최솟값은 보너스가 가장 작은 c개, 최댓값은 가장 큰 c개를 고른 것이다.

예를 들어 (예시) 치명이 가능한 황금 처치가 보너스 10짜리 3마리, 보너스 50짜리 2마리이고 치명이 2번 났다고 하자.

  • 최솟값: 가장 작은 2개 = 10 + 10 = 20
  • 최댓값: 가장 큰 2개 = 50 + 50 = 100
  • 보너스 합이 20~100 밖이면 거부한다.
// 치명이 날 수 있는 황금 처치: (보너스 한 번의 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)
  • 치명 확률이 0이면(치명 노드를 사지 않았으면) 황금 처치가 있어도 치명은 날 수 없다. (critWithoutCritChanceIsRejected)
  • 이 범위 검사는 "황금 처치가 전부 치명이었다"는 주장을 막는다. 범위 안에서의 선택까지 가려내지는 못하는 약한 검증이다. 3편의 설계 그대로이다.

S8. 경과 시간: 가능한 시간의 상한

// 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)이 상한에 영향을 준다.
  • 시간 추가 횟수(S7) 는 먼저 별도로 본다. 시간 추가는 수거형이 아니고 시간 추가 확률이 0보다 큰 종류의 처치에서만 날 수 있으므로, 횟수가 그런 처치 수를 넘으면 거부한다.
  • 허용 오차를 둔다. 상한 + 0.01초 + 상한의 0.01%이다.
    • 클라이언트의 제한 시간은 float를 여러 번 더해서 만들어지기 때문에 서버가 정확히 계산한 합과 조금 달라질 수 있다.
    • 반대로 판은 제한 시간을 넘겨서 진행되지 않으므로(클라이언트가 판 시간을 제한 시간 안으로 자른다), 프레임 단위의 초과는 허용하지 않는다. 그래서 오차를 float 누적 오차 수준으로만 작게 둔 것이다.
    • 이 값은 서버 내부 값이다. 실제 판의 로그(골든 로그)로 검증해 보정할 예정이다.

S9. 결산 금액: 서버가 다시 계산해서 적용한다

/** 서버가 계산한 결산 금액: 이정표에 닿았으면 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, 목표 골드 − 지금 골드)를 받는다. 이정표 보상은 "목표 골드까지 채워 주는" 방식이기 때문이다.
  • 이 두 함수가 같은 규칙을 두 곳에서 쓴다. 하나는 S9에서 클라이언트가 보낸 settledGold, nextGrowthStage와의 일치를 확인할 때이고, 다른 하나는 처리기가 진행도에 반영할 때이다.
  • 반영할 때는 클라이언트가 보낸 값이 아니라 서버가 계산한 값을 쓴다. 3편의 "서버 계산이 정산에 쓰이고, 클라이언트가 주장하는 값은 검증 대상일 뿐"이라는 원칙이 코드로 드러나는 곳이다. 일치를 확인하는 것도 사실은 "클라이언트의 계산이 서버와 같은가"를 보는 것이고, 반영하는 값은 서버가 계산한 것이다.
  • 이정표 금액은 지금 서버 진행도의 골드를 기준으로 한다(context.gold()). 클라이언트가 보낸 판 시작 골드가 아니다.

6. 어떤 조작을 막고 무엇을 막지 못하는가

테스트로 확인한 조작과, 설계상 막지 못하는 것을 나눠 둔다.

조작결과
결산의 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편에서 이 검증은 "의도적으로 약한 검증"이라고 밝혔고, 그 한계가 이 코드에 그대로 드러난다.

  • 값들을 서로 맞춰서 조작한 로그: 처치 수를 부풀리고, 그에 맞는 경험치와 골드, 레벨, 시간을 모두 계산해서 보내면 이 검사들을 모두 통과한다. 처치 수 자체의 상한을 두지 않았기 때문이다.
  • 범위 안의 선택: 치명 보너스처럼 "가능한 범위"로 보는 검사는, 범위 안에서 가장 유리한 값을 고르는 것까지는 막지 못한다.
  • 이것은 설계 단계에서 받아들인 한계이다. 획득한 자원이 오직 인게임 노드 업그레이드에만 쓰이기 때문이다.

7. 테스트

결산 처리기는 DB 없이 단위 테스트한다. 5편에서 본 SyncContext 덕분이다.

  • 진행도(골드, 성장도, 산 노드, 열린 판)를 메모리로 들고 있는 가짜 SyncContext를 넘긴다. 변경 메서드가 불리면 mutated가 true가 되도록 해서, 거부한 로그는 진행도를 바꾸지 않았는지를 모든 거부 테스트에서 함께 확인한다.
  • 규칙은 실제 구현(ContentBattleRules)을 샘플 콘텐츠 묶음으로 만들어 쓴다. 샘플은 값이 고정되어 있어서 실제 콘텐츠가 바뀌어도 테스트가 깨지지 않는다.
  • 테스트는 32개이다. 검사마다 걸리는 경우를 두고, 경계가 중요한 검사는 통과하는 경계도 함께 확인한다.
// 샘플 로그(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);
}
  • 정상 결산은 반영되어 골드가 서버 계산값만큼 오르고 판이 닫히는지 확인한다. (처음 골드 5 + 결산 20 = 25)
  • 조작 결산은 정해진 거부 코드로 거부되고, 진행도가 그대로인지 확인한다.
  • 여러 검사에 동시에 걸리는 결산은 계약의 순서에서 먼저인 검사의 코드가 나오는지도 테스트한다(firstFailingCheckWins).
  • 경계도 확인한다. 시간 추가 횟수가 가능한 처치 수와 같을 때는 통과하고 넘으면 거부되는지, 레벨이 오르면 시간 상한이 그만큼 늘어나는지 등이다.

8. 정리

구성역할
SettlementHandler흐름을 맡는다: S1 → 판 설정 → 값 검증 → 서버 값으로 반영·판 닫기
SettlementValidator상태 없는 읽기 전용 검사기. S2~S9를 순서대로 검사하고 첫 위반 하나를 돌려준다
BattleRules (서버 팀원)산 노드와 성장도로 "이 판에서 가능한 값"을 계산한다. 인터페이스로만 호출한다
  • 결산 검증의 핵심은 장부에서 경험치, 레벨, 이정표, 골드가 한 줄로 이어지게 하고, 서버가 같은 계산을 다시 해서 대조하는 것이다. 정확한 값이 있는 곳은 정확히, 확률이 낀 곳은 가능한 범위로 본다.
  • 거부할 때는 아무것도 바꾸지 않고, 통과하면 서버가 계산한 값만 반영한다.
  • 처리기가 엔진과 BattleRules 모두와 인터페이스로만 이어져 있어서, 엔진도 규칙 구현도 이 검사기를 고치지 않고 바뀔 수 있다. 계약을 먼저 정하고 병행 작업한 이득이 코드 구조에 남아 있다.

0개의 댓글