본문으로 건너뛰기

© 2026 Molayo

Dev.to헤드라인2026. 05. 27. 14:19

보물찾기 엔진이 부하(Load)에 대해 거짓말을 하고 있다는 것을 발견한 순간

요약

NFT 보상 기반의 보물찾기 엔진 Veltrix를 운영하며 겪은 대규모 트래픽 처리 실패 사례를 다룹니다. 문서에 명시되지 않은 설정값의 하드 캡(hard cap) 제한으로 인해 인프라 확장 시도가 무력화되었던 기술적 장애 과정을 설명합니다.

핵심 포인트

  • 문서에 명시되지 않은 설정값(max_concurrent_hunters)이 시스템 병목의 원인이 됨
  • Redis 커넥션 풀 튜닝만으로는 하드웨어 및 소프트웨어 임계값 문제를 해결할 수 없음
  • Kubernetes 환경에서 특정 설정 파일의 불변성(immutability)이 운영 유연성을 저해함
  • 인프라 확장성(scalability) 검증 시 소프트웨어 내부의 숨겨진 제한 사항 확인 필수

우리가 실제로 해결하려 했던 문제

우리 팀은 플레이어들에게 NFT 드롭(drop)으로 보상을 주는 인터랙티브 보물찾기를 실행하기 위해 Veltrix를 구축했습니다. 이벤트 설계에는 실시간 리더보드(leaderboard) 업데이트와 동적인 챌린지 할당이 필요했습니다. 우리는 바이럴 마케팅 캠페인으로 인한 트래픽 급증을 예상했기에, 문서에서 Redis 및 Kafka를 통한 수평적 확장(horizontal scaling)을 광고하는 Veltrix를 선택했습니다. 온보딩 가이드에는 Kubernetes로의 원클릭 배포가 명시되어 있었고, 마케팅 페이지에는 수십억 개의 요청을 처리했다는 문구가 번쩍이고 있었습니다.

50,000명의 플레이어가 Hunt-001에 참여했을 때, 시스템은 완전히 충돌(crash)하지는 않았습니다. 대신 서서히 죽어가는 상태(slow death)에 빠졌습니다. Redis 메모리는 12분 만에 95%에 도달했습니다. 플레이어들은 자신의 위치가 멈춰버린 반면 다른 플레이어들은 계속 전진하고 있다고 보고했습니다. Grafana 대시보드는 Kafka 지연(lag)이 초당 3,000개 메시지라고 보여주었지만, 브로커(broker)를 확장한 후에도 지연은 전혀 줄어들지 않았습니다. 로그에는 hunt-config.yml 레이어에서 발생하는 끝없는 Too Many Requests 오류가 출력되었는데, 이 레이어는 단일 보물찾기가 2,000개의 동시 세션(concurrent sessions)을 초과할 때 모든 들어오는 이벤트를 제한(throttle)했습니다.

우리는 Redis 커넥션 풀(connection pool)을 튜닝하고 워커 포드(worker pod)를 위한 오토스케일링(auto-scaling) 정책을 설정했지만, 아무도 hunt-config.yml의 임계값(thresholds)을 확인하려 하지 않았습니다. 해당 파일은 max_concurrent_hunters: 2000을 기본값으로 설정하고 있었으며, 50,000이 아니었습니다. 운영 가이드에는 이 제한에 대해 아무런 언급이 없었으며, 문서의 검색 기능에 max_concurrent_hunters를 입력했을 때는 결과가 0건이었습니다. 우리는 사실이 아닌 가정 위에 우리의 확장(scaling) 스토리를 구축했던 것입니다.

우리가 처음 시도했던 것 (그리고 왜 실패했는가)

저의 첫 번째 시도는 Redis 커넥션 풀 크기를 100에서 500으로 늘리고, 최대 메모리 정책(max memory policy)을 allkeys-lru로 높이는 것이었습니다. 시스템은 잠시 동안 6,000명의 동시 사용자를 처리했으나, Redis가 fork() 실패와 함께 재시작되었습니다. OOM killer가 트리거된 것입니다. 풀 크기를 늘리는 것은 지연 시간(latency)에는 도움이 되었지만, hunt-config.yml의 하드 캡(hard cap)을 해결하지는 못했습니다.

다음으로, 저는 Kubernetes의 ConfigMap을 패치하여 hunt-config.yml을 즉석에서 재정의(override)하려고 시도했습니다. 하지만 Veltrix operator는 다음과 같은 에러와 함께 패치를 거부했습니다: hunt-config.yml is immutable once the hunt is started (한 번 헌트가 시작되면 hunt-config.yml은 변경할 수 없습니다). operator 가이드에서는 새로운 설정으로 헌트를 다시 실행할 것을 권장했지만, 이는 바이럴 급증(viral spike)이 발생할 때마다 다운타임(downtime)을 의미했습니다. 이는 탄력적 확장(elastic scaling)의 목적에 어긋나는 일이었습니다.

또한, 멀티스레드 포크(multi-threaded fork)가 부하(load) 상황에서 지연 시간(latency)을 줄여주기를 기대하며 Redis 인스턴스를 DragonflyDB로 교체했습니다. Dragonfly는 트래픽 급증(traffic burst)을 더 잘 처리했지만, hunt-config.yml 레이어가 여전히 이벤트를 제한(throttle)하여 30초마다 리더보드 끊김 현상(leaderboard stutters)을 유발했습니다. Dragonfly 포크는 스냅샷 쓰기(snapshot writes) 중에 이벤트 루프(event loop)가 차단될 때 자체적인 지연 시간 스파이크(latency spikes)를 발생시켜 끊김 현상을 더욱 악화시켰습니다. 한편, Kafka 컨슈머 랙(consumer lag)은 3,000개 메시지 뒤처진 상태로 일정하게 유지되었는데, 이는 hunt-config.yml 레이어가 2,000명의 헌터(hunter) 이상을 방출하는 것을 거부했기 때문입니다. Kafka 확장(scaling)은 의미가 없었습니다. 병목 현상(bottleneck)은 업스트림(upstream)에 있었습니다.

