[서버 개발 일지 #12] 서버 통합 테스트: 규칙 검증

KimGwangmin·2026년 10월 21일

11편까지 서버와 클라이언트가 각자 맡은 기능을 구현했다. 기능마다 단위 테스트를 거쳤고, 클라이언트와 로컬 서버를 붙여 한 번 돌려 보기도 했다. 이번 편부터 두 편에 걸쳐 서버를 "게임 전체의 흐름"으로 검증하는 통합 테스트를 다룬다(W4-1, W4-2). 이번 편은 왜 필요했는지, 전체 구조는 어떤지, 그리고 그중 규칙의 값이 클라이언트와 같은지 확인하는 W4-1이다. 다음 편에서 로그 전체를 서버에 넣어 보는 W4-2를 다룬다.

1. 왜 통합 테스트가 필요했나

지금까지의 테스트

서버를 만드는 동안 기능마다 구현과 함께 단위 테스트를 붙였다. 처리기는 가짜 SyncContext로, 검사기는 값만 넣어서, 전투 규칙은 샘플 콘텐츠 위에서 확인했다. 그 테스트들은 모두 통과했고, 기능 하나하나는 내가 이해한 대로 동작한다고 말할 수 있었다.

단위 테스트가 놓치는 것

서버 기능 구현을 마치고 나니 오히려 불안한 점이 둘 생겼다.

  1. "내가 이해한 규칙"이 기준이다. 단위 테스트의 기대값은 내가 클라이언트 코드를 읽고 옮겨 적은 규칙에서 나온다. 옮기다가 틀렸다면 구현과 테스트가 같이 틀려서 통과한다. 그런데 결산 검증에는 정확한 등식 검사(exp, earnedGold, settledGold)가 있다. 값이 1만 달라도 거부된다. 서버가 규칙을 조금이라도 다르게 옮겼다면 정상 플레이어의 정상 결산이 거부된다. 이 검증은 서버의 판단이 아니라 클라이언트와 같은 값을 내는지가 정답이다.
  2. 부품을 이어 붙인 뒤의 동작은 따로 확인하지 않았다. 판 시작 → 결산 → 구매가 수백 번 이어지면 Gold가 체인처럼 이어진다. 이정표 보상, 노드가 드러나는 순서, 시간 한도가 한 줄로 맞물리는지는 부품 테스트로는 알 수 없다.

직접 플레이는 답이 아니다

가장 단순한 확인 방법은 클라이언트를 직접 플레이하면서 서버가 잘 받는지 보는 것이다. 하지만 이 방법은 한계가 분명했다.

  • 오래 걸린다. 이정표 하나를 지나려면 수십 판이 필요하고, 후반의 결산까지 가려면 수백 판을 해야 한다.
  • 정상 흐름만 본다. 사람이 플레이해서 나오는 로그는 정상 로그뿐이다. 값을 조작한 로그가 정해진 곳에서 막히는지는 직접 만들어야 한다.
  • 다시 하기 어렵다. 밸런스가 바뀔 때마다 처음부터 다시 플레이해야 한다.

그래서: 클라이언트가 만든 데이터를 서버에 넣는다

플레이를 사람이 하는 대신, 클라이언트의 실제 코드를 에디터에서 돌려 데이터를 뽑고, 그 데이터를 서버에 넣어 보기로 했다. 기대값을 내가 옮겨 적은 규칙이 아니라 클라이언트 코드가 실제로 낸 값에서 가져오면 "같이 틀리는" 문제가 사라진다. 그리고 플레이가 아니라 코드이므로 몇 초 만에 같은 결과로 다시 돌릴 수 있다.

2. 두 가지 테스트와 전체 구조

확인하고 싶은 것을 두 층으로 나눴다.

W4-1W4-2
이름규칙 이식 일치 테스트골든 캠페인 테스트
클라이언트가 만드는 것기대값 표(rules-expected.json)캠페인 로그(golden-campaign.json)
서버가 확인하는 것전투 규칙의 값 하나하나가 C#과 같은가그 규칙을 거친 전체 흐름(Gold 체인, 이정표, 노드 노출, 시간 한도, 거부 코드)이 같은가
실패하면어느 값이 얼마나 다른지어느 seq에서 어긋나는지

W4-1이 부품을 따로 검사하고, W4-2가 부품을 조립한 흐름을 검사한다.

[클라이언트 에디터 도구]                          [서버 저장소]
 실제 콘텐츠(밸런스 데이터)
   │
   ├─ 기대값 표 ─────▶ fixtures/rules-expected.json ──▶ RulesTableTests      (4-1)
   └─ 캠페인 로그 ───▶ fixtures/golden-campaign.json ─▶ GoldenCampaignTests  (4-2)

두 가지 공통 결정

1. 서버 테스트도 실제 콘텐츠 위에서 돈다.

그동안 서버의 스프링 테스트는 샘플 콘텐츠 묶음 위에서 돌았다. 샘플은 값이 고정이라 실제 콘텐츠가 바뀌어도 테스트가 깨지지 않는다는 장점이 있다. 하지만 이 두 테스트는 클라이언트의 실제 콘텐츠로 만든 데이터를 쓰므로 서버도 같은 콘텐츠를 읽어야 한다. 그래서 이 테스트만 예외로 실제 묶음(src/main/resources/content)을 읽는다. 먼저 서버의 콘텐츠 묶음을 클라이언트의 현재 콘텐츠로 갱신했다(이때 노드가 276개에서 263개로 줄었다).

2. 데이터는 콘텐츠 버전에 묶는다.

파일에 contentVersion(콘텐츠 파일 바이트의 해시)을 넣고, 서버 테스트가 가장 먼저 서버의 콘텐츠 버전과 비교한다. 다르면 값을 비교하지 않고 이렇게 실패한다.

기대값 표의 콘텐츠 버전(...)이 서버 콘텐츠 버전(...)과 다르다. 픽스처 재생성 필요: 클라이언트에서
BlackHole > Sync Fixtures > Export Rules Table...을 다시 실행해 표를 서버 저장소에 커밋한다.

콘텐츠가 다른 채로 값을 비교하면 수천 개의 불일치가 쏟아져 원인을 알 수 없다. 버전이 다르다는 한 줄이 먼저 나오는 편이 훨씬 낫다.

3. W4-1: 규칙의 값이 같은가

무엇을 비교하나

서버의 BattleRules는 노드 진행도와 성장도로 "이번 판의 설정"을 계산한다.

UpgradeValues upgradesFor(Map<String, Integer> nodeRanks);   // 산 노드 → 수치 값
RunSetup      setupFor(int growthStage, UpgradeValues upgrades);
// RunSetup: 시작 레벨, 제한 시간, 시작 적 수, 종류별 구성(최대 크기, 나올 수 있는 색, 처치 하나의 Gold·EXP) ...

결산 검증이 이 값들로 클라이언트가 보낸 결산을 다시 계산하므로, 이 값이 C#과 다르면 정상 결산도 거부된다. 비교 대상은 이 메서드들이 돌려주는 거의 모든 값이다.

클라이언트가 값을 만드는 방법

기대값을 만들 때 규칙을 다시 옮겨 쓰지 않았다. 게임이 쓰는 실제 코드 경로를 그대로 지나가게 했다.

진행도 상태 → 산 노드로 수치 값 계산(NodePurchase.StatsFor)
           → 판 조립(GameSessionFactory.Create)
              ├ GameSession.StartInfo   시작 적 수, 제한 시간, 시작 레벨
              ├ World.Stats             칸별 Gold·EXP
              └ Hq                      레벨 계산
           → 이정표 보상(HqMilestone.RewardFor)

표를 만드는 코드는 Unity에 의존하지 않는 어셈블리에 두어 에디터 테스트로도 검증한다. 에디터 메뉴는 콘텐츠를 읽고 "어떤 진행도 상태들을 확인할지"를 정해 넘기기만 한다.

어떤 상태를 확인할까: 성장도와 노드를 나눈다

입력은 (성장도, 산 노드) 두 가지다. 모든 조합을 만들면 파일이 너무 커진다. 그런데 RunSetup에서 두 축은 서로 독립이다. 성장도는 레벨과 이정표만, 노드는 시간·공급·적 구성만 정한다. 그래서 둘을 나눠 담고, 서버 테스트가 교차해서 돌린다.

  • stage: 성장도 0부터 마지막까지 전부.
  • loadout: 산 노드의 상태 6종. empty(없음), partial-10/25/50/75, all(전부 마지막 Rank까지).

partial은 사는 순서를 정해 두면 결정적으로 만들 수 있다. 클라이언트의 구매 정책(싼 노드부터)으로 Gold 제한 없이 끝까지 산 순서를 구하고, 그 순서의 앞쪽 10%, 25%, 50%, 75%까지 산 상태를 쓴다. 앞 상태가 뒤 상태에 포함된다.

이렇게 나누면 서버가 두 축을 섞어서 구현했을 때도 교차 비교에서 잡힌다.

파일의 모양

{
  "formatVersion": 1,
  "contentVersion": "adc0e51625c400d8",
  "stages": [
    {
      "growthStage": 1,
      "startLevel": 10, "goalLevel": 20, "startExp": 180000,
      "nextMilestone": {"level": 20, "targetGold": 4000000000000},
      "levelForExp": [[179999, 10], [180000, 10], [279999, 10], [280000, 11], ...],
      "milestoneRewards": [...]
    }
  ],
  "loadouts": [
    {
      "name": "partial-25",
      "nodes": { "timer-01": 1, "breaker.radius-01": 1, "asteroid.size-01": 1, ... },
      "stats": { "timer": {"value": 14, "gain": 2}, "growth.time": {"value": 3, "gain": 3}, ... },
      "timeLimitSeconds": 14, "growthTimeSeconds": 3, "killTimeBonusSeconds": 0.3,
      "startSupply": [{"enemyId": "asteroid", "count": 26}, {"enemyId": "planet", "count": 57}],
      "kinds": [
        {
          "enemyId": "asteroid", "maxSize": 3,
          "tierPossible": [false, false, false, false, false, true],
          "golden": {"multiplier": 32, "critChance": 0.25, "critRewardScale": 3},
          "gold":       [[1, 2, 3], [6, 12, 18], ...],     // [색 등급][크기 - 1]
          "goldGolden": [[32, 64, 96], [192, 384, 576], ...],
          "exp":        [[1, 2, 3], [3, 6, 9], ...],
          "critBonus":  [[96, 192, 288], [576, 1152, 1728], ...]
        }
      ]
    }
  ],
  "arithmetic": { ... }
}

값에는 사람이 읽고 검산할 수 있는 규칙이 있다. 위 예시에서 goldGolden의 첫 값 32는 gold 1 × 황금 배율 32이고, critBonus의 첫 값 96은 그 32 × 치명 보상 배율 3이다. 표의 크기는 현재 콘텐츠 기준 약 100KB다.

서버의 검사

서버 테스트(RulesTableTests)는 네 가지를 확인한다. 표의 구성(stage 수, loadout 이름), 성장도 값, 노드 상태 값, 산술이다. 몇 가지 원칙을 지켰다.

  • 완전 일치. float은 비트까지 비교하고(Float.floatToIntBits), 정수는 ==다. 허용 오차를 두면 정확한 등식 검사의 의미가 사라진다.
  • 전부 모아서 알린다. 첫 불일치에서 멈추지 않고, 어긋난 값을 모두 모아 "불일치 N개 / 비교 M개"와 앞 30개를 보여 준다.
  • 위치가 보이게. 메시지에 partial-50 @성장도2 asteroid goldGolden...처럼 어느 상태의 어느 필드인지가 나온다.
  • 진단을 한 겹 더. 값 자체(stats)와 그 값으로 만든 결과(시간, 공급, Gold)를 따로 비교한다. 어긋나면 노드 합산 단계가 원인인지, 그 뒤 규칙 단계가 원인인지 바로 구분된다.
void eq(String what, Object expected, Object actual) {
    compared++;
    boolean same = expected instanceof Float e && actual instanceof Float a
            ? Float.floatToIntBits(e) == Float.floatToIntBits(a)
            : expected.equals(actual);
    if (!same) {
        found.add(what + ": 기대(C#) " + expected + " / 서버 " + actual);
    }
}

처음 만든 표(콘텐츠 4ab481c6a60d6e49)로는 노드 상태 비교 7,164개, 성장도 비교, 산술 448개가 모두 일치했다.

4. 테스트를 테스트한다: 변이 확인

테스트가 모두 통과했다고 안심하지 않았다. 테스트가 정말 틀린 구현을 잡는지를 확인하려고, 서버 식을 일부러 틀리게 바꿔 보았다(변이 확인).

일부러 넣은 오류결과
치명 보너스를 double로 곱함통과(못 잡음)
반올림을 HALF_EVEN으로통과(못 잡음)
배율 곱을 float으로통과(못 잡음)
노드 합산에 float 오차잡음(494개)
치명 보너스가 황금이 아닌 Gold를 씀잡음(360개)

앞의 세 가지를 못 잡았다. 이유가 중요하다. 현재 콘텐츠가 그 경로를 건드리지 않기 때문이다.

  • 황금 배율이 정수라서 float로 곱하나 double로 곱하나 결과가 같다.
  • 황금 Gold가 작아서 큰 수에서 갈리는 정밀도 차이가 나오지 않는다.
  • 값이 정확히 .5가 되는 곱이 하나도 없어서 반올림 방식 차이가 드러나지 않는다.

그런데 이 차이들은 콘텐츠가 바뀌면 바로 문제가 된다. 황금 배율을 1.5로 바꾸는 밸런스 조정 한 번이면 서버가 틀린 채로 정상 결산을 거부한다. 지금 통과한다는 사실이 앞으로의 안전을 말해 주지 않았다.

해법: 콘텐츠와 무관한 산술 확인 지점

콘텐츠가 건드리지 않는 경로를 직접 찌르는 입력을 표에 넣었다(arithmetic). 큰 값, 소수 배율, 정확히 .5가 되는 곱 등 값 16개와 배율 14개의 조합이다. 결과는 클라이언트의 실제 식이 낸다.

"arithmetic": {
  "multiply":  [[5, 0.5, 3], [16777217, 1, 16777217], ...],
  "critBonus": [[5, 0.5, 3], [16777217, 1, 16777216], ...]   // [값, 배율, 결과]
}

5 × 0.5 = 2.5는 정확히 .5라서 반올림 방식이 드러난다(0에서 먼 쪽이면 3). 16777217은 float이 정확히 표현하지 못하는 첫 정수라서 float과 double의 차이가 드러난다. 서버는 두 식을 Rounding.scaled, Rounding.critBonus로 한곳에 모았고, 표의 모든 쌍을 이 두 함수로 비교한다.

이 확인 지점을 넣고 같은 변이를 다시 넣으니 다섯 가지가 모두 잡혔다. 테스트의 기대값이 현재 콘텐츠에 갇혀 있으면 안 된다는 교훈이다. 통과한다는 것만으로는 부족하고, 틀리게 만들었을 때 실패하는지를 봐야 한다.

5. 발견: 같은 C# 코드가 플랫폼마다 다르다

산술 확인 지점은 서버가 아니라 클라이언트의 문제도 찾아냈다.

치명 보너스는 long × float을 float 곱셈으로 계산하고 반올림한다. 그런데 16777215 × 1.5를 넣었더니 값이 갈렸다.

실행 환경결과
Unity 에디터(Mono)25165823
IL2CPP 빌드(Android, WebGL), 서버(Java)25165822

정확한 곱은 25165822.5다. 이 크기의 float은 2씩 건너뛰므로(25165822, 25165824) 엄격한 float 곱셈이라면 가장 가까운 25165822로 맞춰지고, 그 뒤 반올림해도 그대로다. 그런데 Mono는 곱셈의 중간 결과를 더 높은 정밀도로 들고 있다가 반올림한다. 25165822.5를 그대로 두었다가 반올림하니 25165823이 된 것이다.

기대값 표를 에디터(Mono)에서 내보내므로, 이 차이를 그냥 두면 서버의 정답과 어긋나는 표가 만들어진다. 그리고 더 나쁘게는 같은 게임이 에디터와 실제 빌드에서 다른 값을 낼 수 있다는 뜻이다. 클라이언트의 계산 끝에 (float) 캐스트를 붙여 곱 결과를 float 정밀도로 확정하도록 고쳤다. 실제 게임에서 나오는 값(황금 Gold 최대 약 1,900)에서는 결과가 같아 동작은 바뀌지 않는다. 다만 앞으로 값이 커져도 플랫폼 차이로 흔들리지 않는다.

서버와 클라이언트를 이어 붙인 테스트가 아니었다면 몰랐을 문제다. 한쪽 코드만 봐서는 "C#과 Java의 float 곱셈이 같은가"라는 질문 자체가 떠오르지 않는다.

6. 정리

구성핵심
필요성단위 테스트의 기대값은 "내가 옮긴 규칙"이라 같이 틀릴 수 있다. 직접 플레이는 느리고 정상 흐름만 본다
방법클라이언트의 실제 코드가 낸 값을 파일로 뽑아 서버가 읽는다
기대값 표성장도(stage)와 산 노드(loadout)를 나눠 담고 서버가 교차 비교한다
서버 검사완전 일치(float은 비트). 불일치를 모아 위치와 함께 알린다
버전 묶음파일에 콘텐츠 버전을 넣고 다르면 "픽스처 재생성 필요"로 가장 먼저 실패한다
변이 확인서버를 일부러 틀리게 해서 테스트가 실패하는지 확인한다. 현재 콘텐츠가 건드리지 않는 경로는 산술 확인 지점으로 직접 찌른다

이번 편에서 얻은 교훈이다.

  • 통과하는 테스트는 증거가 아니다. 틀린 구현이 실패하는 것을 봐야 테스트가 일을 하고 있다고 말할 수 있다.
  • 기대값은 구현과 다른 곳에서 가져온다. 같은 사람이 옮긴 규칙으로 기대값을 만들면 같이 틀린다.
  • 두 코드베이스를 잇는 테스트는 한쪽의 숨은 가정을 드러낸다. 이번에는 플랫폼마다 다른 float 계산이었다.

다음 편에서는 이 값들이 하나의 흐름으로 이어졌을 때를 본다. 클라이언트가 실제로 판을 수백 번 돌려 로그 전체를 뽑고, 서버가 그 로그를 그대로 받아 같은 결과가 나오는지, 일부러 망가뜨린 로그가 정해진 곳에서 막히는지 확인한다.

0개의 댓글