서버가 클라이언트에게 한 방향으로 계속 밀어 보내는 방식. 새 프로토콜이 아니라 평범한 HTTP 응답을 끝내지 않고 조금씩 흘려보내는 것이다.
형식은 네 필드가 전부다
GET /events HTTP/1.1
Accept: text/event-stream
HTTP/1.1 200 OK
Content-Type: text/event-stream ← 이 응답은 끝나지 않는다
Cache-Control: no-cache
id: 42
event: order-status
data: {"orderId":"A-1","status":"SHIPPED"}
← 빈 줄 하나가 메시지 하나의 끝
data: 이름 없는 메시지
| 필드 | 뜻 |
|---|---|
data: | 본문. 여러 줄이면 줄바꿈으로 이어 붙는다 |
event: | 이벤트 이름. 생략하면 기본 message |
id: | 이 이벤트의 식별자. 재연결 복구에 쓰인다 |
retry: | 재연결 대기 시간(밀리초) |
가장 큰 장점 — 재연결이 규격에 들어 있다
연결 끊김
↓ 클라이언트가 알아서 다시 붙는다 (retry 간격만큼 대기)
재연결 요청에 자동으로 헤더가 붙는다
Last-Event-ID: 42 ← 마지막으로 받은 id
↓
서버는 43번부터 이어 보내면 된다 → 끊긴 사이의 이벤트를 복구
WebSocket에는 이런 규격이 없다. 재연결도, 어디까지 받았는지 추적도 전부 직접 만들어야 한다. "단순한 알림에 WebSocket을 쓰면 오히려 손해"라는 판단의 근거가 이것이다.
한계 두 가지
① 방향이 하나뿐이다. 클라이언트가 보내려면 일반 REST 요청을 따로 쓴다. 실제로 SSE(수신) + REST(송신) 조합이 흔하고, 이 편이 WebSocket보다 단순한 경우가 많다.
② HTTP/1.1에서는 브라우저 연결 제한에 걸린다. 브라우저는 같은 도메인에 연결을 6개까지만 연다. SSE 연결 하나가 그 자리를 계속 차지하므로 탭을 여러 개 열면 7번째부터 대기한다. HTTP/2의 멀티플렉싱으로 해소되는 문제다 — 한 연결 안의 여러 스트림으로 처리되기 때문이다.
언제 SSE인가
알림 · 피드 · 진행률 · 주문 상태 · 로그 스트림 · LLM 토큰 스트리밍
- 서버가 밀어 주기만 하면 된다 → SSE
채팅 · 협업 편집 · 게임
- 클라이언트도 자주 보낸다 → WebSocket
판단 기준은 "실시간인가"가 아니라 "양방향이 정말 필요한가" 다.
실무 주의
- 프록시 버퍼링 — 중간 프록시가 응답을 모아 두었다 보내면 실시간성이 사라진다. 버퍼링을 끄는 설정이 필요하다.
- 유휴 타임아웃 — 이벤트가 뜸하면 연결이 끊기므로, 주석 줄(
: keep-alive)을 주기적으로 보내 살려 둔다. - 압축 — 스트리밍 응답에 압축을 걸면 버퍼가 차야 내보내지므로 지연이 생길 수 있다.
면접 함정
- ❌ "SSE는 WebSocket의 열등한 버전" → 단방향 용도에서는 자동 재연결·유실 복구·HTTP 그대로라는 점에서 오히려 낫다.
- ❌ "SSE는 HTTP가 아니다" → 완전히 평범한 HTTP 응답이다.
서버 쪽 구현
@GetMapping(value = "/events", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter subscribe(@RequestHeader(value = "Last-Event-ID", required = false) String lastId) {
var emitter = new SseEmitter(0L); // 타임아웃 없음
if (lastId != null) replayMissedSince(lastId, emitter); // 놓친 것부터 복구
registry.add(userId, emitter);
emitter.onCompletion(() -> registry.remove(userId)); // 정리를 꼭 건다
return emitter;
}
// 이벤트 발행
emitter.send(SseEmitter.event().id("42").name("order-status").data(payload));
onCompletion·onTimeout에서 레지스트리를 정리하지 않으면 끊긴 연결 객체가 계속 쌓여 누수가 된다. 실시간 기능에서 가장 흔한 메모리 문제다.
여러 인스턴스로 늘리면 registry 는 그 인스턴스의 것뿐이다
- 다른 인스턴스에 붙은 사용자에게는 안 닿는다
- Redis Pub/Sub 등으로 전파해야 한다 (WebSocket과 같은 문제)