프로젝트를 구현하는 중, OpenAI API와의 채팅 기능을 개발하게 되었다. 실시간으로 AI 응답을 스트리밍해야 했고, 자연스럽게 WebSocket과 SSE 중 하나를 선택해야 하는 상황에 놓였다. 결론적으로 SSE를 선택했다. 이 글에서는 WebSocket과 SSE를 비교하고, 왜 SSE를 선택하게 되었는지 정리하고자 한다.
HTTP는 기본적으로 클라이언트와 서버 간의 요청(Request) / 응답(Response) 모델로 동작한다. 클라이언트가 요청을 보내면, 서버는 그에 대한 응답을 반환한다. 응답이 완료되면 연결은 종료된다.
이 구조는 단순하고 명확하지만, 실시간성이 필요한 상황에서는 한계가 있다.
이를 해결하기 위한 가장 단순한 방법이 Polling이다. Polling은 클라이언트가 일정 주기로 서버에 요청을 보내 상태를 확인하는 방식이다.
클라이언트 → 서버: "new data?" (매 N초마다 반복)
서버 → 클라이언트: "yes" or "no"
구현 방식이 가장 간단하지만, 명확한 단점이 있다.
이러한 단점을 해결하기 위해 등장한 것이 WebSocket과 SSE다.
WebSocket은 양방향(Full-Duplex) 통신 기술이다. 일반 HTTP와 달리 Stateful하며, 한 번 연결이 수립되면 클라이언트와 서버가 서로 필요할 때마다 자유롭게 메시지를 주고받을 수 있다.
연결 수립 시 HTTP Handshake를 통해 WebSocket 프로토콜로 업그레이드한다.
클라이언트 → 서버: HTTP Upgrade 요청
서버 → 클라이언트: 101 Switching Protocols
--- 이후 WebSocket 연결 유지 ---
클라이언트 ↔ 서버: 자유로운 양방향 메시지
주요 특징:
SSE는 서버 → 클라이언트 단방향 스트리밍 기술이다. HTTP 연결을 유지한 채로, 서버가 클라이언트에게 데이터를 지속적으로 흘려보낼 수 있다.
클라이언트 → 서버: 연결 요청 (한 번)
서버 → 클라이언트: 데이터 스트리밍 (지속적)
응답 형식은 data: prefix를 가진 텍스트 스트림이다.
data: {"content": "Hello"}
data: {"content": " world"}
data: [DONE]
주요 특징:
| 항목 | WebSocket | SSE |
|---|---|---|
| 통신 방향 | 양방향 | 단방향 (서버 → 클라이언트) |
| 프로토콜 | ws:// / wss:// | HTTP |
| 연결 방식 | Handshake 후 업그레이드 | 일반 HTTP 연결 유지 |
| 상태 | Stateful | HTTP 기반 |
| 자동 재연결 | 직접 구현 | 브라우저 내장 지원 |
| 구현 복잡도 | 높음 | 낮음 |
| 적합한 상황 | 실시간 채팅, 게임, 협업 | 알림, 피드, AI 스트리밍 |
이 프로젝트의 통신 구조를 분석해보면 다음과 같다.
클라이언트 → 서버: 사용자 메시지 전송 (HTTP POST)
서버 → 클라이언트: AI 응답 스트리밍 (실시간)
클라이언트 → 서버 방향은 단순한 HTTP POST로 충분하다. 서버가 클라이언트에게 먼저 메시지를 자발적으로 보내야 하는 상황이 없기 때문이다.
결국 서버 → 클라이언트 방향의 스트리밍만 필요하다. WebSocket의 양방향 기능은 이 프로젝트에서 불필요한 복잡도를 추가할 뿐이다.
단방향 스트리밍 특성상 WebSocket보다 SSE가 더 적합하다.
SSE 스트리밍 응답을 처리하기 위해 Spring WebFlux의 Flux를 활용했다.
implementation 'org.springframework.boot:spring-boot-starter-webflux'
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);
}
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()));
});
클라이언트 입장에서는 조각조각 실시간으로 받고, 서버는 모든 조각이 도착했을 때 전체 응답을 저장하는 구조다.