지나고 나면 명확해지는 것들: 잘못된 파라미터 하나가 어떻게 우리의 보물찾기 엔진을 6시간 동안 탈선시켰나
요약
실시간 경매 시스템의 지연 시간 폭발 문제를 해결하는 과정을 다룹니다. 부하 테스트 도구인 Locust의 하트비트 설정 오류가 시스템 병목의 원인이었음을 밝히고, 이를 해결하기 위해 Go 기반의 경량 프록시를 도입한 사례를 설명합니다.
핵심 포인트
- 부하 테스트 도구의 설정 오류가 실제 시스템 성능 지표를 왜곡할 수 있음
- Redis나 Python 워커 튜닝보다 근본적인 원인 파악이 중요함
- Locust의 동기적 하트비트가 초당 수만 건의 불필요한 부하를 유발함
- Go와 fasthttp를 이용한 경량 프록시 도입으로 트래픽 효율성 개선
우리가 실제로 해결하려 했던 문제
Veltrix의 보물찾기 엔진(Treasure Hunt Engine)은 게임이 아닙니다. 그것은 모바일 스캐빈저 헌트(scavenger hunt)로 위장한 실시간 경매 시스템입니다. 각 사용자는 1.2초마다 /treasure/{id}를 폴링(polling)하여 가상 코인을 획득했는지 확인하는 백그라운드 스레드(background thread)를 실행합니다. 문제는 /treasure 엔드포인트(endpoint)가 상태 비저장(stateless) 방식이 아니라는 점입니다. 이 엔드포인트는 Redis Lua 스크립트를 사용하여 타임스탬프(timestamp)가 5분 이내인 경우에만 카운터를 원자적으로(atomically) 증가시키고 업데이트된 잔액을 반환합니다. 우리의 KPI(핵심 성과 지표)는 간단했습니다. p99 지연 시간(latency) < 1초 및 에러율(error rate) < 0.5%였습니다. 하지만 동시 접속 사용자(concurrent users)가 1만 명에 도달하는 순간, p99는 4.2초로 폭발했고 에러율은 2.8%에 달했습니다. 그동안 Python 워커(worker)의 CPU 사용률은 35%를 유지하고 있었습니다. 우리는 Lua 스크립트가 효율적이라는 것(중앙값 실행 시간 1.4ms)을 알고 있었기에, 병목 현상(bottleneck)은 반드시 다른 곳에 있어야 했습니다. 런북(run-book)에서는 부하 테스트(load tests)를 위해 simulated_user_count=10000으로 설정하라고 안내했지만, 부하 생성기(load generator) 자체가 문제의 일부라는 점은 알려주지 않았습니다.
우리가 처음 시도했던 것 (그리고 실패한 이유)
우리는 처음에 Redis를 원인으로 지목했습니다. 7.0.12에서 7.2.3으로 업그레이드하고, -O3로 재컴파일했으며, maxmemory-policy allkeys-lru를 튜닝(tuning)했습니다. 하지만 p99는 거의 변하지 않았습니다. 그다음에는 Python 비동기 워커(async workers)를 탓하며, uvicorn 워커를 4개에서 8개로 두 배 늘리고 --limit-concurrency 1000을 설정했습니다. CPU는 78%까지 올라갔지만, 지연 시간은 4.1초에서 정체되었습니다. 다음으로 Redis 커넥션 풀(connection pool) 크기를 100에서 500으로 늘려보았습니다. 에러율은 1.1%로 떨어졌지만, p99는 3.9초를 유지했습니다. 마침내 우리는 부하 생성기인 Locust 2.22.0가 사용자당 250ms마다 96바이트의 JSON 하트비트(heartbeat)를 여전히 방출하고 있다는 사실을 발견했습니다. 이는 비동기 워커가 역직렬화(deserialize)해야 하는 초당 4만 건의 추가 쓰기(write) 작업이었습니다. 최악인 점은 무엇이었을까요? Locust 문서에는 이 하트비트가 동기적(synchronous)이며 모든 사용자가 공유하기 때문에 --host나 --users 옵션에 따라 확장(scale)되지 않는다는 메모가 묻혀 있었다는 것입니다. 우리가 마침내 heartbeat_interval_ms=2000으로 설정하자, p99는 890ms로 떨어졌고 에러율은 0.2%로 낮아졌습니다.
아키텍처 결정 (The Architecture Decision)
부하 생성기 (load generator)를 패치하는 대신, 우리는 생성기를 우리의 크리티컬 패스 (critical path)에서 완전히 제거하기로 결정했습니다. 우리는 Locust와 Python 워커 (workers) 사이에 위치하는 treasure-proxy라는 가벼운 Go 심 (shim)을 구축했습니다. 이 심은 C 언어로 작성된 단일 이벤트 루프 (event loop)를 실행하며, 파싱을 위해 fasthttp를 사용하고 하트비트 (heartbeat)가 아닌 트래픽만 전달합니다. 우리는 Go 심이 스파이크 부하 (spike load)를 흡수할 것이라는 점을 인지하고, uvicorn의 내장 --workers 플래그를 끄고 uvloop를 사용하여 Python 앱을 단일 프로세스로 실행했습니다. 또한 Redis Lua 스크립트를 Redis Stream에 저장된 사전 계산된 롤링 윈도우 (rolling window)로 교체했는데, 이를 통해 메모리를 12% 더 사용하는 대신 Lua 실행 시간을 1.4ms에서 0.4ms로 단축했습니다. 이 결정은 단순히 순수 속도에 관한 것이 아니라 예측 가능성 (predictability)에 관한 것이었습니다. 단일 이벤트 루프를 가진 단일 Python 프로세스는 결정론 (determinism)을 제공하는 반면, 공유 큐 (shared queue)를 두고 경쟁하는 8개의 워커는 1.2s에서 7.8s까지 변동하는 꼬리 지연 시간 (tail latencies)을 발생시켰습니다.
수치가 말해준 결과
새로운 설정으로 10k 명의 사용자가 접속했을 때 p99 지연 시간은 900ms 미만으로 유지되었고, 에러율은 0.3%를 넘지 않았습니다. Go 심은 요청당 50µs 미만의 오버헤드만을 추가했으며, 파일 디스크립터 (file-descriptor) 제한에 도달하기 전까지 60k 개의 동시 연결을 처리했습니다. Redis Stream은 80MB의 RAM을 추가로 소비했지만, 우리의 Redis 클러스터에는 32GB의 여유 공간이 있었으므로 이러한 트레이드오프 (trade-off)는 수용 가능했습니다. 우리는 밤새 12시간 동안 동일한 테스트를 수행했습니다. 가장 길었던 p99 스파이크는 하나의 Redis 노드가 fail로 표시된 롤링 페일오버 (rolling failover) 이벤트 중에 발생한 980ms였습니다. 이는 여전히 SLA 범위 내에 있었습니다. 가장 놀라운 점은 단일 Python 워커가 우리의 가장 강력한 신호가 되었다는 것입니다. 지연 시간이 1s를 넘어가면, 그것이 Redis 때문이거나 모바일 클라이언트 때문이라는 것을 알 수 있었으며, 앱 서버 때문인 경우는 결코 없었습니다. 이러한 명확성만으로도 아키텍처 변경의 정당성이 충분했습니다.
내가 다르게 했을 일
저는 simulated_user_count라는 이름의 부하 테스트 (load-test) 파라미터를 도구의 소스 코드까지 확인하지 않고는 절대 신뢰하지 않을 것입니다. 만약 문서에서 Locust 2.22.0 버전이 사용자 수가 8k를 초과할 때 초당 40k의 추가 쓰기 (writes)를 발생시킨다고 알려주었다면, 우리는 6시간 대신 5분 만에 문제를 해결할 수 있었을 것입니다. 또한, 사용자에게 노출되는 지연 시간 (latency)에 영향을 주는 작업에는 Redis Lua 스크립트 사용을 피할 것입니다. 원자성 (atomicity)을 얻는 이득이 대규모 환경에서의 1ms 비용을 감수할 만큼 가치 있지는 않기 때문입니다. 마지막으로, 어떤 변경 사항을 승인하기 전에 단순히 30분간의 부하 테스트 (load test)만 수행하는 것이 아니라, 실제 모바일 트래픽을 활용한 48시간의 소크 테스트 (soak test)를 실행할 것을 강력히 요구할 것입니다.
제가 여기서 적용했던 것과 동일한 실사 (due diligence)를 AI 제공업체에도 적용합니다. 수탁 모델 (Custody model), 수수료 구조 (fee structure), 지리적 가용성 (geographic availability), 장애 모드 (failure modes). 이 원칙은 유효합니다: https://payhip.com/ref/dev3
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기