[Dev-Fortune] 시리즈 다시보기


1. 서론: AI 답변, 왜 사용자를 기다리게 만들면 안 되는가?

일반적인 웹 API는 데이터베이스에서 데이터를 조회한 뒤 50~100ms 내외로 즉시 응답을 반환합니다. 하지만 거대 언어 모델(LLM)은 문장을 한 번에 만들어내지 않고, 단어(토큰) 단위로 확률을 계산하며 점진적으로 완성해 나갑니다.

만약 전통적인 동기식(Blocking) HTTP POST 방식을 사용하여 전체 문장이 완성될 때까지 기다린다면, 사용자는 3초에서 5초 동안 멈춰있는 로딩 스피너만 멀뚱히 바라보아야 합니다. 이는 모바일이나 웹 환경에서 매우 답답한 사용자 경험(UX)을 초래합니다.

ChatGPT가 대중을 사로잡았던 핵심 비결 중 하나는 **“질문을 던지자마자 0.3초 만에 첫 글자가 타자 치듯 화면에 찍히는 실시간 스트리밍(Streaming)”**이었습니다. 이번 5편에서는 Spring Boot의 비동기 리액티브 프레임워크인 WebFlux와 SSE(Server-Sent Events)를 결합하여, AI가 뱉어내는 토큰을 실시간으로 클라이언트에 스트리밍하는 고성능 API 파이프라인을 구축합니다.


2. 왜 WebSocket 대신 SSE(Server-Sent Events)인가?

실시간 웹 통신을 구현할 때 흔히 WebSocket과 SSE 사이에서 고민하게 됩니다. 생성형 AI 챗봇 인터페이스에서는 SSE가 압도적으로 유리합니다.

비교 항목 Server-Sent Events (SSE) WebSocket
통신 방향 단방향 (서버 ➔ 클라이언트 전송) 완전 양방향 (클라이언트 ⇆ 서버)
프로토콜 표준 HTTP (HTTP/1.1, HTTP/2) ws://, wss:// (독자 TCP 프로토콜)
프록시 / 방화벽 일반 HTTP 트래픽과 동일하여 차단 없음 L4/L7 방화벽 및 Nginx 특별 설정 필요
연결 재시도 브라우저 기본 내장 자동 재연결 지원 클라이언트에서 직접 재연결 로직 구현
적합한 유스케이스 LLM 답변 스트리밍, 실시간 알림 피드 실시간 멀티플레이어 게임, 협업 캔버스

AI 답변 스트리밍은 사용자가 질문(단발성 요청)을 보낸 후 서버가 토큰을 일방적으로 내려주는 단방향 흐름이므로, 복잡한 핸드셰이크와 세션 유지가 필요한 WebSocket보다 가볍고 단순한 SSE가 최적의 선택입니다.


3. 리액티브 스트리밍 시퀀스 다이어그램

Spring WebFlux의 Flux 스트림과 로컬 Ollama, 그리고 브라우저 간의 비차단 데이터 파이프라인입니다.

sequenceDiagram autonumber actor User as 사용자 브라우저 participant Controller as FortuneController participant Service as FortuneAIService participant Ollama as Ollama Daemon (:11434)
User->>Controller: POST /api/fortune/stream (고민 내용)
activate Controller
Controller->>Service: streamFortune(query)
activate Service

Service->>Ollama: POST /api/chat (stream=true)
activate Ollama
Ollama-->>Service: HTTP Chunk 1 ("오늘")
Service-->>Controller: Flux Token ("오늘")
Controller-->>User: data: {"token": "오늘"}

Ollama-->>Service: HTTP Chunk 2 (" 네 사주는")
Service-->>Controller: Flux Token (" 네 사주는")
Controller-->>User: data: {"token": " 네 사주는"}

Note right of User: 브라우저 화면에 실시간으로<br/>글자가 즉시 렌더링됨

Ollama-->>Service: HTTP Chunk N (done=true)
deactivate Ollama
Service-->>Controller: Stream Complete
deactivate Service
Controller-->>User: event: fortune-done (스트림 종료)
deactivate Controller

4. WebFlux Controller 및 Service 구현

Spring AI의 ChatClient는 응답을 리액티브 스트림으로 받을 수 있는 .stream() 메서드를 내장하고 있습니다.

1) FortuneAIService 구현

@Service
@RequiredArgsConstructor
@Slf4j
public class FortuneAIService {

    private final ChatClient chatClient;
    private final PromptOrchestrator promptOrchestrator;

