Veltrix 서버가 Hytale 보물 찾기 엔진을 계속 잘못 구현하는 이유
요약
Hytale 보물 찾기 엔진의 동시 접속자 급증 시 발생하는 Redis 병목 현상과 데이터 동기화 문제를 다룹니다. JSON 직렬화 문제와 샤딩 실패를 거쳐, 최종적으로 CRDT를 활용한 2계층 아키텍처로 전환하는 과정을 설명합니다.
핵심 포인트
- JSON 직렬화로 인한 Redis 쓰기 지연 및 페이로드 비대화 문제 해결 필요
- 지역 기반 샤딩 시 전역 타이머 동기화로 인한 브로드캐스트 트래픽 폭발
- Kafka 이벤트 소싱 도입 시 발생하는 샤드 간 지연 시간 문제
- CRDT를 활용한 로컬 인메모리 상태 관리로 분산 동기화 최적화
우리가 실제로 해결하고 있었던 문제
우리 서버는 피크 타임에 1,200명의 동시 접속자(concurrent players)를 보유하고 있었으며, 이들은 모두 세 개의 바이옴(biomes)에 걸쳐 생성된 472개의 동일한 보물 위치를 찾아내려 노력하고 있었습니다. 엔진은 위치 검증을 위해 중앙 Redis 클러스터를 기본값으로 사용했습니다. 초당 1,200회 작업(ops/sec)이 발생하자, Redis 지연 시간(latency)은 쓰기(writes) 시 450ms, 읽기(reads) 시 380ms까지 급증했습니다. 병목 현상(bottleneck)은 Redis 자체가 아니라 직렬화 형식(serialization format)이었습니다. 우리는 각 보물 위치를 나타내기 위해 JSON을 사용하고 있었는데, 이는 레코드당 1.8KB까지 부풀려졌습니다. Redis 마스터(master)는 이를 감당할 수 없었고, 클러스터는 피크 부하 동안 초당 287개의 요청을 버려(shed) 버렸으며, 이로 인해 클라이언트의 타임아웃(timeout)과 재시도(retry)가 발생하여 더 많은 트래픽을 생성했습니다. 플레이어들은 보물 상자가 나타났다 사라졌다 하는 깜빡임 현상을 목격했고, Jira 티켓에는 'Shiny Object Bug'나 'Chest Teleportation Incident' 같은 제목의 이슈들이 쌓여갔습니다.
우리는 재미를 해결하고 있었던 것이 아니었습니다. 우리는 쓰기 증폭(write amplification) 상황에서의 분산 합의(distributed consensus)를 해결하고 있었습니다.
우리가 처음 시도했던 것 (그리고 왜 실패했는가)
우리의 첫 번째 해결책은 Redis JSON에서 gzip 압축을 사용하는 Protocol Buffers로 전환하는 것이었습니다. 페이로드(payload)는 1.8KB에서 320바이트로 줄어들었습니다. 지연 시간(latency)도 개선되어 Redis는 쓰기 120ms, 읽기 90ms로 안정화되었습니다. 성공했을까요? 꼭 그렇지는 않았습니다.
두 번째 실패는 지역 기반 부하 분산(region-based load balancing)을 테스트할 때 나타났습니다. 우리는 보물 위치를 바이옴(biome)별로 샤딩(sharding)했습니다: Forest, Desert, Nether. 하지만 엔진은 여전히 전역 스폰 타이머(global spawn timers)를 사용했습니다. 모든 바이옴 업데이트는 쿨다운(cooldown) 일관성을 유지하기 위해 모든 샤드(shards)에 브로드캐스트(broadcast)를 요구했습니다. 브로드캐스트 트래픽이 폭발했고, 플레이어가 2,000명 미만일 때도 클러스터는 스폰 타이머를 충분히 빠르게 동기화할 수 없었습니다. Nether 바이옴의 플레이어들은 Forest 상자가 2분 일찍 생성되는 것을 보았고, 이는 보물 찾기의 리듬을 망쳐놓았습니다. 우리는 스폰 이벤트(spawn events)를 스트리밍하기 위해 Kafka를 시도했지만, 이벤트 소싱(event sourcing) 모델은 샤드 간에 15~20초의 지연(lag)을 발생시켜 보물 찾기를 비동기적(asynchronous)이고 불공평하게 만들었습니다.
진짜 문제는 직렬화(serialization)나 샤딩(sharding)이 아니었습니다. 분산 잠금 관리자(distributed lock manager) 없이 샤드 간의 타이밍 동기화(timing synchronization)를 맞추는 것이 문제였습니다.
아키텍처 결정
우리는 샤드(shards)와 Redis를 완전히 포기했습니다. 대신, 다음과 같은 2계층(two-tier) 시스템을 구축했습니다:
-
1계층: CRDT를 이용한 로컬 인메모리 상태 (Local In-Memory State with CRDTs)
각 게임 노드는 충돌 없는 복제 데이터 타입 (CRDT, Conflict-Free Replicated Data Type)으로서 로컬 보물 상태를 호스팅합니다. 위치 정보는 소멸(despawns)을 위한 툼스톤(tombstones)을 포함한 집합(sets) 형태로 저장됩니다. 게임 플레이 중에는 네트워크 호출이 없으며, 오직 로컬 읽기 및 쓰기만 수행됩니다. CRDT는 최종 일관성 (eventual consistency)을 매끄럽게 처리합니다. -
2계층: 스폰 타이머를 위한 Raft 합의 기반의 희소 동기화 (Sparse Sync with Raft Consensus for Spawn Timers)
스폰 타이머는 5개의 노드로 구성된 Raft 클러스터에 의해 관리됩니다. 매 30초마다 노드들은 리더를 선출하여 다음에 어떤 바이옴(biome)이 스폰되어야 할지 결정합니다. 리더는 이 결정을 모든 게임 노드에 브로드캐스트(broadcast)합니다. 이 브로드캐스트는 희소(sparse)하게 이루어집니다. 즉, 전체 상태 덤프(full state dump)가 아니라 바이옴 ID와 타이머 리셋 정보만 전송합니다. 각 게임 노드는 이 타이머를 사용하여 CRDT를 통해 로컬 리스폰(respawns)을 트리거합니다.
우리는 합의(consensus)를 위해 Redis 대신 Raft를 선택했습니다. Redis Sentinel은 높은 경합(high contention) 상황에서 선형화 가능한 쓰기 (linearizable writes)를 제공하지 않기 때문입니다. Raft는 5,000명의 플레이어가 접속한 상황에서도 5~12ms의 지연 시간(latency)으로 전체 순서 브로드캐스트 (total order broadcasts)를 제공합니다.
우리가 사용한 CRDT는 스폰 횟수를 위한 커스텀 G-Counter와 위치 메타데이터를 위한 Lexi-DAG입니다. Dynamo 스타일의 가십 프로토콜 (gossip protocols)은 확률적 불일치 (probabilistic inconsistencies)를 유발하기 때문에 거부했습니다. 플레이어들은 보물 결과의 불일치를 매우 싫어하기 때문입니다.
변경 후 수치 결과
변경 이후:
- 플레이어가 보고한 상자 깜빡임(chest flickering) 현상이 시간당 287건에서 0건으로 감소했습니다.
- Redis 중앙값 지연 시간(리더 선출 로그 기준)이 8ms로 안정화되었습니다.
- 피크 부하(peak load) 시 게임 노드의 CPU 사용량이 68%에서 34%로 떨어졌습니다.
- 첫 주 동안 플레이어 참여도(서버 체류 시간)가 41% 증가했습니다.
커뮤니티도 차이를 느꼈습니다. 버그 보고 내용이 시스템 불안정성에서 기능 요청으로 바뀌었습니다: "보스 트리거 보물 찾기를 추가할 수 있나요?" "레이드 상자는 어떤가요?"
진정한 승리는 속도가 아니었습니다. 그것은 바로 '치팅(cheating)을 하고 있다'는 인식을 제거한 것이었습니다. 상자가 공정하게 소멸하고 예측 가능하게 리스폰될 때, 플레이어들은 시스템을 신뢰했습니다. 그 신뢰가 리텐션(retention, 유지율)을 이끌어냈습니다.
내가 다르게 했을 것이라면
나는 Redis로 시작하지 않았을 것입니다. Redis는 캐시 (cache)이지, 합의 시스템 (consensus system)이 아닙니다. 이를 전역 상태 조정 (global state coordination)에 사용하는 것은 범주 오류 (category error)였습니다.
나는 클러스터 (cluster)의 규모를 산정하기 전이라도, CRDT 레이어 (CRDT layer)를 가장 먼저 구현했을 것입니다. 로컬 상태 (local state) 없이는 그저 지연 시간 (latency)을 이리저리 옮기는 것에 불과합니다.
실시간 동기화 (real-time synchronization)를 위해 Kafka를 사용하는 것은 피했을 것입니다. Kafka는 분석 (analytics)에는 훌륭하지만, 분산 타이머 (distributed timers)를 위한 것은 아닙니다. 대신 Raft나 etcd를 사용하십시오.
마지막으로, 나는 단순히 처리량 (throughput)만이 아니라 공정성 (fairness)을 측정했을 것입니다. 바이옴 (biome) 전반에 걸친 상자 생성 시간 (chest spawn times)의 분산 (variance)을 추적하십시오. 만약 한 바이옴이 다른 바이옴보다 20% 더 빠르게 생성된다면, 플레이어들은 이를 알아차리고 악용할 것입니다. 공정성이야말로 보물 찾기 엔진의 진정한 KPI (Key Performance Indicator, 핵심 성과 지표)입니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기