LLM 응답 스트리밍 구현: 서버에서 화면까지 흘려보내는 경로
모델에서 서버, 서버에서 브라우저, 브라우저에서 화면까지 세 구간으로 나눠 스트리밍을 구현하고 버퍼링·조각 경계·중단 처리에서 걸리는 지점을 정리했습니다.
기다리는 화면과 흘러나오는 화면
LLM 응답은 생성에 몇 초에서 수십 초가 걸립니다. 완성될 때까지 로딩 스피너만 보여주면 사용자는 멈춘 것으로 느낍니다. 같은 시간이 걸려도 글자가 하나씩 나오기 시작하면 체감이 완전히 달라집니다.
구현 자체는 어렵지 않은데, 경로가 세 구간으로 나뉘어 있어 어느 한 곳만 막혀도 결과가 뭉쳐서 도착합니다. 모델 → 서버 → 브라우저 순서로 짚습니다.
1구간: 모델에서 서버로
대부분의 LLM API가 스트리밍 옵션을 제공합니다. 켜면 응답이 완성될 때까지 기다리지 않고 조각 단위로 도착합니다.
const stream = await client.chat.completions.create({
model: MODEL,
messages,
stream: true,
});
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta?.content;
if (delta) { /* 다음 구간으로 흘려보낸다 */ }
}
여기서 받은 조각을 서버에서 모아 한 번에 응답하면 스트리밍의 의미가 없어집니다. 받는 즉시 내보내야 합니다.
2구간: 서버에서 브라우저로
서버가 응답을 열어둔 채로 조각을 계속 내보내야 합니다. 브라우저에 텍스트를 단방향으로 흘려보내는 용도라면 SSE 형식이 가장 무난합니다. 별도 프로토콜이 필요 없고 재연결 규약이 표준에 들어 있습니다.
export async function POST(req) {
const { messages } = await req.json();
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
try {
const completion = await client.chat.completions.create({ model: MODEL, messages, stream: true });
for await (const chunk of completion) {
const delta = chunk.choices[0]?.delta?.content;
if (delta) controller.enqueue(encoder.encode(`data: ${JSON.stringify({ delta })}\n\n`));
}
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
} catch (err) {
// 스트림 도중 오류는 상태 코드로 알릴 수 없다 — 본문에 실어 보낸다
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ error: "generation_failed" })}\n\n`));
} finally {
controller.close();
}
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
"X-Accel-Buffering": "no",
},
});
}
헤더 세 줄이 중요합니다. no-transform과 X-Accel-Buffering: no는 중간 프록시가 응답을 모아서 보내는 것을 막습니다. 로컬에서는 잘 되다가 배포하면 글자가 뭉쳐서 한 번에 나오는 현상이 대부분 여기서 옵니다.
주석으로 표시한 오류 처리도 놓치기 쉽습니다. 스트림이 시작되면 이미 200 응답이 나간 상태라, 중간에 실패해도 500을 보낼 수 없습니다. 오류를 이벤트로 실어 보내고 클라이언트가 해석하게 해야 합니다.
3구간: 브라우저에서 화면으로
EventSource는 GET만 지원하므로, 메시지 본문을 보내야 하는 채팅에는 fetch로 직접 읽는 방식이 편합니다.
const res = await fetch("/api/chat", {
method: "POST",
body: JSON.stringify({ messages }),
signal: controller.signal, // 중단용
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// 이벤트 경계는 빈 줄. 조각이 중간에 잘려 도착하므로 버퍼에 모아 처리한다
const parts = buffer.split("\n\n");
buffer = parts.pop() ?? "";
for (const part of parts) {
const line = part.replace(/^data: /, "");
if (line === "[DONE]") return;
const { delta, error } = JSON.parse(line);
if (error) throw new Error(error);
if (delta) setText((prev) => prev + delta);
}
}
여기서 가장 흔한 버그가 경계 처리 누락입니다. 네트워크 조각은 이벤트 단위로 오지 않습니다. data: {"del까지만 도착하고 나머지는 다음 조각에 오는 일이 정상적으로 발생합니다. 받은 즉시 JSON.parse를 호출하면 간헐적으로 파싱 오류가 납니다. 재현이 어렵고 운영에서만 터지는 종류의 버그입니다.
decoder.decode(value, { stream: true })의 옵션도 같은 이유입니다. 한글은 여러 바이트로 인코딩되므로 글자 중간에서 잘릴 수 있는데, 이 옵션이 그 상태를 유지해줍니다. 빠뜨리면 글자가 깨집니다.
중단 처리
사용자가 멈추면 생성도 멈춰야 합니다. 비용이 계속 발생하고 있기 때문입니다.
클라이언트에서 AbortController로 요청을 끊으면 서버 쪽에서도 연결이 끊긴 것을 감지할 수 있습니다. 이때 모델 호출도 함께 중단시켜야 합니다. 클라이언트만 끊고 서버가 계속 받고 있으면 사용자는 멈췄다고 생각하는데 토큰은 계속 소비됩니다.
페이지를 닫거나 새로고침한 경우도 마찬가지입니다. 서버가 연결 종료를 확인하고 정리하는 경로를 반드시 만들어두세요.
저장은 언제 하나
스트리밍 중에는 화면에만 보이고 아직 저장되지 않은 상태입니다. 생성이 끝나는 시점에 전체 텍스트를 한 번 저장하는 방식이 단순합니다.
문제는 중간에 끊겼을 때입니다. 네트워크가 끊기거나 사용자가 탭을 닫으면 그때까지 생성된 내용이 사라집니다. 대화 기록이 중요한 기능이라면 서버 쪽에서 조각을 누적해두고, 스트림이 끝나거나 끊길 때 그 시점까지를 저장하는 편이 안전합니다.
서버리스에서도 스트리밍이 되나요?
플랫폼에 따라 다릅니다. 스트리밍 응답을 지원하는 런타임이 있고, 함수 실행 시간 제한이 짧아 긴 생성에는 부적합한 경우도 있습니다. 배포 대상의 스트리밍 지원 여부와 최대 실행 시간을 먼저 확인하세요. 제한이 빠듯하다면 생성을 백그라운드 작업으로 보내고 진행 상황을 따로 조회하는 구조가 대안입니다.
마크다운을 실시간으로 렌더링해도 되나요?
가능하지만 주의가 필요합니다. 생성 중에는 문법이 미완성 상태로 도착합니다. 코드 블록이 열리고 닫히지 않은 상태, 표가 절반만 온 상태가 계속 나타납니다. 매 조각마다 전체를 다시 파싱하면 화면이 요동치고 성능도 나빠집니다. 생성 중에는 평문으로 보여주고 완료 시점에 렌더링하거나, 완성된 블록 단위로만 렌더링하는 방식이 안정적입니다.
응답이 뭉쳐서 오는데 원인을 어떻게 찾나요?
구간을 나눠 확인하는 것이 빠릅니다. 서버 코드에서 조각을 받을 때마다 로그를 찍어보고, 거기서는 순차적으로 찍히는데 브라우저에서만 뭉친다면 2구간(프록시 버퍼링)입니다. 서버 로그부터 뭉쳐서 찍힌다면 모델 호출에 스트리밍 옵션이 안 켜진 것입니다.
직접 흘려보면 차이가 보인다
같은 기능을 스트리밍 없이 만들어보고 비교해보면 체감 차이가 분명합니다. 총 소요 시간은 같은데 사용자가 느끼는 대기는 크게 줄어듭니다.
AI 기능이 들어간 프로젝트를 만들었다면 Vibeollio에 프로젝트 등록해서 공개해보세요. 스트리밍은 네트워크 환경에 따라 체감이 달라지는 부분이라, 여러 환경의 방문자가 직접 써보는 것이 확인에 가장 빠릅니다.