    public Flux<String> streamFortune(String userQuery) {
        // 4편에서 구축한 오케스트레이터로 프롬프트 빌드 (Persona + RAG)
        Prompt prompt = promptOrchestrator.buildFortunePrompt(userQuery);

        // Ollama로부터 토큰 단위로 수신되는 Flux<String> 반환
        return chatClient.prompt(prompt)
                .stream()
                .content()
                .doOnSubscribe(sub -> log.info("AI 스트리밍 시작"))
                .doOnComplete(() -> log.info("AI 스트리밍 정상 종료"))
                .doOnError(e -> log.error("스트리밍 에러 발생: {}", e.getMessage()));
    }
}

2) WebFlux Controller 구현

produces = MediaType.TEXT_EVENT_STREAM_VALUE를 선언하면 Spring WebFlux는 자동으로 응답 헤더를 Content-Type: text/event-stream 및 Transfer-Encoding: chunked로 설정합니다.

@RestController
@RequestMapping("/api/fortune")
@RequiredArgsConstructor
@Slf4j
public class FortuneStreamController {

    private final FortuneAIService fortuneAIService;

    @PostMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public Flux<ServerSentEvent<String>> getFortuneStream(@RequestBody FortuneRequest request) {
        log.info("사용자 고민 접수: {}", request.content());

        return fortuneAIService.streamFortune(request.content())
                .map(token -> ServerSentEvent.<String>builder()
                        .event("token")
                        .data(token)
                        .build())
                // 스트림 종료 알림 이벤트 부착
                .concatWith(Mono.just(ServerSentEvent.<String>builder()
                        .event("done")
                        .data("[DONE]")
                        .build()))
                // 클라이언트 연결 끊김 시 백엔드 파이프라인 정리
                .doOnCancel(() -> log.info("클라이언트 브라우저 닫힘 감지. 스트리밍 강제 중단"));
    }
}

5. 프론트엔드 연동: Fetch API와 ReadableStream

기본 브라우저 EventSource API는 HTTP GET 요청만 지원하므로, 긴 사주 고민 텍스트를 JSON 바디로 전송하려면 **fetch API의 ReadableStream**을 사용하는 것이 표준입니다.

async function requestFortuneStreaming(userQuery) {
    const chatContainer = document.getElementById("chat-box");
    
    // 1. POST 요청 발송
    const response = await fetch("/api/fortune/stream", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ content: userQuery })
    });

    // 2. 바이트 스트림 리더 획득
    const reader = response.body.getReader();
    const decoder = new TextDecoder("utf-8");
    let accumulatedText = "";

    // 3. 청크 스트림 비동기 읽기
    while (true) {
        const { value, done } = await reader.read();
        if (done) break;

        const chunk = decoder.decode(value, { stream: true });
        
        // SSE 포맷 ('data: 토큰\n\n') 파싱
        const lines = chunk.split("\n");
        for (const line of lines) {
            if (line.startsWith("data:")) {
                const token = line.replace("data:", "").trim();
                if (token === "[DONE]") {
                    console.log("스트리밍 완료!");
                    return;
                }
                accumulatedText += token;
                // DOM에 실시간 타이핑 렌더링
                chatContainer.innerText = accumulatedText;
            }
        }
    }
}

6. 대규모 동시 접속 관점에서의 WebFlux의 위력

전통적인 Tomcat 환경(스레드 풀 200개)에서 200명의 사용자가 동시에 스트리밍 답변을 5초간 요청하면 200개의 스레드가 모두 I/O 대기에 묶여 서버 전체가 마비됩니다(Thread Starvation).

반면 Spring WebFlux는 소수의 Netty 이벤트 루프 워커 스레드(보통 CPU 코어 수의 2배)만으로 수천 개의 스트리밍 SSE 커넥션을 가뿐하게 유지합니다. AI 연산 자체는 Ollama 데몬이 백그라운드에서 수행하고, WebFlux는 데이터가 준비될 때마다 네트워크 소켓에 밀어 넣기만 하므로 자원 소비가 극도로 적습니다.


7. 결론 및 다음 편 예고

WebFlux와 SSE의 조합으로 우리 시스템은 0.3초 만에 사용자에게 반응하는 부드럽고 매력적인 실시간 챗봇 인터페이스를 확보했습니다.

이제 프로젝트의 모든 개별 컴포넌트 구현이 완료되었습니다! 다음 6편에서는 사용자 요청부터 RAG 검색, 프롬프트 조합, LLM 스트리밍 추론까지의 전체 시스템 워크플로우를 하나로 모아 심층 시퀀스 분석과 지연 시간(Latency) 프로파일링을 진행하겠습니다.