Server-Sent Events(SSE)는 서버에서 클라이언트로 단방향 실시간 데이터를 전송하는 HTTP 기술이다. WebSocket에 비해 구조가 단순하고 HTTP 프로토콜만으로 동작해 방화벽 우회가 용이하다는 장점이 있다. 별도의 프로토 협상 없이 바로 사용할 수 있어 실시간 알림, 로그 스트리밍, 주가 변동 등의 시나리오에 적합하다.
SSE 동작 원리
SSE는 클라이언트가 표준 HTTP GET 요청을 보내면 서버가 text/event-stream 미디어 타입으로 응답 스트림을 유지하는 방식이다. 연결이 어지면 클라이언트가 자동으로 재연결을 시도하며, 마지막 수신 이벤트 ID를 Last-Event-ID 헤더로 전달해 누락된 메시지를 복구할 수 있다.
Spring Boot 프로젝트 설정
필요한 의존성은 spring-boot-starter-web 하나로 충분하다. 별도의 추가 라이브러리가 필요하지 않다.
build.gradle 예시
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
}
SSE 엔드포인트 구현
SseEmitter를 활용해 비동기 스트리밍 엔드포인트를 구성한다. 연결 타임아웃을 설정하고 외부 스레드 풀에서 메시지를 전송하면 다수의 클라이언트를 동시에 처리할 수 있다.
NotificationStreamController.java
package com.example.streaming.controller;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.io.IOException;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
@RestController
@RequestMapping("/api/v1/stream")
public class NotificationStreamController {
private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(4);
@GetMapping(value = "/alerts", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter openAlertChannel() {
String channelId = UUID.randomUUID().toString().substring(0, 8);
SseEmitter channel = new SseEmitter(300_000L); // 5분 타임아웃
channel.onCompletion(() -> System.out.printf("채널 %s 종료됨%n", channelId));
channel.onTimeout(() -> System.out.printf("채널 %s 타임아웃%n", channelId));
CompletableFuture.runAsync(() -> transmitPeriodicAlerts(channel, channelId));
return channel;
}
private void transmitPeriodicAlerts(SseEmitter emitter, String identifier) {
scheduler.scheduleAtFixedRate(() -> {
try {
String payload = String.format("{\"timestamp\":%d,\"source\":\"%s\",\"content\":\"%s\"}",
System.currentTimeMillis(), identifier, generateRandomContent());
emitter.send(SseEmitter.event()
.id(String.valueOf(System.currentTimeMillis()))
.name("alert")
.data(payload));
} catch (IOException ex) {
emitter.completeWithError(ex);
}
}, 0, 2, TimeUnit.SECONDS);
}
private String generateRandomContent() {
String[] levels = {"INFO", "WARN", "CRITICAL"};
return levels[(int) (Math.random() * levels.length)] + " - 시스템 이벤트 발생";
}
}
SseEmitter.event() 빌더를 사용하면 이벤트 이름, ID, 데이터를 명시적으로 구성할 수 있다. 이는 클라이언트 측에서 특정 이벤트 타입만 선택적으로 수신하는 데 유용하다.
클라이언트 수신 로직
브라우저의 EventSource API는 자동 재연결과 이벤트 ID 추적을 기본으로 제공한다. 다음 예시는 이벤트 타입별 리스너를 분리하고 연결 상태를 모니터링한다.
alert-dashboard.html
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<title>실시간 알림 대시보드</title>
<style>
.log-entry { padding: 8px; margin: 4px 0; border-radius: 4px; }
.INFO { background: #e3f2fd; }
.WARN { background: #fff3e0; }
.CRITICAL { background: #ffebee; }
</style>
</head>
<body>
<h2>실시간 스트림 모니터</h2>
<div>상태: <span id="connState">연결 중...</span></div>
<div id="alertContainer"></div>
<script>
const feedUrl = '/api/v1/stream/alerts';
let eventFeed = null;
function initializeStream() {
eventFeed = new EventSource(feedUrl);
eventFeed.addEventListener('alert', (evt) => {
const alertData = JSON.parse(evt.data);
renderLogEntry(alertData);
});
eventFeed.addEventListener('error', () => {
document.getElementById('connState').textContent = '오류 발생, 재연결 시도...';
});
eventFeed.onopen = () => {
document.getElementById('connState').textContent = '연결됨';
};
}
function renderLogEntry(payload) {
const container = document.getElementById('alertContainer');
const entry = document.createElement('div');
entry.className = `log-entry ${payload.content.split(' ')[0]}`;
entry.textContent = `[${new Date(payload.timestamp).toLocaleTimeString()}] ${payload.source}: ${payload.content}`;
container.prepend(entry);
}
initializeStream();
</script>
</body>
</html>
운영 고려사항
- 연결 한도: 톰캣 기본 설정에서는 동시 연결 수가 제한될 수 있다.
server.tomcat.max-threads또는 별도의 비동기 서버(Netty)를 고려해야 한다. - 프록시 설정: Nginx 등 리버스 프록시 환경에서는
proxy_buffering off;와proxy_read_timeout을 적절히 설정해야 실시간성이 보장된다. - 연결 정리: 클라이언트가 연결을 끊어도 서버의
SseEmitter가 즉시 해제되지 않을 수 있으므로, 주기적인 하트비트나 타임아웃 설정으로 리소스 누수를 방지해야 한다.