보물찾기 엔진이 지연 시간(Latency)에 대해 우리에게 거짓말을 하기로 결심한 날
요약
보물찾기 게임 엔진의 지연 시간 문제를 해결하기 위해 분산 시스템 아키텍처를 검토한 사례입니다. 사가 패턴과 Redis Streams를 통한 분산 트랜잭션 시도가 데이터 불일치와 중복 처리 문제를 야기하여, 결국 단일 ACID 트랜잭션 방식으로 전환하게 된 과정을 다룹니다.
핵심 포인트
- 분산 시스템의 사가 패턴은 오케스트레이터 지연을 유발할 수 있음
- 샤딩 도입 시 서비스 간 상태 불일치로 인한 데이터 오류 위험 존재
- Redis Streams의 컨슈머 그룹 리밸런싱 시 중복 처리 문제 발생
- 성능과 데이터 정합성 사이의 트레이드오프를 고려한 아키텍처 결정 필요
우리가 실제로 해결하려 했던 문제
마케팅 부서는 보물찾기가 즉각적으로 느껴지기를 원했습니다. 단순히 '반응하는(responsive)' 수준이 아니라, '심리적으로 즉각적인(psychologically immediate)' 느낌, 즉 상자가 열리거나, 열쇠를 찾거나, 보상이 잠금 해제되는 것을 1초 미만의 시간 내에 확인해 주는 것이었습니다. CFO(최고 재무 책임자)는 인지된 속도가 높을수록 세션당 수익이 높아진다는 이유로 이를 승인했습니다. 시스템은 플레이어의 엄지손가락이 화면에서 떨어지기도 전에 200 OK를 반환해야 했습니다.
기본 구현 방식은 팬아웃 패턴(fan-out pattern)을 사용하는 이벤트 버스(event bus)를 사용했습니다. 모든 동작(상자 열기, 보상 수령)은 인벤토리(inventory), 지갑(wallet), 분석(analytics)이라는 3개의 다운스트림 서비스(downstream services)로 발행되는 메시지였습니다. 각 서비스는 자체적인 데이터베이스를 가지고 있었습니다. 약속된 사항은 보상 트랜잭션(compensating transactions)을 포함한 사가 패턴(saga pattern)을 통한 원자적 일관성(atomic consistency)이었습니다. 실제로 사가 오케스트레이터(saga orchestrator)는 부하가 걸린 상황에서 80~140ms의 왕복 지연 시간(round-trip latency)을 추가했습니다. 마케팅 대시보드에는 95백분위수(95th percentile) 지연 시간이 85ms로 표시되었는데, 이는 모든 다운스트림 서비스가 확인(acknowledge)할 때까지의 시간이 아니라 오케스트레이터의 완료 시간만을 측정했기 때문입니다.
우리가 처음에 시도했던 것 (그리고 실패한 이유)
우리는 플레이어 ID별로 각 서비스를 샤딩(sharding)하는 것을 시도했습니다. 이를 통해 팬아웃 경로를 60% 단축했지만, 새로운 장애 모드인 끔찍한 '이중 크레딧(double-credit)' 에지 케이스(edge case)가 발생했습니다. 지갑 서비스가 수령 처리를 하고 성공 이벤트를 발행했지만, 분석 서비스로 발행하기 전에 중단되는 상황이 발생한 것입니다. 사가 오케스트레이터는 타임아웃(timeout)을 감지하고 지갑 업데이트를 롤백(rollback)했습니다. 플레이어의 잔액은 복구되었지만, 분석 이벤트는 이미 해당 이벤트가 권위 있다고 가정하는 별도의 대시보드 로더(dashboard loader)에 의해 소비된 상태였습니다. CFO는 실제로 발생하지 않은 수익 급증을 보여주는 보고서를 받게 되었습니다. 우리는 2주 만에 8만 7천 달러를 환불해 주어야 했습니다.
그다음 우리는 컨슈머 그룹 (Consumer Groups)을 활용한 Redis Streams를 시도했습니다. 스트림 (Streams)은 순서가 보장된 처리 (Ordered processing)와 정확히 한 번 (Exactly-once semantics)의 의미론을 약속했습니다. 우리는 사가 오케스트레이터 (Saga orchestrator)를 완전히 꺼버렸습니다. 첫 번째 장애는 컨슈머 그룹 리밸런싱 (Consumer group rebalance)이 4.2초 동안 지속되었을 때 발생했습니다. 그 시간 동안, 컨슈머 오프셋 (Consumer offsets)이 승인 (Acknowledgment)과 함께 원자적 (Atomically)으로 전진하지 않았기 때문에 1,800건의 중복된 보물 상자 개봉이 처리되었습니다. 우리의 재시도 예산 (Retry budget)은 120ms였고, 백로그 (Backlog)는 우리가 포드 (Pods)를 확장하는 속도보다 더 빠르게 증가했습니다.
아키텍처 결정 (The Architecture Decision)
우리는 이벤트 버스 (Event bus)를 뜯어내고, 인벤토리 (Inventory), 지갑 (Wallet), 분석 (Analytics)을 단일 ACID 블록 내에서 업데이트하는 단 하나의 데이터베이스 트랜잭션 (Database transaction) 방식으로 돌아갔습니다. 트레이드오프 (Tradeoff)는 PostgreSQL만 사용할 수 있다는 점이었습니다. 우리는 플레이어 ID (Player ID)를 기준으로 기본 키 (Primary key)를 샤딩 (Sharded)했으며, 전체 보물찾기 작업은 RETURNING 구문을 포함한 단일 UPDATE 문으로 구성됩니다. 지연 시간 (Latency) 백분위수는 95퍼센타일 (95th percentile) 기준 15ms로 개선되었지만, 서비스 독립성 (Service independence)을 희생했습니다. 인벤토리 스키마 (Schema)가 변경되면 지갑이 깨집니다. 분석에 새로운 컬럼 (Column)이 필요하면 전체 보물찾기 엔드포인트 (Endpoint)를 함께 배포해야 합니다.
우리는 피처 플래그 (Feature flags)를 통해 결합도 (Coupling)를 완화했습니다. 엔드포인트는 먼저 LaunchDarkly 플래그를 확인합니다. 만약 비활성화되어 있다면, 사가 (Saga)와 보상 트랜잭션 (Compensating transactions)을 사용하는 이벤트 버스 경로로 폴백 (Fallback)합니다. 우리는 이 플래그를 사용하여 새로운 경로를 점진적으로 배포하되, 세션 ID (Session ID)가 7의 배수인 플레이어들에게만 적용했습니다. 이를 통해 전체 성공 지표 (Success metrics)를 오염시키지 않으면서도 엣지 케이스 (Edge cases)를 포착할 수 있는 14%의 카나리 그룹 (Canary group)을 확보했습니다. 또한 30초 구간 내에서 500 에러 (500 errors)가 0.3%를 초과하면 폴백으로 전환되는 서킷 브레이커 (Circuit breaker)를 추가했습니다.
그 이후의 수치들 (What The Numbers Said After)
새로운 경로는 오탐 (False-positive) 보물 지급 없이 47일 동안 실행되었습니다. 95백분위수 지연 시간 (95th percentile latency)은 15ms를 유지했으며, 요청의 99.7%가 50ms 이내에 완료되었습니다. 피크 시간대에 데이터베이스 CPU가 급증하여, 분석용 읽기 요청을 처리하기 위해 us-west-2 지역에 읽기 복제본 (Read replica)을 추가했습니다. 또한, 보물찾기의 12%가 실제로는 자동화 스크립트를 사용하는 플레이어인 _봇 (Bots)_이라는 사실을 발견했습니다. 사가 패턴 (Saga pattern)을 포기함으로써, 이전에 봇들이 타이밍 간극을 악용하여 중복 청구를 유발했던 두 개의 비동기 (Async) 단계를 제거했습니다.
진정한 놀라움은 운영 측면에서 나타났습니다. 이전에는 이벤트 버스 (Event bus)에 지연이 발생했을 때, 다른 서비스에는 영향을 주지 않고 단일 서비스만 재시작할 수 있었습니다. 하지만 이제는 인벤토리 스키마 (Inventory schema)에 파괴적 변경 (Breaking change)이 발생하면 클러스터 전체를 배포해야 합니다. 롤백 (Rollback) 프로세스는 S3 스냅샷으로부터 데이터베이스 전체를 복구하는 방식입니다. 스테이징 (Staging) 환경에서 롤백을 연습해 본 결과 8분 22초가 소요되었습니다. 우리는 비상시에만 사용할 수 있도록 기존의 사가 경로를 피처 플래그 (Feature flag) 뒤에 남겨두기로 결정했습니다. 이는 속도 면에서는 연극적인 버전일지 모르나, 몇 분 내에 롤백이 가능한 방식입니다.
내가 다르게 했을 일 (What I Would Do Differently)
나는 첫날부터 1초 미만의 요구 사항에 대해 반대했을 것입니다. 심리적인 진실은 100ms 미만은 즉각적으로 느껴지지만, 200ms 미만은 수용 가능한 수준으로 느껴진다는 것입니다. 우리는 마케팅 대시보드에만 의미가 있는 15ms의 이득을 쫓느라 세 번의 스프린트 (Sprint)를 소비했습니다. 만약 내가 엔드포인트 완료 시점이 아닌, 탭(Tap)부터 시각적 피드백까지의 시간인 인지 지연 시간 (Perceived latency)을 측정하자고 고집했다면, 수개월간의 엔지니어링 드라마를 줄일 수 있었을 것입니다.
또한, 샤딩된 Redis Streams 실험도 거절했을 것입니다. 컨슈머 그룹 리밸런싱 (Consumer group rebalances)은 운영 환경의 부하 상황에서 비결정론적 (Non-deterministic)입니다. 대규모 환경에서 작동하는 유일한 스트림은 파티션 키 (Partition key)가 플레이어 ID, 세션 ID와 같이 불변의 비즈니스 키 (Immutable business key)와 일치하는 스트림뿐입니다. 이를 보장할 수 없다면, 스트림 사용을 피하십시오.
마지막으로, 저는 관측성 (Observability)을 더 일찍 구축했을 것입니다. 우리는 클라이언트에서 탭 (Tap) 시점부터 보물 획득 성공 확인 (Treasure claim confirmation) 시점까지의 시간을 측정하는 treasure_latency_seconds_histogram이라는 커스텀 Prometheus 메트릭 (Metric)을 추가했습니다. 해당 메트릭은 즉각적으로 플레이어의 8%가 우리 백엔드 (Backend)가 아닌 모바일 네트워크 지터 (Mobile network jitter)로 인해 300ms 이상의 지연 시간 (Latency)을 겪고 있다는 사실을 드러냈습니다. 우리는 클라이언트가 200 OK를 받은 후에만 확인 모달 (Confirmation modal)을 띄우도록 수정함으로써 이 문제를 해결했습니다. 마케팅적 약속은 그대로 유지되었지만, 엔지니어링 측면의 거짓말은 마침내 가시화되었습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기