SSE가 왜 나왔을까?
채팅이나 알림을 만들 때 서버에서 클라이언트로 새 데이터가 생겼다는 걸 알려야 한다. 근데 HTTP는 클라이언트가 먼저 요청해야만 서버가 응답한다. 그러면 어떻게?
첫 번째 시도 — 폴링(Polling):
[클라이언트] "새 알림 있어?" → [서버] "없어"
[클라이언트] "새 알림 있어?" → [서버] "없어"
[클라이언트] "새 알림 있어?" → [서버] "없어" ← 5초마다
[클라이언트] "새 알림 있어?" → [서버] "있어!" ← 발생 후 최대 5초 뒤에야 앎
실제로 얼마나 낭비인가? 유저 10만 명 × 5초 주기 = 초당 2만 요청. 실제로 새 데이터가 있는 요청은 몇 건. 나머지는 서버에서 “없어” 반환하고 끝. DB는 초당 2만 번 쿼리를 날린다.
두 번째 시도 — 롱 폴링(Long Polling):
클라이언트가 요청하면 서버가 새 이벤트가 생길 때까지 응답을 안 보낸다. 이벤트가 생기면 그때 응답한다. 나아졌다. 하지만 이벤트를 받을 때마다 HTTP 연결을 새로 맺는다. TCP 3-way handshake 왕복 1번 = 수십 ms. 알림 서비스에서 이게 누적되면 체감된다.
SSE는 연결을 한 번 맺고 끊지 않는다. 서버가 이벤트가 생길 때마다 같은 연결로 밀어넣는다.
| 방식 | 불필요한 요청 | 지연 | 연결 수립 비용 |
|---|---|---|---|
| Polling (5초) | 초당 2만 (10만 유저) | 최대 5초 | — |
| Long Polling | 없음 | 이벤트 발생 즉시 | 이벤트마다 handshake |
| SSE | 없음 | 이벤트 발생 즉시 | 최초 1회만 |
그런데 WebSocket도 서버 push가 된다. 왜 SSE를 따로 만들었나?
WebSocket이 있는데 왜 SSE를 따로 만들었나?
WebSocket이 없어서 SSE를 만든 게 아니다. WebSocket이 과도한 경우가 있기 때문이다.
WebSocket은 HTTP를 버리고 새 프로토콜로 업그레이드한다:
[클라이언트] GET /ws HTTP/1.1
Upgrade: websocket
Connection: Upgrade
[서버] HTTP/1.1 101 Switching Protocols
Upgrade: websocket
이 순간부터 HTTP가 아니다. 그러면 어떤 문제가 생기나?
- 방화벽 차단: 기업 네트워크는 HTTP/HTTPS 외 프로토콜을 차단한다. WebSocket 업그레이드가 중간 프록시에서 막히는 경우가 흔하다.
- 로드밸런서 설정: L7 LB가 WebSocket을 별도로 처리해야 한다. 오래된 장비는 아예 지원 안 한다.
- Spring Security 분리: WebSocket은 HTTP 필터 체인을 거치지 않는다. 인증, CORS를 별도로 구현해야 한다.
- 재연결 로직: 연결이 끊기면 클라이언트 코드에서 직접 재연결을 짜야 한다.
SSE는 그냥 HTTP GET 요청이다. Accept: text/event-stream 헤더 하나가 전부다. Spring Security 필터가 그대로 적용되고, 브라우저 EventSource가 재연결을 자동으로 처리한다.
한 가지 알려진 제약이 있다. 브라우저 네이티브 EventSource는 커스텀 헤더(Authorization 등)를 붙일 수 없다. 생성자가 URL과 withCredentials 정도만 받기 때문이다. 그래서 토큰 인증을 붙이려면 쿠키(withCredentials: true)에 담거나 쿼리 파라미터로 넘겨야 하고, 헤더 방식을 고집하려면 fetch 기반 polyfill(예: @microsoft/fetch-event-source)을 써야 한다. 쿼리 파라미터로 토큰을 넘기면 액세스 로그에 남을 수 있으니 보통 쿠키 방식을 택한다.
단방향이면 SSE가 맞다. 알림, 실시간 피드, 서버 모니터링 — 이 케이스들은 서버→클라이언트 방향뿐이다. WebSocket을 쓰는 건 양방향 도로를 만들어놓고 한 방향만 쓰는 것이다.
그런데 SSE가 HTTP라면, HTTP는 요청-응답인데 응답을 어떻게 안 끊나?
HTTP는 요청-응답인데 어떻게 응답을 끊지 않나?
일반 HTTP 응답:
HTTP/1.1 200 OK
Content-Length: 27
Content-Type: application/json
{"message": "hello world"}
[서버가 연결 종료]
Content-Length: 27 — 서버가 미리 응답 크기를 알려준다. 클라이언트는 27바이트를 받으면 끝이라고 안다.
SSE는 응답이 언제 끝날지 모른다. 이벤트가 언제 올지 모르니까. 그러면 Content-Length를 어떻게 보내나?
안 보낸다. 대신 Transfer-Encoding: chunked를 쓴다.
HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
Transfer-Encoding: chunked
[이벤트 도착 시 — 청크 하나]
1e\r\n
data: {"type":"update"}\n\n
\r\n
[30초 후 — 또 다른 청크]
1f\r\n
data: {"type":"refresh"}\n\n
\r\n
[종료 청크 — SSE에서는 보내지 않음]
0\r\n
\r\n
청크 인코딩은 “0바이트 청크가 오면 끝”이라는 약속이다. SSE는 그 0바이트 청크를 보내지 않는다. 서버가 의도적으로 응답을 열어둔 채로 이벤트를 흘려보낸다.
이게 TCP 레벨에서는 어떻게 보이나?
TCP 레벨에서 어떤 일이 벌어지나?
클라이언트가 /events에 접속하는 순간부터 따라가보자.
1단계: TCP 3-way handshake
클라이언트 서버
│ │
│──── SYN ──────────────────────>│ "연결 시작할게"
│<─── SYN-ACK ───────────────────│ "OK, 준비됐어"
│──── ACK ──────────────────────>│ "확인"
│ │
[OS 커널에 소켓 fd 생성, 송수신 버퍼 할당]
3-way handshake는 왕복 1번이다. 서울-미국 서버라면 RTT 150ms. HTTP 요청마다 이 비용이 든다. SSE는 딱 1번만 낸다.
2단계: HTTP 요청
GET /events HTTP/1.1
Host: api.example.com
Accept: text/event-stream
Last-Event-ID: 42 ← 재연결 시 마지막으로 받은 이벤트 ID
3단계: SSE 응답 헤더 (바디는 열린 상태)
HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
Transfer-Encoding: chunked
4단계: 이벤트 스트리밍 — FIN 없이 계속
[이벤트 발생 시]
data: {"type":"notification","id":1}\n\n
[30초 후 이벤트 발생]
data: {"type":"update","id":2}\n\n
[아무 이벤트 없어도 TCP 소켓은 열려 있음]
핵심: 양쪽 모두 FIN 패킷을 보내지 않는 한 TCP 커넥션은 유지된다. 데이터를 안 보내도 소켓은 살아있다.
여기서 문제가 생긴다. 오랫동안 아무 데이터가 안 오면 중간 장비(NAT, 방화벽, Nginx)가 “이 연결 죽었나?” 하고 세션 테이블에서 지운다. NAT 타임아웃은 장비마다 다르지만 보통 30분~2시간, 기업 방화벽은 5분도 있다. 이게 heartbeat가 필요한 이유다 — 뒤에서 다룬다.
그런데 이벤트를 어떻게 표현하나? 포맷이 독특하다.
text/event-stream 포맷이 왜 이렇게 생겼나?
SSE 이벤트:
id: 42\n
event: notification\n
data: {"userId":1234,"message":"새 댓글"}\n
retry: 3000\n
\n
왜 JSON을 통째로 보내지 않고 이런 텍스트 포맷인가?
스트리밍에서 파싱이 단순해야 하기 때문이다. HTTP 응답이 바이트 스트림으로 흘러온다. 이벤트의 시작과 끝을 어떻게 구분하나? 길이 필드를 앞에 붙이면 복잡해진다. \n\n — 빈 줄 두 개는 HTTP 헤더 구조와 같은 패턴이다. 버퍼에서 \n\n을 찾으면 이벤트 하나가 끝난 것이다.
각 필드의 역할과 없으면 어떻게 되는가:
| 필드 | 역할 | 없으면 |
|---|---|---|
id | 이벤트 식별자 | 재연결 시 이벤트 유실 |
event | 이벤트 타입 | 기본 message 이벤트로 처리 |
data | 실제 페이로드 (필수) | 이벤트 발행 안 됨 |
retry | 재연결 간격 힌트(ms) | 브라우저 기본값 (보통 3000ms) |
id 필드는 선택이지만 반드시 넣어야 한다. 재연결 시 브라우저가 Last-Event-ID 헤더로 마지막 수신 이벤트 ID를 보낸다. 서버가 그 이후 이벤트를 재전송할 수 있게. 없으면 재연결마다 그 사이 이벤트가 유실된다.
그런데 서버가 send()로 보냈는데 클라이언트에 바로 안 오는 경우가 있다. TCP 레이어가 범인이다.
Nagle 알고리즘이 SSE를 방해한다는데, 없으면 어떤 증상이 나오나?
증상부터 보자.
[서버] 이벤트 발행 → send()
[클라이언트] ...200ms 후...에야 수신
혹은:
[서버] 이벤트 1 발행 → 이벤트 2 발행 (5초 후)
[클라이언트] 이벤트 1, 2가 동시에 도착
원인은 TCP의 Nagle 알고리즘이다. 1984년 John Nagle이 제안했다. 핵심 아이디어: “작은 패킷 여러 개를 보내지 말고 모아서 한 번에 보내자.”
구체적으로: ACK를 아직 못 받은 데이터가 있으면, 새 데이터가 생겨도 버퍼에 쌓아두고 기다린다. ACK가 오거나 버퍼가 가득 차면 한 번에 보낸다.
엄밀히 말하면 그 200ms 지연은 Nagle 단독이 아니라 Nagle 알고리즘과 상대편의 delayed ACK가 맞물려서 생긴다. 수신 측은 보낼 데이터가 없으면 ACK를 최대 ~200ms까지 미뤘다가(delayed ACK) 보내는데, 송신 측 Nagle은 그 ACK를 기다리느라 작은 패킷을 붙들고 있다. 둘이 서로를 기다리며 교착에 빠진 시간이 곧 체감 지연이다.
일반 API 서버에선 좋은 최적화다. 실시간 스트리밍에선 독이다.
[서버] data: event\n\n 전송
[OS] "ACK 아직 안 왔는데... 좀 더 모아볼까"
200ms 기다림
"다른 데이터 없네, 이제 보낼게"
[클라이언트] 200ms 지연 후 수신
해결: TCP_NODELAY
TCP_NODELAY 소켓 옵션을 켜면 Nagle 알고리즘이 비활성화된다. 데이터가 생기면 즉시 전송한다.
좋은 소식: Tomcat은 기본적으로 TCP_NODELAY=true다. Netty(WebFlux)도 마찬가지. 대부분의 경우 별도 설정 없이 된다.
<!-- server.xml — 명시적 확인/설정 시 -->
<Connector port="8080" protocol="org.apache.coyote.http11.Http11NioProtocol"
tcpNoDelay="true" />
# 소켓 설정 확인
ss -tiep | grep 8080
# 출력에 "nodelay" 있으면 TCP_NODELAY 활성화됨
그런데 TCP_NODELAY가 켜져 있는데도 이벤트가 늦게 오거나 한꺼번에 오는 경우가 있다. 범인은 Nginx 레이어에 있다 — 뒤에서 다룬다.
Nagle보다 더 중요한 게 있다. Spring Boot에서 SseEmitter를 쓰면 Tomcat 스레드가 어떻게 되는지다.
Spring Boot에서 SseEmitter를 쓰면 Tomcat 스레드는 어떻게 되나?
이걸 잘못 이해하면 서버가 수백 명만 접속해도 멈춘다.
만약 SSE 연결이 스레드를 점유한다면:
Tomcat 스레드 풀: 200개 (기본값)
SSE 연결 1개 = 스레드 1개 점유
→ 200번째 유저가 접속하면 스레드 풀이 바닥난다
→ 201번째 요청은 스레드가 날 때까지 대기
→ 서버가 사실상 멈춤
실제로 이런 코드를 쓰면 이 문제가 생긴다:
// 이렇게 하면 안 된다 — 스레드를 점유한다
@GetMapping("/events-wrong")
public void subscribeWrong(HttpServletResponse response) throws Exception {
response.setContentType("text/event-stream");
PrintWriter writer = response.getWriter();
while (true) { // 스레드가 여기서 블로킹
writer.println("data: ping\n");
writer.flush();
Thread.sleep(1000);
}
}
SseEmitter를 쓰면 이 문제를 해결한다:
@GetMapping(value = "/events", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter subscribe(@RequestParam Long userId) {
SseEmitter emitter = new SseEmitter(Long.MAX_VALUE);
registry.register(userId, emitter);
emitter.onCompletion(() -> registry.remove(userId));
emitter.onTimeout(() -> { emitter.complete(); registry.remove(userId); });
emitter.onError(e -> registry.remove(userId));
return emitter;
}
SseEmitter를 return하는 순간 내부적으로 일어나는 일:
[요청 도착]
Tomcat worker thread → subscribe() 실행 → SseEmitter 반환
↑
Spring이 SseEmitter를 감지 → request.startAsync() 호출
Worker thread는 즉시 스레드 풀로 반환 (연결은 유지)
[이후 이벤트 발생 시]
이벤트 발행 스레드 (Kafka Consumer, Scheduler 등)
→ emitter.send() 호출
→ Tomcat NIO Poller → 소켓에 데이터 쓰기
핵심: worker thread는 subscribe() 메서드가 끝나는 즉시 반환된다. 커넥션이 살아있어도 스레드를 점유하지 않는다. Tomcat 스레드 200개로 수만 개의 SSE 연결을 유지할 수 있다.
주의: SseEmitter(Long.MAX_VALUE) 타임아웃 설정. 서버가 타임아웃을 관리하지 않겠다는 뜻이다. 클라이언트가 갑자기 꺼지거나 배터리 방전이 되면 TCP FIN이 안 온다. 서버는 연결이 살아있다고 생각한다. 좀비 커넥션이 쌓인다. heartbeat로 죽은 연결을 감지해야 한다 — 운영상 주의사항에서 다룬다.
그런데 HTTP/1.1과 HTTP/2에서 SSE 동작이 다르다.
HTTP/2에서는 SSE가 어떻게 달라지나?
HTTP/1.1에서 SSE를 쓸 때 잘 알려지지 않은 문제가 있다. 브라우저가 같은 도메인에 최대 6개의 TCP 커넥션만 허용한다 (Chrome, Firefox 기준).
브라우저 커넥션 풀 (최대 6개):
┌──────────────────────┐
│ SSE 연결 (탭 1) │
│ SSE 연결 (탭 2) │
│ SSE 연결 (탭 3) │
│ SSE 연결 (탭 4) │
│ SSE 연결 (탭 5) │
│ SSE 연결 (탭 6) │
└──────────────────────┘
탭 7을 열면? 이미지 안 로딩, API 요청도 대기 상태
이게 실제 장애로 이어진다. 유저가 탭 6개 이상을 열면 새 탭에서 모든 HTTP 요청이 SSE 연결이 끊길 때까지 대기한다. 이미지가 안 뜨고, API가 안 간다.
HTTP/2는 이 문제를 구조적으로 해결한다. 하나의 TCP 커넥션 위에 여러 스트림을 다중화(multiplexing)한다.
HTTP/1.1:
TCP conn 1 → SSE (탭 1)
TCP conn 2 → SSE (탭 2)
...최대 6개
HTTP/2:
TCP conn 1 → Stream 1: SSE (탭 1)
Stream 3: SSE (탭 2)
Stream 5: GET /api/users
Stream 7: GET /static/app.js
↑ 모두 같은 TCP 커넥션
탭을 몇 개를 열어도 HTTP/2라면 SSE 연결이 모두 하나의 TCP 커넥션 안의 스트림으로 처리된다.
HTTP/2 활성화:
server:
http2:
enabled: true
주의: HTTP/2는 TLS가 필요하다. Nginx가 HTTPS를 처리하고 백엔드와 HTTP/1.1로 통신하는 구성이 일반적이다. Nginx 설정에서 명시해야 한다:
proxy_http_version 1.1; # Nginx → Spring Boot 구간
Nginx를 언급했으니, Nginx 뒤에서 SSE가 안 오는 문제를 짚어보자.
Nginx 뒤에 두면 왜 이벤트가 안 오나?
증상이 이렇다:
[서버] 이벤트 발행 → emitter.send()
[클라이언트] ...이벤트가 안 온다...
...10분 후 이벤트 수십 개가 한꺼번에 도착
또는:
[서버] 이벤트 발행
[클라이언트] 60초마다 연결이 끊기고 재연결됨
원인 두 가지:
원인 1: Nginx 프록시 버퍼링
Nginx는 기본적으로 업스트림(Spring Boot) 응답을 메모리 버퍼에 쌓아두었다가 클라이언트에게 보낸다. 일반 API 응답에서는 좋은 최적화다. SSE에서는 치명적이다.
Spring Boot가 이벤트를 send()해도 Nginx 버퍼가 쌓아두고 보내지 않는다. 버퍼가 가득 찰 때까지. SSE는 응답이 끝나지 않으니 버퍼에 쌓인 이벤트가 한참 후에야 전달된다.
원인 2: proxy_read_timeout 기본값 60초
Nginx proxy_read_timeout 기본값은 60초다. 60초 동안 업스트림이 아무것도 안 보내면 타임아웃으로 연결을 끊는다. 클라이언트 입장에선 60초마다 재연결이 반복된다.
해결 설정:
location /events {
proxy_pass http://spring-boot-backend;
proxy_http_version 1.1;
# 핵심 1: 버퍼링 비활성화 — 이게 없으면 이벤트가 안 온다
proxy_buffering off;
# 핵심 2: 타임아웃 — heartbeat 주기보다 충분히 길게
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
# 업스트림과 keep-alive 유지
proxy_set_header Connection '';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
# 재연결 시 Last-Event-ID 전달
proxy_set_header Last-Event-ID $http_last_event_id;
}
Spring Boot 응답 헤더로도 제어할 수 있다. Nginx 설정을 바꾸기 어려운 상황에서 유용하다:
@GetMapping(value = "/events", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter subscribe(HttpServletResponse response) {
// X-Accel-Buffering: no → 이 응답에 한해 Nginx 버퍼링 비활성화
response.setHeader("X-Accel-Buffering", "no");
response.setHeader("Cache-Control", "no-cache");
SseEmitter emitter = new SseEmitter(Long.MAX_VALUE);
// ...
return emitter;
}
이제 마지막 문제. 연결이 끊겼다 재연결될 때 그 사이 이벤트를 어떻게 복구하나?
재연결 시 이벤트를 놓치지 않으려면?
브라우저 EventSource는 연결이 끊기면 자동으로 재연결한다. 이때 Last-Event-ID 헤더를 마지막으로 받은 이벤트 ID와 함께 보낸다.
id 필드가 없으면:
[서버] id 없이 이벤트 전송
[연결 끊김]
[클라이언트 재연결] Last-Event-ID 헤더 없음
[서버] 어디서부터 재전송해야 할지 모름
→ 재연결 시 그 사이 이벤트 유실
id 필드가 있으면:
[서버] id: 42로 이벤트 전송
[연결 끊김]
[클라이언트 재연결] Last-Event-ID: 42 헤더 포함
[서버] 42 이후 이벤트를 재전송
→ 이벤트 복구
서버 구현:
@GetMapping(value = "/events", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter subscribe(
@RequestParam Long userId,
@RequestHeader(value = "Last-Event-ID", required = false) String lastEventId,
HttpServletResponse response) {
response.setHeader("X-Accel-Buffering", "no");
response.setHeader("Cache-Control", "no-cache");
SseEmitter emitter = new SseEmitter(Long.MAX_VALUE);
registry.register(userId, emitter);
if (lastEventId != null) {
replayMissedEvents(userId, Long.parseLong(lastEventId), emitter);
}
emitter.onCompletion(() -> registry.remove(userId));
emitter.onTimeout(() -> { emitter.complete(); registry.remove(userId); });
emitter.onError(e -> registry.remove(userId));
return emitter;
}
이벤트 저장소는 Redis Sorted Set으로 구현하면 편하다. score를 이벤트 ID로 쓰면 범위 조회가 O(log N)이다:
// 이벤트 저장 (최근 100개, 24시간 유지)
public void store(Long userId, SseEvent event) {
String key = "sse:events:" + userId;
redisTemplate.opsForZSet().add(key, toJson(event), event.getId());
redisTemplate.expire(key, Duration.ofHours(24));
redisTemplate.opsForZSet().removeRange(key, 0, -101); // 100개 초과 시 오래된 것 삭제
}
// lastEventId 이후 이벤트 조회
public List<SseEvent> findAfter(Long userId, long lastEventId) {
String key = "sse:events:" + userId;
Set<Object> raw = redisTemplate.opsForZSet()
.rangeByScore(key, lastEventId + 1, Double.MAX_VALUE);
return raw.stream().map(o -> fromJson((String) o)).collect(Collectors.toList());
}
id 없이 SSE를 운영하면 재연결마다 이벤트가 유실된다. 모바일에서 WiFi→LTE 전환, 터널 진입, 화면 잠금 — 이런 상황에서 연결이 끊긴다. 알림 서비스에서 이벤트 유실은 직접 유저 경험에 영향을 준다.
최적화
Nginx 버퍼링 — 가장 먼저 확인할 것
SSE 관련 이슈의 80%는 Nginx proxy_buffering이다. 이벤트가 지연되거나 한꺼번에 오거나 60초마다 끊기면 가장 먼저 Nginx 설정을 확인한다.
# 적용된 설정 확인
nginx -T | grep -A10 "location.*events"
grep -r "proxy_buffering" /etc/nginx/
grep -r "proxy_read_timeout" /etc/nginx/
Heartbeat 주기 계산
heartbeat 주기 < proxy_read_timeout × 0.7
Nginx proxy_read_timeout이 60초면 heartbeat는 25~30초. 타임아웃 전에 heartbeat가 도착해야 하는데 네트워크 지연을 고려해 여유를 둔다.
기업 방화벽은 5분, AWS NAT Gateway는 기본 350초까지 짧을 수 있다. 30초 heartbeat가 가장 안전한 기본값이다.
@Scheduled(fixedDelay = 30_000)
public void sendHeartbeat() {
registry.getAllEmitters().forEach((userId, emitter) -> {
try {
// SSE comment — 브라우저는 무시, 중간 장비에게 "연결 살아있음" 신호
emitter.send(SseEmitter.event().comment("heartbeat"));
} catch (IOException e) {
emitter.completeWithError(e);
registry.remove(userId);
}
});
}
SSE comment (:heartbeat\n\n 형식)는 브라우저 EventSource가 무시한다. TCP 패킷을 실제로 전송해서 중간 장비에 연결이 살아있다는 걸 알리는 용도다.
이벤트 페이로드 최소화
SSE는 텍스트 기반이다. 페이로드가 크면 직렬화 비용과 네트워크 비용이 올라간다.
// 피해야 할 패턴: 이벤트에 전체 데이터 포함
{"type":"new_comment","postTitle":"...","postContent":"...","author":{...},"replies":[...]}
// 권장 패턴: 최소 정보만, 상세는 별도 API 조회
{"type":"new_comment","commentId":5678}
알림 이벤트는 “무슨 일이 생겼는지”만 담고, 상세 내용은 클라이언트가 별도 API로 가져오게 한다.
튜닝
측정해야 할 것들
1. 활성 SSE 연결 수 — 서버 용량 가늠
2. 이벤트 end-to-end 지연 — 이벤트 생성 시각 vs 클라이언트 수신 시각
3. Heartbeat 실패율 — 좀비 연결 비율 지표
4. 재연결 비율 — 연결 안정성 (Nginx 설정, 네트워크 품질)
5. 이벤트 유실률 — id/replay 구현 품질
Micrometer로 기본 지표 수집:
@PostConstruct
public void registerMetrics() {
Gauge.builder("sse.connections.active", registry, SseConnectionRegistry::size)
.description("활성 SSE 연결 수")
.register(meterRegistry);
Counter.builder("sse.heartbeat.failed")
.description("Heartbeat 실패 수 (좀비 연결 감지)")
.register(meterRegistry);
}
증상별 원인 찾기
이벤트가 수십 초 지연되거나 한꺼번에 온다:
→ Nginx proxy_buffering off 설정 확인. 전형적인 버퍼링 증상이다.
60초마다 연결이 끊긴다:
→ Nginx proxy_read_timeout 기본값(60초) 초과. heartbeat를 추가하거나 타임아웃을 늘린다.
GC 멈춤이 길어진다: → 좀비 연결이 힙에 쌓이고 있다. heartbeat의 IOException 처리 코드가 제대로 동작하는지 확인. 활성 연결 수 지표에서 점진적 증가 추세면 누수다.
이벤트 ID 생성: → 단조 증가(monotonically increasing)여야 한다. 여러 서버 인스턴스라면 각자 발급하면 중복될 수 있다. Redis INCR로 원자적 카운터를 쓰는 것이 안전하다:
public long generateEventId() {
return redisTemplate.opsForValue().increment("sse:event-id-seq");
}
운영상 주의사항
1. 좀비 연결 — 모르면 메모리가 쌓인다
클라이언트가 갑자기 꺼지면 TCP FIN이 안 온다. 서버는 연결이 살아있다고 생각한다. emitter.send()를 하면 IOException이 나야 비로소 안다. 이 예외를 처리하지 않으면 좀비 연결이 레지스트리에 남아 힙을 점유한다.
try {
emitter.send(SseEmitter.event().comment("heartbeat"));
} catch (IOException e) {
// IOException = 연결이 이미 끊긴 신호
emitter.completeWithError(e); // AsyncContext 정리
registry.remove(userId); // 레지스트리에서 제거
meterRegistry.counter("sse.heartbeat.failed").increment();
}
OS 레벨 TCP keepalive를 줄이면 더 빨리 감지된다:
# /etc/sysctl.conf
net.ipv4.tcp_keepalive_time = 60 # 기본 7200초 → 60초 (idle 60초 후 probe 시작)
net.ipv4.tcp_keepalive_intvl = 10 # 10초 간격으로 keepalive probe
net.ipv4.tcp_keepalive_probes = 3 # 3번 무응답 → OS가 연결 종료
# 결과: 최대 90초(60 + 10×3) 후 OS가 죽은 연결 정리
2. 중복 탭 — 명시적으로 처리해야 한다
같은 유저가 탭을 두 개 열면 연결이 두 개 생긴다. userId 기반 레지스트리라면 기존 연결을 명시적으로 닫아야 한다:
public void register(Long userId, SseEmitter emitter) {
SseEmitter existing = emitters.put(userId, emitter);
if (existing != null) {
existing.complete(); // 기존 연결 종료 → 브라우저가 자동 재연결
}
}
탭 여러 개를 허용하려면 userId + sessionId 조합으로 관리해야 한다.
3. 재배포 시 재연결 폭풍 — 미리 대비하라
서버를 재배포하면 모든 SSE 연결이 동시에 끊기고, 브라우저 기본 재연결 간격 3초 후 모든 유저가 동시에 재연결 요청을 보낸다.
대응:
// 초기 연결 시 재연결 간격에 랜덤성 추가
emitter.send(SseEmitter.event()
.comment("connected")
.reconnectTime(3000 + (long)(Math.random() * 7000)));
// 3~10초 사이 랜덤 → 재연결이 시간에 퍼짐
인프라 레벨: Rolling 배포로 전체 서버를 동시에 재시작하지 않는다.
4. Spring Security 통합 — 비동기에서 SecurityContext 주의
SSE 엔드포인트에 인증을 붙일 때, Servlet async 환경에서는 기본적으로 SecurityContext가 다른 스레드에 전파되지 않는다.
@Bean
public AsyncTaskExecutor sseTaskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.initialize();
return new DelegatingSecurityContextAsyncTaskExecutor(executor);
}
가장 단순한 방법은 연결 시점에 userId를 레지스트리에 저장하고, 이벤트 발행 시 userId 기반으로 라우팅하는 것이다. SecurityContext를 비동기 컨텍스트에 끌고 다닐 필요가 없다.
SSE 관련 장애의 90%는 코드가 아니라 Nginx 설정이다. proxy_buffering off와 proxy_read_timeout — 이 두 설정이 핵심이다. TCP와 async 스레드 모델을 이해하면 왜 이 설정이 필요한지가 명확해지고, 새벽 3시에 로그 뒤지는 시간이 줄어든다.