Wintersalmon | Blog

이벤트 소싱 버그 세 개, 하나의 계약을 떠받치는 세 기둥

5 min read

6주 동안 발생한 버그 세 개가 같은 계약을 세 가지 다른 방식으로 깨뜨렸습니다: Math.random()을 호출한 applier, read-then-write 방식의 시퀀스 할당기, 그리고 클라이언트 본문의 senderId를 그대로 믿어 버린 릴레이입니다. 하나의 API, N개의 게임에서 만든 통합 플랫폼 덕분에 새 게임은 클라이언트 전용으로만 작성하면 됩니다 — 클라이언트가 엔진을 돌리고, 서버는 불투명한 이벤트만 저장하며, 상태는 seq 0부터의 리플레이입니다. 그 약속에는 대가가 따르며, 이 세 버그가 바로 그 대가가 이론이 아니라 실재라는 증거입니다.

  • 3월 21일, Augmented ChessgetRandomAugmentationChoicesapplyMove 안에서 실행되어 각 클라이언트가 서로 다른 선택지를 굴렸고 검증이 어긋났습니다. 교훈: applier는 결정론적이어야 합니다.
  • 4월 12일, submit-event 백엔드getLatestSequenceNumber 다음에 create가 부하 상황에서 경합을 벌여 두 작성자가 모두 seq 8을 받았고, Mongo E11000이 500으로 표면화됐습니다. 교훈: 할당과 쓰기는 한 번의 연산이거나, 그렇지 않으면 경쟁 상태입니다.
  • 4월 24일, game-validator — 릴레이가 클라이언트가 보낸 senderId를 그대로 믿어, 게스트가 호스트 전용 resolveRound를 트리거할 수 있었습니다. 교훈: 정체성은 서버가 위임할 수 없는 단 하나의 것입니다.

버그 1: applier 안의 무작위성은 리플레이를 어긋나게 합니다

체스 augmentation 1라운드는 호스트의 createInitialState() 내부에서 선택지가 생성되어 init 이벤트로 실려 왔기 때문에 정상 동작했습니다 — 두 클라이언트가 동일한 바이트를 리플레이했죠. 2라운드가 깨진 건 선택지가 applyMove 자체 안에서 생성됐기 때문입니다:

if (turnNumber === 6) {
  state.triggeredAugmentationChoices = getRandomAugmentationChoices(3);
}

플레이어 A는 [X1, X2, X3]을 봤고, 플레이어 B는 [Y1, Y2, Y3]을 봤습니다. A가 X1을 고른 순간, B의 validateSelectAugmentation이 그것을 거부하고(augmentationId not in choices) 클라이언트들은 조용히 어긋났습니다.

수정은 비결정성을 액션 안으로 끌어올리는 것입니다. 제출하는 클라이언트가 한 번 굴리고, 액션이 그 결과를 실어 나르며, 모든 클라이언트가 동일한 바이트를 적용합니다:

interface MoveAction {
  type: "move";
  from: Square;
  to: Square;
  triggeredAugmentationChoices?: AugmentationChoice[];
}

세 개의 파일이 바뀌었습니다: packages/augmented-chess-engine/src/types/actions.ts, move.applier.ts, 그리고 apps/board-game-client/src/games/chess/game-state-store.ts. 일반 규칙은 이렇습니다 — applier는 (state, action) -> state의 순수 함수입니다. Math.random(), Date.now(), 네트워크 읽기 — 그 입력의 함수가 아닌 어떤 것이든 액션 안으로 끌어올려 직렬화해야 합니다.

버그 2: 비원자적 시퀀스 할당은 부하 아래에서 경합합니다

functions/game/room/submit-event/logic.ts는 시퀀스 번호를 read 후 write 방식으로 할당했습니다:

const seqNum = await eventRepository.getLatestSequenceNumber(roomId);
const nextSeq = seqNum + 1;
await eventRepository.create(roomId, eventType, payload, nextSeq);

동시에 들어온 두 submit이 모두 seqNum = 7을 읽고, 모두 8을 썼습니다. game_room_events(gameId, sequenceNumber) 복합 유니크 인덱스가 그것을 잡아냈고 — 두 번째 작성자는 Mongo 코드 11000을 받았습니다 — 하지만 처리되지 않은 채로 클라이언트에는 500으로 표면화됐습니다. 인덱스가 없었다면 리플레이는 seq 8에 두 개의 이벤트를 갖게 되고 엔진 상태가 갈라졌을 것입니다.

game-event.repository.tscreateWithAtomicSequence()는 그 윈도우를 한 번의 round trip으로 압축합니다:

const counter = await db.collection("game-sequence-counters").findOneAndUpdate(
  { _id: `room-${roomId}` },
  { $inc: { seq: 1 } },
  { upsert: true, returnDocument: "after" },
);
await db.collection("game_room_events").insertOne({ ...event, sequenceNumber: counter.seq });

$inc를 사용하는 findOneAndUpdate는 문서 단위로 원자적입니다. 동시 호출자들은 카운터에서 직렬화됩니다. 유니크 인덱스는 안전망으로 남고, handleRequest의 방어적 catch가 잔여 11000에 대해 409 Conflict를 반환합니다. 리플레이의 정확성이 단조 증가 시퀀스에 달려 있다면, 할당과 쓰기는 한 번의 연산이어야 합니다.

버그 3: 클라이언트 필드의 정체성은 정체성이 아닙니다

릴레이는 이벤트의 작성자를 표시하기 위해 클라이언트의 submit-event 본문에 있는 senderId를 사용했습니다:

{ "type": "resolve_round", "senderId": "<host-user-id>", "payload": { ... } }

호스트 전용 검증기들(fruit-shop의 resolveRound, horror-race의 revealCard)은 action.resolverId === state.hostPlayerId를 검사했습니다 — 그런데 resolverIdsenderId에서 왔고, senderId는 클라이언트에서 왔습니다. 게스트가 자신이 누구인지 거짓말함으로써 호스트 전용 액션을 트리거할 수 있었습니다.

수정은 한 줄짜리 패치가 아니었습니다 — 릴레이가 정체성을 끝에서 끝까지 소유해야 했습니다(PR #398, #400, #402):

  1. 엔진 정체성 메타데이터. fruit-shop-engine, horror-race-engine, stone-flicking-core-engine, augmented-stone-flicking-engineInitGameOptions와 영속 상태에 hostPlayerId를 추가했습니다. 호스트 전용 검증기는 액션이 아니라 state의 필드를 검사합니다.
  2. 검증 서비스. POST /validate를 갖는 새로운 apps/game-validator Bun 서비스. 정체성은 검증된 세션에서 주입되며, 본문에서 읽지 않습니다 — action.resolverId = senderId (JWT에서 검증), action.revealerId = senderId.
  3. 릴레이 통합. go/cmd/game-api/handlers/room.go가 HTTP로 검증기를 호출하고, 룸에 currentState를 영속화하며, 검증을 통과한 경우에만 이벤트를 씁니다.

클라이언트 권위 시스템에서 서버는 거의 모든 것을 놓을 수 있습니다 — 엔진, 규칙, 대부분의 검증. 놓을 수 없는 단 하나는 당신이 누구인가입니다. 클라이언트가 쓸 수 있는 것이라면, 클라이언트는 거짓말할 수 있습니다.

세 기둥, 한 장의 체크리스트

기둥 구체적 규칙 잡아내는 방법
결정론적 applier random 없음, 시간 없음, I/O 없음 — 액션 안으로 끌어올리기 테스트의 리플레이 발산
원자적 시퀀스 할당 한 번의 연산; 백스톱으로서의 유니크 인덱스 code 11000 → 409
서버가 소유하는 정체성 진입 시점에 인증된 세션으로 정체성을 다시 씀 영속 상태 위에서 동작하는 호스트 전용 검증기

새 게임을 위한 미래의 나에게 보내는 체크리스트: random 또는 시간이 새어 들어올 수 있는가, 할당은 서버 측에서 원자적인가, 누가 내가 누구인지 결정하는가. 답이 하나라도 흔들린다면, 버그는 이미 존재합니다 — 단지 아직 트리거되지 않았을 뿐입니다.

#event-sourcing #determinism

AI 워크플로우 메모

Claude는 세 개의 task-log를 가로지르는 패턴 매처였습니다. 20260321-chess-augmentation-event-sync-fix.md, 20260412-backend-atomic-event-sequence.md, 20260424-game-event-validation/progress.md를 나란히 읽고 그것들이 공유하는 것을 드러내 달라고 요청했습니다 — “세 기둥”이라는 프레이밍은 개별 로그가 아니라 그 패스에서 나왔습니다. 버그 1 이후, 다른 엔진 applier들에 대해 code-reviewer 에이전트를 한 가지 질문으로 돌렸습니다: “applier 내부에 Math.random() 또는 Date.now()가 또 어디에 숨어 있을 수 있는가?” 출시 전에 두 곳을 더 찾아냈습니다. 가장 큰 효과를 본 규율은 수정을 회귀 테스트로 먼저 작성하는 것이었습니다 — 버그 2의 duplicate-key race는 테스트가 있으면 단언하기 쉬웠지만, 없으면 추론하기 어려웠습니다.


Hungjoon

I'm Hungjoon, a software engineer based in South Korea. This is my long-form notebook — homelab, Kubernetes, AI infra, and whatever else keeps me up at night.