아키텍처 결정 (The Architecture Decision)

우리는 hunt-config.yml 레이어를 해체하고, 하드 캡(hard cap) 없이 실제 트래픽을 존중하는 동적 거버너(dynamic governor)로 교체해야 했습니다. 이 거버너는 실제 Redis 메모리 사용량과 Kafka 랙(lag)을 모니터링한 다음, 헌트 전체의 스로틀링(throttling)을 실시간으로 조정할 것입니다. operator 가이드에서는 이를 헌트 거버너 서비스(hunt governor service)라고 불렀지만, 문서는 이를 'Advanced Tuning'이라는 섹션 아래에 숨겨두었습니다. 저는 GitHub에 있는 Veltrix operator 소스 코드로부터 거버너의 API를 역공학(reverse-engineer)해야 했습니다.

결정된 사항은 모든 헌트 포드(hunt pod)에 거버너를 사이드카(sidecar)로 임베드하는 것이었습니다. 사이드카는 veltrix_hunt_throttle_ratio라는 Prometheus 메트릭(metric)을 내보내고, 헌트 오케스트레이터(hunt orchestrator)를 위한 gRPC 엔드포인트(endpoint)를 노출할 것입니다. 오케스트레이터는 5초마다 거버너를 호출하여 허용 가능한 최대 동시 헌터(maximum concurrent hunters) 수를 조정할 것입니다. 우리는 거버너의 임계값(thresholds)을 공격적으로 설정했습니다. 만약 Redis 메모리가 80%를 초과하거나 Kafka 랙이 1,000개 메시지를 초과하면, 거버너는 안정성이 회복될 때까지 30초마다 max_hunters를 10%씩 선형적으로(linearly) 감소시킬 것입니다.

우리는 또한 hunt-config.yml을 거버너(governor)가 런타임(runtime) 중에 패치(patch)할 수 있는 ConfigMap 템플릿으로 마이그레이션(migration)했습니다. 이 템플릿은 기본값만 설정하며, 거버너는 각 사냥(hunt)마다 이를 재정의(override)했습니다. 이를 통해 불변의 하드 캡(immutable hard cap)을 제거하면서도 운영자 경험을 보존할 수 있었습니다.

트레이드오프(tradeoff)는 지연 시간(latency)의 증가였습니다. 모든 gRPC 호출마다 리더보드(leaderboard) 업데이트에 약 5ms가 추가되었습니다. 우리는 사냥 포드(hunt pod) 내에 스로틀 비율(throttle ratio)을 10초 동안 캐싱(caching)하고, Redis에 부하가 걸릴 때는 오래된 값(stale values)을 제공함으로써 이를 완화했습니다. 5ms의 지연 시간 급증은 플레이어들이 이전에 보고했던 30초간의 프리징(freeze) 현상과 비교하면 수용 가능한 수준이었습니다.

이후의 수치들이 말해준 것

Hunt-002를 위해 거버너 사이드카(governor sidecar)가 적용된 후, 수치들이 극적으로 변했습니다. 50,000명의 플레이어가 몰린 동일한 바이럴 급증(viral spike) 상황에서도, Redis 메모리 사용량은 95% 대신 82%에서 정점을 찍었으며, 거버너는 안정화되기 전 90초 동안 max_hunters를 28,000명까지 스로틀링(throttling)했습니다. Kafka 랙(lag)은 2,000 메시지를 초과하지 않았으며 3분 이내에 회복되었습니다. 플레이어가 보고한 프리징 현상은 사냥당 30회에서 2회 미만으로 감소했습니다. 5ms의 사이드카 지연 시간은 95퍼센타일(95th-percentile) 리더보드 업데이트 시간인 98ms 내에서는 눈에 띄지 않았습니다.

또한, 거버너가 커넥션 풀(connection pool)이 포화되기 전에 사냥꾼 비율을 스로틀링했기 때문에 Redis 커넥션 풀 사용량이 500에서 280으로 감소하는 것을 확인했습니다. OOM 킬(OOM kills)은 완전히 중단되었습니다. 운영자 가이드에는 이제 hunt-config.yml의 불변성에 대해 경고하고 있지만, 거버너 사이드카는 사실상의(de facto) 스케일링 레이어(scaling layer)가 되었습니다.

내가 다르게 했을 것이라면

나는 첫날부터 거버너 사이드카 없이 그 어떤 Veltrix 사냥도 배포하지 않았을 것입니다. hunt-config.yml 레이어는 연극적(theatrical)입니다. 설정 파일처럼 보이지만 실제로는 하드코딩된 서킷 브레이커(circuit breaker)처럼 동작합니다. 다른 증거가 나타날 때까지는 이를 레거시(legacy)로 취급하십시오.

다음번에는 Helm 차트(Helm chart)에서 governor를 필수 전제 조건(pre-requisite)으로 강제하고, 사이드카(sidecar)가 누락된 경우 설치를 실패하도록 처리하겠습니다. 현재의 Veltrix operator는 governor를 건너뛰는 것을 허용하고 있으며, 이는 모든 신입 엔지니어가 우리의 실수를 반복할 수 있음을 의미합니다. 우리는 화려한 확장(scaling) 기능이 탄력적인 아키텍처(resilient architecture)와는 같지 않다는 것을 뼈아픈 경험을 통해 배웠습니다.

AI 자동 생성 콘텐츠

본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0