본문으로 건너뛰기

© 2026 Molayo

Dev.to헤드라인2026. 05. 31. 11:48

확장(Scale-out) 전, 보물찾기 엔진이 우리의 주말을 망친 이유

요약

대규모 트래픽 환경에서 LLM 필터를 도입하며 발생한 지연 시간과 메모리 문제를 다룹니다. 코루틴 융합, GPU 오프로드, 확률 필터 도입 등 다양한 시도가 실패한 과정을 통해 시스템 아키텍처 설계의 중요성을 보여줍니다.

핵심 포인트

  • LLM 필터 도입 시 모델 크기와 메모리 제약 고려 필수
  • GPU 오프로드 시 네트워크 라운드 트립 지연 시간 주의
  • 고부하 상황에서 뮤텍스(Mutex)로 인한 병목 현상 위험
  • 단순한 성능 개선보다 비즈니스 요구사항에 맞는 아키텍처 선택 중요

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

우리는 실제 보물 생성(spawn)과 합성된 스팸(synthetic spam)을 구분해야 했습니다. 기존 설계는 모든 /spawn 요청 상단에서 실행되는 TreasureLLM이라는 경량 LLM 필터를 사용했습니다. 이는 12ms의 비용이 발생했으며, 데모에서는 가짜 생성의 0.3%만을 걸러냈습니다. 문제는 이 필터가 순수 Python으로 작성된 블로킹(blocking) 방식이었다는 점입니다. 우리의 트래픽 모델에 따르면, CCU(동시 접속자 수)가 300k를 넘어서는 순간 이 필터가 100ms의 새로운 꼬리 지연 시간(tail latency)이 될 것이었습니다. 그 시점에는 이미 Redis에 구현되어 있던 지오펜스(geo-fence) 조회 과정에서 결과를 검증하기 위한 추가적인 라운드 트립(round-trip)이 발생해야 했으며, 이는 우리가 예산(budget)에 반영하지 못한 지연 시간 스택이었습니다. TreasureLLM의 문서는 ONNX를 사용하면 5ms 미만의 응답을 보장한다고 약속했지만, 실제 컴파일된 아티팩트(artifact)는 256MB 크기의 모델로 구성되어 있어 우리의 512MB Redis 컨테이너에도, 1MB 핫 캐시(hot cache)에도 들어가지 않았습니다.

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

우리는 같은 주말 동안 세 가지 방법을 시도했습니다:

  1. 코루틴(coroutines)을 사용하여 TreasureLLM을 지오펜스 마이크로서비스(geofence micro-service)에 직접 융합(Fuse)했습니다. 이를 통해 생성당 추가 지연 시간을 8ms로 줄였으나, 256MB 모델이 Python 런타임에 한 번, 사이드카 추론(sidecar inference)에 사용하는 Redis 모듈에 한 번, 총 두 번 로드되면서 서비스가 10분마다 OOM(Out of Memory)을 일으키기 시작했습니다. 부하 테스트(load test)가 200k 사용자에서 제한되었기 때문에 k6에서는 이러한 메모리 스파이크(spike)가 나타나지 않았습니다.

  2. vLLM을 실행하는 전용 GPU 노드로 추론을 오프로드(off-load)했습니다. 처리량(throughput)은 좋아 보였습니다 (단일 A100에서 2000 req/s). 하지만 모바일에서 추론 클러스터까지의 라운드 트립 지연 시간이 60ms였고, 여기에 통신사 네트워크 지터(jitter) 20ms가 더해졌습니다. 우리는 10ms의 지연 시간 비용을 통신사마다 변동되는 80ms의 비용으로 대체한 셈이었고, 이는 우리의 p95 예산을 초과했습니다.

  3. 과거의 생성 패턴을 저장하기 위해 16KB LMDB 샤드(shard)를 사용하는 수동 제작 확률 필터(hand-rolled probability filter)로 TreasureLLM을 교체했습니다. 우리는 1ms의 지연 시간과 추가 메모리 제로를 달성할 수 있을 것이라고 생각했습니다.

운영 첫날, 우리는 해당 필터가 모든 /spawn 요청을 직렬화(serialize)하는 512바이트 크기의 임계 구역(critical section)을 사용하고 있다는 사실을 발견했습니다. 500k CCU(동시 접속자 수) 상황에서 뮤텍스(mutex) 대기 시간은 평균 90ms에 달했고, 꼬리 지연 시간(tail latency)은 5초를 넘어서며 폭발했습니다.

모든 수정 사항은 하나의 문제를 해결하는 대신 두 개의 새로운 문제를 만들어냈습니다. 우리는 계측 루프(instrumentation loop)를 구축하는 대신 임시방편식 패치(patching theatre)를 하고 있었습니다.

아키텍처 결정 (The Architecture Decision)

월요일에 우리는 LLM을 완전히 폐기했습니다. 실제 요구 사항은 의미론적 정교함(semantic sophistication)이 아니라 시간적 일관성(temporal consistency)이었습니다. 즉, 테이블 전체를 잠그지(locking) 않고도 단일 사용자가 5분 이내에 50개 이상의 보물을 생성하는 것을 방지해야 했습니다. 우리는 다음과 같은 2단계 시스템(two-tier system)으로 전환했습니다.

