Veltrix의 보물찾기 엔진이 폭발한 이유 (그리고 새벽 3시 알람 이후 우리가 이를 해결한 방법)
요약
Veltrix의 게임 엔진에서 발생한 대규모 트래픽 상황의 캐시 무효화 문제를 다룹니다. 기존 TTL 방식과 이벤트 소싱 방식의 실패 원인을 분석하고, 버전 관리형 캐시 키와 Redis pub/sub을 활용한 해결책을 제시합니다.
핵심 포인트
- TTL 연장 시 보물 중복 생성 및 데이터 불일치 문제 발생
- 이벤트 소싱 도입 시 Kafka 랙으로 인한 지연 시간 증가
- 버전 관리형 캐시 키를 통한 상태 동기화 문제 해결
- Redis pub/sub 기반의 글로벌 버전 카운터 브로드캐스트 적용
우리가 실제로 해결하려 했던 문제
우리의 보물찾기 엔진 (Treasure Hunt Engine)은 빨라야 할 필요는 없었습니다. 대신 _예측 가능 (predictable)_해야 했습니다. 플레이어들은 하루에 한 번 발생하는 200ms의 세션 깜빡임은 견딜 수 있었지만, 문을 통과할 때마다 발생하는 2초간의 블랙아웃은 견딜 수 없었습니다. 기존 아키텍처 (architecture)는 구역 전환 (zone transitions)이 드문 이벤트라고 가정했습니다. 하지만 실제 운영 환경에서는 그렇지 않았습니다. 주말 이벤트 동안 6개의 트래픽이 높은 구역에 120,000명의 동시 접속 플레이어 (concurrent players)가 몰렸습니다. 각 구역 전환은 플레이어 상태, 리더보드 (leaderboards), 그리고 활성 보물 인벤토리 (active treasure inventories)를 보유한 전체 인메모리 캐시 (in-memory caches)를 무효화했습니다. 엔진의 캐시 무효화 전략 (cache invalidation strategy)은 라이브 시스템이 아닌 데모용으로 조정되어 있었습니다. 8주 차에 접어들었을 때, 우리의 99.9% 가용성 SLO (Service Level Objective)는 이미 저 멀리 사라진 상태였습니다.
우리가 처음 시도했던 것 (그리고 왜 실패했는가)
저의 첫 번째 충동은 캐시 TTL (Time To Live)을 30초로 늘리는 것이었습니다. 이는 캐시 재구축 스파이크 (cache rebuild spikes)를 제거했지만, 새로운 문제인 보물 중복 생성 (duplicate treasure spawns)을 야기했습니다. 게임 로직은 생성 중복을 제거하기 위해 zone:12:player:4567과 같은 캐시 키 (cache keys)를 사용했습니다. 두 플레이어가 30초 이내에 동일한 구역에 진입했을 때, 그들의 캐시는 약간씩 다른 시간에 만료되었습니다. 플레이어 A는 루비가 생성되는 것을 보았고, 플레이어 B의 캐시가 갱신될 때쯤에는 루비가 사라졌습니다. 하지만 TTL 윈도우 (window)가 겹쳤기 때문에 서버에는 이미 두 번의 생성이 기록된 상태였습니다. 우리는 인벤토리에서 중복된 보석이 나타나는 것을 보기 시작했고, 플레이어들은 이를 부정행위 (cheating)로 신고했습니다.
다음으로, 우리는 라이트 비하인드 캐시 (write-behind cache)와 결합된 이벤트 소싱 (event sourcing)을 시도했습니다. 우리는 무효화 이벤트 (invalidation events)를 Kafka 토픽 (topic)으로 밀어 넣고 비동기적으로 캐시를 업데이트했습니다. 이는 모든 인벤토리 업데이트에 150ms의 추가 지연 시간 (latency)을 도입했습니다. 더 나쁜 것은, 트래픽 급증 시 발생하는 Kafka 랙 (lag)으로 인해 플레이어들이 오래된 리더보드와 일치하지 않는 보물 개수를 보게 되었다는 점입니다. 인벤토리 조회에 대한 p95 지연 시간이 89ms에서 245ms로 치솟았고, 제품 팀은 반발했습니다.
아키텍처 결정 (The Architecture Decision)
우리는 캐시 무효화 (Cache Invalidation) TTL을 완전히 제거했습니다. 대신, 버전 관리형 캐시 키 (Versioned Cache Key) 시스템을 구현했습니다. 보물이 생성되거나 경계가 이동하는 등 구역 상태 (Zone State)가 변경될 때마다, 우리는 글로벌 버전 카운터 (Global Version Counter)를 증가시키고 이를 Redis pub/sub을 통해 브로드캐스트했습니다. 클라이언트는 버전 번호를 캐시 키의 일부로 사용했습니다: zone:12:version:42:player:4567. 클라이언트가 버전 불일치를 감지하면, 5초간의 잠금 (Lock)과 함께 전체 캐시 재구축 (Full Cache Rebuild)을 수행했습니다. 이 잠금은 동시 재구축을 방지하여 중복 생성 문제를 해결했습니다. 버전 카운터는 원자성 (Atomicity)을 유지하기 위해 Redis 내의 Lua 스크립트를 통해 증가되었으며, 별도의 외부 조정 (External Coordination)은 필요하지 않았습니다.
트레이드오프 (Tradeoff)가 있었을까요? 뒤처지는 클라이언트 (Straggler Clients)들이 데이터를 소진할 수 있도록 이전 버전을 60초 동안 유지했기 때문에 메모리 사용량이 22% 증가했습니다. 하지만 메모리 비용은 예측 가능하고 제한적이었으며, Redis를 한 차례 수직 확장 (Vertical Scaling)함으로써 SLO를 안정화할 수 있었습니다.
수치가 보여준 결과
변경 이후, 구역 전환 (Zone Transition)에 대한 p99 지연 시간 (Latency)은 2.1초에서 140ms로 감소했습니다. 인벤토리 조회 (Inventory Fetch)는 p95 기준 78ms로 안정화되었습니다. Redis 포드 (Pod)당 메모리는 8.2GB에서 10.1GB로 증가했지만, Redis 클러스터 (Redis Cluster) 노드의 메모리 제한을 두 배로 늘려 이를 수용했습니다. SLO는 3개월 만에 처음으로 99.95%의 가용성 (Availability)을 달성했습니다. 버전 카운터 충돌 (Version Counter Collision)에 대한 알람은 여전히 발생하지만, 이는 전환의 약 0.03% 정도로 매우 드물며, 오프라인 분석을 위해 로그를 남기고 있습니다.
내가 다르게 했을 것이라면
TTL을 패치하는 대신 처음부터 버전 관리형 캐시 키 아이디어로 시작했을 것입니다. TTL 방식은 인지적 지름길 (Cognitive Shortcut)이었습니다. 즉, 진짜 문제(동시 전환 중의 일관성 문제)를 해결하는 대신 시간 휴리스틱 (Time Heuristic)으로 문제를 덮으려 했던 것입니다. 또한, 설계 단계부터 캐시 무효화를 분산 잠금 (Distributed Lock) 문제로 모델링했어야 했습니다. 만약 설계 단계에서 10만 명의 플레이어를 대상으로 30분간 부하 테스트 (Load Test)를 수행했다면, TTL 레이스 컨디션 (Race Condition)이 운영 환경에 배포되기 전에 잡아낼 수 있었을 것입니다.
진정한 교훈은 무엇일까요? 설정 (Configuration)이란 단순히 숫자를 조정하는 것이 아닙니다. 로컬 환경에서 시뮬레이션할 수 없는 장애 모드 (Failure Modes)를 위해 설계하는 것에 관한 것입니다. 우리의 데모 환경은 최대 플레이어가 500명이었습니다. 운영 환경은 120,000명이었습니다. 데모 환경의 그 어떤 것도 이러한 차이에 대비할 수 있게 해주지 않았습니다.
저는 이것을 AI 도구 (AI tooling)를 평가하는 것과 동일한 방식으로 평가했습니다: 무엇이 실패하는가, 얼마나 자주 발생하는가, 그리고 실패했을 때 어떤 일이 일어나는가. 이 항목은 통과입니다: https://payhip.com/ref/dev3
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기