Spring AI에서 LLM 응답을 스트리밍하는 방법 (Server-Sent Events)
요약
Spring AI를 사용하여 LLM의 응답을 Server-Sent Events(SSE) 방식으로 스트리밍하는 방법을 설명합니다. WebFlux의 Flux를 활용하거나 Spring MVC의 SseEmitter를 사용하여 사용자 경험(UX)을 개선하는 구체적인 구현 가이드를 제공합니다.
핵심 포인트
- 스트리밍을 통해 인지된 지연 시간(Perceived latency)을 획기적으로 줄일 수 있습니다.
- Spring AI의 ChatClient는 .stream() 메서드를 통해 리액티브 Flux를 지원합니다.
- WebFlux 환경에서는 text/event-stream 타입을 통해 간단히 SSE를 구현할 수 있습니다.
- Spring MVC 환경에서는 SseEmitter를 사용하여 스트리밍 응답을 처리할 수 있습니다.
LLM을 일반적인 방식으로 호출하면, 모델이 전체 답변을 생성하는 동안 사용자는 스피너(spinner)를 바라보게 되며, 그 후 전체 블록이 한꺼번에 나타납니다. 문장 하나보다 긴 내용이라면, 전체 소요 시간이 스트리밍 응답과 동일하더라도 느리게 느껴집니다. **스트리밍 (Streaming)**은 이 경험을 뒤집습니다. 토큰이 생성되는 대로 나타나기 때문에 사용자가 즉시 읽기 시작할 수 있습니다. 이는 모든 LLM 기능에서 가장 비용 효율적인 UX 개선 방법이며, Spring AI를 사용하면 본질적으로 메서드 호출 한 번으로 가능합니다.
이 가이드는 전체 경로를 다룹니다: ChatClient로부터 스트리밍하기, WebFlux 및 클래식 Spring MVC 모두에서 이를 **Server-Sent Events (SSE)**로 노출하기, 프론트엔드에서 소비하기, 그리고 사람들이 실수하기 쉬운 운영 환경의 세부 사항까지 다룹니다.
왜 스트리밍을 해야 할까요?
주로 인지된 지연 시간 (Perceived latency) 때문입니다. 300단어의 답변을 생성하는 데 6초가 걸리는 모델이, 사용자가 텍스트 뭉치를 보기 위해 6초를 기다려야 한다면 고장 난 것처럼 느껴지지만, 400ms 만에 단어들이 흐르기 시작한다면 빠르게 느껴집니다. 스트리밍을 통해 다음과 같은 작업도 가능합니다:
- 진행 상황을 보여주고 사용자가 잘못된 생성을 조기에 **취소 (cancel)**할 수 있도록 합니다.
- 다운스트림 프로세싱 (마크다운 렌더링, 코드 하이라이팅 등)을 점진적으로 시작할 수 있습니다.
- 사용자가 실제로 느끼는 주요 지연 시간 지표인 첫 번째 토큰 도달 시간 (time-to-first-token)을 줄일 수 있습니다.
[!NOTE]
스트리밍은 토큰이 도착하는 _시점_을 바꿀 뿐, 총 비용이나 토큰 수를 바꾸지는 않습니다. 비용은 동일하게 청구되며, 단지 경험을 개선하는 것뿐입니다.
스트리밍을 위한 단 한 줄의 코드
Spring AI의 ChatClient는 .call() 대신 .stream()을 통해 스트리밍을 노출합니다. .call().content()가 String을 반환하는 반면, .stream().content()는 모델이 생성할 때마다 청크(chunks)를 방출하는 리액티브 Flux<String>을 반환합니다.
@Service
public class ChatService {
...
이것이 모델 측면에서의 전체 변경 사항입니다. 그 외의 모든 것은 해당 Flux를 브라우저로 전달하는 것에 관한 것입니다.
WebFlux를 사용하여 SSE로 노출하기
애플리케이션이 Spring WebFlux를 사용한다면, 스트리밍 엔드포인트를 만드는 것은 매우 간단합니다. text/event-stream 콘텐츠 타입과 함께 Flux를 반환하면 Spring이 SSE 프레이밍 (framing)을 처리합니다.
@RestController
public class ChatController {
...
모델이 생성하는 즉시 각 방출된 청크 (chunk)는 별도의 data: 이벤트로서 클라이언트로 전송됩니다. 만약 명시적인 SSE 이벤트 (ID, 이벤트 이름 또는 하트비트 (heartbeat) 포함)를 원한다면, ServerSentEvent로 매핑하십시오:
@GetMapping(value = "/api/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<String>> stream(@RequestParam String message) {
return chatService.stream(message)
...
WebFlux를 사용하지 않는 경우? Spring MVC에서 SseEmitter 사용하기
@GetMapping("/api/chat/stream")
public SseEmitter stream(@RequestParam String message) {
SseEmitter emitter = new SseEmitter(Duration.ofMinutes(2).toMillis());
...
[!TIP]
SseEmitter에 명시적인 타임아웃 (timeout)을 설정하십시오. 클라이언트가 스트리밍 도중 사라질 경우, 기본 설정은 서블릿 스레드 (servlet thread)를 무기한 대기 상태로 만들 수 있습니다.
프론트엔드에서 스트림 소비하기
GET 엔드포인트의 경우, 브라우저에 내장된 EventSource가 가장 간단한 소비자 (consumer)입니다. 청크가 도착하는 대로 연결하여 사용하십시오:
function streamChat(message, onToken, onDone) {
const url = `/api/chat/stream?message=${encodeURIComponent(message)}`;
const source = new EventSource(url);
...
만약 POST 방식이 필요하다면 (POST를 통해 큰 본문 (body)이나 헤더 (headers)를 전송해야 하는 경우), EventSource는 작동하지 않습니다. 대신 스트리밍 리더 (streaming reader)를 사용하는 fetch를 사용하십시오:
const res = await fetch("/api/chat/stream", {
method: "POST",
headers: { "Content-Type": "application/json" },
...
엔드 투 엔드 (end to end) 흐름
sequenceDiagram
participant B as Browser
participant C as Controller (SSE)
...
실제로 문제를 일으키는 운영 환경의 세부 사항들
스트리밍은 데모 환경에서는 한 번에 잘 작동합니다. 하지만 운영 환경에서 이를 망가뜨리는 요소들은 다음과 같습니다:
- 프록시 버퍼링 (Proxy buffering). Nginx 및 많은 API 게이트웨이는 기본적으로 응답을 버퍼링하며, 이는 스트리밍의 목적을 무색하게 만듭니다. 즉, 클라이언트가 모든 데이터를 한꺼번에 받게 됩니다. 스트림 경로에 대해 버퍼링을 비활성화하고 (
Nginx에서proxy_buffering off;),X-Accel-Buffering: no` 헤더를 전송하세요. - 압축 (Compression). 응답 압축 또한 스트림을 버퍼링할 수 있습니다. gzip 대상에서
text/event-stream을 제외하세요. - 취소 (Cancellation). 사용자가 페이지를 벗어나면 브라우저는 연결을 종료합니다. WebFlux에서는 이것이 자동으로
Flux를 취소하며 (상위 LLM 호출도 중단됨),SseEmitter를 사용하는 경우에는onCompletion/onTimeout콜백을 연결하여 구독을 해제(dispose)함으로써 아무도 읽지 않을 토큰에 대해 비용이 청구되지 않도록 하세요. - 스트림 중간의 오류 (Errors mid-stream). 첫 번째 바이트가 전송된 후에는 HTTP 상태 코드를 변경할 수 없습니다. 클라이언트가
사용자가 취소할 때 LLM 호출을 어떻게 중단하나요?
WebFlux에서는 연결을 닫으면 Flux와 상위(upstream) 요청이 자동으로 취소됩니다. SseEmitter를 사용하는 경우, onCompletion/onTimeout 핸들러에서 리액티브 구독(reactive subscription)을 해제(dispose)하세요.
스트리밍은 LLM 기능에 적용할 수 있는 가장 영향력 있는 UX(사용자 경험) 변화이며, Spring AI를 사용하면 .call()을 .stream()으로 바꾸는 것만으로 이를 구현할 수 있습니다. 엔드포인트를 올바르게 설정하고, 프록시 버퍼링(proxy buffering)을 비활성화하며, 취소 처리를 구현하면 추가 비용 없이 앱이 훨씬 더 빠르게 느껴질 것입니다.
Spring AI가 처음이신가요? Spring AI 개요부터 시작한 다음, 도구 호출 (tool calling)과 엔터프라이즈 Spring AI 가이드를 확인해 보세요. 스트리밍 기능의 용량과 비용을 계획 중이신가요? 무료 LLM 토큰 카운터 (LLM Token Counter)를 사용하여 프롬프트 크기를 추정해 보세요.
프로덕션 AI 기능을 구축 중이며 아키텍처에 대한 검토가 필요하신가요? 함께 이야기해 봅시다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기