SSE, 어디까지 써봤니? 실전 적용과 서버 구현 (1편)

최기웅·2025년 3월 4일
0
post-thumbnail

1. 들어가며

실시간 데이터 전송이 필요한 환경에서는 WebSocket, Long Polling, Server-Sent Events(SSE) 같은 기술이 활용됩니다. 이번 글에서는 SSE를 실전에서 적용하면서 경험한 것들을 공유하고자 합니다.

SSE를 처음 접한 것은 이전에 진행한 끄적끄적 프로젝트에서 실시간 알림 기능을 구현할 때였습니다. 당시 SSE를 적용하면서 기본적인 동작 방식과 활용 가능성을 경험할 수 있었습니다. 하지만 실제 운영 환경에서는 연결 유지, 다중 서버 대응 등 여러 가지 개선이 필요하다는 점을 알게 되었습니다.

이 블로그에서는 SSE 기반 실시간 알림 기능을 최적화하고 확장하는 과정을 정리하고자 합니다. 1편에서는 SSE의 기본 개념과 프로젝트 적용 과정을 다루고, 2편에서는 Redis를 활용한 SSE 연결 관리 및 Kafka를 이용한 다중 서버 환경에서의 동작 방식을 소개하겠습니다.


2. SSE란?

SSE(Server-Sent Events)는 서버에서 클라이언트로 단방향 스트리밍 데이터를 전송하는 기술입니다.

HTTP 기반으로 작동하며, 클라이언트가 연결을 맺으면 서버가 지속적으로 데이터를 전송할 수 있습니다.

image

2-1. SSE 특징

  • 단방향 통신 (서버 → 클라이언트)
    • 클라이언트가 한 번 구독하면 끊길때까지 서버는 지속적으로 통신이 가능합니다.
  • HTTP 기반으로 별도의 프로토콜 없이 사용이 가능합니다.
  • 자동 재연결을 지원합니다.

2-2. SSE 선택 이유

SSE는 단방향 데이터 전송에 최적화되어 있어 실시간 알림 시스템에 적합합니다.

기본적으로 자동 재연결을 지원하며, 별도의 핸드셰이크 없이 HTTP 기반으로 간편하게 적용할 수 있습니다.

그리고 EventSource API를 활용하면 클라이언트 구현이 간단하고 유지보수가 용이합니다.

WebSocket보다 가벼운 연결 방식으로 리소스 사용이 적으며, 프록시 환경에서도 안정적으로 동작합니다. 특히, WebSocket은 비표준 프로토콜(ws, wss)을 사용하기 때문에 방화벽이나 프록시 서버에서 차단될 가능성이 있으며, 연결이 불안정할 수 있습니다.

따라서, 양방향 통신이 필요하지 않은 실시간 알림 기능에 가장 적합한 기술이라 생각했기에 SSE를 사용하였습니다.


3. 프로젝트에서 SSE 적용하기

3-1. 동작 과정

프로젝트에 적용하기 전에 먼저 동작 과정을 살펴보겠습니다.

  1. 클라이언트가 SSE 연결 요청을 보내면, 서버는 HTTP 스트림을 열어 유지합니다.
  2. 서버는 text/event-stream 형식으로 데이터를 지속적으로 전송합니다.
  3. 이벤트가 발생할 때마다 데이터를 전송하며, 필요하면 특정 클라이언트에게만 전송할 수도 있습니다.
  4. 클라이언트 연결이 끊기면 자동으로 재연결 요청이 오며, 서버는 이를 처리하여 다시 연결을 유지할 수 있습니다.

3-2. 프로젝트 적용

먼저 SSE를 연결하기 위해서는 SseEmitter 를 알고가야합니다.

SseEmitter는 Spring에서 SSE 기능을 지원하는 클래스로, 서버가 클라이언트에게 비동기적으로 데이터를 지속적으로 전송할 수 있도록 도와줍니다.

SseEmitter의 특징으로는

  • 비동기 처리를 지원합니다.
    • 클라이언트와 연결을 유지하면서 데이터 전송이 가능합니다.
  • 연결을 유지합니다.
    • 서버에서 이벤트가 발생할 때마다 클라이언트에 데이터를 푸시합니다.
  • 타임아웃 설정이 가능합니다.
    • 기본적으로 일정 시간이 지나면 자동으로 종료됩니다.
  • 멀티스레드 환경에서 안전합니다.
    • 다른 쓰레드에서 데이터를 전송할 수 있습니다.

