
최근 LLM 서비스 프로젝트를 시작하면서 스트리밍 응답을 구현하기 위한 공부가 필요하게 됐습니다. 그래서 공부하는겸 정리하는겸 작성해보려고 합니다!
대표적으로 ChatGPT나 Claude와 같은 서비스들을 보면 사용자가 질문을 던졌을 때, 답변을 한번에 기다리는게 아니라 사람이 타이핑하듯 실시간으로 답변이 나타나는 것을 볼 수 있습니다.
이러한 스트리밍 응답을 구현하기 위해서는 서버에서 클라이언트로 실시간 데이터를 전송할 수 있는 기술이 필요한데요. 대표적으로 웹 소켓(WebSocket)과 SSE(Server-Sent Events)가 있습니다.
웹 소켓은 클라이언트와 서버 간의 양방향 실시간 통신을 가능하게 하는 프로토콜입니다. HTTP와는 다르게 한 번 연결을 해놓으면 클라이언트와 서버가 자유롭게 데이터를 주고받을 수 있습니다.
const socket = new WebSocket("ws://localhost:8080");
socket.onopen = () => {
console.log("연결됨");
socket.send("Hello Server!");
};
socket.onmessage = (event) => {
console.log("서버로 부터:", event.data);
};
socket.onclose = () => {
console.log("연결 끊김");
// 재연결 로직을 직접 구현해야 한다.
setTimeout(() => {
connectWebSocket();
}, 1000);
};
socket.onerror = (error) => {
console.error("WebSocket 에러:", error);
};
SSE는 서버에서 클라이언트로 단방향 실시간 데이터 전송을 위한 웹 표준 기술입니다. 일반적인 HTTP 연결을 유지하면서 서버가 클라이언트에게 지속적으로 이벤트를 보낼 수 있습니다.
const eventSource = new EventSource("/api/events");
eventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
console.log("서버로 부터:", data);
};
eventSource.onerror = (error) => {
console.error("SSE 에러:", error);
// 브라우저가 자동으로 재연결 시도
};
// 헤더가 필요한 경우 fetch를 사용
async function connectSSEWithFetch() {
const response = await fetch("/api/stream", {
headers: {
"Authorization": `Bearer ${token}`,
"Accept": "text/event-stream",
},
});
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const {value, done} = await reader.read();
if (done) {
break;
}
const chunk = decoder.decode(value);
// SSE 데이터 파싱 및 처리
console.log(chunk);
}
}
| 구분 | 웹 소켓 (WebSocket) | SSE (Server-Sent Events) |
|---|---|---|
| 통신 방향 | 양방향 (클라이언트 <-> 서버) | 단방향 (서버 -> 클라이언트) |
| 프로토콜 | WebSocket (ws://, wss://) | HTTP/HTTPS |
| 재연결 | 수동 구현 필요 | 브라우저 자동 처리 |
| 데이터 형식 | 바이너리/텍스트 자유 | 텍스트(주로 JSON) |
| 브라우저 지원 | 모든 모던 브라우저 | 모든 모던 브라우저 |
| 방화벽/프록시 | 차단될 수 있음 | HTTP이므로 통과 용이 |
| 서버 구현 | 복잡 (별도 프로토콜 핸들링 필요) | 간단 (HTTP 스트림) |
| HTTP/2 호환성 | 제한적 | 완벽 지원 |
| 연결 오버헤드 | 낮음 | 약간 높음 (HTTP 헤더 오버헤드) |
| 구현 복잡도 | 높음 | 낮음 |
| 사용 사례 | 채팅, 게임, 협업 도구 | 알림, 라이브 업데이트, LLM 스트리밍 |
LLM 응답은 본질적으로 서버에서 클라이언트로 흐르는 단방향 데이터입니다. 사용자가 질문을 보내는 것은 별도의 POST 요청으로 처리하고 응답은 SSE로 처리하는 것이 자연스럽다고 생각했습니다.
웹소켓은 연결 관리, 재연결, 에러 처리 등을 직접 구현해야 합니다. SSE는 브라우저가 대부분 자동으로 처리해주기 때문에 적절하다고 판단했습니다.
// WebSocket - 복잡한 재연결 로직 필요
function connectWebSocket() {
const socket = new WebSocket("ws://localhost:8080");
socket.onclose = () => {
// 재연결 로직 직접 구현
setTimeout(() => connectWebSocket(), 1000);
};
}
// SSE - 브라우저가 자동 처리
const eventSource = new EventSource("/api/stream");
// 재연결은 브라우저가 알아서!
많은 사용자 환경에서 웹소켓 연결이 방화벽이나 프록시에 의해 차단되는 경우가 있다고 합니다. 반면 SSE는 일반적인 HTTP 연결이기 때문에 이러한 문제는 상대적으로 적다고 봅니다.
SSE는 HTTP/2의 멀티플렉싱을 자연스럽게 활용할 수 있어 여러 개의 스트림을 효율적으로 처리할 수 있습니다.
HTTP/2 멀티플렉싱이란? 하나의 연결에서 여러 개의 요청과 응답을 처리할 수 있는 기능입니다. 예를 들어 사용자가 여러개의 질문을 던져도 각각 독립적인 스트림으로 처리되어 서로 블로킹이 되지 않습니다.
SSE에 대해서 자료를 찾아보는 중에,, SSE는 Axios를 쓸 수 없다고 합니다. 저는 주로 Axios로 구현된 환경이 많았기 때문에 왜그럴까 조사를 해보았습니다.
Axios는 전통적인 요청-응답 패턴을 위해 설계된 라이브러리로 응답이 완전히 끝난 후에야 Promise를 resolve 하기 때문입니다.
전체 응답이 끝나야 then이 풀리는 모델이라 중간 중간 들어오는 텍스트 청크를 실시간으로 처리하기 어렵습니다.
// Axios - 모든 데이터를 받은 후에야 처리
const response = await axios.get("/api/data");
console.log(response.data); // 모든 데이터가 도착한 후에야 실행
// SSE - 실시간 데이터 처리 필요
const eventSource = new EventSource("/api/stream");
eventSource.onmessage = (event) => {
// 데이터 청크가 올때마다 실시간 처리
console.log("실시간:", event.data);
};
SSE의 표준 클라이언트는 EventSource이고, 헤더가 필요하면 fetch를 사용하면 됩니다. 그래서 저는 로그인 기능도 있고 , 헤더를 보낼 일이 종종 있을 것이기 때문에 fetch를 사용해서 구성할 예정입니다!
SSE는 특정한 텍스트 형식을 사용합니다.
data: 실제 데이터 내용
event: 이벤트 타입 (선택 사항)
id: 이벤트 ID (선택 사항)
retry: 재연결 간격 밀리초 (선택 사항)
각 필드는 개행문자로 구분되고, 메세지는 빈 줄로 끝납니다.
LLM 스트리밍에서는 대략적으로 이런 형태를 가지게 됩니다.
// OpenAI 스타일
data: {"choices":[{"delta":{"content":"안녕"}}]}
data: {"choices":[{"delta":{"content":"하세요"}}]}
data: {"choices":[{"delta":{"content":"!"}}]}
data: [DONE]
// Anthropic Claude 스타일
event: content_block_delta
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"안녕"}}
event: content_block_delta
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"하세요"}}
event: message_stop
data: {"type":"message_stop"}
마크다운을 실시간으로 렌더링하는 방법도 구상이 필요해 보입니다! event는 각 모델에서 내려오는 방법이 다르다고 해서 어떻게 내려오는지 의논이 필요한 단계입니다.
Next.js + TypeScript를 이용해서 SSE를 구현하기 위한 대략적인 구상입니다.
async const function POST(request: NextRequest) {
const { message } = await request.json();
const headers = new Headers({
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
});
const stream = new ReadableStream({
async start(controller) {
// LLM API 호출 및 스트림 처리
// 각 토큰을 SSE 형식으로 전송
}
});
return new NextResponse(stream, { headers });
}
interface ChatMessage {
id: string;
role: "user" | "assistant";
content: string;
status: "streaming" | "complete" | "error";
timestamp: Date;
}
export default function ChatInterface() {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [isStreaming, setIsStreaming] = useState(false);
const sendMessage = async (content: string) => {
const userMessage: ChatMessage = {
id: generateId(),
role: "user",
content,
status: "complete",
timestamp: new Date(),
};
setMessages((prev) => [...prev, userMessage]);
const assistantMessageId = generateId();
const assistantMessage: ChatMesssage = {
id: assistantMessageId,
role: "assistant",
content: "",
status: "streaming",
timestamp: new Date(),
};
setMessages((prev) => [...prev, assistantMessage]);
setIsStreaming(true);
try {
const response = await fetch("/api/chat/stream", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer: ${token}`,
},
body: JSON.stringify({ message: content }),
});
const reader = response.body?.getReader();
const decoder = new TextDecoder();
} catch {
// 에러 처리
}
}
}
Message의 타입을 지정해서 상태 관리를 할까 고민 중입니다. 스트리밍 상태(streaming, complete, error)를 추가해서 UI에서 로딩이나 에러 상태를 보여줄 수 있을 것 같습니다.
LLM 스티리밍 서비스에는 웹소켓보다 SSE가 더 적합한 경우가 많습니다. 구현이 간편하고 브라우저와의 호환성이 좋고, LLM의 단방향 응답 특성과도 잘 맞기 때문입니다.
앞으로 실제 구현을 진행하면서 마주친 문제들이나, 고려했던 점... 해결책 등등 다음에 추가로 정리해보겠습니다!
MDN WebSocket API
MDN Server-Sent Events
Understanding Server-Sent Events (SSE) with Node.js
Server-Sent Events 정리 (+사용법)
SSE로 실시간 데이터 전송하기
Next.js API Routes
SSE를 들어만 봤지 실제 어떻게 동작하고 어떻게 구현되는지 알 수 있어서 좋았습니다!
SSE에서 자주 사용되는 Axios같은 라이브러리가 있지 않을까 궁금해집니다. 혹시 없다면 직접 만들어보시는 것도 좋을 것 같습니다~!!!!
SSE에 특징에 대해 자연스레 알게 되는 글인 것 같습니다! 웹 소켓과의 차이점도 명확하게 알게 됐습니다!
그리고 프로젝트에서 SSE로 선택하게 되는 과정이 흥미로웠습니다!!!
SSE 하니까, 제가 맡았던 프로젝트에서 데이터를 실시간으로 보여줘야 한다는 요구사항이 들어와서, 실시간 이라는 키워드에 꽂혀서 SSE를 도입할 뻔 했던 경험이 문득 떠올랐습니다.
결국에는 사용자의 환경을 고려했을 때 실시간 데이터를 꼭 실시간으로 사용자가 확인할 필요는 없다! 라는 결론이 나서 적당한 refetching 전략으로 문제를 해결했었는데 상황에 맞는 기술 도입의 중요성을 느꼈었는데, 글의 의도와는 다르게 다시 한번 그때의 교훈을 상기할 수 있었습니다..^^
SSE에 대한 아쉬움이 있었는데 다음 포스팅도 기대하겠습니다!
저희 솔루션에서, 대기중인 환자 리스트를 10초에 한번씩 인터벌로 불러오는걸 보고 (...) sse를 도입해야겠다고 생각하고 있었거든요! 도움이 되는 글입니다!
읽으면서 흐름이 매끄럽고, 개념 설명 → 비교 → 선택 이유 → 구현 계획까지 체계적으로 잘 정리되어 있어서 이해하기 쉬웠습니다.
특히 Axios가 SSE에 적합하지 않은 이유까지 짚어준 부분이 신기하고 인사이트 있던거 같았어요!! 좋은글 감사합니다~