SSE 를 이용한 이벤트 전송

희운·2025년 9월 29일

SpringBoot

목록 보기
10/10

SSE 는 그냥 단순하게 단향방 통신에서 사용된다 이정도만 알고 사용하였다.
그저 HTTP에서 지원해는 기능? 정도 구나라고 생각했지만
HTTP가 지원하는 기능이라기보다는
HTTP 위에 정의된 이벤트 전송 규약(HTML5 표준)이라고 한다.
SSE는 HTTP 위에서 동작하는 표준화된 이벤트 스트리밍 방식이다.

그래서 별도의 라이브러리 없이 사용할 수 있나보다....

클라이언트 → 서버 구독 요청

const eventSource = new EventSource("/subscribe");

GET /subscribe HTTP/1.1
Host: api.example.com
Accept: text/event-stream

브라우저는 /subscribe에 HTTP GET 요청을 보낸다.
client 가 보내는 단순 GET 요청에 Accept: text/event-stream 헤더 포함만 하는것이다. (단순 요청)

서버 → 클라이언트 응답 (SseEmitter 반환)

    @GetMapping(value = "/api/subscribe", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    @ResponseStatus(HttpStatus.OK)
    public SseEmitter subscribe(@AuthenticationPrincipal UserDetails userDetails,
                                // Last-Event-ID 헤더는 마지막으로 받은 이벤트부터 이벤트 스트리밍을 재개하는 데 사용됩니다.
                                @RequestHeader(value = "Last-Event-ID", required = false, defaultValue = "")
                                String lastEventId) {
                                
        return sseNotificationService.subscribe(userDetails.getUsername(), lastEventId);
        
    }
    

HTTP/1.1 200 OK
Content-Type: text/event-stream;charset=UTF-8
Cache-Control: no-cache
Connection: keep-alive
Transfer-Encoding: chunked

이때 컨트롤러가 SseEmitter를 반환하면
Spring MVC가 HTTP 응답을 끊지 않고 열린 스트림으로 바꿔줍니다.

  • SseEmitter 는 간단하게 말하면 Event-stream 의 파이프역할(통로) 을 한다고 생각하면 쉽다.
  • Client 는 최초에 받은 SseEmitter(파이프) 를 통해서 이벤트를 통해서 받으면 된다.

여기서 궁금한게 컨트롤러에서 단순하게 SSeEmitter 를 반환했을 뿐인데
헤더에 Content-Type: text/event-stream을 반환 하는것일까 ?

Spring MVC 가 요청응답의 헤더를 추가해주는 과정을 찾아보았다.

1. DispatcherServlet


// DispatcherServlet 내부
returnValueHandler.handleReturnValue(returnValue, returnType, mavContainer, webRequest);

2. Handler 선택

  • SseEmitterResponseBodyEmitter를 상속
  • 그래서 ResponseBodyEmitterReturnValueHandler가 선택됨
public class ResponseBodyEmitterReturnValueHandler implements HandlerMethodReturnValueHandler {
    @Override
    public boolean supportsReturnType(MethodParameter returnType) {
        return ResponseBodyEmitter.class.isAssignableFrom(returnType.getParameterType());
    }
}

3. ResponseBodyEmitterReturnValueHandler 실행


@Override
public void handleReturnValue(Object returnValue, MethodParameter returnType,
        ModelAndViewContainer mavContainer, NativeWebRequest webRequest) throws Exception {

    mavContainer.setRequestHandled(true); // DispatcherServlet이 view 렌더링 안 함

    HttpServletResponse response = webRequest.getNativeResponse(HttpServletResponse.class);
    response.setHeader("Cache-Control", "no-store");
    response.setContentType("text/event-stream;charset=UTF-8"); // 🔹 여기서 헤더 세팅

    ResponseBodyEmitter emitter = (ResponseBodyEmitter) returnValue;
    emitter.initialize(response); // Emitter와 응답 OutputStream 연결
}

4. Emitter 동작

  • 이제 컨트롤러에서 반환한 SseEmitter 객체는 응답 OutputStream에 바인딩됨

  • 이후 다른 스레드에서 emitter.send(...) 하면 → response stream에 바로 쓰여 나갑니다.

  • HTTP 응답은 200 OK 상태로 열려 있고, 끊어지지 않음

SseEmitter.send()가 내부적으로 ServletResponse.getOutputStream().write(...) 까지 이어지는 실제 I/O 플로우는 나중에 자바 입출력을 공부하고 복습해보자.

이제 이벤트가 발생했을때 (emitter.send를 했을때) Client 에 어떤 형태로 응답이 보내질까?
SSE 는 서버 -> Client 니까 매번 새로운 HTTP 응답이 발생하는것인줄 알았다.

하지만 그게 아니라 최초의 응답의 헤더를 그대로 사용하는것이였다.

HTTP/1.1 200 OK
Content-Type: text/event-stream;charset=UTF-8
Cache-Control: no-cache
Connection: keep-alive
Transfer-Encoding: chunked

응답 body (계속 append 됨)

data: hello world
id: 1

event: notification
data: {"msg":"새 알림"}
id: 2

data: ping

정리하면

  • 헤더는 최초 한 번 내려가고 그 상태에서 연결 유지됨.

  • 이벤트가 생길 때마다 body에 줄 단위로 append.

  • 클라이언트(EventSource)는 이 body 스트림을 읽어서 이벤트로 파싱.


SseEmitter.send() 를 통한 Event 전달 과정

  1. Emitter와 HTTP 응답 OutputStream 연결
  • 컨트롤러에서 return new SseEmitter() 하면
    Spring MVC(ResponseBodyEmitterReturnValueHandler)가
    이 emitter를 현재 HTTP 응답 스트림(ServletResponse.getOutputStream())과 연결해둡니다.
    다시 말하면, HttpServletResponse.getOutputStream()을 얻어와서 emitter와 연결해둡니다.
    -> 즉, emitter 객체가 내부적으로 response outputStream을 가지고 있고, 그걸 통해 write 가능한 상태가 됩니다.

즉, emitter 객체가 내부적으로 response outputStream을 가지고 있고, 그걸 통해 write 가능한 상태가 됩니다.

  1. emitter.send() 호출 순간
  • 내부적으로는 이렇게 처리돼요:
outputStream.write("data: ...\n\n".getBytes());
outputStream.flush();

즉, 호출 즉시 응답 body에 write + flush가 일어납니다.
메서드 종료와 무관
일반적인 @ResponseBody 응답은 메서드가 return 할 때 한 번에 body를 만들어 내려주죠.
SSE는 다릅니다.
SseEmitter가 응답을 열어둔 상태라, 메서드가 끝났든 말든 send() 하는 순간 body에 즉시 append됩니다.

  1. 클라이언트 수신 시점
  • 서버에서 flush()까지 호출되면 TCP 버퍼를 통해 바로 클라이언트로 흘러갑니다.
    클라이언트는 이걸 이벤트 블록 단위로 파싱해서 onmessage 또는 addEventListener로 전달받죠.

정리

1.SseEmitter.send() 호출

  • 서버 쪽에서 이벤트 데이터를 준비 (data: ..., id: ..., event: ... 같은 문자열)
    이걸 Spring 내부가 비동기 TaskExecutor를 통해 HTTP 응답 body에 write + flush

2.응답 body는 이미 열려 있는 HTTP 스트림 (Content-Type: text/event-stream)

  • 즉, send()가 호출될 때마다 body에 append 됨
    헤더는 최초 한 번만 내려갔기 때문에 이후엔 body만 계속 이어짐

3.네트워크 전송

  • flush 된 데이터는 OS의 TCP 버퍼로 흘러가고
    연결이 살아있는 클라이언트(EventSource)는 즉시 그 데이터를 수신

4.클라이언트 쪽 이벤트 발생

  • 브라우저의 EventSource는 body에 새 블록이 append 될 때마다 파싱 → 이벤트 발생
profile
기록하는 공간

0개의 댓글