Server-Sent Events

WebResponse.sse(…​)는 본문 대신 text/event-stream으로 응답하며, 호출마다 이벤트 하나를 플러시한다:

app.get("/decks/{deckId}/events", req -> {
    long deckId = req.pathParamLong("deckId");
    return WebResponse.sse(stream -> {
        while (stream.isOpen()) {
            JsonObject eventData = Json.object().put("count", service.due(deckId));
            stream.id(String.valueOf(revision))
                  .event("due")
                  .send(eventData.toJson());
            Thread.sleep(1000);
        }
    });
});
id: 41
event: due
data: {"count":12}

send(data)는 이벤트 하나를 보내고, retry(…​)는 재연결 지연을 설정하며, comment(text)는 어떤 리스너도 보지 못하는 하트비트를 보낸다. event(name)과 id(…​)는 다음 send가 쓰는 이벤트에 라벨을 붙이며, 그 뒤의 이벤트에는 붙지 않는다. 이름 있는 이벤트는 그 이름으로 등록된 리스너에, 이름 없는 이벤트는 EventSource.onmessage에 도착한다. id가 있으면 브라우저가 Last-Event-ID로 재개할 수 있다. 여러 줄에 걸친 데이터는 줄마다 data: 줄 하나가 되며, 클라이언트가 이를 다시 합친다. id나 이벤트 이름에는 그런 형태가 없으므로, 둘 중 어디에든 줄바꿈이 있으면 IllegalArgumentException을 던지며, id에 든 NUL도 마찬가지다.

재연결

retry(Duration)은 스트림이 끊긴 뒤 브라우저가 돌아오기 전에 기다리는 지연을 설정하며, 밀리초 단위의 retry: 줄 하나로 기록된다.

stream.retry(Duration.ofSeconds(2));

retry: 줄을 보내지 않는 스트림에서는 브라우저가 자체 기본값인 몇 초 뒤에 재연결한다. 지연은 애플리케이션이 정해야 하는데, 브라우저가 얼마나 빨리 돌아오길 원하는지는 배포 때 모든 스트림을 닫는 서버만 알기 때문이다.

retry: 줄은 다음 이벤트와 함께가 아니라 retry(…​)를 호출한 시점에 나가는데, 지연은 이벤트 하나가 아니라 스트림에 속하기 때문이다. 지연은 다른 retry(…​)가 바꿀 때까지 스트림의 나머지와 뒤따르는 연결에 유지된다. 음수 지연은 IllegalArgumentException을 던지는데, 프로토콜이 음수 값에 아무 의미도 부여하지 않고 브라우저는 이를 조용히 무시하기 때문이다.

id(…​)는 재개되는 스트림의 나머지 절반이다: 브라우저는 마지막으로 본 id를 Last-Event-ID로 돌려보내고, 핸들러는 req.header("Last-Event-ID")로 읽는다.

SSE는 코어에 있고 WebSocket은 없는 이유

SSE는 별도의 등록이 아니라, 다른 형태로 응답하는 평범한 get 라우트다. 따라서 app.routes()가 이를 나열하고, 필터가 이를 다루며, 요청 로거는 스트림이 끝날 때 이를 보고한다. WebSocket 업그레이드는 서블릿 디스패치를 벗어나므로 이 세 가지 중 어느 것도 적용되지 않는다. 이 차이가 SSE는 코어에 있고 WebSocket은 없는 이유의 전부다. 애플리케이션에 정말 필요한 것이 소켓이라면, spider-silk-jetty-websocket이 Jetty 위에서 자기 이름을 걸고 소켓을 매핑한다. SSE에는 AppServlet 말고 필요한 것이 없으므로, 평범한 서블릿 컨테이너에 배포해도 SSE를 쓸 수 있다.

스레드와 수명

요청은 스트림이 지속되는 동안 스레드를 붙들고 있다. 스레드를 붙드는 것은 서블릿 네이티브로 남는 대가다. 가상 스레드 실행기가 많은 동시 스트림을 싸게 만든다.

스트림은 다음 세 가지 방식 중 하나로 끝나며, 어느 것도 에러가 아니다:

  • 핸들러가 반환한다,

  • 클라이언트가 떠난다: 이를 발견한 쓰기가 SseStream.Closed를 던지고, 핸들러는 그 자리에서 끝난다,

  • app.stop()이 Jetty에 드레인을 요청하기 전에 아직 열려 있는 모든 스트림을 닫는데, 스트림은 그러지 않으면 결코 끝나지 않을 진행 중인 요청이기 때문이다. Ctrl-C나 SIGTERM에 의한 JVM 종료도 서버 자체의 셧다운 훅과 나란히 스트림을 닫는다. 클라이언트가 읽기를 멈춘 스트림은 밖에서 닫을 수 없는 쓰기에 막혀 있으므로, 그 쓰기를 기다리지 않고 닫힌 것으로 표시되며 서버의 stop timeout이 그 쓰기를 끝낸다.

Jetty 커넥터의 30초 유휴 타임아웃보다 조용한 스트림은 서버가 닫는다. 타이머로 stream.comment("ping")을 보내면 스트림이 살아 있으며, 앞단의 프록시에도 보통 같은 하트비트가 필요하다.

핸들러가 WebResponse.sse(…​)를 반환하는 순간부터 스트림이 닫힐 때까지, 수명 전체가 요청 하나다:

핸들러가 WebResponse.sse를 반환한다; AppServlet이 헤더를 커밋하고
Figure 1. SSE 스트림의 수명