개발 블로그
← 블로그 목록

Spring Boot SSE 실시간 서버 푸시 구현하기


LOL 전적 트래커에서 게임 시작/종료, 랭크 변동이 발생하면 페이지를 새로고침하지 않아도 브라우저에 실시간으로 반영됩니다. 이를 구현한 기술이 SSE(Server-Sent Events)입니다. WebSocket보다 단순하고, 서버 → 클라이언트 단방향 데이터 전송에 딱 맞습니다.

WebSocket 대신 SSE를 선택한 이유

WebSocket은 양방향 통신이 가능하지만 설정이 복잡하고 프록시/로드밸런서에서 문제가 생기는 경우가 있습니다. SSE는 일반 HTTP 연결을 유지하는 방식이라 Nginx 리버스 프록시 뒤에서도 별다른 설정 없이 동작합니다. 서버에서 클라이언트로 이벤트를 푸시하기만 하면 되는 용도에는 SSE가 더 적합합니다.

Spring Boot SseEmitter 기본 구현

@RestController
public class SseController {

    private final List<SseEmitter> emitters = new CopyOnWriteArrayList<>();

    // 클라이언트가 SSE 연결 요청
    @GetMapping(value = "/sse/connect", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public SseEmitter connect() {
        SseEmitter emitter = new SseEmitter(Long.MAX_VALUE); // 타임아웃 없음

        emitters.add(emitter);

        emitter.onCompletion(() -> emitters.remove(emitter));
        emitter.onTimeout(() -> emitters.remove(emitter));
        emitter.onError(e -> emitters.remove(emitter));

        // 연결 즉시 초기 이벤트 전송 (연결 확인용)
        try {
            emitter.send(SseEmitter.event().name("connected").data("ok"));
        } catch (IOException e) {
            emitters.remove(emitter);
        }

        return emitter;
    }

    // 이벤트 브로드캐스트
    public void broadcast(String eventName, Object data) {
        List<SseEmitter> dead = new ArrayList<>();
        for (SseEmitter emitter : emitters) {
            try {
                emitter.send(SseEmitter.event().name(eventName).data(data));
            } catch (Exception e) {
                dead.add(emitter);
            }
        }
        emitters.removeAll(dead);
    }
}

Redis Pub/Sub과 연동

수집 서버(lol-collect)와 API 서버(lol-api)가 분리되어 있어서 수집 서버에서 발생한 이벤트를 API 서버로 전달해야 합니다. Redis Pub/Sub을 중간에 두면 서버 간 의존 없이 이벤트를 전달할 수 있습니다.

// lol-collect: 이벤트 발생 시 Redis에 publish
redisTemplate.convertAndSend("lol:events", objectMapper.writeValueAsString(event));

// lol-api: Redis 메시지 수신 후 SSE 브로드캐스트
@Component
public class RedisEventSubscriber {

    private final SseController sseController;

    public void onMessage(String message) {
        GameEvent event = objectMapper.readValue(message, GameEvent.class);
        sseController.broadcast("game-event", event);
    }
}

// Redis 리스너 등록
@Bean
public RedisMessageListenerContainer container(RedisConnectionFactory factory) {
    RedisMessageListenerContainer container = new RedisMessageListenerContainer();
    container.setConnectionFactory(factory);
    container.addMessageListener(
        new MessageListenerAdapter(redisEventSubscriber, "onMessage"),
        new PatternTopic("lol:events")
    );
    return container;
}

프론트엔드 연결 (React)

useEffect(() => {
    const eventSource = new EventSource('/api/sse/connect');

    eventSource.addEventListener('game-event', (e) => {
        const event = JSON.parse(e.data);
        // 상태 업데이트 → 리렌더링
        setEvents(prev => [event, ...prev]);
    });

    eventSource.onerror = () => {
        eventSource.close();
        // 3초 후 재연결
        setTimeout(() => reconnect(), 3000);
    };

    return () => eventSource.close();
}, []);

Nginx 설정 주의사항

Nginx가 앞단에 있을 때 SSE가 버퍼링되어 이벤트가 즉시 전달되지 않는 문제가 생길 수 있습니다. SSE 엔드포인트에 대해 버퍼링을 끄는 설정이 필요합니다.

location /api/sse/ {
    proxy_pass http://lol-api:8080;
    proxy_buffering off;           # 버퍼링 비활성화 (핵심)
    proxy_cache off;
    proxy_read_timeout 3600s;      # 연결 유지 타임아웃
    proxy_set_header Connection '';
    proxy_http_version 1.1;
    chunked_transfer_encoding on;
}

운영에서 마주친 문제들

  • 연결 누수: 클라이언트가 브라우저를 닫아도 서버에서 즉시 감지하지 못할 수 있습니다. onCompletion/onTimeout 콜백과 주기적인 heartbeat 전송으로 죽은 연결을 정리해야 합니다.
  • 브라우저 연결 제한: HTTP/1.1에서는 같은 도메인에 대해 브라우저가 SSE 연결을 6개로 제한합니다. 탭을 여러 개 열면 연결이 대기 상태가 됩니다. HTTP/2를 사용하면 이 제한이 없어집니다.
  • heartbeat: 연결이 유휴 상태로 너무 오래 있으면 중간 프록시나 로드밸런서가 끊어버릴 수 있습니다. 30초마다 빈 이벤트를 전송해서 연결을 유지합니다.
// 30초마다 heartbeat
@Scheduled(fixedDelay = 30000)
public void sendHeartbeat() {
    broadcast("heartbeat", System.currentTimeMillis());
}