SSE에서는 왜 하트비트를 통해 종료된 연결을 삭제해야할까요? 이에 관련해 CS 레벨까지 내려가 학습을 해봅시다.
해당 글을 쉽게 이해하기 위해 하단의 포스팅을 읽어보는 것을 추천드립니다.
https://codingmasterlsw.tistory.com/77
HTTP에서 Tomcat Thread 동작 흐름 (feat. OS)
Thread 종류 Acceptor Thread - 서버 소켓에서 새로운 클라이언트 연결 (TCP Connection)을 수락하는 역할- ServerSocket.accept()를 호출하며 블로킹 대기하는 루프 구조- 기본적으로 스레드 1개로 동작 Poller Thread
codingmasterlsw.tistory.com
흐름 분석
우선 일반적인 HTTP와 Client의 연결 흐름을 그림으로 이해해 봅시다.

1. Client 측에서 먼저 종료하는 경우이기 때문에 먼저 FIN을 보냄
2. OS kernel이 ACK 응답
3. ready 상태의 소켓을 가져온 후 워커스레드에게 작업 할당
4. read() 단계에서 EOF(-1) 확인
5. worker thread가 close() 호출
6. OS Kernel이 FIN 보내고 ACK 수신 후 종료
이전의 포스팅을 봤다면 이해가 잘 될 거라고 생각합니다. 하지만 설명이 모호한 부분이 있는데요, Client측에서 먼저 종료하는 경우는, keep-alive가 활성화되어 있는 상황이라고 생각해 주시면 될 것 같습니다.
keep-alive가 활성화되어 있다면, worker 스레드는 작업을 완료 (wrtie() 호출) 후에 close()를 호출하지 않고, 다시 NIO Selector로 해당 소켓을 재등록합니다. 그렇기에 다시 select()를 호출하는 것이죠. 그림을 통해 이해해 봅시다.

이전 포스팅의 그림을 그대로 가져와봤는데요, ⭐️ 부분만 살펴보면 됩니다. write() 호출 후에 keep-alive 즉 소켓을 유지하기 위해 Worker Thread가 NIO Selector에 소켓을 재등록하는 것이죠. 이후에는 맨 첫 번째 그림 (Cleint가 먼저 연결을 종료하는 경우) 의 흐름대로 동작합니다. accept() 과정은 이미 소켓이 존재하니 필요없고, select()를 통해 Ready 상태의 소켓을 큐에 등록해 EOF(-1)을 read 하는것이죠.
이제 SSE 방식을 살펴봅시다.

이미 SSE 연결 API를 호출했고, 연결된 소켓이 존재한다고 가정하겠습니다.
SSE의 경우에는 close() 호출을 안 합니다. 계속 write() 상태에서 머무릅니다. Client 측에서 새로운 요청을 보내도, 이걸 읽을 수가 없습니다. 읽으려면 NIO Selector에 소켓이 등록되어있어야 하는데 write() -> NIO Selector 재등록의 HTTP 요청과 다르게, 그냥 write() 상태를 계속 유지하고 있는 것이죠.
정리를 하자면 NIO Selector의 관리 대상이 아니고, 관리 대상이 아니기 때문에 당연히 Worker Thread에게 작업 할당도 불가합니다.
즉 FIN 요청이 왔을 때 이미 OS Kernel이 FIN을 수신하고 ACK를 응답했지만, Server 측에 close() 호출을 못 하기 때문에 FIN 전송이 불가합니다. 연결을 끊기 위해서는 새로운 쓰기 이벤트가 발생해 연결이 끊긴 걸 알아챈 후 close()를 호출하거나, Timeout을 적절히 조절해 close() 호출을 해줘야 하는 것이죠.
결국 Timeout을 너무 빠르게 설정하면 Client 측에서 SSE 재연결을 수행해야 하고, Timeout이 너무 길면 이미 끊긴 연결을 계속 가지고 있어 서버 측 리소스가 낭비되는 것이죠. 클라이언트 측도 서버 측 FIN을 못 받았기에 대기하는 문제도 발생하고요. 이 문제를 해결하고자 하트비트라는 개념이 나온 것이죠.
학습을 하다 보니 문득 의문점이 생겼습니다. SseEmitter를 사용하면 비동기처리가 가능한데, Poller Thread를 만들면 되는 거 아닌가?? 이전에 설명을 하진 않았지만, Spring의 SseEmitter를 사용하면, 비동기로 사용됩니다. 기존 설명에서는 SSE 연결이 200개가 있다면, 200개의 Worker Thread가 write() 상태로 대기하고 있는거지만, 사실은 emitter 객체를 만든 후 소켓 정보를 저장한 후 스레드풀에 worker thread를 반환하는 것이죠.

그러면 Sse 전용 NIO Selector를 만들고, 이를 감시하는 Poller Thread를 하나 만들면 되는거 아닐까요?? 전용 Poller Thread를 만들고, kernel에 별도의 Sse 전용 Selector를 만들면 아래와 같은 구조가 가능합니다.

