SSE 연결은 영원하지 않다. 네트워크 불안정, 프록시 타임아웃, 서버 재시작 등으로 언제든 끊길 수 있다. 이 편에서는 연결이 끊겼을 때 어떤 문제가 생기는지, 그리고 Heartbeat와 이벤트 저장소로 어떻게 안정성을 확보하는지를 다룬다.
SSE 연결이 끊어지는 시나리오
SSE 연결이 끊기는 원인은 다양하다:
- 네트워크 순단 : Wi-Fi 전환, 모바일 데이터 끊김 등
- 프록시 타임아웃 : Nginx 같은 리버스 프록시가 일정 시간 데이터가 없으면 연결을 끊음
- 서버 재시작 : 배포나 스케일링으로 서버 인스턴스가 교체됨
- 클라이언트 슬립 : 노트북 덮기, 모바일 백그라운드 전환
Nginx 등] C --> C2[서버 배포/재시작] C --> C3[스케일링 인스턴스 교체] D --> D1[노트북 덮기
Sleep 모드] D --> D2[모바일 백그라운드 전환]
문제는 연결이 끊긴 동안 서버에서 발생한 이벤트가 유실된다는 것이다. 브라우저가 자동으로 재연결해 주지만, 그 사이에 놓친 알림은 알아서 복구하지 않는다.
Heartbeat 패턴
왜 Heartbeat가 필요한가
연결이 끊겼는지 서버가 알 수 있는 시점은 다음 send()를 호출할 때뿐이다. 알림이 자주 발생하는 서비스면 금방 감지되지만, 조용한 시간대에는 끊긴 연결이 Map에 한참 동안 남아 있을 수 있다.
주기적으로 더미 메시지를 보내면:
- 연결이 살아 있는지 확인할 수 있다.
- 끊긴 연결을 빠르게 감지하고 정리할 수 있다.
- 프록시의 유휴 타임아웃을 방지할 수 있다.
이 더미 메시지가 바로 Heartbeat, 혹은 ping이다.
@Scheduled로 주기적 실행
Spring의 @Scheduled를 사용하면 특정 메서드를 일정 주기로 자동 실행할 수 있다.
@Scheduled(fixedRate = 30000) // 30초마다
public void cleanUp() {
// 비활성 연결 정리 + ping 전송
}
fixedRate는 이전 실행 시작 시점 기준, fixedDelay는 이전 실행 종료 시점 기준이다. Heartbeat처럼 일정한 간격이 중요한 경우 fixedRate를 쓴다.
@Scheduled를 활성화하려면 메인 클래스에 @EnableScheduling을 붙여야 한다. 이걸 빠뜨리면 스케줄러가 아무 동작도 하지 않는다.
비활성 연결 감지와 정리
ping을 보냈는데 IOException이 발생하면 해당 연결은 이미 죽은 것이다. Map에서 제거하고 emitter를 종료한다.
여기에 하나 더 — 마지막 활동 시간을 추적하면 "연결은 살아 있지만 오래 놀고 있는" 연결도 정리할 수 있다. 사용자별로 마지막 ping 성공 시각을 기록하고, 일정 시간(예: 5분) 이상 응답이 없으면 정리하는 방식이다.
Nginx의 기본 proxy_read_timeout은 60초다. ping 주기를 30초로 설정하면 프록시 타임아웃 전에 항상 데이터가 흐르므로 연결이 유지된다. 운영 환경에서는 프록시 설정과 ping 주기를 맞춰야 한다.
이벤트 유실 문제
유실이 발생하는 구간
사용자 A가 연결된 상태에서 알림이 3건 발생했다고 하자.
- 알림 1 → 전송 성공
- 연결 끊김
- 알림 2 발생 → 전송 시도 →
IOException→ 실패 - 알림 3 발생 → emitter가 이미 제거됨 → 전송 불가
- 브라우저 재연결
재연결 후 사용자는 알림 2, 3을 받지 못한다. DB에는 저장돼 있으므로 REST API로 조회하면 보이지만, 실시간 푸시는 유실된 것이다.
Last-Event-ID 심화
1편에서 SSE 프로토콜의 id 필드와 브라우저의 Last-Event-ID 헤더를 봤다. 이 메커니즘을 서버 측에서 구현하면 유실을 복구할 수 있다.
흐름은 이렇다:
- 서버는 이벤트를 보낼 때마다 고유 ID를 부여한다.
- 브라우저는 마지막으로 받은 ID를 기억한다.
- 재연결 시
Last-Event-ID헤더로 서버에 알려준다. - 서버는 그 ID 이후의 이벤트를 찾아서 재전송한다.
이 4번이 핵심이다. "그 ID 이후의 이벤트를 찾아서 재전송"하려면 이벤트를 어딘가에 보관해두어야 한다.
이벤트 저장소 설계
왜 DB가 아닌 메모리인가
알림 데이터는 이미 DB에 저장되어 있다. 하지만 DB에서 조회하면:
- 쿼리 오버헤드가 있다.
- DB에 저장되기 전에 유실될 수 있다.
- 이벤트 ID와 DB의 알림 ID가 반드시 일치하지 않을 수 있다.
그래서 SSE 이벤트만을 위한 별도의 인메모리 저장소를 둔다. 짧은 기간(보통 30분 정도)의 이벤트만 보관하고, 그 이전 이벤트는 REST API로 조회하게 한다.
AtomicLong으로 순차 ID 생성
이벤트 ID는 순차적이어야 한다. "이 ID 이후의 이벤트"를 걸러내려면 대소 비교가 가능해야 하기 때문이다.
여러 스레드가 동시에 알림을 생성할 수 있으므로, ID 생성기도 스레드 안전해야 한다. AtomicLong의 incrementAndGet()은 CAS 연산으로 원자성을 보장한다. 잠금 없이 동시 호출해도 같은 번호가 나오지 않는다.
private final AtomicLong eventIdGenerator = new AtomicLong(0);
public String generateEventId() {
return String.valueOf(eventIdGenerator.incrementAndGet());
}
Compare-And-Swap의 약자다. "현재 값이 내가 읽은 값과 같으면 새 값으로 바꿔라"라는 원자적 연산이다. 값이 달라져 있으면 실패하고 재시도한다. synchronized보다 가볍고, 경합이 적을 때 성능이 좋다.
사용자별 이벤트 보관
이벤트 저장소의 구조는 두 층이다:
- 사용자별 보관함 :
Map<String, List<SseEvent>>. 사용자마다 이벤트 리스트를 관리한다. - ID 기반 인덱스 :
Map<String, SseEvent>. 이벤트 ID로 빠르게 검색할 때 쓴다.
computeIfAbsent를 활용하면 키가 없을 때만 새 리스트를 생성할 수 있다. 리스트 자체도 Collections.synchronizedList로 감싸서 동시 접근에 안전하게 만든다.
메모리 관리
인메모리 저장소는 관리하지 않으면 메모리가 무한히 증가한다. 두 가지 제한을 건다:
- 최대 개수 제한 : 사용자당 이벤트 수를 100개로 제한한다. 초과하면 가장 오래된 이벤트부터 삭제한다.
- TTL 기반 정리 :
@Scheduled로 10분마다 실행하면서, 30분 이상 된 이벤트를 일괄 삭제한다. 보관함이 비면 보관함 자체(Map의 키)도 삭제한다.
이벤트 복구 흐름
모든 조각을 합치면 전체 복구 흐름은 이렇다:
- 클라이언트가 재연결 요청을 보낸다.
Last-Event-ID파라미터를 함께 전달한다. - 서버가 새
SseEmitter를 생성한다. - 초기 연결 메시지를 보낸다.
Last-Event-ID가 있으면 이벤트 저장소에서 해당 ID 이후의 이벤트를 조회한다.- 누락된 이벤트를 순서대로 재전송한다.
- 이후 새 이벤트는 정상 흐름으로 전달된다.
존재 여부} GetID -- 있음 --> FindEvents[이벤트 저장소에서
ID 이후 데이터 조회] GetID -- 없음 --> SendInit[초기 연결 메시지 전송] FindEvents --> SendMissing[누락된 이벤트 순차 전송] SendMissing --> SendInit SendInit --> NormalFlow[정상 스트리밍 시작] NormalFlow --> End([연결 유지])
프론트엔드 측에서도 Last-Event-ID를 관리해야 한다. 브라우저의 EventSource가 자동 재연결할 때는 알아서 보내주지만, 수동으로 재연결하는 경우에는 localStorage 등에 마지막 ID를 저장해두고 쿼리 파라미터로 직접 전달해야 한다.
자주 하는 실수
@Scheduled를 붙여도 @EnableScheduling이 없으면 스케줄러가 동작하지 않는다. Heartbeat가 전혀 실행되지 않아서 죽은 연결이 계속 쌓인다.
[!DANGER] 이벤트 저장소에 TTL 미설정
인메모리 저장소에 이벤트를 쌓기만 하고 정리하지 않으면 서버 메모리가 고갈된다. 반드시 최대 개수와 TTL 기반 정리를 모두 적용한다.
[!DANGER] Last-Event-ID를 무시
클라이언트가 Last-Event-ID를 보내도 서버에서 처리하지 않으면 이벤트 복구가 불가능하다. 재연결 시 이 값을 받아서 이벤트 저장소를 조회하는 로직을 반드시 구현한다.
면접 대비
SSE는 서버 → 클라이언트 단방향, HTTP 기반, 자동 재연결 내장이다. WebSocket은 양방향 전이중, TCP 기반, 재연결은 직접 구현해야 한다. 알림처럼 서버 푸시만 필요한 경우 SSE가 더 적합하고, 채팅처럼 양방향이 필요하면 WebSocket을 쓴다.
[!QUESTION] ConcurrentHashMap과 synchronized Map의 차이는?
synchronized Map은 모든 연산에 전체 잠금을 건다. ConcurrentHashMap은 버킷(노드) 단위로 잠금을 걸어서 서로 다른 키에 대한 동시 접근이 가능하고, 읽기는 잠금 없이 수행된다. SSE처럼 읽기가 잦은 경우 ConcurrentHashMap이 훨씬 효율적이다.
[!QUESTION] SSE 연결이 끊겼을 때 이벤트 유실을 어떻게 방지하나요?
세 가지를 조합한다. 이벤트를 보낼 때 고유 ID를 부여한다. 이벤트를 인메모리 저장소에 일정 시간 보관한다. 재연결 시 클라이언트가 Last-Event-ID를 보내면 그 이후의 이벤트를 재전송한다. 함정: "DB에서 조회하면 되지 않나요?" → DB 조회는 오버헤드가 크고, DB 저장 전에 유실된 이벤트는 복구가 불가능하다.
[!QUESTION] SseEmitter의 타임아웃은 어떻게 설정하나요? 왜 중요한가요?
생성자에 밀리초 단위로 전달한다. 설정하지 않으면 서블릿 컨테이너 기본값(보통 30초)이 적용되어, 30초마다 연결이 끊기고 재연결되는 비효율이 생긴다. 운영에서는 서비스 특성에 맞게 조절하되, Heartbeat와 함께 사용해서 프록시 타임아웃도 함께 고려해야 한다.
[!QUESTION] @Async를 사용할 때 주의할 점은?
같은 클래스 내부에서 호출하면 프록시를 거치지 않아서 비동기가 적용되지 않는다. 반드시 다른 빈에서 호출해야 한다. 또한 별도 스레드에서 실행되므로 호출한 쪽의 트랜잭션과는 독립적이다. 스레드 풀 설정도 확인해야 하며, 예외가 void 반환 메서드에서 발생하면 조용히 유실될 수 있다.