잡담

Scan API 50->500 VUs, RPM 400+ 디벨롭까지의 수기, 그리고 잡담과 사견

mango_fr 2025. 12. 29. 05:08
처리량과 함께 폭증한 GPT 토큰 비용. 당연하게도 클로드 토큰 비용은 또 별도다. Sry to ma..ma..

 
처음엔 Task용 Queue로 시작했던 Scan API가 SSE면서 Async와 큐잉, Event가 공존하는 "FOR REAL" LLM API가 되어있었다. 8-10초 딜레이의 GPT API에 두 번 묶여, 각 서버 간 내부 통신을 gRPC 동기로 빼도 동시처리량 80-100을 간당간당하게 버티던 Scan API가 어느덧 VU 500을 돌파해 RPM 400의 속도로 600 테스트를 앞두게 되니, 이코에코의 아버지로서 정말 감개가 무량하다. (친부는 Opus일 수도 있다.) 
기존 Scan API는 문자 그대로 'VU 50도 간당간당했다.' 클러스터의 베이스라인(안좋게 말하면 글로벌 초크)인 ext-authz는 테스트 기준 동시접속자 2500명에 RPS 1200+를 뽑는 상황에서, AI API가 MQ를 구성했음에도 동시접속자 50은 고사하고 RPS가 7..이  나오던 순간 절망감은 정말 말로 표현하기 힘들다.

개발하다가 알게된 사실이지만 에이전트 앱, 심지어 OpenAI조차도 추론시간 때문에 RPS가 아닌 RPM으로 한계 요청을 측정하더라.(정말 기가 막힐 노릇이다.) LLM API를 개발하려면 배포 환경은 마련해두기를 권장한다. 로컬 환경에서의 단일 E2E와 배포는 정말.. 천지차이다. 일반적인 API 개발에서도 마찬가지지만 LLM API의 경우는 더 그런 듯 싶다.
이건 아무런 부하없이, 단일 호출이 5-10초, 심지어 Scan API의 파이프라인은 LLM 모델이 두 번 묶여서 10-20초 남짓이다. Scan API의 경우 절차별 시퀀셜을 보장해야 하기 때문에 LangChain이 없다면 서버측에서 state를 잡아줘야 한다. 그렇지 않으면 통으로 20초를 처리하는 방법 밖에 없다.
그 부하가 크고 느리다는 스토리지(RDB, Persistence Layer)도 부하 장애가 터지면 요청당 -1s 내, 평균 ms 단위인 걸 감안하면 정말.. 서버로 띄우기 경악스러운 부하다. 이걸 대규모 트래픽으로 처리하는 OpenAI와 기타 모델 개발사들에게 경의를 표한다.
잠시 'Celery Chain으로 풀어서 그런가..?' 라는 생각도 했지만 부하 면에선 단일 호출이 서버에서 관리하기 용이하다. 시퀀셜이 생기는 순간, state를 논리적으로 피할 방안이 없다. 그렇게 되면 안그래도 무거운 API가 동기로 묶이는 길이만 길어지게 된다.. 만약 품이 남는다면 LangChain으로 전환해 state와 시퀀셜 관리 부담을 줄이거나, 프롬프트를 조립해 단일 호출로 빼도록 AI 파이프라인을 고치는 방향으로 디벨롭하지 않을까 싶다.

