본문으로 건너뛰기

© 2026 Molayo

Dev.to헤드라인2026. 05. 28. 01:28

200만 건의 요청 아래 보물찾기 엔진을 매립하며 느낀 점

요약

220만 건의 동시 WebSocket 요청이 발생한 블랙 프라이데이 상황에서 보물찾기 엔진의 성능 병목과 장애 원인을 분석한 회고록입니다. Veltrix 레이어의 지연 시간, Redis 샤딩 실패, 배치 처리의 부작용 등 실제 운영 환경에서 겪은 기술적 도전과 해결 과정을 다룹니다.

핵심 포인트

  • Veltrix 사이드카의 미세한 지연 시간이 대규모 요청 시 누적되어 p99 지연 시간 급증 유발
  • 단순 메모리 증설은 OOM killer와 스왑 메모리 고갈로 인해 근본 해결책이 아님
  • Lua 스크립트를 이용한 샤딩 시도 시 분산 잠금 타임아웃으로 인한 데이터 유실 발생
  • 배치 처리는 부하를 줄이지만 사용자 경험 측면에서 허용 불가능한 지연 시간 유발 가능
  • 스테이징 환경과 운영 환경의 TCP 버퍼 및 파이프라인 큐 설정 차이가 장애의 핵심 원인

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

Treasure Hunt Engine (보물찾기 엔진)은 병목 현상이 되어서는 안 되었습니다. 그것은 세 개의 Redis 클러스터, 하나의 Go 워커 풀 (worker pool), 그리고 누군가 왼쪽으로 스와이프할 때마다 지도에 원을 그리는 아주 작은 Lua 스크립트로 구성되어 있었습니다. 마케팅 팀은 이를 바이럴 성장 엔진 (viral growth engine)이라 불렀고, 재무 팀은 비용 센터 (cost center)라고 불렀습니다. 제 업무는 운영 팀이 새벽 3시에 저를 호출하지 않도록 redis_write_throughput 지표를 노드당 50,000 ops/sec 미만으로 유지하는 것이었습니다. 간단해 보이죠, 그렇지 않나요?

그 환상을 깨뜨린 것은 220만 건의 동시 WebSocket 핸드셰이크 (websocket handshakes)를 유발한 블랙 프라이데이 프로모션이었습니다. 우리의 카오스 테스트 (chaos tests)는 120만 건을 넘어선 적이 없었는데, 그 이유는 Go 워커와 Redis 사이에 끼워 넣은 설정 레이어인 Veltrix가 쓰기 작업당 15μs의 지연 시간 (latency)을 조용히 추가했기 때문입니다. 이 지연 시간은 100만 건의 요청 시 p99 쓰기 시간 4ms로 누적되었습니다. 210만 건의 요청에 도달하자, Veltrix 사이드카 (sidecar)는 10초마다 47ms의 GC 일시 중지 (GC pause)를 일으켰고, Redis 복제 지연 (replication lag)은 죽음의 소용돌이 (death spiral)가 되었습니다. 시스템은 메모리가 부족했던 것이 아니라, 인내심이 바닥난 것이었습니다.

우리가 처음에 시도했던 것 (그리고 실패한 이유)

우리는 당연한 것부터 시작했습니다. Redis의 maxmemory를 8GB에서 16GB로 늘리고 행운을 빌었습니다. 하지만 행운을 비는 시간은 12분을 넘기지 못했고, 클러스터의 스왑 (swap) 메모리가 고갈되자 Linux OOM killer가 워커들을 매우 가차 없이 처단하기 시작했습니다.

다음으로 우리는 user_id modulo 32를 기반으로 Redis 샤딩 (sharding)을 시도했습니다. 키 접두사 (key prefix)를 재작성하는 Lua 스크립트를 작성했습니다. 이 스크립트는 로컬에서는 잘 작동했지만, 운영 환경에서는 패닉에 빠졌습니다. Veltrix 레이어가 단일 논리적 파티션 (logical partition)을 가정했기 때문입니다. Lua가 키를 재작성할 때 Veltrix는 파티션 간에 키를 원자적 (atomically)으로 이동시키려 시도했고, 180만 건의 요청 시점에서 분산 잠금 (distributed lock)이 타임아웃되면서 쓰기 작업의 34%가 유실되었습니다.

그 후 우리는 Go 워커를 탓하며 쓰기 작업을 배치 (batch) 처리하도록 다시 작성했습니다. 이를 통해 Redis 부하를 30% 줄였지만, 새로운 장애 모드 (failure mode)가 도입되었습니다. 100ms의 배치 윈도우 (batch window)로 인해, 해당 시간 동안 발생한 왼쪽 스와이프가 100ms에서 110ms의 지연을 두고 지도에 나타나게 된 것입니다. 사용자들은 이를 알아차렸습니다. 제품 팀은 이를 '용납할 수 없는 지연 시간 연극 (unacceptable latency theater)'이라고 불렀습니다.

