회사 뿐만 아니라 개인 프로젝트에 LLM을 웹서비스에 연결하면 생각보다 자주 만나는 문제가 있다.
답변은 잘 나오는데 사용자가 오래 기다린다고 느낀다.
예를 들어 사용자가 도서 추천 질문을 입력했다고 하자.
직장 생활에 지쳐 있을 때
가볍게 읽을 만한 책을 추천해줘.
그러면 서버에서는 먼저 검색을 수행하고, 검색된 도서를 Context로 구성한 뒤 LLM을 호출하게 된다.
최종 응답을 한 번에 반환하는 구조라면 사용자는 모든 작업이 끝날 때까지 화면에서 아무것도 볼 수 없다.
사용자 질문
↓
Elasticsearch 검색
↓
RAG Context 구성
↓
LLM 생성 시작
↓
LLM 생성 중...
↓
LLM 생성 완료
↓
전체 응답 반환
↓
사용자가 처음 결과를 봄
여기에서 LLM이 답변을 생성하는 데 몇 초가 걸린다면 사용자는 그 시간 동안 계속 기다리고 있다가 슬슬 답답함과 이거 에러 아니야? 라는 생각까지 도지게 될 수 있다.
그래서! 결국… 자연스럽게 스트리밍을 생각하게 된다.
OpenAI Responses API도 stream=true를 사용하면 응답이 완전히 끝난 뒤 한 번에 전달하는 대신, 생성 과정에서 발생하는 이벤트를 Server-Sent Events 형태로 받을 수 있다.
그런데 여기서 한 가지를 정확히 구분할 필요가 있다.
스트리밍을 적용하면 모델의 전체 처리시간이 빨라지는가?
와
사용자가 결과를 보기 시작하는 시간이 빨라지는가?
는 같은 질문이 아니다.
이 차이를 이해하지 않으면 스트리밍을 적용한 뒤에도:
스트리밍으로 바꿨는데 전체 응답시간이 별로 줄지 않았다.
라고 판단할 수 있다.
하지만 스트리밍의 가장 중요한 효과는 반드시 전체 생성시간 단축에 있는 것은 아니다.
오히려 사용자가 첫 번째 결과를 볼 때까지의 시간을 줄이는 것에 더 가깝다.
응답시간을 하나의 숫자로만 보면 부족하다
API 성능을 측정할 때 흔히 다음처럼 기록한다.
response_time = 4.8초
물론 중요한 데이터다.
하지만 LLM 스트리밍에서는 이 숫자 하나만 가지고 사용자 경험을 설명하기 어렵다.
예를 들어 두 가지 서비스를 생각해보자.
서비스 A
0초 요청
│
│
│ 아무것도 표시되지 않음
│
5초 전체 답변 표시
서비스 B
0초 요청
│
1.2초 첫 문장 표시
│
2초 계속 생성
│
3초 계속 생성
│
4초 계속 생성
│
5초 전체 완료
두 서비스 모두 전체 작업 완료에는 5초가 걸렸다.
하지만 아까 말한 것처럼 사용자가 느끼는 경험은 상당히 다르다.
A에서는 5초 동안 화면에 변화가 없다.
B에서는 1.2초부터 답변이 나타난다.
따라서 LLM 서비스를 볼 때는 최소한 시간을 두 종류로 나눌 필요가 있다.
첫 출력이 사용자에게 도착한 시간
전체 출력이 완료된 시간
실무에서는 첫 번째 값을 흔히 TTFT(Time To First Token) 또는 첫 응답시간과 비슷한 개념으로 측정한다.
그리고 두 번째는 전체 응답 완료시간이다.
스트리밍에서 정말 줄이고 싶은 것은 무엇인가
내가 스트리밍 성능을 본다면 다음처럼 구분할 것이다.
Request Start
↓
Retrieval 완료
↓
LLM 요청 시작
↓
First Token
↓
Streaming...
↓
Last Token
↓
Response Complete
여기에는 사실 여러 종류의 시간이 들어 있다.
Retrieval Time
Elasticsearch에서 관련 도서를 가져오는 시간.
Context Construction Time
검색 결과를 정리하고 LLM Prompt를 만드는 시간.
LLM First Output Time
LLM 요청을 시작한 뒤 첫 번째 출력이 도착하는 데 걸리는 시간.
Generation Time
첫 출력 이후 마지막 출력까지 생성하는 시간.
Total Response Time
사용자 요청부터 전체 응답 완료까지 걸린 시간.
이렇게 5단계로 나누고 이 시간을 전부 합쳐:
응답시간 = 5초
이지만 각 단계마다 시간을 측정할 수 있으면 빠르고 정확하게 시간 단축을 위한 솔루션을 제시할 수 있다.
RAG 서비스에서는 LLM 이전 시간이 이미 존재한다
스트리밍을 적용할 때 특히 조심해야 할 부분이다.
내 도서 검색 서비스 같은 RAG 구조에서는 LLM 호출 전에 이미 검색 과정이 존재한다.
사용자 질문
↓
Embedding 생성
↓
Elasticsearch 검색
↓
도서 후보 정리
↓
Context 구성
↓
LLM 요청
스트리밍은 기본적으로 LLM이 출력을 생성하기 시작한 이후의 전달 방식을 바꾼다.
따라서 앞쪽 검색 단계가 매우 느리다면 LLM을 스트리밍으로 바꿔도 사용자는 여전히 오래 기다릴 수 있다.
예를 들어 설명을 위한 상황을 생각해보자.
검색 및 Context 준비
3초
LLM 첫 출력
1초
이후 전체 생성
3초
스트리밍을 사용해도 사용자가 첫 글자를 볼 수 있는 시점은 약 4초 뒤다.
문제의 상당 부분이 LLM 생성이 아니라 Retrieval 앞단에 있기 때문이다.
따라서:
스트리밍을 붙였는데도 느리다.
면 먼저 전체 요청을 단계별로 나눠볼 필요가 있다.
Retrieval이 느린가?
Embedding API가 느린가?
DB가 느린가?
LLM 첫 출력이 느린가?
프록시가 데이터를 모아두고 있는가?
스트리밍 하나로 모든 대기시간을 해결할 수 있는 것은 아니다.
그래서 요청 시작시간만 저장하면 부족하다
예를 들어 기존 로그가 다음과 같다고 하자.
{
"request_time": "...",
"response_time_ms": 4820
}
일반 API라면 어느 정도 사용할 수 있다.
하지만 LLM 스트리밍 성능을 분석하기에는 부족하다.
내가 다시 로그를 만든다면 대략 다음처럼 나누고 싶다.
request_started_at
retrieval_completed_at
llm_requested_at
first_chunk_received_at
response_completed_at
그러면 다음 값을 계산할 수 있다.
Retrieval Time
=
retrieval_completed_at
-
request_started_at
LLM First Chunk Time
=
first_chunk_received_at
-
llm_requested_at
그리고 사용자가 실제로 처음 결과를 볼 수 있는 시점은:
User First Response Time
=
first_chunk_received_at
-
request_started_at
가 된다.
전체 완료시간은:
Total Response Time
=
response_completed_at
-
request_started_at
이다.
이렇게 나눠야 스트리밍 적용 효과를 제대로 볼 수 있다.
전체 응답시간이 그대로여도 사용자 경험은 달라질 수 있다
이 부분 때문에 스트리밍 테스트에서 평균 response_time만 비교하면 안 된다.
예를 들어 실제 테스트에서 다음과 같은 결과가 나왔다고 가정해보자.
숫자는 설명을 위한 예일 뿐 실제 내 서비스 측정값은 아니다.
Non Streaming
첫 화면 표시: 5.1초
전체 완료: 5.1초
Streaming
첫 화면 표시: 1.3초
전체 완료: 5.0초
전체 처리시간은 거의 동일하다.
따라서 전체 응답시간만 보면:
스트리밍 효과가 거의 없다.
라는 결론을 낼 수 있는데
사용자는 5초 동안 빈 화면을 보는 대신 1.3초 후부터 결과를 읽기 시작할 수 있다고 볼 수 있다.
즉 스트리밍의 효과는 다음 식에 더 가깝다.
서버의 모든 작업을 더 빨리 끝낸다
X
결과를 완전히 만들기 전에
사용자에게 전달하기 시작한다
O
OpenAI의 현재 streaming API도 응답이 생성되는 동안 이벤트를 순차적으로 전달하는 구조다.
FastAPI에서는 생성되는 데이터를 바로 넘겨줄 수 있다
FastAPI에서는 StreamingResponse를 이용해 generator나 async generator에서 만들어지는 데이터를 순차적으로 반환할 수 있다.
FastAPI 공식 문서에서도 streaming endpoint가 생성한 각 데이터 chunk를 StreamingResponse가 순차적으로 전달하는 방식을 제공한다.
개념적으로는 다음과 같은 구조다.
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
app = FastAPI()
async def generate():
yield "첫 번째 데이터\n"
yield "두 번째 데이터\n"
yield "세 번째 데이터\n"
@app.get("/stream")
async def stream():
return StreamingResponse(
generate(),
media_type="text/plain"
)
LLM과 연결하면 generator 안에서 LLM에서 받은 event를 읽고 필요한 text delta를 순차적으로 yield하는 구조를 만들 수 있다.
구조를 단순화하면:
OpenAI Stream
↓
Event 1
↓
FastAPI yield
↓
Client
Event 2
↓
FastAPI yield
↓
Client
Event 3
↓
FastAPI yield
↓
Client
가 된다.
OpenAI Responses API에서는 streaming을 활성화하면 response.created, text delta, completion과 같은 여러 종류의 streaming event가 전달될 수 있으므로 실제 코드에서는 필요한 이벤트 종류를 구분해 처리해야 한다.
그런데 FastAPI에서 yield했다고 사용자가 바로 보는 것은 아닐 수 있다
여기에서 꽤 중요한 문제가 나온다.
개발 환경에서는 스트리밍이 잘 보이는데 운영 서버에 배포하자 다음처럼 동작할 수 있다.
첫 chunk 생성
두 번째 chunk 생성
세 번째 chunk 생성
네 번째 chunk 생성
↓
한참 뒤
↓
브라우저에 한꺼번에 표시
애플리케이션에서는 분명히 streaming하고 있는데 사용자 입장에서는 일반 응답처럼 보인다.
이럴 때는 FastAPI 코드만 계속 수정해서는 해결되지 않을 수 있다.
중간에 Nginx 같은 reverse proxy가 있기 때문이다.
Nginx의 buffering 때문에 스트리밍이 뭉쳐 보일 수 있다
내가 운영 구조를 본다면 다음과 같은 흐름을 생각해야 한다.
LLM
↓
FastAPI
↓
Nginx
↓
Browser
FastAPI에서 chunk를 즉시 보냈다고 해도 Nginx가 이를 buffer에 모았다가 클라이언트로 전달한다면 사용자가 보는 시점은 달라질 수 있다.
Nginx의 공식 ngx_http_proxy_module 문서에 따르면 proxy_buffering이 활성화되어 있으면 upstream 응답을 buffer에 저장할 수 있고, buffering을 비활성화하면 upstream에서 받은 데이터를 클라이언트에 동기적으로 전달한다. 또한 upstream의 X-Accel-Buffering 응답 헤더를 통해 buffering 동작을 제어할 수도 있다.
따라서 운영 환경에서 스트리밍을 테스트할 때는:
FastAPI가 yield하는가?
만 보면 부족하다.
다음 전체 경로를 봐야 한다.
LLM
↓
FastAPI
↓
Reverse Proxy
↓
CDN이 있다면 CDN
↓
Browser
어느 단계에서 데이터를 모아두고 있는지 확인해야 한다.
그래서 로그에는 “서버가 yield한 시간”도 유용할 수 있다
문제가 있을 때 이런 상황을 생각할 수 있다.
FastAPI 로그:
12:00:01 first chunk yield
브라우저:
12:00:04 first chunk render
그러면 LLM이 첫 출력까지 4초 걸린 것이 아니다.
서버에서는 1초에 이미 데이터를 만들었다.
그 이후 전달 과정에서 3초가 추가된 것이다.
따라서 좀 더 세밀하게 분석하려면:
LLM first event received
FastAPI first chunk emitted
Browser first chunk received
Browser first render
까지 나눌 수도 있다.
모든 서비스에서 이 정도 로그를 상시 저장할 필요는 없지만 스트리밍이 제대로 동작하지 않는 문제를 디버깅할 때는 상당히 유용하다.
“첫 토큰”과 “첫 문장”도 사용자 입장에서는 다르다
또 하나 생각해야 할 점이 있다.
기술적으로 첫 token이 도착했다고 해도 사용자가 유용한 정보를 봤다는 뜻은 아니다.
예를 들어 stream이 이렇게 시작될 수 있다.
네
,
요
청
하
신
...
브라우저가 매우 작은 단위로 매번 바로 렌더링하면 화면이 지나치게 자주 갱신될 수도 있다.
반대로 서버나 프론트엔드에서 어느 정도 데이터를 모아서 표시하면:
요청하신 조건에 맞는 책을 찾아보면,
처럼 조금 더 읽기 자연스러운 형태로 나타낼 수 있다.
즉 실제 사용자 경험에서는:
TTFT
뿐 아니라:
Time To First Meaningful Content
처럼 사용자가 읽을 만한 첫 콘텐츠가 나타나는 시간도 생각해볼 가치가 있다.
기술적으로 가장 빠른 stream과 사용자가 가장 편하게 읽는 stream이 반드시 같은 것은 아니다.
너무 작은 단위로 렌더링하면 프론트엔드 작업도 늘어난다
LLM이 작은 delta를 매우 자주 보내는 상황에서 매 delta마다 UI state를 갱신한다고 생각해보자.
chunk 1 → render
chunk 2 → render
chunk 3 → render
chunk 4 → render
...
응답이 길면 많은 UI 업데이트가 발생할 수 있다.
따라서 프론트엔드에서는 전달받은 chunk를 바로 표시할지, 짧은 간격으로 모아서 표시할지 별도로 결정할 수 있다.
이 문제는 모델 속도와는 별개다.
네트워크 streaming
과:
화면 rendering 전략
은 다른 계층이다.
스트리밍 서비스를 튜닝할 때 이 둘을 한 문제로 보면 분석하기 어렵다.
RAG 결과의 metadata와 LLM text는 스트리밍 전략이 다를 수도 있다
도서 추천 서비스를 생각하면 모든 데이터를 token 단위로 stream할 필요도 없다.
예를 들어 Elasticsearch 검색이 완료되면 이미 다음 데이터는 알고 있다.
도서 제목
저자
ISBN
표지
반면 아직 생성 중인 것은:
추천 이유
활용 방법
개인화 설명
일 수 있다.
그렇다면 사용자 인터페이스를 다음처럼 구성할 수도 있다.
검색 완료
↓ 즉시
책 표지
제목
저자 표시
↓ 그 뒤
AI 추천 이유 streaming
이 방식에서는 사용자가 LLM 첫 token을 기다리기 전부터 실제 검색 결과를 볼 수 있다.
RAG 서비스에서는 이 접근이 특히 의미가 있다.
왜냐하면 검색 결과와 생성 결과의 준비 시점이 다르기 때문이다.
Retrieval Result Ready
↓
도서 기본정보 표시
LLM Generation
↓
추천 설명 Stream
즉 LLM Streaming만이 체감속도를 개선하는 유일한 방법은 아니다.
이미 준비된 데이터를 먼저 사용자에게 보여주는 것도 중요한 방법이다.
이 구조에서는 검색이 끝났다는 사실 자체가 피드백이 된다
사용자가 AI 검색을 눌렀는데 몇 초 동안 아무 변화가 없으면:
버튼이 눌렸나?
서버가 멈췄나?
라는 생각을 할 수 있다.
반면:
도서 검색 중
↓
관련 도서 찾음
↓
추천 이유 생성 중
처럼 단계가 보이면 실제 처리시간이 동일해도 사용자는 시스템이 작동하고 있다는 것을 알 수 있다.
그래서 체감속도 문제를 해결할 때 반드시 서버 처리시간만 줄여야 하는 것은 아니다.
현재 처리 상태를 사용자에게 설명하는 것도 UX에서는 중요하다.
Streaming을 적용하면 오류 처리도 달라진다
일반 API에서는 서버가 모든 작업을 끝낸 후 응답한다.
예를 들어 LLM 호출 중 오류가 발생하면:
HTTP 500
같은 형태로 전체 요청 실패를 처리하기 쉽다.
하지만 streaming에서는 이미 일부 데이터가 사용자에게 전달된 뒤 오류가 발생할 수 있다.
예를 들어:
첫 번째 문장 전달
두 번째 문장 전달
세 번째 문장 생성 중
LLM 오류 발생
사용자는 이미 답변 일부를 보고 있다.
이때 단순히 기존 응답을 없애고 HTTP 오류 페이지로 바꾸는 방식은 맞지 않을 수 있다.
따라서 streaming protocol 안에서:
data
done
error
같은 이벤트 상태를 구분하는 구조가 필요할 수 있다.
OpenAI streaming API 자체도 생성 과정에서 여러 이벤트 타입을 전달하며 error와 completion을 포함한 상태를 구분할 수 있도록 되어 있다.
사용자가 중간에 페이지를 닫았을 때도 생각해야 한다
Streaming response는 연결이 상대적으로 오래 유지된다.
사용자가 답변을 읽다가 페이지를 닫거나 새 검색을 시작할 수도 있다.
이때 기존 생성 작업을 계속 진행한다면:
사용자는 이미 떠남
하지만
서버는 계속 Stream 처리
LLM은 계속 Generation
비용 계속 발생
같은 상황을 생각해볼 수 있다.
따라서 실제 운영에서는 client disconnect나 cancellation을 어떻게 처리할지도 중요하다.
FastAPI의 현재 streaming 관련 문서는 streaming generator와 cancellation을 고려한 방식을 제공하고 있다.
특히 LLM API처럼 생성 자체에 비용이 연결되는 서비스에서는 불필요한 작업을 얼마나 빨리 중단할 수 있는지가 단순 서버 자원 이상의 의미를 가진다.
스트리밍은 비용을 자동으로 줄여주지 않는다
이것도 흔히 오해할 수 있다.
Streaming
=
빠름
=
저렴함
이라고 연결해서 생각하면 안 된다.
스트리밍은 기본적으로 생성 결과를 전달하는 방식이다.
동일한 모델이 동일한 입력을 받고 동일한 양의 출력을 생성한다면 출력 전달을 stream으로 바꿨다고 해서 생성해야 하는 내용 자체가 사라지는 것은 아니다.
오히려 사용자가 중간에 취소했을 때 생성도 적절히 중단할 수 있는 구조를 만들거나, 결과 일부만 필요하도록 UX를 설계했을 때 비용 측면의 추가 효과를 기대할 수 있다.
따라서 성능과 비용은 따로 측정해야 한다.
스트리밍 성능을 비교한다면 같은 조건에서 해야 한다
실제로 Non-Streaming과 Streaming을 비교하고 싶다면 조건부터 고정해야 한다.
예를 들어:
같은 모델
같은 System Prompt
같은 사용자 질문
같은 RAG 문서
같은 출력 조건
으로 테스트한다.
그리고 최소 다음 값을 기록한다.
Non-Streaming
Request Start
Response Complete
Streaming
Request Start
First Chunk
Response Complete
그 후 비교한다.
| 측정값 | Non-Streaming | Streaming |
|---|---|---|
| 첫 콘텐츠 표시시간 | 측정 | 측정 |
| 전체 완료시간 | 측정 | 측정 |
| 출력 길이 | 측정 | 측정 |
| 오류 발생 | 기록 | 기록 |
여기에서 핵심은:
전체 완료시간만 비교하지 않는다.
는 것이다.
평균만 보면 느린 요청을 놓칠 수도 있다
예를 들어 10번 테스트해서 평균 TTFT가 괜찮게 나왔다고 하자.
그런데:
대부분 1초
일부 5초
처럼 편차가 크다면 사용자 중 일부는 계속 느린 경험을 하게 된다.
서비스를 실제 운영한다면 평균뿐 아니라:
p50
p95
p99
같은 percentile을 보는 것도 의미가 있다.
예를 들어 p95 TTFT는 요청의 95%가 어느 시간 안에 첫 출력을 받는지를 보는 방식으로 사용할 수 있다.
평균값 하나만 보면 극단적으로 느린 요청이 얼마나 자주 발생하는지 알기 어렵기 때문이다.
첫 출력이 늦다면 문제 위치도 다시 나눌 수 있다
TTFT가 느리다고 바로 LLM 모델을 교체할 필요는 없다.
전체 구조를 다시 보면:
사용자 요청
↓
DB 조회
↓
Embedding
↓
Elasticsearch
↓
Context
↓
LLM 요청
↓
첫 Token
이 모든 시간이 사용자 관점의 첫 응답시간에 포함된다.
따라서 측정값을 나누면:
Retrieval 2.5초
LLM First Token 0.8초
처럼 나올 수도 있다.
이 경우 모델보다 검색 쪽이 더 큰 병목이다.
반대로:
Retrieval 0.2초
LLM First Token 4초
라면 LLM 호출과 입력 크기, 모델 선택 등을 좀 더 봐야 한다.
측정하지 않으면 둘을 구분할 수 없다.
RAG Context가 커지면 TTFT에도 영향을 줄 수 있는지 확인해야 한다
앞 글에서 RAG Context가 커지면 입력 토큰과 비용이 증가할 수 있다고 정리했다.
성능에서도 마찬가지로 Context 크기를 실험 항목으로 둘 수 있다.
예를 들어 같은 질문에서:
Top 3 문서
Top 5 문서
Top 10 문서
를 각각 Context에 넣는다.
그리고:
LLM Request 시작
First Chunk 도착
전체 완료
Input Token
을 기록한다.
실제 측정에서 Context가 늘어날수록 첫 응답시간까지 증가하는 패턴이 있다면 검색 품질과 체감속도의 trade-off도 평가할 수 있다.
이때 중요한 것은 미리:
Context가 크면 반드시 몇 초 느려진다.
라고 결론내리지 않는 것이다.
사용하는 모델과 입력 크기, 서버 상황 등에 따라 실제 결과는 달라질 수 있으므로 직접 측정해야 한다.
내가 다시 구현한다면 로그 구조를 이렇게 가져가고 싶다
사용자 요청 하나에 request_id를 부여한다.
그리고 다음 시간을 기록한다.
request_started_at
embedding_completed_at
search_completed_at
llm_requested_at
first_chunk_at
last_chunk_at
여기에:
model
input_tokens
output_tokens
retrieval_count
context_book_count
status
error
도 연결한다.
그러면 요청 하나를 나중에 이렇게 분석할 수 있다.
총 4.8초
├─ Embedding 0.3초
├─ Search 0.4초
├─ Context 0.1초
├─ LLM First Token 1.0초
└─ Streaming 3.0초
위 숫자는 설명을 위한 예시지만 이런 형태로 실제 시간을 분리해서 기록하는 것이 목표다.
그러면:
AI가 느리다.
라는 막연한 문제를:
Retrieval이 느리다.
LLM 첫 출력이 느리다.
생성 자체가 길다.
프록시 때문에 전달이 늦다.
로 구체화할 수 있다.
스트리밍에서 가장 먼저 확인할 체크리스트
실제 서비스를 다시 만든다면 다음 순서로 확인할 것이다.
백엔드
- LLM API가 실제 stream 모드로 호출되는가
- 첫 event가 언제 도착하는가
- event를 받은 즉시
yield하는가 - 불필요하게 전체 응답을 조립한 뒤 반환하고 있지 않은가
FastAPI
StreamingResponse가 실제 generator를 사용하고 있는가- chunk 사이에서 blocking 작업이 발생하지 않는가
- client disconnect 시 작업을 정리할 수 있는가
FastAPI는 streaming data를 위한 StreamingResponse 방식을 공식적으로 제공한다.
Reverse Proxy
- Nginx가 response buffering을 하고 있지 않은가
- streaming endpoint에 필요한 proxy 설정이 적용되어 있는가
Nginx 공식 문서에 따르면 proxy_buffering이 켜져 있으면 upstream 응답을 buffering하며, 꺼져 있으면 받은 데이터를 클라이언트로 바로 전달하는 방식으로 동작한다.
Frontend
- 첫 chunk를 실제로 수신하고 있는가
- 모든 데이터를 모은 뒤 한 번에 렌더링하고 있지 않은가
- 너무 작은 chunk마다 과도하게 UI를 갱신하고 있지 않은가
- 사용자가 새 요청을 시작하면 이전 stream을 정리하는가
내가 스트리밍의 성공 여부를 판단한다면
단순히:
글자가 한 글자씩 나온다.
면 성공이라고 판단하지 않을 것이다.
다음 조건을 같이 보고 싶다.
1. 첫 유용한 콘텐츠가 빨리 보이는가
기술적인 first chunk보다 실제 사용자가 읽을 수 있는 콘텐츠가 나타나는 시간이 중요하다.
2. 전체 응답시간은 지나치게 악화되지 않는가
Streaming 처리 때문에 다른 병목이 생기지는 않는지 확인한다.
3. 사용자 취소가 정상적으로 처리되는가
새 검색을 했는데 이전 LLM 생성이 계속 돌아가서는 안 된다.
4. 오류가 발생했을 때 UI가 깨지지 않는가
일부 답변이 표시된 뒤 stream이 실패할 수도 있다.
5. Reverse Proxy 환경에서도 실제 streaming되는가
로컬 환경에서만 되는 것으로 끝내지 않는다.
결국 스트리밍은 “속도 기술”보다 “대기시간을 보여주는 방식”에 가깝다
LLM Streaming을 처음 보면 성능 최적화 기술이라고 생각하기 쉽다.
물론 사용자 경험을 크게 개선할 수 있다.
하지만 내가 더 정확하게 표현한다면:
모든 계산을 더 빨리 끝내는 기술이라기보다 계산이 끝날 때까지 기다리지 않고 완성되는 결과부터 전달하는 방식
이라고 보는 편이 낫다.
이 차이를 이해하면 성능 측정 기준도 달라진다.
기존 API에서는:
Total Response Time
하나가 매우 중요했다.
Streaming LLM에서는:
Time To First Useful Content
+
Total Response Time
을 같이 봐야 한다.
RAG라면 한 단계 더 나눠야 한다.
Retrieval Time
+
LLM First Output Time
+
Generation Time
그리고 운영 환경에서는:
FastAPI
Nginx
Frontend
까지 데이터가 실제로 끊김 없이 전달되는지 확인해야 한다.
마무리
LLM 응답을 스트리밍으로 바꾸면 사용자가 느끼는 기다림은 크게 달라질 수 있다.
하지만 그 이유를:
스트리밍이 LLM을 더 빠르게 만들어서
라고 설명하는 것은 부족하다.
실제로 중요한 것은 완성될 때까지 결과 전체를 숨겨두지 않는 것이다.
일반 응답에서는:
모든 생성 완료
↓
사용자에게 표시
하지만 스트리밍에서는:
일부 생성
↓
표시
↓
추가 생성
↓
표시
↓
완료
가 가능하다.
OpenAI의 Responses API도 현재 stream=true일 때 응답 생성 과정의 server-sent events를 전달하고 있으며, FastAPI 역시 생성되는 데이터를 순차적으로 전달하는 streaming response를 제공한다.
다만 서비스 전체의 첫 응답시간에는 Elasticsearch 검색, Embedding, Context 구성 같은 LLM 이전 작업도 포함된다.
그래서 스트리밍을 적용했는데 여전히 느리다면 단순히 모델만 볼 것이 아니라 전체 요청을 단계별로 측정해야 한다.
그리고 FastAPI에서 데이터를 정상적으로 yield하고 있는데도 브라우저에서 한꺼번에 나타난다면 Nginx 같은 중간 proxy의 buffering도 확인해야 한다. Nginx는 공식적으로 proxy response buffering을 지원하며, 이를 끄면 upstream에서 받은 응답을 클라이언트로 즉시 전달하는 방식으로 동작한다.
결국 내가 LLM Streaming을 평가한다면 가장 먼저 묻고 싶은 것은:
전체 생성이 몇 초 걸렸는가?
하나가 아니다.
더 중요한 질문은:
사용자가 몇 초 만에 첫 번째 유용한 정보를 볼 수 있었는가?
이다.
그리고 그 숫자를 실제로 측정하기 시작해야 스트리밍이 정말 사용자 경험을 개선했는지 판단할 수 있다.