Spring SSE구현

SIHA·2026년 4월 18일

SSE(Server-Sent Events)란

프로젝트를 구현하는 중, OpenAI API와의 채팅 기능을 개발하게 되었다. 실시간으로 AI 응답을 스트리밍해야 했고, 자연스럽게 WebSocket과 SSE 중 하나를 선택해야 하는 상황에 놓였다. 결론적으로 SSE를 선택했다. 이 글에서는 WebSocket과 SSE를 비교하고, 왜 SSE를 선택하게 되었는지 정리하고자 한다.


1. 웹의 실시간 통신

HTTP는 기본적으로 클라이언트와 서버 간의 요청(Request) / 응답(Response) 모델로 동작한다. 클라이언트가 요청을 보내면, 서버는 그에 대한 응답을 반환한다. 응답이 완료되면 연결은 종료된다.

이 구조는 단순하고 명확하지만, 실시간성이 필요한 상황에서는 한계가 있다.

Polling

이를 해결하기 위한 가장 단순한 방법이 Polling이다. Polling은 클라이언트가 일정 주기로 서버에 요청을 보내 상태를 확인하는 방식이다.

클라이언트 → 서버: "new data?"  (매 N초마다 반복)
서버 → 클라이언트: "yes" or "no"

구현 방식이 가장 간단하지만, 명확한 단점이 있다.

  • 서버 리소스 낭비: 변화가 없어도 주기적으로 요청이 발생한다.
  • 실시간성 부족: 이벤트 발생 시점과 클라이언트가 데이터를 받는 시점이 일치하지 않는다.

이러한 단점을 해결하기 위해 등장한 것이 WebSocketSSE다.


2. WebSocket & SSE

WebSocket

WebSocket은 양방향(Full-Duplex) 통신 기술이다. 일반 HTTP와 달리 Stateful하며, 한 번 연결이 수립되면 클라이언트와 서버가 서로 필요할 때마다 자유롭게 메시지를 주고받을 수 있다.

연결 수립 시 HTTP Handshake를 통해 WebSocket 프로토콜로 업그레이드한다.

클라이언트 → 서버: HTTP Upgrade 요청
서버 → 클라이언트: 101 Switching Protocols
--- 이후 WebSocket 연결 유지 ---
클라이언트 ↔ 서버: 자유로운 양방향 메시지

주요 특징:

  • 양방향 통신
  • 지속적인 연결 유지 (Stateful)
  • 실시간 채팅, 게임, 협업 도구에 적합

SSE (Server-Sent Events)

SSE는 서버 → 클라이언트 단방향 스트리밍 기술이다. HTTP 연결을 유지한 채로, 서버가 클라이언트에게 데이터를 지속적으로 흘려보낼 수 있다.

클라이언트 → 서버: 연결 요청 (한 번)
서버 → 클라이언트: 데이터 스트리밍 (지속적)

응답 형식은 data: prefix를 가진 텍스트 스트림이다.

data: {"content": "Hello"}
data: {"content": " world"}
data: [DONE]

주요 특징:

  • 서버 → 클라이언트 단방향
  • HTTP 기반 (별도 프로토콜 업그레이드 불필요)
  • 자동 재연결 지원
  • 구현이 WebSocket보다 단순

3. WebSocket vs SSE

항목WebSocketSSE
통신 방향양방향단방향 (서버 → 클라이언트)
프로토콜ws:// / wss://HTTP
연결 방식Handshake 후 업그레이드일반 HTTP 연결 유지
상태StatefulHTTP 기반
자동 재연결직접 구현브라우저 내장 지원
구현 복잡도높음낮음
적합한 상황실시간 채팅, 게임, 협업알림, 피드, AI 스트리밍

4. SSE를 선택한 이유

이 프로젝트의 통신 구조를 분석해보면 다음과 같다.

클라이언트 → 서버: 사용자 메시지 전송 (HTTP POST)
서버 → 클라이언트: AI 응답 스트리밍 (실시간)

클라이언트 → 서버 방향은 단순한 HTTP POST로 충분하다. 서버가 클라이언트에게 먼저 메시지를 자발적으로 보내야 하는 상황이 없기 때문이다.

결국 서버 → 클라이언트 방향의 스트리밍만 필요하다. WebSocket의 양방향 기능은 이 프로젝트에서 불필요한 복잡도를 추가할 뿐이다.

단방향 스트리밍 특성상 WebSocket보다 SSE가 더 적합하다.


5. Spring WebFlux에서 SSE 구현

SSE 스트리밍 응답을 처리하기 위해 Spring WebFlux의 Flux를 활용했다.

의존성 추가

implementation 'org.springframework.boot:spring-boot-starter-webflux'

Controller

produces = MediaType.TEXT_EVENT_STREAM_VALUE를 설정하면 Spring이 자동으로 SSE 형식으로 응답한다.

@PostMapping(value = "/{sessionId}/messages", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> sendMessage(
        @AuthenticationPrincipal CustomOAuth2User principal,
        @PathVariable Long sessionId,
        @RequestBody SendMessageRequest dto
) {
    return conversationService.sendMessage(principal.getUserId(), sessionId, dto);
}

Service - OpenAI 스트리밍

OpenAI API에 "stream": true를 설정하고, bodyToFlux(String.class)로 응답을 스트림으로 받는다.

public Flux<String> stream(List<ChatMessage> conversationHistory, String userMessage) {
    Map<String, Object> requestBody = Map.of(
            "model", model,
            "max_tokens", maxTokens,
            "stream", true,
            "messages", buildMessages(PromptConstants.CHAT_PROMPT, conversationHistory, userMessage)
    );

    return openAiWebClient.post()
            .uri("/chat/completions")
            .bodyValue(requestBody)
            .retrieve()
            .bodyToFlux(String.class)
            .filter(chunk -> !chunk.equals("[DONE]"))
            .mapNotNull(this::extractStreamContent);
}

스트리밍 응답 수집 및 저장

스트리밍으로 오는 조각들을 doOnNext로 모으고, 완료 시점에 doOnComplete로 DB에 저장한다.

StringBuilder fullResponse = new StringBuilder();

return openAiService.stream(history, dto.getContent())
        .doOnNext(fullResponse::append)
        .doOnComplete(() -> {
            saveAssistantMessage(sessionId, fullResponse.toString());
            conversationRedisService.addMessage(sessionId, ChatMessage.ofAssistant(fullResponse.toString()));
        });

클라이언트 입장에서는 조각조각 실시간으로 받고, 서버는 모든 조각이 도착했을 때 전체 응답을 저장하는 구조다.

profile
뭐라도 해보자

0개의 댓글