Code › system-design
LLM 서빙과 피드백 파이프라인 분리하기
Peak 2만 TPS 규모의 LLM 서빙 시스템에서 실시간 추론 경로와 비동기 피드백 수집 경로를 나눠 본 시스템 디자인 연습
최근 시스템 디자인 연습으로 글로벌 LLM 서빙 구조를 정리해 봤는데 조건은 다음과 같다. 사용자는 coding agent를 사용하는 개발자이고, 요청은 프롬프트 입력에서 LLM 호출, 응답 스트리밍, tool calling, 대화 이력 저장, 피드백 수집까지 이어지는 흐름이다.
규모는 Peak 2만 TPS 정도의 큰 트래픽으로 잡았다. 정확한 숫자 자체보다 중요한 것은, 이 정도 규모에서는 LLM 추론 경로와 로그/피드백 수집 경로를 같은 방식으로 다루기 어렵다는 점이다. 목표는 최초 토큰 반환 시간, 즉 TTFT(Time To First Token)를 1500ms 미만으로 유지하는 것이고, 동시에 대화 이력과 피드백 데이터를 학습 파이프라인으로 넘겨야 한다. 시스템 디자인 연습이니 세션과 캐시 레이어는 managed 서비스에 바로 맡기기보다, 직접 설계한다고 가정했다.
이 글에서는 GPU 대수 산정이나 모델별 token throughput 계산까지 깊게 들어가지는 않는다. 실제로는 모델 크기, 평균 prompt 길이, 평균 output token 수, GPU 종류, continuous batching 효율을 알아야 하는데, 여기서는 요청이 어떤 경로로 흘러야 하는지 그리고 큐를 어디에 두어야 하는지에 집중한다.
문제 정의
요구사항을 먼저 정리하면 다음과 같다.
- 글로벌 사용자가 LLM 기반 coding agent를 사용한다
- Peak TPS는 2만 정도로 가정한다
- 최초 토큰은 1500ms 미만에 반환되어야 한다
- 응답은 WebSocket 또는 SSE로 streaming된다
- 프롬프트, 응답, tool call, 피드백, 토큰 사용량을 수집해야 한다
- 수집된 데이터는 이후 학습 또는 분석 파이프라인으로 넘어간다
- 글로벌 세션과 대화 이력을 유지해야 한다
- 세션과 캐시 레이어는 직접 설계한다고 가정한다
처음에는 일반적인 대규모 트래픽 처리 문제처럼 생각했었다. 앞단에 API Gateway를 두고, 메인 API 서버를 거친 뒤, SQS에 요청을 넣고, LLM worker가 consume하는 구조가 바로 스쳐 지나갔는데, 생각해보니 LLM 서빙 시에 사용자가 기다리는 것은 나중에 완료되는 작업 결과가 아니라 지금 연결된 세션에서 흘러나오기 시작하는 첫 번째 토큰을 빠르게 서빙해줘야 하는 점을 간과했다.
메시지 큐를 어디에 둘 것인가
LLM 채팅 서빙에서 먼저 봐야 하는 것은 사용자가 열린 연결 위에서 응답 스트리밍을 기다리고 있다는 점이다. 프롬프트를 입력한 뒤 첫 토큰이 나오기 전까지 사용자는 시스템이 실제로 처리 중인지 알기 어렵고, 이 구간이 길어지면 전체 답변이 아직 완성되지 않았더라도 체감 지연이 커진다.
추론 요청 자체를 SQS 같은 비동기 큐에 넣으면 queueing latency가 사용자 응답 경로에 그대로 들어온다. 특히 WebSocket이나 SSE처럼 열린 연결 위로 token stream을 보내야 한다면, 메인 API 서버와 LLM worker 사이의 연결도 실시간 응답을 전제로 설계해야 한다. worker가 언젠가 메시지를 가져가 처리하는 구조는 백그라운드 작업에는 맞지만, 1500ms 안에 첫 토큰을 보여줘야 하는 서빙 경로에는 맞지 않는다.
그래서 이 시스템은 경로를 둘로 나누는 쪽이 더 자연스럽다.
- Fast Path: 사용자 요청 → 세션 조회 → LLM worker 호출 → token streaming
- Slow Path: 로그/피드백/토큰 사용량 → 큐 → consumer → 데이터 레이크
전체 구조
전체 구조를 단순화하면 다음과 같다.
그림에서는 그룹 박스를 억지로 나누기보다 전체 흐름을 한 번에 보이게 두었다. 파란색은 사용자가 첫 토큰을 기다리는 실시간 서빙 경로이고, 주황색은 로그와 피드백을 나중에 처리하는 비동기 경로이며, 보라색은 세션과 대화 이력을 위한 저장 경로다.
앞단의 Gateway layer는 인증, rate limiting, routing을 담당한다. 실제로는 API Gateway, ALB, CloudFront, WebSocket gateway 같은 선택지가 나뉠 수 있지만, 이 글에서는 외부 요청을 받아 메인 API 서버로 넘기는 진입 계층으로 묶어서 본다.
메인 API 서버는 ECS Fargate 같은 stateless 실행 환경에 둘 수 있다. 이 서버가 직접 모델 추론을 하지는 않는다. 대신 사용자의 연결을 유지하고, 세션과 대화 이력을 조회하고, LLM worker로 요청을 전달하며, worker가 생성하는 토큰을 사용자에게 다시 흘려보낸다.
LLM worker는 EC2 GPU cluster 같은 별도 계층으로 두는 편이 자연스럽다. vLLM이나 TensorRT-LLM 같은 서빙 엔진을 사용한다면 continuous batching이나 KV cache 관리가 중요해진다. 여기서 중요한 점은 worker 내부 최적화가 아무리 좋아도, 앞단에서 큐 대기 시간이 길어지면 TTFT 목표를 맞추기 어렵다는 것이다.
Fast Path
Fast Path는 사용자가 직접 체감하는 경로다.
User
→ Gateway
→ Main API Server
→ LLM Worker
→ Main API Server
→ User
이 경로에서 메인 API 서버가 해야 하는 일은 무겁지 않아야 한다. 인증과 권한 확인, rate limiting, 세션 조회, 요청 검증, streaming 연결 유지 정도에 집중한다. 대화 이력은 로컬 캐시에서 먼저 읽고, 캐시에 없다면 저장소에서 가져오는 방식이 필요하다.
LLM worker와의 통신은 요청마다 새로 무거운 연결을 만드는 방식보다, 내부 네트워크에서 유지되는 persistent connection이나 gRPC streaming 같은 방식을 생각할 수 있다. worker가 첫 토큰을 만들면 그 즉시 API 서버를 통해 사용자에게 전달한다.
여기서 TTFT를 맞추려면 여러 조건이 함께 맞아야 한다.
- Gateway와 API 서버에서 불필요한 대기 제거
- 세션 조회를 빠르게 처리
- LLM worker까지의 네트워크 hop 최소화
- GPU worker의 queue depth와 saturation 관리
- prompt 길이와 context 길이 제한
- worker 내부 continuous batching과 KV cache 관리
즉 1500ms 미만 TTFT는 서버를 많이 띄우는 것만으로 맞추기 어렵고, 요청이 사용자 응답 경로에서 어디에 머무는지 계속 봐야 한다.
Slow Path
Slow Path는 사용자가 기다릴 필요가 없는 데이터 수집 경로다.
Main API Server
→ SQS
→ Feedback Consumer
→ S3 Data Lake
여기에는 프롬프트, 생성된 응답, tool call 기록, 토큰 사용량, latency, 에러 정보, 사용자 피드백 같은 데이터가 들어간다. 이 데이터는 모델 품질 개선, 제품 분석, 비용 모니터링, 장애 분석에 필요하지만, 사용자에게 첫 토큰을 보내는 경로를 막으면 안 된다.
그래서 메인 API 서버는 응답 스트리밍을 처리하면서 별도로 로그 이벤트를 발행하고, 이 이벤트는 SQS에 들어간 뒤 consumer가 가져가 정규화해서 S3 같은 데이터 레이크에 저장한다.
이 경로에서는 queue depth가 중요한 지표가 된다. 큐가 밀려도 사용자의 실시간 응답은 계속되어야 하지만, 너무 오래 밀리면 피드백 데이터가 늦게 적재되고 장애 분석도 늦어진다. Slow Path라고 해서 중요하지 않은 경로는 아니지만, 이 지연이 사용자 요청의 TTFT를 직접 깨뜨리지 않도록 분리하는 것이 핵심이다.
세션과 대화 이력
LLM 서빙에서는 세션과 대화 이력도 설계의 큰 부분이다. coding agent는 단발성 질문만 처리하는 서비스가 아니라, 이전 프롬프트와 응답, tool call 결과, 현재 작업 중인 컨텍스트를 이어가야 하기 때문이다.
가장 단순한 구조는 리전별 Redis cache와 Persistent Store 계층을 함께 두는 것이다. 여기서 저장소는 세션만 저장하는 DB라기보다, 대화 이력과 세션 메타데이터를 오래 보관하는 persistent store에 가깝다. 실제 구현에서는 PostgreSQL, MySQL, 분산 KV store 등 여러 선택지가 있을 수 있지만, 이 글에서는 특정 제품보다 캐시와 Persistent Store의 역할 분리에 초점을 둔다.
- Regional Redis Cluster: 최근 대화 컨텍스트 캐시
- Persistent Store: 대화 이력과 세션 메타데이터 저장
- Cross-region replication: 리전 간 대화 이력 복제
사용자가 같은 리전에 계속 붙어 있다면 로컬 Redis에서 최근 컨텍스트를 빠르게 읽고, 대화가 한 턴 끝났을 때 Persistent Store에 기록한 뒤 다른 리전으로 비동기 복제한다.
문제는 failover다. 사용자가 리전 A에서 대화하다가 장애 때문에 리전 B로 넘어가면, 리전 B의 Redis에는 세션이 없을 수 있다. 이때 API 서버는 로컬 캐시에만 의존하지 않고 Persistent Store의 최신 원본이나 가장 최신 복제본에서 컨텍스트를 가져오는 fallback을 둬야 하고, 가져온 뒤에는 리전 B의 Redis를 다시 채워 다음 요청부터 빠르게 처리한다.
여기에는 trade-off가 있다. 비동기 복제는 빠르지만 replication lag가 생기고, 동기 복제를 강하게 걸면 정합성은 좋아지지만 글로벌 latency가 커질 수 있다. 이 글의 조건에서는 실시간 서빙의 latency가 중요하므로, 기본은 로컬 캐시와 비동기 복제로 두고 failover 시 fallback으로 보완하는 쪽이 현실적으로 보인다.
모니터링 포인트
모니터링은 문제에서 요구한 범위에 맞춰 단순하게 잡았다. 글로벌 분산 환경이므로 최소한 latency, error rate, token usage, queue length는 계속 봐야 한다.
- latency
- error rate
- token usage
- queue length
여기서 latency는 하나의 평균값으로만 보면 부족하다. 사용자 응답 경로에서는 TTFT가 중요하고, 피드백 수집 경로에서는 SQS queue length가 밀리는지 보는 것이 더 직접적이다. token usage는 비용과 capacity planning에 연결되고, error rate는 API 서버와 LLM worker, 피드백 consumer를 나누어 봐야 장애 위치를 좁힐 수 있다.
정리
이번 설계에서 제일 크게 정리된 것은 큐를 쓰느냐 마느냐가 아니라, 큐를 어디에 두느냐였다.
실시간 추론은 Fast Path로 남기고, 로그와 피드백 수집은 Slow Path로 분리했다. SQS는 사용자 응답을 기다리는 경로가 아니라, 모델 개선과 분석을 위한 데이터 수집 경로에서 버퍼 역할을 맡는다.
TTFT 요구사항을 만족하려면 사용자가 기다리는 경로를 짧게 유지해야 한다. API 서버는 세션을 빠르게 읽고, LLM worker로 요청을 넘기고, worker가 만든 첫 토큰을 가능한 한 바로 WebSocket이나 SSE 연결 위로 흘려보내야 한다. 로그와 피드백 수집은 중요하지만, 그 작업이 첫 토큰을 막는 순간 실시간 서빙 경로의 목적과 충돌한다.