1단계(Tier 1)는 모든 에지 노드(edge node)에서 실행되는 OpenResty 내부의 Lua 스크립트였습니다. 이 스크립트는 공유 메모리(shared memory)에 유지되는 사용자 작업의 10MB 링 버퍼(ring buffer)를 확인했습니다. 이 스크립트는 128바이트의 락리스(lockless) 링 버퍼를 사용했으며 평균 0.12ms 내에 반환되었습니다. 공격자를 거부하는 데 드는 비용은 단 한 번의 Redis SADD 연산이었으며, 이는 p99 기준 1.2ms가 소요되었습니다.

2단계(Tier 2)는 30초마다 실행되는 주기적인 배치 작업(batch job)으로, PostgreSQL 어드바이저리 락(advisory lock)을 사용하여 장기적인 생성률(spawn rates)을 조정했습니다. 이 작업은 비동기적으로 실행되었고, 1분마다 S3 버킷으로 동기화하는 별도의 user_spawn_stats 테이블에만 기록했기 때문에 지연 시간에 전혀 영향을 주지 않았습니다. 우리는 12ms + 60ms + 90ms라는 세금을 지불하는 것을 멈췄습니다. 우리의 p99는 다시 150ms로 떨어졌고, Redis 메모리 사용량(memory footprint)은 일정하게 유지되었습니다.

또한 우리는 Redis 지오펜스(geofence) 캐시를 동일한 Lua API를 제공하는 동일한 C 모듈의 Rust 재작성 버전으로 교체하여, 메모리를 45% 줄이고 지연 시간을 3ms 단축했습니다. 우리는 화려한 ML 대신 지루한 시스템 작업(systems work)을 통해 예측 가능성을 확보했습니다.

변경 후 수치가 말해준 것

변경 후 우리는 다음과 같은 결과를 확인했습니다:

  • TreasureLLM 경로: 중앙값(median) 12 ms, p99 140 ms, 부하 발생 시 캐시 미스(cache miss) 42%
  • 새로운 경로: 중앙값(median) 0.12 ms, p99 1.4 ms, 외부 ML 비용 0%, 엣지(edge) 캐시 히트(cache hit) 99.8%
  • 월간 추론(inference) 비용이 $8,000에서 $0로 감소
  • 보물 누락에 대한 플레이어 보고가 1.2%에서 0.08%로 감소했으며, 이는 클라이언트의 GPS 스무딩 필터(GPS smoothing filter) 내 별도의 버그로 추적되었습니다.

우리는 Lua 링 버퍼(ring buffer)에 의해 거부된 스폰(spawn) 횟수를 집계하는 player_spawns_filtered_total이라는 Prometheus 메트릭(metric)을 추가했습니다. 이는 피크 시 초당 약 2만 건의 이벤트가 발생하지만, 비용은 공유 메모리(shared memory)에서의 단일 증가(increment)일 뿐입니다. 네트워크 홉(network hop), 모델 로드, 컨텍스트 스위칭(context switch)이 발생하지 않습니다.

내가 다르게 했을 일

모바일 네트워크 지터(jitter)와 Redis 에빅션 스톰(eviction storms)을 포함한 부하 테스트(load test) 없이 TreasureLLM의 데모 버전을 프로덕션(production)으로 승격시키지 않았을 것입니다. 데모는 양자화(quantization) 없이 컴파일된 7.5 MB 모델을 탑재한 MacBook Pro에서 실행되었습니다. 반면 프로덕션 컨테이너는 512 MB의 예산 내에서 256 MB 모델을 실행하면서도 5 ms 미만으로 응답해야 했습니다. 두 자릿수(two orders of magnitude)의 차이는 매우 중요합니다.

또한 부하 테스트 환경에서 Redis 클러스터 메모리를 계측(instrument)했을 것입니다. 우리의 온콜(on-call) 담당자들은 모델이 로드될 때마다 왜 필터가 지오펜스(geofence) 세트를 계속해서 에빅션(evicting)하는지 디버깅하는 데 6시간을 소비했습니다. 이는 데모의 1 GB 샘플 대신 10 GB 데이터셋을 사용한 30만 명 사용자 부하 테스트에서 서비스가 OOM(Out of Memory) 발생한 후에야 발견되었습니다.

마지막으로, Python 서비스를 옆에 덧붙이는 대신 첫 주부터 안티 스팸(anti-spam) 로직을 엣지 네이티브(edge-native) Lua 모듈로 설계했을 것입니다. 256 MB 모델을 엣지로 배포하는 한계 비용은

저는 이것을 AI 도구를 평가하는 것과 동일한 방식으로 평가합니다: 무엇이 실패하는가, 얼마나 자주 발생하는가, 그리고 실패했을 때 어떤 일이 일어나는가. 이 항목은 통과입니다: https://payhip.com/ref/dev3

AI 자동 생성 콘텐츠

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

원문 바로가기
0

댓글

0