그럼 LangChain 없이 시퀀스가 존재하는 파이프라인을 어떻게 비동기 분산 환경에서 풀었나를 확인해보자. 사실 이 과정은 LLM 파이프라인의 모든 절차를 나눠 (이젠 전통적이라 할 수 있는)분산 환경에서 풀려는 시도라 권장하지 않는다는 점을 먼저 알린다. 웬만하면 하나의 절차로 묶거나, 시퀀셜을 원한다면 적어도 서버에선 LangChain이 적용된 파이프라인을 사용하자. 그렇지 않으면 동시 접속을 소화하기 위해 정말 매끄럽지 않은 하드 엔지니어링 과정이 수반되게 된다. 안그런 개발이 어딨겠냐 싶지만 말이다. 그래도 추상화로 풀려는 시도가 보일 거라 생각한다.
1-2주 전 쯤 Event Driven을 선언하긴 했지만 이걸 Redis Pub/Sub + Streams(+State KV)으로 풀 줄은 몰랐다. 만들면서도 Opus와 매번 복기했던 사안이라 그런진 몰라도 구현체 자체가 Kafka에서 모티브를 가져온 부분이 많다.
이코에코의 Event Bus(혹은 Event Relay Layer)는 Kafka의 로깅 기반 이벤트 스트림 파츠를 경량 Redis 스택으로 재조립한 구현체에 가깝다. 그만큼 가볍고 빠르지만 보존성이 높진 않다. Streams 전환 초기에 마주쳤던 문제로는 event들이 클라이언트로 전달되던 중간에 유실되어 보장이 되지 않는 문제였다. 예를 들면 vision-[rule, answer state 유실]->done과 같은 형태다.
최종 JSON 응답은 잘 생성됐으나.. 이렇게 되면 SSE을 택한 이유 중 하나인 실시간 UX가 무너지는 것이었기에 그렇게 반기지 않았다.
비동기 분산 환경에서 ‘고부하에서도 클라이언트에게 Event가 실시간으로 온전히 전달되길 바라는 보장’은 바람직하지 못한 접근인 건 인지하고 있었다. Eventual Consistency로 강결합은 진작에 포기하고, 논리적인 보장만으로 유연하고 빠른 동작을 선택한 입장에서 '일관되며, 시퀀셜한 state 스트림'은 아이러니한 선택지였다.

 
그럼에도 HA가 보장되는 비동기 분산 환경에서, 연속적이면서 시퀀셜한 이벤트들이 요청 파드에서 출발해 목적지 파드로 올바르게 인도되도록 하기 위해 라우팅까지 손을 댔었다. 이 과정에서 SSE-Gateway가 나왔으니 무의미하진 않다고 여긴다.
파드가 스케일 아웃했을 때 올바른 Dest Pod를 선정하기 위한 방법론의 일환으로 Consistency Hashing까지 검토하긴 했지만, 아무리 Opus와 함께라 해도 Worker에, 라이브러리 없이, 순수 구현으로 Consistency Hash Ring을 구성하는 건 잘못 짚어도 너무 잘못 짚은 방향이라 여겼다. 이 시점엔 ‘RabbitMQ와 Celery를 모두 거두고 Kafka로 전면 마이그레이션을 해야하나..‘ 싶은 생각도 들었다. (농담이다.)
Opus와 작업하면 이런 점이 힘들긴 하다. 구현체를 만들기 전에 근원 기술을 먼저 서칭한 뒤 나아가도록 하는 워크플로우를 유지하고 있어서 코드와 Foundations이 함께 쌓이는 건 바람직하나, 방향을 잘못 짚으면 "Python으로 Consistency Hashing을 구현하겠습니다-!"로 나아가기 때문에 정말 골치가 아파진다.
결국 Opus와의 문답과 자료조사 끝에 '단일 파드 간의 라우팅은 포기하고 이걸 모두 수신하는 이벤트 모듈을 별도로 둔 뒤, 적합한 Consumer 그룹으로 나누어 Fanout을 수행하자.'는 방법이 도출됐다. 중간에 많은 과정이 있었지만 생략하겠다. 나도 모든 걸 다 기억하진 않는다. 포스팅에 개발 로그를 기록해뒀으니 참고하면 좋겠다.
다음은 Fanout을 할 주체를 정해야 했는데 이건 짧은 문답으로 Redis Pub/Sub로 도출됐다. 이미 클러스터에 Redis Operator가 존재해 배포 부담이 극도로 낮은 상태였기에 마다할 필요가 없었다. (CR 파일 하나면 된다. 만약 Redis Pub/Sub이 아니라면 별도의 이벤트 Fan-out 계층을 "직접" 구현하거나, NATS 혹은 Kafka를 선택해야 한다.)
여기서 Redis Pub/Sub이 지닌 한계가 부상하는데 Redis Pub/Sub은 발행자에서 구독자로 이벤트를 Fanout(분산 확산)하는 작업만 수행하지 별도의 안전장치를 두지 않는다. 때문에 State 간 발행 시점이 꼬여 Race Condition이 발생하면 중간 이벤트가 유실되는 문제가 발생한다. 이 부분을 보완한게 State KV와 Redis Streams다. 클라이언트와 서버 간 발생한 이벤트들의 시퀀스와 멱등성을 보장하는 역할은 State, 복구와 조회를 위한 저장은 Streams가 맡았다. 두 피쳐만으로는 원하는 job_id에 state를 찍고, Consumer Group을 나누는 건 불가능해서 브릿지 역할을 수행할 별도의 구현체는 필요했다.