하지만 구현이 안 되어있죠...
Spring이 별도 Poller Thread를 구현하지 않은 이유는 Servlet 스펙 자체의 제약 때문이라고 합니다. Servlet 스펙은 클라이언트 연결 끊김을 서버에 알려주는 메커니즘을 제공하지 않고, Spring MVC는 Servlet 스펙 위에서 동작하기 때문에, 소켓에 직접 접근해서 Selector에 등록하는 것이 스펙을 벗어나는 행위가 될 수 있다고 하네요.
이 부분이 궁금해 구글링과 깃헙 이슈들을 찾아봤는데, Spring WebFlux는 제가 말한 방식으로 구현이 되어있다고 합니다. 즉, Spring MVC의 SseEmitter가 아닌 Spring WebFlux + SSE를 사용할 때는 위와 같이 동작한다고 하네요??!
해당 이슈에서 메인테이너의 말을 들어보면, Servlet 기반 즉, MVC를 사용하고 있을 땐 응답이 안 오니, Netty를 써봐라라고 이야기 하는 걸 볼 수 있네요. 역시나 구현이 되어있군요ㅋㅋ (대단한 사람들...)
https://github.com/spring-projects/spring-framework/issues/18523
WebFlux SSE controller does not detect disconnected client [SPR-15306] · Issue #18523 · spring-projects/spring-framework
Sergei Egorov opened SPR-15306 and commented Spring SSE implementation doesn't cancel the subscription when the client disconnects but waits until the next failed emission. It leads to a huge numbe...
github.com
하지만 Spring 측에서 권장하는 건 하트비트입니다. MVC는 대안이 없고, Webflux를 사용할 때도 하트비트를 사용하라고 문서에 나와있긴 하네요. 네트워크 레벨에서 연결 해제를 즉시 감지하지 못하는 경우가 있으므로 주기적 전송을 권고한다고 합니다. 이슈를 추적해 봤는데, Spring 측에서 아직 해결 못 한 문제 같아요.


https://docs.spring.io/spring-framework/reference/web/webflux/reactive-spring.html
연결 재시도
SSE의 eventStream값 내부에는 readyState이라는 값이 존재합니다.
ReadyState == 0: CONNECTING, 연결이 아직 수립되지 않았거나, 연결이 끊어진 후 재연결을 시도 중인 상태
ReadyState == 1: OPEN: 연결이 정상적인 상태
ReadyState == 2: CLOSED: 연결이 완전히 닫힌 상태
Client는 readyState의 값을 통해 연결 재시도를 결정합니다. 즉, closed 상태일 경우 재요청을 하지 않고, connecting 상태일 때만 연결 재시도를 하는 것이죠.
그렇다면 Connecting, Closed는 어떤 기준으로 결정되는 걸까요?
Connecting: 브라우저가 일시적 문제로 판단하면 자동 재연결 시도
- 네트워크 연결이 일시적으로 끊김 (Wi-Fi 불안정 등)
- 서버가 연결을 먼저 닫음 (서버 재시작, 타임아웃 등)
- TCP 연결 자체가 끊어진 경우
Closed: 브라우저가 복구 불가능으로 판단하면 재연결 포기
- eventSource.close() 명시적 호출
- HTTP 응답 자체가 실패 — 서버가 연결을 수립하는 단계에서 에러 응답을 반환한 경우
- 200이 아닌 HTTP 상태 코드 (예: 401, 403, 404, 500 등)
- Content-Type이 text/event-stream이 아닌 경우
자세한 스펙이 궁금하다면, 하단의 문서를 참고하면 될 것 같습니다.
https://html.spec.whatwg.org/multipage/server-sent-events.html
HTML Standard
This section is non-normative. To enable servers to push data to web pages over HTTP or using dedicated server-push protocols, this specification introduces the EventSource interface. Using this API consists of creating an EventSource object and registerin
html.spec.whatwg.org
정말 readyState 상태에 따라 재요청을 하는지 확인해 봅시다.

브라우저 콘솔에서 sse 연결 요청을 보내봤습니다. readyState : 1 , OPEN 상태입니다.
이후에 Client 측에서 SSE 연결을 complete 하는 간단한 API를 만든 후 호출해 봤습니다.
@PostMapping("/api/sse/disconnect-all")
public ResponseEntity<Void> disconnectAll() {
sseEmitterRepository.completeAll();
return ResponseEntity.ok().build();
}

Error를 응답받고, 재연결 요청을 수행 후, 다시 연결 상태로 바뀌었습니다.
그 외의 다양한 서버 측 결함으로 테스트를 진행해 본 결과 ex) docker kill, graceful shutdown 전부 readyState: 0을 반환했고, 재연결 요청을 보내는 것을 확인할 수 있었습니다.
진행중인 프로젝트에서 SSE 재연결과 관련된 문제가 발생해 해당 시리즈를 작성하게 되었는데요, 다음 포스팅에서는 SSE 재연결과 관련해 어떤 문제가 발생했는지 적어보겠습니다.
관련 글
'CS' 카테고리의 다른 글
| HTTP에서 Tomcat Thread 동작 흐름 (feat. OS) (0) | 2026.02.24 |
|---|