2025. 2. 10. 18:26ㆍ카테고리 없음
개요
저희 프로젝트는 LangChain을 활용하여 AI 응답을 반환하는 API를 만들어서 사용하고 있습니다.
AI가 생성한 응답을 그대로 HTTP 응답으로 반환해주는 API입니다.
하지만 이러한 형태는 AI의 응답이 길어질 경우 클라이언트가 기다리는 시간이 늘어나고 UX가 저하된다고 판단했기 때문에 streaming 기능을 적용하기로 결정했습니다.

Language Model들의 대부분은 token 단위로 응답을 반환하는 스트리밍 기능을 제공합니다.
token 단위로 받은 응답을 차례대로 클라이언트에게 뿌려주기만 하면 됩니다.
기술 선택
사용자가 한 번 요청을 보내고 난 뒤 추가적인 요청을 보낼 필요가 없습니다.
서버는 요청에 해당하는 응답을 생성하고 token 단위로 사용자에게 보내주면 되기 때문에 websocket과 같은 양방향 통신 기술을 사용할 필요는 없고 간단하게 SSE(Server Side Event)를 통해 단방향 통신을 사용하기로 했습니다.
SSE를 구현하는 방식에는 크게 두 가지 방식이 존재합니다.
- EventSource 방식
- fetch 방식
EventSource 방식을 사용하면 서버의 엔드포인트에 연결한 뒤 쉽게 데이터를 받아올 수 있습니다.
const eventSource = new EventSource('https://example.com/sse');
eventSource.onmessage = function(event) {
console.log('서버로부터 새로운 메시지:', event.data);
};
eventSource.onerror = function(err) {
console.error('EventSource 오류:', err);
};
하지만 보내는 요청의 형식을 쉽게 바꿀 수 없다는 단점이 존재합니다.
특히 저희 프로젝트의 경우 요청의 내용을 Body에 담아 보내줘야하기 때문에 EventSource 방식에 한계가 있었습니다.
이 때 fetch API를 사용하면 요청의 형식을 프로젝트에 맞게 수정할 수 있습니다.
스트림 데이터로 들어오는 응답을 적절하게 처리해줘야 하는 번거로움이 있지만 EventSource의 한계에서 벗어날 수 있습니다.
그래서 저희는 fetch API를 활용하여 HTTP POST 요청으로 SSE를 구현하기로 결정했습니다.
구현
저희 프로젝트는 Nest.js를 사용했지만 response 객체를 그대로 가져와서 직접 사용했기 때문에 express 같은 node.js 기반 프레임워크라면 거의 동일하게 사용할 수 있습니다.
우선 서버의 API입니다.
- POST API 생성
- header의 content-type을 text/event-stream으로 세팅
- 보내려는 데이터를 chunk 단위로 write
@Post('/')
@HttpCode(HttpStatus.OK)
async query(@Body() body: QueryRequest, @Res() res) {
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Connection', 'keep-alive');
res.flushHeaders();
const response = await this.landchainService.query(body.query);
for await (const chunk of response) {
console.log(chunk.content);
res.write(`${chunk.content}\n\n`);
}
res.end();
}
중간에 langchainService.query는 LLM을 stream 형식으로 응답으로 보내주는 메소드입니다.
chunk 단위로 나누어서 받을 수 있고 각 chunk마다 write를 해주면 됩니다.
모든 chunk를 보낸 뒤에는 연결을 끊어줍니다.
클라이언트에서는 이러한 스트림 데이터를 받을 때마다 화면에 띄워주면 됩니다.
const response = await fetch(
import.meta.env.VITE_API_URL + "/api/langchain",
{
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "text/event-stream",
},
body: JSON.stringify({ query: question }),
},
);
const reader = response.body
.pipeThrough(new TextDecoderStream())
.getReader();
while (true) {
const { value, done } = await reader.read();
if (done) break;
console.log("Received: ", value);
onHandleAnswer((prev) => prev + value);
}
method는 POST이고 body 데이터를 넣을 것이기 때문에 Content-Type은 application/json으로 설정해두었고 응답 형식은 text/event-stream으로 보내달라는 header를 넣어주었습니다.
데이터가 도착할 때마다 문자열을 그대로 이어주었습니다.
Nginx buffering 이슈
위와 같이 세팅을 해줬음에도 불구하고 스트리밍 형식이 아닌 데이터를 모두 보낸 뒤 한 번에 모아서 출력하는 이슈가 발생했습니다.
한참 동안 원인을 찾지 못 했는데 stack overflow의 글을 보고 해결할 수 있었습니다.
https://stackoverflow.com/questions/13672743/eventsource-server-sent-events-through-nginx
EventSource / Server-Sent Events through Nginx
On server-side using Sinatra with a stream block. get '/stream', :provides => 'text/event-stream' do stream :keep_open do |out| connections << out out.callback { connections.del...
stackoverflow.com
바로 저희 프로젝트는 Nginx를 reverse proxy server로 사용했기 때문이었습니다.
Nginx는 기본적으로 proxy_buffering이 항상 활성화 되어있습니다.
이는 서버로부터 받은 응답을 내부 버퍼에 저장해두고 전체 응답이 버퍼링 될 때까지 기다렸다가 응답을 내려줍니다.
일반적으로 활성화 해두는 것이 좋지만 저희 프로젝트처럼 실시간으로 데이터를 바로 클라이언트에게 보내줘야 할 때 문제가 발생할 수 있습니다.
Nginx 버퍼링이 활성화 되어있는 상태에서 서버가 chunk를 클라이언트에게 계속 전달해주면 Nginx는 내부 버퍼에 해당 chunk들을 모두 모아둡니다.
그리고 응답이 모두 끝났을 때 한 번에 응답을 내려주기 때문에 저희가 원했던 스트리밍 방식 구현에 실패했던 것입니다.
해결 방법은 단순합니다.
Nginx의 버퍼링 기능을 비활성화 하면 됩니다.
비활성화를 위해 비활성화를 원하는 location block 내부에 proxy_buffering off를 추가하면 됩니다.
이렇게 SSE를 사용하는 경우가 아니고 일반 API의 경우 당연히 버퍼링 기능을 활성화 해두는 것이 좋습니다.
그래서 이렇게 스트리밍이 필요한 API 경로에만 버퍼링을 비활성화 해두었습니다.
</etc/nginx/default.conf>
location /api/langchain {
proxy_buffering off;
proxy_pass http://backend:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
결과
TTFB(Time To First Byte)가 3310ms에서 9ms로 감소하였습니다.
<스트리밍 적용 전>

TTFB 3310ms
<스트리밍 적용 후>

TTFB 9ms
그런데 문득 이상한 점을 느꼈습니다.
스트리밍을 적용한다고 해도 서버에서 LLM API 요청을 보내고 응답을 받아올 때까지 시간이 9ms보다 적을 것 같진 않았습니다.
이유를 생각해보니 header를 세팅하자마자 바로 헤더를 클라이언트에게 보내준 것이 원인이었습니다.
const abortController = this.abortService.createController(requestId);
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Request-Id', requestId);
res.flushHeaders();
정확한 측정을 위해 우선 res.flushHeaders를 제거하고 다시 테스트를 해보았습니다.
<스트리밍 적용 전>

TTFB 2760ms
<스트리밍 적용 후>

TTFB 1300ms
실질적인 사용자 대기 시간이 2760ms에서 1300ms로 52% 감소한 것을 확인하실 수 있습니다.