# Event Router의 주요 구현체, event loop로 구성된 걸 확인할 수 있다.
while not self._shutdown:
    events = await self._redis.xreadgroup(
        groupname=self._consumer_group,
        consumername=self._consumer_name,
        streams=self._streams,  # {"scan:events:0": ">", ...}
        count=self._count,
        block=self._block_ms,
    )

 
그게 Event Router라 명명한 컴포넌트다. 실제로 라우팅을 수행하지 않지만 논리적인 라우팅을 보장하기에 이렇게 지었다.
Event Router는 이벤트 루프를 돌며 Redis Streams에서 수신되는 이벤트(메시지)를 Consumer Group(XREADGROUP)별로 읽어온 다음, Redis의 published 키를 활용해 멱등성을 체크한다. 이미 published된 이벤트라면 해당 이벤트 처리를 스킵하고, 그렇지 않으면 다음 스텝으로 넘긴다.
다음 스텝은 1. 스냅샷 역할을 하는 State KV 갱신과 2. Publisher가 되어 Subscriber인 SSE Gateway에게 이벤트 전달로 나뉜다. Router가 Redis Client로 Pub/Sub을 호출하면, 실제 Event Fanout은 Redis Pub/Sub이 수행한다. 위 작업들을 마치면 XACK로 Redis Streams에 처리 완료를 알리고, Streams는 해당 이벤트를 펜딩에서 제외한다.
 

닭껍질 교자다. 별미로 좋다.

Event Router + Redis Streams + Redis Pub/Sub로 구성된 Event Bus가 생긴 후로는 그리 난항을 겪진 않았다. 코드 퀄리티가 의심스럽긴 해 리팩토링을 할 예정이지만.. 모듈 분리를 전제로 별도 도메인처럼 구현된 피쳐니, 하드코딩된 몇몇 값들만 constant나 별도의 데이터 스트럭쳐로 빼는 선에서 마무리될 듯 싶다. 리팩토링이 필요한 건 Event Router보다 오히려 공통 모듈이다. Auth Offloading 때 제거했던 공통 모듈이 슬금슬금 커지고 있다.
음.. 프론트엔드 UX와 비동기 SSE API를 연동시켜야 하는데 한동안 에너지를 좀 많이 쏟아서 그런지 상당히 피로하다.
졸업 과제, 사이드 프로젝트, 라쿠텐 CNS와 Object Storage, 이코에코까지 이런저런 개발을 하면서도 쓰는 언어는 그닥 가리지 않는 편이지만(웬만하면 강타입, 로우레벨이 좋다.) '분산 시스템'이라는 카테고리를 벗어나지는 않으려고 노력 중인데 이게 말처럼 쉬운 일은 아니다. 여러모로 주니어가 다루기엔 피로도가 높은 작업은 맞다. 무엇보다 확신을 가지고 행동하기 어렵다. 비동기 분산 서버는 기저 패턴과 작동 방식이 강한 원자성을 보장하지 않으니 더 그렇다. 때문에 배포 클러스터에서의 테스트와 시스템에서 뽑아져 나오는 관측 데이터, 디버깅을 Opus와 함께 나눠 소화하며 디벨롭을 하는게 가장 신뢰도가 높은 방식이라고 생각한다. 만들면서 마주치는 개념들은 익히며 가는데 아후..참.. 왜 내 돈을 쓰면서 일을 만들어서 하고 있는지는 잘 모르겠다. 요즘 유독 현타가 많이 온다. 아무튼 벌써 한주도 다가고 월요일 새벽이다. 해피 월요일되길 바란다.