LLM 파이프라인 Latency, 어디까지 줄여봤습니까
여행 추천 엔진을 PoC로 만든 적이 있습니다. 외부 웹 검색 API로 최신 여행 정보를 가져오고, 내부 예약 데이터를 RAG 패턴으로 통합해서, LLM이 맞춤 추천을 생성하는 구조였습니다.
문제는 latency였습니다.
사용자가 "제주도 3박4일 가족여행"을 입력하면, 외부 API 호출에 1~2초, 내부 벡터 검색에 수백ms, 거기에 LLM 생성 시간까지 직렬로 더해집니다. 체감 5~8초. 그 사이에 사용자는 이미 탭을 닫습니다.
LLM latency 최적화를 검색하면 대부분 "프롬프트 줄이세요", "캐싱 쓰세요" 수준에서 끝납니다. 틀린 말은 아닌데, 실제 파이프라인을 운영하면 그 정도로는 부족합니다.
이 글은 LLM 호출이 포함된 전체 파이프라인을 단계별로 해부하고, 각 구간에서 latency를 극한까지 줄이는 접근을 정리합니다.
1. 측정이 먼저다
최적화의 첫 번째 원칙은 측정입니다. 어디가 느린지 모르면 어디를 고칠 수도 없습니다.
전형적인 LLM 기반 추천 파이프라인은 이렇게 생겼습니다.
쿼리 → 쿼리 분류/라우팅 → [외부 검색 + 내부 검색] → 컨텍스트 조립 → LLM 호출 → 출력
개발 단계에서도 측정은 가능합니다. 거창한 도구가 필요한 게 아닙니다. 각 구간을 time.perf_counter()로 감싸서 로그를 찍는 것만으로도 병목이 보입니다. 파이프라인을 다양한 쿼리로 수십 번 돌려보면, 어떤 구간이 일정하고 어떤 구간이 들쭉날쭉한지 감이 잡힙니다. 외부 API는 mock으로 대체하되, 실제 응답시간 분포를 시뮬레이션하면 타임아웃이나 fallback 로직도 검증할 수 있습니다.
프로덕션으로 넘어가면 이야기가 달라집니다.
실제 트래픽이 들어오면 외부 API의 응답시간이 시간대별로, 요일별로 달라집니다. 동시 요청이 몰리면 커넥션 풀이 고갈되고, 어제까지 200ms이던 구간이 갑자기 2초가 됩니다. 이때 필요한 게 분산 트레이싱입니다. OpenTelemetry로 요청 하나가 파이프라인의 각 구간을 지나며 얼마를 소비하는지 per-stage breakdown을 걸어두면, 장애 시점에 어떤 구간이 튀었는지 바로 보입니다.
그리고 평균이 아니라 tail을 봐야 합니다.
Tail latency: 전체 요청 중 가장 느린 상위 N%의 응답시간. P95는 상위 5%, P99는 상위 1%. 평균이 빠르더라도 이 꼬리가 길면 일부 사용자는 극단적으로 느린 경험을 하게 된다.
P50이 200ms여도 P95가 3초면, 20명 중 1명은 3초를 기다리는 겁니다. 대부분의 사용자 불만은 이 꼬리에서 나옵니다. per-stage P95를 대시보드에 띄워놓으면, 어떤 구간이 전체 tail latency를 끌어올리는지 상시 모니터링이 가능합니다.
개발 단계에서 구조적 병목을 찾고, 프로덕션에서 실시간으로 추적하는 것. 이 두 단계가 연결되어야 최적화가 일회성이 아니라 지속적으로 동작합니다.
여기서 가장 중요한 건 직렬 구간과 병렬 가능 구간을 식별하는 것입니다. 직렬로 연결된 구간은 latency가 단순 합산됩니다. 외부 검색 1.5초 + 컨텍스트 조립 100ms + LLM 호출 2초면, 그게 3.6초입니다. 어느 하나를 줄이지 않으면 전체가 줄지 않습니다.
타임아웃 버짓
전체 SLO가 5초라고 합시다. 그러면 각 구간에 5초씩 줄 수는 없습니다.
Cascading timeout: 전체 시간 버짓(SLO)을 정해두고, 앞 구간이 소비한 시간에 따라 뒷 구간의 버짓이 동적으로 줄어드는 패턴. 앞이 느려지면 뒤가 더 빠르게 처리하거나, 품질을 낮춰서라도 전체 시간 안에 응답을 보낸다.
이걸 실제로 적용하면 이렇습니다. 전체 시간 버짓을 정해두고, 앞 구간이 얼마를 썼느냐에 따라 뒷 구간의 버짓이 동적으로 조정됩니다. 외부 검색이 2초를 먹었으면, LLM 호출에는 남은 3초 안에서 돌아가야 합니다. 그러면 max_tokens를 줄이거나, 더 빠른 모델로 전환하는 판단을 런타임에 내릴 수 있습니다.
이걸 설계하지 않으면, 앞 구간이 느려졌을 때 뒷 구간까지 느려져서 전체가 타임아웃 나는 상황이 반복됩니다.
2. LLM 호출 전 — 10ms 단위의 싸움
LLM 호출 전에 일어나는 일들이 있습니다. 쿼리 분류, 라우팅, 검색. 이 구간은 ms 단위로 싸워야 합니다.
쿼리 분류/라우팅
시맨틱 라우터를 예로 들겠습니다. "제주도 여행"이라는 쿼리가 들어오면, 이게 여행 추천인지, 항공권 검색인지, 일반 질문인지 분류해야 합니다.
여기서 외부 임베딩 API를 호출하면 네트워크 I/O만 수십ms입니다. 클라우드 벡터 DB를 쓰면 거기에 또 수십ms.
극한으로 가면 이렇게 됩니다. 로컬에서 경량 임베딩 모델을 돌립니다. all-MiniLM-L6-v2 같은 모델을 ONNX 런타임으로 CPU에서 돌리면, 추론에 10ms도 안 걸립니다. 벡터 검색도 FAISS나 HNSWLib 같은 인메모리 라이브러리를 쓰면, 네트워크를 타지 않기 때문에 지연시간을 줄일 수 있습니다.
내부 데이터 검색 (RAG)
내부 예약 데이터나 목적지 정보 검색도 동일한 전략이 적용됩니다. 데이터가 수십만 건 이하라면, 외부 벡터 DB 없이 로컬 인메모리로 충분합니다. 네트워크 I/O를 제거하는 것만으로도 구간 latency가 수십ms에서 수ms로 떨어집니다.
외부 웹 검색 API
외부 API는 통제할 수 없습니다. 응답 시간이 500ms일 수도 있고, 2초일 수도 있습니다.
여기서의 전략은 여러 가지입니다.
첫째, 병렬 호출. 외부 검색과 내부 검색을 동시에 실행합니다. asyncio.gather 같은 걸로 묶으면, 전체 latency는 둘 중 느린 쪽이 됩니다. 직렬이면 합산이던 게, 병렬이면 max가 됩니다.
둘째, 타임아웃 + graceful fallback. 외부 API가 2초 안에 안 오면, 내부 데이터만으로 진행합니다. 정보가 조금 부족하더라도 응답은 나갑니다. 안 나가는 것보다 낫습니다.
셋째, connection pooling. 매 요청마다 새 TCP 연결을 맺고 TLS 핸드셰이크를 하면, 그것만으로 수십ms입니다. HTTP keep-alive와 커넥션 풀을 유지하면 이 비용이 사라집니다.
넷째, pre-computation. 인기 목적지나 시즌별 검색 결과를 미리 수집해 둡니다. "제주도"나 "오사카" 같은 쿼리는 매번 외부 API를 칠 필요가 없습니다. 주기적으로 캐시를 갱신하면 됩니다.
3. LLM 호출 — 토큰과 시간의 관계
LLM 호출 자체는 latency의 하한선이 가장 높은 구간입니다. 근데 여기서도 깎을 수 있는 게 있습니다.
입력 토큰 = TTFT
TTFT (Time to First Token): LLM에 요청을 보낸 후 첫 번째 토큰이 생성되기까지의 시간. 입력 프롬프트가 길수록 TTFT가 늘어난다. 스트리밍 환경에서 사용자가 "응답이 시작됐다"고 느끼는 시점을 결정하는 지표.
입력 토큰 양은 TTFT에 직접 영향을 줍니다. 프롬프트가 길면 첫 토큰이 나오기까지 오래 걸립니다.
리트리벌 결과를 통째로 프롬프트에 넣는 경우가 많습니다. 검색된 문서 5개를 원문 그대로 넣으면 수천 토큰이 됩니다. 핵심 정보만 추출하거나, 요약해서 넣으면 토큰 수가 절반 이하로 줄어듭니다. 그만큼 TTFT가 빨라집니다.
Prompt Caching
Prompt caching: LLM API가 제공하는 기능으로, 이전 요청과 동일한 프롬프트 접두사(prefix)를 재사용할 때 해당 구간의 처리를 건너뛰어 TTFT와 비용을 줄이는 메커니즘.
시스템 프롬프트처럼 매 요청마다 반복되는 구간이 있습니다. Anthropic이나 OpenAI의 prompt caching을 활용하면, 이 반복 구간의 처리 시간과 비용을 줄일 수 있습니다.
Divide and Conquer
하나의 큰 요청 대신, 작은 요청 여러 개로 나눠서 병렬 호출하는 전략입니다.
예를 들어 "3박4일 제주도 가족여행" 추천을 한 번에 생성하는 대신, 숙소/식당/관광지를 각각 별도 LLM 호출로 분리해서 동시에 돌립니다. 전체 wall-clock time은 가장 긴 하나의 호출 시간이 됩니다.
다만 트레이드오프가 있습니다. 분리하면 각 호출이 전체 맥락을 다 보지 못합니다. 숙소와 관광지의 동선이 맞지 않을 수 있습니다. 이건 최종 조합 단계에서 한 번 더 검증하거나, 공통 컨텍스트를 각 호출에 넣는 방식으로 보완합니다.
모델 선택
모든 태스크에 가장 큰 모델이 필요한 건 아닙니다. 쿼리 분류는 작은 모델로 충분합니다. 파인튜닝한 경량 모델이 범용 대형 모델보다 특정 태스크에서는 더 빠르고 더 정확할 수 있습니다.
태스크별로 모델을 다르게 쓰는 것. 이것도 latency 최적화입니다.
4. LLM 출력 후, 체감 latency 줄이기
스트리밍은 이제 기본입니다. 근데 스트리밍 너머에도 할 수 있는 게 있습니다.
TTF-Item이라는 관점
여행 추천처럼 리스트 형태의 결과를 생성할 때, 전체 응답이 끝날 때까지 기다릴 필요가 없습니다.
JSONL structured output을 쓰면, LLM이 첫 번째 아이템을 생성 완료한 시점에 바로 파싱해서 UI에 띄울 수 있습니다.
TTF-Item (Time to First Item): 리스트/추천 등 다중 결과를 생성할 때, 첫 번째 유의미한 아이템이 사용자에게 보여지기까지의 시간. TTFT(첫 토큰)가 아니라 첫 번째 완성된 결과 단위로 체감 속도를 측정하는 관점.
TTFT보다 사용자에게 유의미한 지표입니다.
첫 번째 추천 호텔이 2초 만에 뜨고, 나머지가 하나씩 채워지면 — 전체 응답이 6초 걸려도 체감은 2초입니다.
출력 스키마 설계
출력 토큰 수도 latency입니다. LLM에게 불필요한 필드까지 생성하게 하면 그만큼 느려집니다. 필요한 필드만 정의한 JSON 스키마를 주고, 그 안에서만 생성하게 하면 출력 토큰이 줄어듭니다.
5. 파이프라인 전체 최적화
개별 구간 최적화 외에, 파이프라인 전체에 걸쳐 적용되는 전략들이 있습니다.
병렬화
가장 확실한 방법입니다. 독립적인 구간은 동시에 실행합니다. 외부 검색과 내부 검색을 동시에. 여러 LLM 호출을 동시에. 직렬 합산이 병렬 max로 바뀌는 것만으로도 전체 latency가 크게 줄어듭니다.
캐싱 — LLM을 호출하지 않는 게 가장 빠르다
"제주도 3박4일 가족여행" 같은 쿼리는 여러 사용자가 비슷하게 검색합니다. 동일 쿼리의 결과를 캐싱해 두면, 캐시 히트 시 전체 파이프라인을 스킵할 수 있습니다.
여기서 한 단계 더 가면 시맨틱 캐싱입니다.
Semantic caching: 문자열 일치(exact match)가 아니라, 쿼리의 의미적 유사도를 기반으로 캐시를 매칭하는 방식. 임베딩 벡터 간 유사도를 비교하여 "같은 의도"의 쿼리를 하나의 캐시로 처리한다.
"제주도 3박4일 가족여행"과 "제주 3박 가족여행 추천"은 다른 문자열이지만 같은 의도입니다. 임베딩 유사도로 매칭하면 캐시 히트율이 올라갑니다. 앞에서 쿼리 분류에 쓴 인메모리 벡터 검색을 캐시 룩업에도 재활용할 수 있습니다.
Graceful Degradation — 품질 단계 전환
항상 최고 품질의 응답을 줄 필요는 없습니다.
외부 API가 느리면, 내부 데이터만으로 응답합니다. 전체 latency 버짓이 얼마 안 남았으면, 더 작은 모델로 전환하거나 리랭킹을 스킵합니다.
"느리지만 완벽한 응답" vs "빠르지만 괜찮은 응답". 사용자 입장에서는 후자가 나은 상황이 많습니다. 5초 기다렸다가 완벽한 추천을 받는 것보다, 2초 만에 괜찮은 추천을 받는 게 경험이 더 좋을 수 있습니다.
이 판단을 런타임에 자동으로 내리려면, 앞서 말한 타임아웃 버짓 설계가 필요합니다. 남은 시간이 얼마인지에 따라 품질 단계를 전환하는 것입니다.
Connection Management
네트워크를 타는 모든 구간에서 커넥션 관리가 중요합니다. HTTP 커넥션 풀링, keep-alive 유지, warm-up. 이게 안 되면 매 요청마다 TCP 핸드셰이크 + TLS 협상이 반복됩니다. 한 번에 수십ms. 요청이 여러 외부 서비스를 치는 파이프라인에서는 이게 모여서 수백ms가 됩니다.
지리적 라우팅
LLM API 엔드포인트가 미국 서부에 있고 사용자가 한국에 있으면, 네트워크 RTT만으로 150ms 이상입니다. 스트리밍이면 매 청크마다 이 지연이 반복됩니다.
가능하다면 사용자에게 가까운 리전의 엔드포인트를 사용합니다.
정리
LLM latency 최적화는 "프롬프트를 줄이자" 같은 단일 기법이 아닙니다. 파이프라인 전체를 그리고, 각 구간을 측정하고, 직렬 구간을 찾아서 깎는 작업입니다.
각 구간의 최적화는 독립적이면서도 곱셈으로 작용합니다. 쿼리 라우팅에서 50ms를 아끼고, 검색을 병렬화해서 1초를 줄이고, 프롬프트를 압축해서 TTFT를 300ms 당기고, 출력을 아이템 단위로 스트리밍하면 — 전체 체감 latency는 절반 이하가 됩니다.
결국 핵심은 관점입니다. LLM 호출만 보면 할 수 있는 게 제한적이지만, 파이프라인 전체를 보면, 최적화할 수 있는 구간이 많이 있습니다.