Spring Boot 기반 SSE 실시간 메시지 스트리밍 구현

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가 즉시 해제되지 않을 수 있으므로, 주기적인 하트비트나 타임아웃 설정으로 리소스 누수를 방지해야 한다.

태그: SSE Spring Boot SseEmitter EventSource 실시간 스트리밍

5월 25일 19:41에 게시됨