데모를 믿었다가 보물찾기 엔진이 폭발해버린 이유
요약
대규모 동시 접속자가 발생하는 보물찾기 게임 엔진의 성능 병목 현상과 해결 과정을 다룹니다. 샤딩과 캐싱 시도에서 발생한 크로스 샤드 조인 및 캐시 스탬피드 문제를 분석하고, 최종적으로 PostgreSQL의 수직 파티셔닝과 스타 조인 구조로 최적화한 사례를 설명합니다.
핵심 포인트
- 샤딩 도입 시 크로스 샤드 조인으로 인한 코디네이션 스큐 발생 주의
- 캐시 스탬피드 현상으로 인한 Redis 부하 및 지연 시간 급증 사례
- 데이터 모델을 스타 조인 구조로 재설계하여 쿼리 효율성 개선
- 데모 환경과 실제 운영 환경의 데이터 분포 차이 인지 필요
우리가 실제로 해결하려던 문제
매주 금요일 17:00 UTC마다 Veltrix 플랫폼은 보물찾기 이벤트를 위해 120,000명의 동시 접속 플레이어를 대기열에 올렸습니다. 엔진의 임무는 110 GB 규모의 월드 그래프 (world graph)에 대해 400 ms 이내에 공간 쿼리 (spatial queries)를 해결하는 것이었습니다. 첫 번째 테스트 동안 중앙값 (median) 응답 시간은 180 ms로 SLA 범위 내에 안정적으로 들어왔지만, p99 응답 시간은 1,900 ms까지 치솟았습니다. 우리는 이 스파이크의 원인을 보물 위치, 플레이어 인벤토리 (player inventories), 그리고 동적 전리품 테이블 (dynamic loot tables)을 조인 (join)하는 단일 PostgreSQL CTE에서 찾아냈습니다. Autovacuum이 전리품 테이블에서 멈춰버렸고, 그 사이 autovacuum_wraparound_* 카운터는 30초 만에 200에서 2,000으로 급증했습니다. DB 로그에는 단순히 vacuuming이 진행 중이라고만 표시되었는데, 이는 데모에서는 절대 볼 수 없는 종류의 무책임한 반응이었습니다.
우리가 처음 시도했던 것 (그리고 실패한 이유)
우리의 첫 번째 본능적인 대응은 월드 그래프를 수평적으로 샤딩 (sharding)하는 것이었습니다. 우리는 청크 좌표 (chunk coordinates: X/8192, Y/8192, Z/8192)를 기준으로 그래프를 16개의 샤드 (shards)로 나누고 단순한 나머지 연산 (modulo)을 통해 쿼리를 라우팅했습니다. 샤드 수준의 지연 시간 (latency)은 중앙값 60 ms, p99 320 ms로 떨어졌으며 모니터링 대시보드상으로는 매우 좋아 보였습니다. 하지만 운영 환경에서는 플레이어들이 단일 던전 입구 주변에 몰리면서 쿼리의 절반이 크로스 샤드 (cross-shard) 쿼리가 되었습니다. 그러자 코디네이터 노드 (coordinator node)가 100개 방향의 크로스 샤드 조인을 직렬화 (serializing)하기 시작했고, p99는 다시 2,800 ms까지 상승했습니다. 그 시점에서 클러스터 CPU는 78% 유휴 (idle) 상태였고 네트워크 RTT는 0.6 ms였습니다. 병목 현상은 연산 (compute)이 아니라 코디네이션 스큐 (coordination skew)였습니다.
우리는 또한 Redis 레이어 캐시 (Redis-layer cache)도 시도했습니다. 각 샤드별로 전체 플레이어 인벤토리를 30초 동안 캐싱하는 LUA 스크립트를 사용했습니다. 초기 히트율 (hit ratio)은 89%에 달했지만, 이벤트 시작 직후 발생하는 캐시 스탬피드 (cache stampede) 현상으로 인해 5초 만에 45,000건의 캐시 미스 (cache misses)가 발생했습니다. Redis의 제거율 (eviction rate)이 초당 9,800개 키로 급증하는 것을 지켜보았고, p99 지연 시간은 1,100 ms까지 치솟았습니다. 매니페스트 (manifests)에는 캐시 일관성 (cache coherency)이나 키 무효화 배치 (key invalidation batches)에 대한 언급이 전혀 없었습니다.
아키텍처 결정
우리는 두 솔루션을 모두 제거하고 PostgreSQL 내부의 단일 수직 파티셔닝 (vertical partition)으로 교체했습니다.
핵심적인 통찰은 보물찾기 쿼리에 world_nodes, player_inventories, loot_tables, events_metadata라는 네 개의 테이블만 필요하다는 점이었습니다. 엔진 내의 세 가지 쿼리 모두 중앙 팩트 테이블(fact table)인 treasure_hunts(id, world_id, loot_table_id, player_id, status, updated_at)에 대한 스타 조인 (star join)으로 표현될 수 있었습니다. 우리는 이 스타 구조를 world_id와 updated_at에 대한 BRIN 인덱스 (BRIN indexes)를 갖춘 단일 140 GB 하이퍼트리 (hypertree) 테이블로 비정규화 (denormalized)했으며, 5분마다 갱신되는 플레이어별 요약을 위해 3 GB 크기의 작은 구체화된 뷰 (materialized view)를 유지했습니다.
이제 플래너 (planner)는 world_id에 대한 범위 스캔 (range scans)에 BRIN 인덱스를 사용하여 거대한 CTE 조인을 피할 수 있게 되었습니다. 우리는 autovacuum_naptime을 10초로 설정하고, 플레이어별 요약을 점진적으로 계산하는 커스텀 확장 기능인 pg_partial_agg를 추가했습니다. 그 결과, Vacuum 워크로드는 이전의 3% 수준으로 떨어졌으며, 130,000개의 동시 쿼리 상황에서도 p99 응답 시간이 210ms로 안정화되었습니다.
트레이드오프 (tradeoff)는 디스크 공간이었습니다. 하이퍼트리 테이블은 110 GB에서 140 GB로 불어났지만, Redis 클러스터를 폐기한 후 Veltrix 노드에서 1.4 TB의 SSD 여유 공간을 확보했습니다. 또한 수평 확장 (horizontal scaling)이라는 명분은 잃었습니다. 만약 다음 이벤트가 250,000명의 플레이어를 대상으로 한다면 수동으로 다시 파티셔닝 (repartition)해야 합니다. 여기에는 핫스왑 (hot-swap)이 없습니다.
전환 후 수치가 말해준 것
전환 후:
- 15번의 금요일 이벤트 전반에 걸쳐 p95 응답 시간이 180ms를 유지했습니다.
- p99는 280ms 미만을 유지했으며, 이벤트의 99.9%가 300ms 이내에 완료되었습니다.
- 기본 복제본 (primary replica)의 CPU 사용률이 48%에서 12%로 감소했습니다.
- Autovacuum 래핑어라운드 (wraparound) 경고가 완전히 사라졌습니다.
남아 있는 유일한 실패 모드는 노드 재시작 후 BRIN 페이지가 여전히 콜드 (cold) 상태일 때입니다. 재부팅 후 첫 번째 쿼리는 OS 페이지 캐시 (page cache)가 로드되는 동안 800ms 동안 지연될 수 있습니다. 우리는 노드 부팅 사이클 동안 pg_prewarm을 사용하여 BRIN 페이지를 프리워밍 (pre-warming)함으로써 이를 완화했습니다.
내가 다르게 했을 것이라면
콜드 스타트 (cold-start) 데이터 없이 선형적 확장 (linear scaling)을 보여주는 그 어떤 마케팅 슬라이드도 믿지 않았을 것입니다. 데모 클러스터는 실제 규모의 1/10 수준이었고 이미 워밍업 (warmed up)된 상태였습니다. 우리는 진공 폭풍 (vacuum storms)이나 버퍼 캐시 미스 (buffer cache misses)에 대해 아무것도 배우지 못했습니다. 또한, 카오스 엔지니어링 (chaos-engineering) 예산을 편성할 것을 주장했을 것입니다. 매주 금요일마다 이벤트 시작 시 노드 손실 (node loss)을 시뮬레이션하여, 장애 조치 (failover) 중에 p99가 붕괴되지 않는지 검증해야 합니다. 현재 우리의 장애 조치 시간은 4.2초이며, 이는 새로 승격된 리더 (leader)에 할당되는 불운한 1%의 요청에 대해 텔레메트리 (telemetry)상에서 1,100ms의 스파이크 (spike)로 여전히 나타나고 있습니다.
마지막으로, 보물 엔진 (treasure engine)에 실시간 전리품 등급 분류 (loot-tiering) 알고리즘을 추가하려 했던 기능 팀의 요구를 거부했을 것입니다. 그 기능은 초당 1MHz의 업데이트가 발생하는 또 다른 핫 테이블 (hot table)을 의미했을 것이며, 우리의 현재 p99를 망가뜨렸을 것입니다. 대신, 우리는 전리품 등급 분류를 Kafka로 발행하는 백그라운드 작업 (background job)으로 옮겼고, 엔진은 미리 계산된 등급을 읽기만 합니다. 동적인 전리품 (dynamic loot)이라는 연출은 인상적이지만, 프로덕션 (production) 환경에서는 우리가 필요하지 않은 또 다른 지연 시간 변동성 (latency variance)의 원인일 뿐입니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기