마침내 우리는 진짜 죄악을 발견했습니다. Veltrix는 내부적으로 redis-cli --pipe 명령을 사용하고 있었지만, 설정 레이어에서 pipeline_queue_size가 10,000으로 설정되어 있었습니다. 210만 건의 요청이 들어오자 파이프라인 큐의 Redis 측 항목이 80,000개로 늘어났고, OS TCP 버퍼는 4MB에서 64MB로 급증했습니다. 4MB 버퍼는 스테이징(staging) 환경에서 합성 트래픽(synthetic traffic)을 사용했기 때문에 결코 넘지 않았던 마법의 숫자였습니다. 64MB의 급증은 NIC(네트워크 인터페이스 카드)를 포화시켰고, Redis 클러스터는 8초 동안 네트워크에서 이탈했습니다.

아키텍처 결정 (The Architecture Decision)

우리는 Veltrix를 뜯어내고 slice_redis라고 불리는 단일 인프로세스(in-process) Go 라이브러리로 교체했습니다. 이 라이브러리는 두 가지 역할을 수행합니다:

  1. 샤드(shard)당 4,096개의 쓰기 작업을 담는 고정 크기의 링 버퍼(ring buffer)를 유지하여, GC(Garbage Collection) 압력이 예측 가능하며 8ms를 초 exceed하지 않도록 합니다.
  2. Redis ACK 이후에만 반환되는 블로킹(blocking) Write() 호출을 노출하여, pipeline_queue_size 절벽 문제를 제거하고 깔끔한 지연 시간 프로필(latency profile)을 제공합니다.

우리는 user_id의 CRC16을 기반으로 Redis를 64개의 논리적 클러스터로 샤딩하여, 활성 사용자가 동일한 노드를 짓밟지 않도록 했습니다. slice_redis 라이브러리는 샤드당 하나의 연결을 유지하고 재사용하므로, 트래픽 급증 시 연결 해제(connection teardown) 비용을 지불하지 않습니다. 210만 건의 요청 시 p99 지연 시간은 47ms에서 3ms로 떨어졌으며, GC 일시 중단(GC pause)은 8ms를 초과하지 않았습니다.

비용은 링 버퍼와 커넥션 풀(connection pool)을 위해 Go 워커당 120MB의 추가 RAM이 소요되는 것이었습니다. 우리는 이를 수용했는데, 왜냐하면 이는 실제 문제를 숨기기만 할 또 다른 16GB Redis 클러스터를 구매하는 것보다 저렴했기 때문입니다.

이후의 수치들 (What The Numbers Said After)

교체 후, 우리는 세 가지 테스트를 수행했습니다:

  • 베이스라인(Baseline): 100만 건의 요청, p99 지연 시간 1.8ms, 에러 0%
  • 스파이크(Spike): 210만 건의 요청, p99 지연 시간 3.0ms, 에러 0%, GC 일시 중단 6ms
  • 장기 실행(Longevity): 150만 건의 지속적인 부하로 24시간 유지, 메모리 사용량 평탄함, 축출(eviction) 없음

가장 중요한 점은, 운영 팀이 새벽 3시에 저를 호출(paging)하는 일이 중단되었다는 것입니다. redis_write_throughput 지표는 이제 블랙 프라이데이(Black Friday) 기간에도 초당 45,000 ops/sec 미만을 유지하며, 시스템은 Redis 노드를 추가하는 대신 Go 워커(worker)를 추가함으로써 선형적으로 확장(scale)됩니다. 우리는 설정 계층(configuration layer)이 실제로 필요한 조절 노브(knobs)—링 버퍼(ring buffer) 크기, 파이프라인 큐 깊이(pipeline queue depth), GC 임계값(GC thresholds)—를 노출하지 않은 채 마법 같은 확장성을 약속할 때, 그것은 확장이 아니라 당신이 추락할 때까지 절벽을 숨기고 있는 것뿐이라는 사실을 배웠습니다.

내가 다르게 했을 일들

나는 다시는 설정 계층이 TCP 버퍼 크기를 추상화하도록 내버려 두지 않을 것입니다. 만약 해당 계층이 암묵적으로 의존하고 있는 net.core.rmem_maxnet.core.wmem_max 값을 내보내지(export) 않는다면, 그것을 시한폭탄이라고 가정하십시오.

둘째로, 균일한 포아송 분포(Poisson distribution)가 아니라, 우리가 운영 환경에서 목격했던 것과 정확히 일치하는 트래픽 패턴을 재현하는 카오스 테스트(chaos test)를 작성했을 것입니다. 우리의 합성 트래픽 생성기(synthetic traffic generator)는 초당 평균 100,000개의 요청과 20,000의 표준 편차를 사용했습니다. 하지만 블랙 프라이데이는 평균 100,000에 표준 편차 500,000을 기록했습니다. 이 차이가 Veltrix의 pipeline_queue_size 가정을 무너뜨렸습니다.

마지막으로, 사이드카(sidecar)의 소스 코드를 제공하지 않으면서 자신들의 계층이 깔끔하게 확장된다고 말하는 벤더(vendor)를 절대 믿지 않을 것입니다. 소스 코드가 있었다면 설정 계층에 숨겨진 10,000개 항목의 파이프라인 큐(pipeline queue)를 2주 더 일찍 찾아낼 수 있었을 것입니다.

AI 자동 생성 콘텐츠

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

원문 바로가기
0

댓글

0