(Network) SSE의 동작
SSE는 HTTP에서 어떻게 동작할까?
Network 대해서 공부한 내용을 정리한 글입니다.
SSE 동작 방식에 대한 내용입니다.
Study 동기
부쩍 여러 프로젝트를 하면서 LLM API를 다양한게 연동해 보았습니다.
단순히 요청 문자열을 Request하고 응답 전체를 Response하는 API도 있지만, Streaming 옵션을 붙여 SSE 통신을 통해 Token 단위로 데이터를 받아오는 API도 굉장히 많습니다.
이를 구현하기 위해서 Spring Emitter도 사용해보고 높은 가용성을 위해서 Webflux를 도입하는 등 다양한 경험을 해봤습니다.
하지만 SSE가 HTTP를 어떻게 사용하여 통신하는지 정확히 알고있지 않다고 생각하여 알아보게되었습니다.
SSE (Server Sent Event) 개요
SSE는 기술인가 표준인가?
SSE(Server-Sent Events)는 HTTP 응답을 “이벤트 스트림” 형태로 사용하는 방식에 대한 표준 규약입니다.
이 표준은 W3C(Word Wide Web Consortium)이라는 컨소시엄에서 규정했습니다.
W3C 컨소시엄 - 웹이 서로 호환되게 돌아가도록 “표준”을 정하는 곳.
W3C에서 합의한 내용을 바탕으로 Browser들이 웹 기술을 구현합니다. 때문에 굉장히 중요한 역할을 한다고 할 수 있습니다.
W3C에서 초안을 발의했지만 현재는 WHATWG라는 브라우저 벤더들이 운영하는 HTML 표준 그룹에서 관리합니다.
결론적으로 HTTP를 기반으로 Server에서 이벤트 발생시에 단방향으로 Client에게 streaming 할 수 있는 연결방식의 표준이라고 이해할 수 있을거 같습니다.
W3C의 SSE 문서 - https://www.w3.org/TR/2012/WD-eventsource-20120426
WAHTWG의 SSE 문서 - https://html.spec.whatwg.org/multipage/server-sent-events.html
SSE의 정의
SSE(Server-Sent Events)는 HTTP 응답을 event stream 형태로 사용하는 방식에 대한 표준 규약입니다.
event stream이라는 용어가 굉장히 추상적인데 이는 HTTP 응답을 어떻게 해석할지에 대한 약속입니다.
표준 문서에서는 event stream을 Client를 위한 EventSource라는 표준 API를 통해서 제공하고 있습니다.
Java에서는 MVC 기반 블로킹 구조에서는 SseEmitter가 비동기 구조에서는 Flux로 EventSource의 서버역할을 담당합니다.
SSE 통신 방식
SSE는 클라이언트가 요청한 HTTP 연결을 유지하면서 서버에서 이벤트가 발생 할 때마다 클라이언트에 데이터를 단방향으로 스트리밍하는 방식입니다.
통신순서를 알아보겠습니다.
[ 통신순서 ]
- Client에서 GET 요청을 통해 리소스를 구독하여 세션을 연결합니다.
- 서버는 HTTP 응답을 반환하고 응답을 종료하지 않습니다.
- 이후 클라이언트는 연결을 닫지 않고 서버의 전송을 기다립니다.
- 마지막으로 받은 Event ID를 기억하고 서버에 재연결을 주기적으로 시도합니다.
- 서버는 이벤트 발생 시점에 클라이언트에게 이벤트 형식의 텍스트를 전송합니다.
- 이벤트는 하나의 응답안에서 여러번 전송합니다.
- 서버가 연결을 닫으면 이벤트 발송은 중단됩니다. (사실상 연결 중단)
HTTP/1.1 에서 SSE 동작
또한 SSE는 대부분 HTTP 1.1 이상부터 동작을 지원합니다.
HTTP/1.0의 기본모델은 Request-and-Response였기 때문에 응답을 종료 후 TCP FIN 플래그를 통해서 연결을 종료합니다. 이렇게 했을때 다음과 같은 단점이 있습니다.
- 요청마다 새로운 TCP 연결을 해야하는 문제가 있습니다.
- 응답 길이를 모르기 때문에 전송 도중 중단될 수 있습니다.
이런 단점을 보완하고자 HTTP에서는 Content-Length, Chunked Transfer Encoding, Persist Connection을 도입했습니다.
브라우저(Client)에서는 HTTP/1.1의 응답의 종료를 3가지로 판단합니다.
- Content-Length 크기만큼 응답을 다 받았을때
- 서버가 TCP 연결을 닫았을때
- chunked econding에서 마지막 chunk를 받았을 때
위 통신 순서를 보면 SSE는 기본적으로 HTTP 연결을 종료하지 않고 데이터를 이벤트 단위로 스트리밍하는 것이 특징임을 알 수 있습니다.
그렇다면 HTTP 연결을 종료하지 않기 위해서 위 조건을 만족하지 않으면 됩니다.
이 내용을 기억하면서 통신을 순서대로 따라가보겠습니다.
https://datatracker.ietf.org/doc/html/rfc7230#section-3.3.2
위 그림을 바탕으로 디테일한 HTTP1.1 기반의 SSE 동작을 알아보겠습니다.
그리고 SSE의 표준 응답 규칙도 함께 알아보겠습니다.
- Client HTTP 요청 (GET)
Client Request
1
2
GET /events HTTP/1.1
Accept: text/event-stream
- 서버에서 응답 반환 (SSE에서 말하는 단방향 Client 전송이 가능해짐)
Server Response
1
2
3
4
5
HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
Transfer-Encoding: chunked
서버의 응답 헤더를 보면 Content-Length는 정해지지 않았고 Connection은 kepp-alive 옵션으로 되어 있습니다.
또한 Transfer-Encoding 방식은 chunked로 되어 있기 때문에 Client는 연결을 유지하면서 응답을 기다리는 상태가 됩니다.
- 이벤트 스트리밍 (Response Body)
1
2
3
data: event-1\n\n
data: event-2\n\n
data: event-3\n\n
서버는 event가 발생할때 마다 문자열을 body에 담아 전송합니다.
Content-Type을 text/event-stream으로 지정했기 때문에 브라우저의 EventSource는 event 단위로 데이터를 처리합니다.
이벤트 단위 구분 = 빈 줄 (\n\n)
서버가 계속해서 응답을 보낸다고 여러개의 HTTP 전송이 되는 것이 아니라, 하나의 HTTP 연결이 지속되어 지속적으로 Server가 응답을 쌓아가는 과정입니다.
- 하트비트 (선택)
1
: keep-alive\n\n
event에서 : 로 시작하는 줄은 comment로 판단하여서 클라이언트는 이를 무시합니다.
:를 붙인 문자열을 축가하여 주기적으로 연결을 확인할 수 있습니다.
- 연결 종료 = 스트림 종료
1
Server -> Client : TCP FIN
- HTTP/1.x에서 응답 종료. 스트림 종료, 연결 종료
- 이 세 가지가 동시에 발생합니다.
- 클라이언트 후처리
- 브라우저(EventSource):
- 연결 종료 감지
- 마지막 이벤트 ID 저장
- 자동 재연결 시도 (표준 동작)
1
2
GET /events
Last-Event-ID: 3
직접 확인해보자
Spring-Boot의 SseEmitter를 기반으로 event1 부터 event100까지 전송하는 간단한 서버를 만들었습니다.
code base
SseController.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
@RestController
@RequestMapping("/api/sse")
@RequiredArgsConstructor
public class SseController {
private final SseService sseService;
@GetMapping(value = "/events", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter streamEvents() {
return sseService.createEmitter();
}
}
SseService.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
@Slf4j
@Service
public class SseService {
private final ExecutorService executor = Executors.newCachedThreadPool();
public SseEmitter createEmitter() {
SseEmitter emitter = new SseEmitter(Long.MAX_VALUE);
executor.execute(() -> {
try {
for (int i = 1; i <= 100; i++) {
String eventData = "event" + i;
log.info("Sending: {}", eventData);
emitter.send(SseEmitter.event()
.name("message")
.data(eventData));
// 이벤트 간 간격 (100ms)
Thread.sleep(100);
}
emitter.complete();
log.info("SSE stream completed");
} catch (IOException e) {
log.error("Error sending SSE events", e);
emitter.completeWithError(e);
} catch (InterruptedException e) {
log.error("Thread interrupted", e);
Thread.currentThread().interrupt();
emitter.completeWithError(e);
}
});
emitter.onCompletion(() -> log.info("SSE connection completed"));
emitter.onTimeout(() -> log.warn("SSE connection timeout"));
emitter.onError(e -> log.error("SSE connection error", e));
return emitter;
}
}
Client HTTP 요청 헤더 확인
Client는 SSE 요청을 하기 위해서 HTTP/1.1 버전을 명시하여 요청을 합니다.
Server HTTP 응답 헤더 확인
위 설명처럼 Content-Length가 별도로 존재하지 않는 응답을 보내는 것을 알 수 있습니다.
또한 클라이언트에서 Keep-Alive의 주기를 60sec로 맞추고Transfer-Encoding을 chunked로 하여 데이터를 나눠 보낼 것을 명시합니다.
클라이언트는 이제 응답의 종료 조건을 모르기 때문에 응답을 기다리는 상태로 대기합니다.
Server 이벤트 발행
수신한 이벤트 확인
1
2
3
4
5
6
7
8
9
10
11
12
13
14
event:message
data:event1
event:message
data:event2
event:message
data:event3
event:message
data:event4
event:message
data:event5




