보물찾기 엔진이 DevOps 악몽을 위한 스캐빈저 헌트가 될 때
요약
게임 'Loot Horizon'의 이벤트 엔진인 Treasure Hunt 구현 과정에서 발생한 데이터 불일치와 동시성 문제를 다룹니다. Redis TTL 설정 오류, DynamoDB 지연 시간, Lua 스크립트 타임아웃 등 분산 시스템에서 겪은 기술적 시행착오를 상세히 기술합니다.
핵심 포인트
- Redis TTL 설정 오류로 인한 시드 재사용 및 해시 충돌 문제
- DynamoDB 조건부 쓰기 사용 시 발생하는 높은 지연 시간 문제
- 배치 처리와 인메모리 캐시 간의 레이스 컨디션 발생
- Redis Lua 스크립트 타임아웃으로 인한 상태 불일치 현상
우리가 실제로 해결하려 했던 문제
우리의 게임인 Loot Horizon은 매주 금요일마다 보스 러시(boss rushes), 시간 제한 동굴, 그리고 가끔씩 열리는 드래곤 알 찾기 같은 라이브 이벤트를 진행합니다. 2025년 10월, 우리는 Treasure Hunt라는 멋진 새 기능이 포함된 Veltrix의 이벤트 엔진을 출시했습니다. 이 기능은 플레이어가 서버와 공유하는 시드(seed)를 기반으로 무작위 전리품(loot)이 떨어지는 상자를 파헤치는 방식입니다. 마케팅 슬라이드에서는 결정론적 혼돈(deterministic chaos)과 실시간 공정성을 약속했고, 이는 첫 번째 불만 사항들이 접수되기 전까지는 매우 멋지게 들렸습니다.
플레이어들은 단순히 버그를 보고하는 것이 아니라, 서로 다른 버그들을 보고하고 있었습니다. 한 클랜은 자신들이 파낸 드래곤 비늘이 재접속 후에 사라졌다고 주장했습니다. 또 다른 플레이어는 3일 된 황금 곡괭이가 철 곡괭이로 강등되었다고 단언했습니다. 로그에는 서버 측 오류가 전혀 나타나지 않았고, 오직 플레이어들의 비난과 "우리가 또 해킹당했다(We Got Hacked Again)"(스포일러: 해킹당한 것이 아니었습니다)라는 제목의 Reddit 스레드들만이 남았습니다.
우리가 처음에 시도했던 것 (그리고 실패한 이유)
우리의 첫 번째 본능은 시드 공유 프로토콜(seed-sharing protocol)을 탓하는 것이었습니다. Veltrix 문서에서는 플레이어 ID를 이벤트별 UUID와 결합하고 SHA-256으로 해싱(hashing)할 것을 제안했습니다. 충분히 쉬운 일이죠, 그렇지 않나요? 우리는 이를 AWS의 t3.large 인스턴스에서 Go 언어로 구현하여 스테이징(staging) 환경에 배포했습니다. 48시간 이내에 우리의 에러 트래커(error tracker)에는 DuplicateSeed 오류가 가득 찼습니다. 동일한 세션에 연속으로 두 번 재접속한 플레이어들이 동일한 상자 그리드를 보고한다고 보고한 것입니다. 문제는 해시(hash)가 아니라 세션 만료 시간(session expiry window)이었습니다. 우리는 메모리를 절약하기 위해 Redis의 TTL을 1시간으로 설정했지만, 플레이어들은 Alt-Tab을 하며 45분 후에 다시 연결하는 경우가 많았습니다. 이전 시드가 만료되기 전에 동일한 시드를 재사용했기 때문에 해시 충돌(hash collision)이 발생한 것입니다.
다음으로 우리는 DynamoDB에 저장된 플레이어별 논스 카운터(nonce counters)를 사용하는 롤링 윈도우(rolling window) 방식을 시도했습니다. 피크 시간대에는 모든 상자 획득 요청이 Dynamo에 조건부 쓰기(conditional write)를 요구했기 때문에 지연 시간(latency)이 200ms 이상으로 치솟았습니다. 우리는 배치(batching) 처리를 통해 평균 지연 시간을 90ms로 낮추었지만, 곧 또 다른 문제에 부딪혔습니다. 배치 처리기와 인메모리 캐시(in-memory cache) 사이의 레이스 컨디션(race conditions)으로 인해, 두 플레이어가 50ms 이내에 동일한 그리드 칸을 파헤칠 때 아이템이 중복 드롭(double-drops)되는 현상이 발생했습니다.
우리의 마지막 시도는 모든 상태(state)를 단일 Redis 클러스터로 옮기고, 원자적 상자 획득(atomic chest claims)을 위해 Lua 스크립팅을 사용하는 것이었습니다. 처음에는 잘 작동했지만, 곧 Redis의 Lua 스크립트에 5ms의 시간 제한이 있다는 사실을 발견했습니다. 스크립트가 실행 도중 타임아웃(time out)되면 Redis 연결이 종료되어 클라이언트는 대기 상태(hanging)에 빠지고, 상자 그리드는 불일치 상태(inconsistent state)로 남게 되었습니다. 플레이어들에게는 상자가 보이지만 서버에는 획득이 등록되지 않았고, 이로 인해 새로고침을 하면 전리품이 다시 나타났습니다. 때로는 중복되기도 하고, 때로는 사라지기도 했습니다.
아키텍처 결정 (The Architecture Decision)
우리는 이벤트가 마법처럼 느껴지게 만들려는 시도를 멈추고, 이벤트가 책임감 있게 처리되도록 만들기 시작했습니다. 돌파구는 해시(hash)나 캐시(cache)에 있었던 것이 아니라, 우리가 플레이어들에게 제시한 계약(contract)에 있었습니다.
우리는 더 단순한 아키텍처로 회귀했습니다. 이제 모든 상자 획득은 partition key = event_id + player_id를 사용하는 treasure_events라는 Kafka 토픽에 이벤트를 기록합니다. 이 이벤트에는 클라이언트에서 생성된 단조 증가 시퀀스 번호(monotonically increasing sequence number)(player_id와 타임스탬프에서 파생된 64비트 스노우플레이크(snowflake))가 포함됩니다. 획득 후, 클라이언트는 해당 상자에 대한 마지막 10개의 이벤트를 다시 읽어오는 ClaimVerifier라는 경량 Go 서비스에 폴링(poll)해야 합니다. 만약 시퀀스 번호가 클라이언트가 보낸 것과 일치하면 클라이언트는 전리품을 렌더링(render)하고, 그렇지 않으면 불일치 화면을 표시하며 자동으로 에러 티켓을 생성합니다.
시퀀스 번호는 암호학적으로 안전(cryptographically secure)하지 않지만, 그럴 필요도 없습니다. 이는 단지 10초 이내의 명백한 동기화 오류(desyncs)를 감지하는 데에만 사용됩니다. 진정한 방어책은 Kafka 토픽입니다. 일단 이벤트가 기록되면, 그것은 불변(immutable)입니다. Lua 스크립트도, Redis TTL 레이스(TTL races)도, DynamoDB 조건부 쓰기(conditional writes)도 필요 없습니다. 만약 두 플레이어가 동일한 칸을 획득하려고 하면, ClaimVerifier는 오직 하나의 시퀀스 번호만 수락할 것입니다. 다른 하나는 409 Conflict 오류를 받게 되며, 클라이언트는 새로운 상자 위치로 재시도하게 됩니다.
우리는 잘못된 형식의 이벤트(malformed events)를 처리하기 위해 데드 레터 토픽(dead-letter topic)을 추가했으며, 모든 불일치 사항을 TreasureHunt.DesyncCount라는 메트릭(metric) 이름으로 CloudWatch에 기록합니다. 이 메트릭은 우리의 조기 경보 시스템이 되었습니다. 메트릭이 전체 이벤트의 0.1%를 초과하여 급증하면, 누군가 클라이언트를 포크(fork)했거나 패킷을 재전송(replaying packets)하고 있다는 것을 알 수 있으며, Reddit의 반응을 기다리는 대신 이벤트를 조기에 롤백(roll)할 수 있습니다.
이후의 수치들이 말해준 것
새로운 아키텍처를 적용한 지 두 달 후:
- 상자 획득(chest claim) 평균 지연 시간(Latency): 45 ms (p99 < 120 ms)
- 동기화 오류율(Desync rate): 0.027% (10만 건의 이벤트 중 27건의 획득)
- 이벤트 상태를 위한 Redis 메모리 사용량 40% 감소 (세션별 시드(seed)를 더 이상 저장하지 않기 때문)
- 이벤트 버그를 언급하는 고객 지원 티켓(Support tickets)이 이벤트당 18건에서 0.9건으로 감소
가장 큰 놀라움은 메트릭이 아니라 태도의 변화였습니다. 플레이어들이 자신들의 불만이 기록되고 조치되고 있다는 것을 확인하자, 근본적인 문제를 해결하기도 전에 노이즈(noise)의 양이 줄어들었습니다. 그들은 게임이 부정행위를 하고 있다고 가정하는 대신 실제 버그를 보고하기 시작했습니다.
내가 다르게 했을 일
저지연(low-latency) 대화형 이벤트에 Kafka를 다시는 사용하지 않을 것입니다. 45 ms의 지연 시간은 상자 획득에는 허용 가능한 수준이지만, 콤보 도중에 화면이 끊길 때는 여전히 느껴집니다. 다음에는 게임 서버와 함께 배치된 랙 인식 브로커(rack-aware brokers)를 갖춘 Pulsar를 사용하여 p99를 30 ms 미만으로 낮출 것입니다.
둘째로, 클라이언트가 시퀀스 번호(sequence number)를 생성하도록 절대 두지 않을 것입니다. 우리는 플레이어들이 이를 조작하지 않을 것이라고 믿었지만, 한 클랜이 스노우플레이크(snowflake)를 역공학(reverse-engineered)하여 전리품을 복제하기 위해 시퀀스 번호를 스푸핑(spoofing)하기 시작했을 때 큰 대가를 치렀습니다. 이제 시퀀스 번호는 서버에서 생성되며 획득 응답(claim response)에 암호화되어 포함됩니다. 클라이언트는 여전히 순서 정렬을 위해 이를 사용하지만, 이를 위조할 수는 없습니다.
마지막으로, 저는 모든 이벤트에 대해 결정론적 재생(deterministic replay) 도구를 구축할 것입니다. 새로운 이벤트 유형을 출시하기 전에, 10,000개의 무작위 플레이어 세션을 샌드박스 클러스터(sandbox cluster)에서 재생하고 보물 로그(treasure logs)를 비교(diff)합니다. 지난달에는 황금 곡괭이 희귀도 곡선(golden-pickaxe rarity curve)에서 드롭률을 15% 인플레이션시킬 뻔한 버그를 잡아냈습니다. 이 도구를 작성하는 데 2주일이 걸렸지만, 3일간의 서비스 중단(outage)과 한 달간의 평판 손상을 막을 수 있었습니다.
제가 여기서 적용한 것과 동일한 실사(due diligence)를 AI 제공업체에도 적용합니다. 수탁 모델(Custody model), 수수료 구조(fee structure), 지리적 가용성(geographic availability), 장애 모드(failure modes). 이 기준은 유효합니다: https://payhip.com/ref/dev3
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기