즉, SseEmitter는 Spring에서 SSE를 구현할 때 핵심 역할을 하는 객체이며, 클라이언트가 구독한 후 비동기적으로 이벤트를 전송하는데 활용됩니다.

SseEmitter는 이와 같이 사용합니다.

  1. 객체 생성

    SseEmitter sseEmitter = new SseEmitter(timeout)

    을 사용하여 인스턴스를 생성합니다.

  2. 데이터 전송

    sseEmitter.send(data)

    를 호출하여 클라이언트로 데이터를 전송합니다.

  3. 완료 처리

    sseEmitter.complte()

    로 스트림을 닫거나, onCompletion()을 활용해 자동으로 정리합니다.

이제 정말로 프로젝트에 적용해보겠습니다.

[SSE 연결]

public class SseEmitterManager {

    private static final Long DEFAULT_TIMEOUT = 60L * 1000 * 60; // 1시간
    private final Map<String, SseEmitter> emitters = new ConcurrentHashMap<>();

    public SseEmitter connect(final Long memberId) {
        String emitterId = String.valueOf(memberId);
        SseEmitter emitter = new SseEmitter(DEFAULT_TIMEOUT);
        
        emitters.put(emitterId, emitter);

        registerEmitterCallbacks(emitter, emitterId);

        log.info("새로운 SSE 연결 생성: {}", emitterId);

        return emitter;
    }
    
    private void registerEmitterCallbacks(SseEmitter emitter, String emitterId) {
        emitter.onCompletion(() -> {
            log.info("비동기 요청 완료");
            removeEmitter(emitterId);
        });

        emitter.onTimeout(() -> {
            log.info("시간 초과");
            emitter.complete();
            removeEmitter(emitterId);
        });

        emitter.onError((e) -> {
            removeEmitter(emitterId);
            throw new EmitterCallbackException("Emitter 에러발생", e);
        });
    }
    
    private void removeEmitter(String emitterId) {
        emitters.remove(emitterId);
    }

}
  1. ConcurrentHashMap을 사용해 SseEmitter 객체를 관리합니다.
    • memberId 기반의 emitterId를 생성하여 저장합니다.
    • 멀티스레드 환경에서도 안전하게 동작합니다.
    • Map으로 관리하는 이유는 사용자별 SSE 연결을 효율적으로 관리하고, 연결이 끊어졌을 때 빠르게 정리하여 메모리 누수를 방지할 수 있기때문입니다.
  2. connect(Long memberId) 메소드
    • 새로운 SseEmitter를 생성하고 emitterId를 키로 하여 저장합니다.
    • registerEmitterCallbacks를 호출해 콜백을 등록합니다.
  3. 콜백 메서드 (onCompletion, onTimeout, onError)
    • SSE 연결이 종료되거나 에러 발생 시 removeEmitter(emitterId) 호출하여 정리합니다.

[데이터 전송]

public class SseEmitterManager {

    public void send(Member targetMember, Object data) {
        String emitterId = String.valueOf(targetMember.getId());
        SseEmitter emitter = emitters.get(emitterId);

        try {
            emitter.send(SseEmitter.event()
                    .id(emitterId)
                    .data(data));
        } catch (IOException exception) {
            removeEmitter(emitterId);
            throw new SendFailedException("전송 실패", exception);
        }
    }
    
}
  • SseEmitter.event()를 이용해 이벤트 객체를 생성합니다.
  • .id(emitterId)로 이벤트 ID를 설정합니다.
  • .data(data)로 클라이언트로 보낼 데이터를 설정합니다.

[실행 결과]

image

이와 같이 SSE 연결이 된 것을 확인할 수 있고, 데이터가 클라이언트에게 전달된 것을 확인할 수 있습니다.


4. 마무리

이번 글에서는 SSE를 프로젝트에 적용하는 과정을 다뤘습니다.

다음 2편에서는 운영 환경에서의 SSE 최적화를 중심으로, 연결 유지, 다중 서버 대응 등에 대해 고민하고 해결한 내용을 공유하겠습니다.

해당 내용의 코드와 자세한 구현 방식은 아래 깃허브에서 확인할 수 있습니다.

🔗 GitHub: sse-study

☺️ 더 나은 개선 방향이나 궁금한 점이 있다면 언제든지 의견 남겨주세요!

profile
https://giwoong01.tistory.com/

0개의 댓글

관련 채용 정보