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()) {
Json.JsonObject eventData = Json.obj().put("count", service.due(deckId));
stream.id(String.valueOf(revision))
.send("due", eventData.toJson());
Thread.sleep(1000);
}
});
});
id: 41
event: due
data: {"count":12}
send(data)`는 이름 없는 이벤트를, `send(event, data)`는 이름 있는 이벤트를 보내고, `id(…)`는 뒤따르는 이벤트에 라벨을 붙여 브라우저가 `Last-Event-ID`로 재개할 수 있게 하며, `retry(…)`는 재연결 지연을 설정하고, `comment(text)`는 어떤 리스너도 보지 못하는 하트비트를 보낸다.
여러 줄에 걸친 데이터는 줄마다 `data: 줄 하나가 되며, 클라이언트가 이를 다시 합친다.
재연결
retry(Duration)`은 스트림이 끊긴 뒤 브라우저가 돌아오기 전에 기다리는 지연을 설정하며, 밀리초 단위의 `retry: 줄 하나로 기록된다.
stream.retry(Duration.ofSeconds(2));
아무 말도 듣지 못한 브라우저는 자체 기본값인 몇 초 뒤에 재연결한다. 이를 말하는 것은 애플리케이션의 몫이다: 배포 때 모든 스트림을 닫는 서버는 브라우저가 얼마나 빨리 돌아오길 원하는지 알지만, 다른 무엇도 알지 못한다.
이 줄은 다음 이벤트와 함께가 아니라 호출된 자리에서 나가는데, 지연은 이벤트 하나가 아니라 스트림에 속하기 때문이다. 다른 값이 전송될 때까지 뒤따르는 연결에 유지된다. 음수 지연은 예외를 던지는데, 프로토콜이 거기에 아무 의미도 부여하지 않고 브라우저는 조용히 무시하기 때문이다.
`id(…)`는 재개되는 스트림의 나머지 절반이다: 브라우저는 마지막으로 본 id를 `Last-Event-ID`로 돌려보내고, 핸들러는 `req.header("Last-Event-ID")`로 읽는다.
SSE는 코어에 있고 WebSocket은 없는 이유
SSE는 별도의 등록이 아니라, 다른 형태로 응답하는 평범한 get 라우트다.
따라서 app.routes()`가 이를 나열하고, 필터가 이를 다루며, 요청 로거는 스트림이 끝날 때 이를 보고한다.
WebSocket 업그레이드는 서블릿 디스패치를 벗어나며 그중 어느 것도 더는 적용되지 않는데, 이것이 SSE는 코어에 있고 WebSocket은 없는 이유의 전부다.
애플리케이션에 정말 필요한 것이 소켓이라면, `spider-silk-jetty-websocket이 Jetty 위에서 자기 이름을 걸고 소켓을 매핑한다.
이는 또한 `AppServlet`이 다른 무엇도 필요로 하지 않으므로, 평범한 서블릿 컨테이너에 배포해도 SSE를 쓸 수 있다는 뜻이다.
스레드와 수명
요청은 스트림이 지속되는 동안 스레드를 붙들고 있다. 그것이 서블릿 네이티브로 남는 대가이며, 많은 동시 스트림을 싸게 만드는 것은 가상 스레드 실행기다.
스트림이 끝나는 것은 에러가 아니며, 세 가지 방식으로 일어난다:
-
핸들러가 반환한다,
-
클라이언트가 떠난다: 이를 발견한 쓰기가 `SseStream.Closed`를 던지고, 핸들러는 그 자리에서 끝난다,
-
`app.stop()`이 Jetty에 드레인을 요청하기 전에 아직 열려 있는 모든 스트림을 닫는데, 스트림은 그러지 않으면 결코 끝나지 않을 진행 중인 요청이기 때문이다.
Jetty 커넥터의 30초 유휴 타임아웃보다 조용한 스트림은 서버가 닫는다. 타이머로 보내는 `stream.comment("ping")`이 스트림을 살아 있게 하며, 앞단의 프록시도 보통 같은 것을 원한다.
핸들러가 `WebResponse.sse(…)`를 반환하는 순간부터 스트림이 닫힐 때까지, 수명 전체가 요청 하나다: