보물찾기 엔진이 이 사실을 깨닫기 전까지 내 클러스터를 세 번이나 터뜨린 이유
요약
실제 서비스 운영 중 발생한 Vercel Edge Functions의 성능 제한, Redis Lua 스크립트의 단일 스레드 병목, 캐시 지원 중단 문제를 다룹니다. 이를 해결하기 위해 Go 기반 마이크로서비스로 전환하고 Redis 샤딩을 도입한 아키텍처 개선 과정을 설명합니다.
핵심 포인트
- Vercel Edge Functions의 CPU 시간 및 페이로드 제한 주의
- Redis Lua 스크립트 사용 시 단일 스레드 블로킹 위험성
- 분산 환경에서의 레이스 컨디션 및 데이터 정합성 문제
- Go와 Protobuf를 활용한 고성능 마이크로서비스 전환
우리가 실제로 해결하려 했던 문제
데모에서는 사용자 한 명, 세션 하나, 경로 하나만을 보여주었습니다. 하지만 실제 시스템은 수천 명의 플레이어에게 서비스를 제공해야 했으며, 각 플레이어는 클릭 패턴, 시간 범위, 그리고—마케팅 팀의 강력한 요청에 따라—점수에 대한 이모지 반응(emoji reactions)에 따라 진화하는 개인용 보물 지도를 가지고 있었습니다. 우리의 첫 번째 스택은 사냥 로직을 위한 Vercel edge functions, 리더보드(leaderboards)를 위한 관리형 Redis, 그리고 플레이어 프로필을 위한 별도의 Postgres를 사용하는 Next.js 13 앱이었습니다. 지연 시간(Latency) 목표는 엔드 투 엔드(end-to-end) p95 기준 300ms 미만이었습니다.
3일 차에 우리가 발견한 사실은 Vercel 무료 티어(free tier)가 CPU 시간이 100ms를 초과하면 edge functions를 조용히 제한(throttle)한다는 것이었습니다. 이는 Redis에서 플레이어의 보물 상태를 조회하는 모든 경로가 페이로드(payload)가 1KB보다 클 경우 한계에 부딪힌다는 것을 의미했습니다. 즐거웠던 데모에서는 300바이트를 넘은 적이 없었습니다.
우리가 처음에 시도했던 것 (그리고 실패한 이유)
우리는 보물 상태 엔드포인트(endpoint)가 UUID만 반환하도록 다시 작성했고, 그 다음 /api/state/{uuid}에 대한 두 번째 호출을 통해 클라이언트에 데이터를 채워넣는(hydrate) 방식을 사용했습니다. 이를 통해 edge function 시간을 45ms로 단축했지만, 레이스 컨디션(race condition)이 발생했습니다. 두 개의 동시 클릭이 발생했을 때, 두 번째 클라이언트가 Postgres에서 오래된 상태(stale state)를 가져오면 동일한 보물을 차지할 수 있게 된 것입니다. 또한 클라이언트가 점수 차이(score delta)를 직렬화(serialize)하도록 허용했기 때문에, Redis 리더보드 쓰기 작업의 42%가 CAS(Compare-And-Swap) 불일치로 실패했습니다.
그다음 우리는 Redis에서 보물 상태를 원자적으로 비교 및 교체(compare-and-swap)하기 위해 Lua 스크립트를 시도했습니다. 이 스크립트는 로컬 Docker 환경에서는 아주 잘 작동했지만, 관리형 Redis 클러스터에 배포했을 때 Lua 엔진이 모든 테넌트(tenants)가 공유하는 단일 스레드에서 실행된다는 사실을 발견했습니다. 실행 시간이 긴 스크립트는 다른 모든 플레이어의 리더보드 업데이트를 차단했습니다. 피크 로드(peak load) 동안 Redis p99 지연 시간은 1.8초로 급증했고, CEO의 데모는 42초 동안 멈춰버렸습니다.
동시에 Vercel 팀이 edge runtime을 Node 18에서 Node 20으로 마이그레이션하면서, 우리가 일종의 저가형 CDN(poor mans CDN)으로 사용해 왔던 문서화되지 않은 글로벌 fetch() 캐시 지원을 조용히 중단했습니다. 보물 지도가 다시 렌더링될 때마다 오리진(origin)에 요청이 전달되었고, 우리의 CloudFront 청구 금액은 하룻밤 사이에 세 배로 뛰었습니다.
아키텍처 결정 (The Architecture Decision)
우리는 Vercel edge functions를 완전히 제거했습니다. 대신, 보물찾기 로직 전체를 Fly.io에서 실행되는 Go 마이크로서비스 (microservice)로 옮겼습니다. 이 서비스는 플레이어의 UUID, 클릭 좌표, 그리고 클라이언트가 제공한 타임스탬프(timestamp)를 포함하는 압축된 프로토버프 (protobuf) 페이로드를 수락하는 단일 HTTP POST /hunt 엔드포인트를 노출합니다. 우리는 Lua 스크립트 (Lua scripts)를 병렬화하기 위해 Redis 클러스터 (Redis cluster)를 6개의 기본 노드 (primary nodes)로 샤딩 (sharding)했으며, 각 노드에는 2개의 읽기 복제본 (read replicas)을 배치했습니다. 이제 Lua 스크립트는 전체 리더보드 (leaderboard)를 스캔하는 대신 단일 키의 Compare-and-Swap (CAS) 작업으로 제한했기 때문에 3ms 이내에 실행됩니다.
상태 일관성 (state consistency)을 위해, Postgres에 경량 아웃박스 (outbox) 패턴을 도입했습니다. 모든 상태 변경은 hunt_events에 행(row)을 기록하며, 사이드카 프로세스 (sidecar process)가 해당 행을 NATS jetstream으로 발행합니다. Go 서비스는 자체 이벤트를 소비하여 인메모리 LRU 캐시 (in-memory LRU cache)를 업데이트하므로, 다음 요청은 Postgres를 호출하지 않고도 최신 상태를 확인할 수 있습니다. 캐시 무효화 (cache invalidation)는 실제 시간 (wall time)이 아닌 단조 시계 (monotonic clock, 플레이어의 마지막 보물 인덱스)를 기반으로 합니다.
우리는 GC 일시 중단 (GC pauses)을 피하기 위해 Redis가 인스턴스 메모리의 약 30%를 Lua 스크립트용으로 사용하도록 구성했습니다. Lua 스크립트 타임아웃 (timeouts)은 1ms로 설정했으며, 스크립트가 이를 초과할 경우 연쇄 장애 (cascade)의 위험을 감수하는 대신 클릭을 즉시 거부하는 패스트 패스 (fast path)로 전환합니다.
이후 수치가 말해준 것 (What The Numbers Said After)
첫 번째 카오스 테스트 (chaos test)에서 우리는 20,000명의 시뮬레이션 플레이어를 새 서비스에 투입했으며, 각 플레이어는 500ms마다 클릭을 수행했습니다. 엔드 투 엔드 (end-to-end) p95 지연 시간 (latency)은 112ms를 유지했습니다. Redis p99는 12ms 미만을 유지했습니다. Lua CAS 불일치율 (mismatch rate)은 0.03%로 떨어졌습니다. NATS jetstream은 엔드 투 엔드 지연 시간을 1.4ms 추가했지만, 샤드 (shards) 전반에 걸친 오래된 리더보드 읽기 (stale leaderboard reads) 문제를 제거했습니다.
Go 서비스가 Fly의 글로벌 애니캐스트 네트워크 (global anycast network)를 통해 엣지 (edge)에서 보물 지도를 캐싱하기 때문에 CloudFront 청구 금액은 기본 수준으로 돌아왔습니다. 분당 40,000개의 상태 업데이트를 처리하고 있음에도 불구하고 Postgres 쓰기 부하 (write load)는 300 TPS 미만을 유지했습니다.
2주간의 프로덕션 트래픽 (production traffic)을 거친 후, 우리는 새로운 장애 모드 (failure mode) 하나를 발견했습니다. 클라이언트 기기와 Go 서비스 간의 시계 왜곡 (clock skew)으로 인해 일부 플레이어들에게 점수 변화량 (score deltas)이 음수로 표시되는 현상이 발생했습니다. 우리는 서버 타임스탬프 (server timestamp)와 플레이어 시퀀스 번호 (player sequence number)를 결합한 하이브리드 논리 시계 (hybrid logical clock)로 전환하여 이를 해결했습니다. 이 수정 사항으로 인해 평균 지연 시간 (latency)이 0.07ms 증가했지만, 마케팅 팀에서 용납할 수 없다고 했던 시각적 오류 (visual glitch)를 제거할 수 있었습니다.
내가 다르게 했을 일들
나는 데모가 규모 가정 (scale assumptions)을 결정하게 두지 않을 것입니다. 스택 (stack)의 어떤 부분을 선택하기 전에, 단일 플레이어와 10,000명의 가상 플레이어로 구성된 합성 부하 (synthetic load)를 통해 스파이크 테스트 (spike test)를 실행했어야 했습니다. 에지 함수 (edge function) 환경은 블랙박스 (black box)였습니다. 로직을 상태 유지 서비스 (stateful service)로 옮기면서 가시성 (visibility)을 확보했지만, 그 대가로 2주의 시간을 허비했습니다.
JSON 대신 첫날부터 protobuf를 채택했더라면 좋았을 것입니다. 대역폭 (bandwidth)과 파싱 시간 (parse time)의 절감 효과는 Vercel의 스로틀링 (throttling)에 직면한 후에야 명확해졌습니다.
마지막으로, 우리가 Grafana를 지켜보는 동안 무작위로 Redis 샤드 (shards)와 Fly 리전 (regions)을 종료시키는 로컬 카오스 리그 (chaos rig)를 구축했을 것입니다. Lua GC (Garbage Collection) 사건 이후의 사후 분석 (post-mortem)은 재현 가능한 장애 시나리오 (failure scenario)가 없었기 때문에 6시간이나 걸렸습니다. 다음에 클러스터 (cluster)를 태워 먹게 된다면, 이미 그 재는 디스크에 저장되어 있을 것입니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기