Spring SseEmitter 로 실시간 알림을 붙이며 겪은 일 — 세션 연장, 중복 로그인, 연결 정리
업무 시스템에 결재 알림을 실시간으로 띄우려고 SseEmitter 를 붙였다가 이틀 정도 씨름했다. 알림 자체는 금방 나왔는데, 세션과 얽힌 문제가 뒤늦게 드러났다. 겪은 문제와 지금 쓰는 구조를 Spring Boot 3.x(Spring Framework 6, Java 21) 기준으로 정리한다.
결론부터
SseEmitter타임아웃을 세션 타임아웃보다 짧게 잡으면, 타임아웃 → 브라우저 재연결(새 요청) 이 반복되면서 세션이 계속 연장된다- 그래서 emitter 는 시간 제한 없이(
new SseEmitter(0L)) 열어 두고, 끊긴 연결은 하트비트로 찾아낸다. 대신 세션이 끝나면 그 세션의 연결을 서버가 직접 닫는다 - 연결 키는 사용자 ID 하나로 잡지 않는다. 같은 사용자가 탭·기기를 여러 개 열면 서로 덮어쓴다. 연결마다 고유 ID 를 준다
- 정리(맵에서 제거)는
onCompletion에 둔다. 타임아웃·오류로 끝나도 호출된다 send()에서IOException이 나면completeWithError()를 부르지 않는다. 컨테이너가 알아서 오류 처리를 시작한다- 앞단에 nginx 가 있으면 버퍼링을 끄고, 하트비트 간격을 프록시의 읽기 타임아웃보다 짧게 한다
겪은 문제
1. 세션이 끝나지 않는다
세션 타임아웃은 2시간인데 SseEmitter 타임아웃은 30분으로 잡았다. 30분마다 연결이 끊기고, 브라우저의 EventSource 가 다시 연결한다. 이 재연결은 세션 쿠키를 들고 오는 새 HTTP 요청이라 서블릿 컨테이너가 세션의 마지막 접근 시각을 갱신한다. 결과적으로 화면을 켜 두기만 하면 세션이 영영 끝나지 않았다.
참고로 emitter 생성자에 타임아웃을 주지 않으면 MVC 설정의 기본값(spring.mvc.async.request-timeout)을 쓰고, 그것도 없으면 컨테이너 기본값을 쓴다. 톰캣은 30초다. 아무 생각 없이 new SseEmitter() 로 만들면 30초마다 재연결하면서 세션을 늘리게 된다.
2. 중복 로그인 처리가 꼬인다
같은 ID 로 A 에서 로그인한 뒤 B 에서 로그인하면 A 를 끊고 “다른 곳에서 로그인했다”는 안내를 띄우는 정책이 있었다. 그런데
- A 의 세션이 끊기지 않거나
- A 가 끊기긴 하는데 “세션 시간이 끝났다”는 엉뚱한 안내가 떴다
당시 emitter 를 USER_ID 하나로 맵에 넣고 있었는데, B 가 연결하면서 A 의 emitter 를 덮어쓰고, 덮어써진 A 의 emitter 가 정리되는 과정이 세션 처리와 뒤엉킨 것이 한 원인이었다.
중복 로그인 쪽의 뿌리(Spring Security 의 SessionRegistry 가 A 의 세션을 0개로 보던 문제)는 SSE 와 별개라 따로 봐야 했다. 같은 증상이라면 아래부터 확인해 보길 권한다. 둘 다 흔한 원인이다.
- 직접 만든 사용자 객체(
UserDetails구현)에equals()/hashCode()가 없다.SessionRegistryImpl은 principal 을 키로 세션을 찾기 때문에, 로그인할 때마다 새로 만든 객체가 서로 다르다고 판단되면 기존 세션을 못 찾는다 HttpSessionEventPublisher빈이 없다. 세션이 끝나도SessionRegistry에 알림이 가지 않는다
지금 쓰는 구조
연결 보관소
연결마다 서버가 UUID 를 붙이고, 사용자 ID·세션 ID 를 함께 보관한다.
import java.io.IOException;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
@Component
public class SseConnections {
private static final Logger log = LoggerFactory.getLogger(SseConnections.class);
private record Connection(String userId, String sessionId, SseEmitter emitter) {}
private final Map<String, Connection> connections = new ConcurrentHashMap<>();
public SseEmitter open(String userId, String sessionId) {
String id = UUID.randomUUID().toString();
SseEmitter emitter = new SseEmitter(0L); // 0 이하 = 시간 제한 없음
Connection conn = new Connection(userId, sessionId, emitter);
connections.put(id, conn);
// 타임아웃·오류·정상 종료 모두 여기로 온다
emitter.onCompletion(() -> connections.remove(id, conn));
// 시간 제한을 둘 경우를 대비. 직접 complete() 해야 깔끔하게 끝난다
emitter.onTimeout(emitter::complete);
emitter.onError(e -> {
log.debug("SSE 연결 오류 id={} user={}: {}", id, userId, e.toString());
connections.remove(id, conn);
});
sendTo(id, conn, SseEmitter.event().name("connect").data("ok"));
return emitter;
}
/** 한 사용자의 모든 연결(탭·기기)에 보낸다 */
public void sendToUser(String userId, String eventName, Object data) {
connections.forEach((id, conn) -> {
if (conn.userId().equals(userId)) {
sendTo(id, conn, SseEmitter.event().name(eventName).data(data));
}
});
}
/** 모든 연결에 주석 한 줄(하트비트)을 보낸다. 끊긴 연결은 여기서 걸러진다 */
public void heartbeat() {
connections.forEach((id, conn) -> sendTo(id, conn, SseEmitter.event().comment("ping")));
}
/** 세션이 끝나면 그 세션의 연결을 닫는다 */
public void closeBySession(String sessionId) {
connections.forEach((id, conn) -> {
if (conn.sessionId().equals(sessionId)) {
connections.remove(id, conn);
conn.emitter().complete();
}
});
}
private void sendTo(String id, Connection conn, SseEmitter.SseEventBuilder event) {
try {
conn.emitter().send(event);
} catch (IOException | IllegalStateException e) {
// IOException: 클라이언트가 떠났다. completeWithError() 는 부르지 않는다
// (컨테이너가 오류 처리를 시작하고 onError/onCompletion 이 불린다)
// IllegalStateException: 이미 끝난 emitter
connections.remove(id, conn);
}
}
}
몇 가지 짚어 둘 점.
ConcurrentHashMap.remove(key, value)로 그 연결일 때만 지운다. 콜백이 늦게 와도 다른 연결을 지우지 않는다onCompletion은 “타임아웃·네트워크 오류를 포함해 어떤 이유로든” 비동기 요청이 끝나면 불린다(Spring Javadoc). 정리는 여기 한 곳이면 된다- Spring Framework 6.2 부터는
onTimeout·onError·onCompletion을 여러 번 등록할 수 있다. 6.1 이하에서는 마지막에 등록한 것 하나만 남으니, 한 곳에서 등록한다 - 컨트롤러가 emitter 를 반환하기 전에
send()해도 된다. 응답이 준비되면 그때 나간다
컨트롤러
import jakarta.servlet.http.HttpSession;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
@RestController
public class SseController {
private final SseConnections connections;
public SseController(SseConnections connections) {
this.connections = connections;
}
@GetMapping(path = "/api/sse/subscribe", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public ResponseEntity<SseEmitter> subscribe(@AuthenticationPrincipal UserDetails user,
HttpSession session) {
SseEmitter emitter = connections.open(user.getUsername(), session.getId());
return ResponseEntity.ok()
.header("X-Accel-Buffering", "no") // nginx 버퍼링 끄기 (아래 참고)
.header("Cache-Control", "no-cache")
.body(emitter);
}
}
알림을 보내는 쪽(결재 승인 서비스 등)에서는 connections.sendToUser(userId, "approval", payload) 만 부르면 된다. 원래는 알림 발송용 HTTP 엔드포인트를 따로 두고 화면에서 호출했는데, 아무 사용자에게나 알림을 보낼 수 있는 구멍이 되기 쉽다. 업무 로직 안에서 서버가 직접 보내는 편이 안전하다.
Spring Boot 3 는
javax.servlet이 아니라jakarta.servlet이다. Boot 2 에서 옮겨 오면 import 부터 바뀐다.
하트비트
서블릿 API 는 클라이언트가 떠났다는 알림을 주지 않는다. 써 봐야 끊긴 걸 안다. 그래서 주기적으로 SSE 주석(: 으로 시작하는 줄)을 보낸다. 브라우저는 주석을 이벤트로 올리지 않는다.
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
@Component
public class SseHeartbeat {
private final SseConnections connections;
public SseHeartbeat(SseConnections connections) {
this.connections = connections;
}
@Scheduled(fixedRateString = "${sse.heartbeat-interval:20s}")
public void beat() {
connections.heartbeat();
}
}
설정 클래스 어딘가에 @EnableScheduling 이 있어야 돈다. 간격은 프록시의 유휴 타임아웃보다 짧게 잡는다(nginx proxy_read_timeout 기본 60초, Cloudflare 프록시는 100초 동안 응답이 없으면 끊는다). 20초 정도면 무난했다.
하트비트는 이미 열린 응답에 쓰는 것이라 새 요청이 아니다. 세션을 연장하지 않는다. 세션을 늘리던 것은 재연결이었다.
세션이 끝나면 연결도 닫는다
시간 제한을 없앴으니, 로그아웃하거나 세션이 만료돼도 SSE 연결은 그대로 살아 있다. 그동안 알림이 계속 가는 것도 문제다. 세션 종료 이벤트에서 닫는다.
import jakarta.servlet.http.HttpSessionEvent;
import jakarta.servlet.http.HttpSessionListener;
import org.springframework.stereotype.Component;
@Component
public class SseSessionListener implements HttpSessionListener {
private final SseConnections connections;
public SseSessionListener(SseConnections connections) {
this.connections = connections;
}
@Override
public void sessionDestroyed(HttpSessionEvent se) {
connections.closeBySession(se.getSession().getId());
}
}
Spring Boot 내장 톰캣에서는 HttpSessionListener 를 구현한 빈이 자동으로 등록된다. Spring Session(Redis 등)을 쓰면 세션 이벤트가 오는 방식이 다르니 그쪽 문서를 따른다.
중복 로그인으로 A 를 내보낼 때는, A 의 세션을 만료시키기 전에 A 의 연결로 forced-logout 같은 이벤트를 먼저 보내고 닫으면 “다른 곳에서 로그인했다”는 안내를 정확히 띄울 수 있다. 세션 만료 뒤에 A 가 받는 것은 그냥 실패한 요청이라, 화면은 타임아웃과 구분하지 못한다. 앞의 2번 문제에서 엉뚱한 안내가 뜬 이유도 이것이었다.
클라이언트
원래 화면은 넥사크로 HTML5 였지만 결국 브라우저의 EventSource 를 쓰는 것이라 일반 자바스크립트로 옮긴다.
let source = null;
function subscribe() {
source = new EventSource('/api/sse/subscribe'); // 같은 출처면 세션 쿠키가 함께 간다
source.addEventListener('approval', (e) => {
const data = JSON.parse(e.data);
showNotification(data);
});
source.addEventListener('forced-logout', () => {
source.close();
alert('다른 곳에서 로그인하여 연결을 종료합니다.');
location.href = '/login';
});
source.onerror = () => {
// 네트워크 문제면 EventSource 가 스스로 다시 연결한다(readyState = CONNECTING).
// 서버가 200 이 아닌 응답(401, 로그인 페이지로 리다이렉트 등)을 주면 CLOSED 가 되고 다시 붙지 않는다.
if (source.readyState === EventSource.CLOSED) {
checkSessionAndMaybeLogin();
}
};
}
subscribe();
- 원래는
onerror에서 직접close()하고setTimeout으로 최대 5번 다시 붙였다.EventSource에는 자동 재연결이 이미 있어서, 네트워크 문제는 맡겨 두고 닫힌 경우(CLOSED)만 처리하는 편이 단순하다 - 연결 ID 를
localStorage에 저장해 쓰던 방식은 버렸다.localStorage는 같은 브라우저의 모든 탭이 공유해서 탭끼리 같은 키를 쓰게 된다. 서버가 연결마다 ID 를 주면 클라이언트는 신경 쓸 필요가 없다 - HTTP/1.1 이면 브라우저는 도메인당 동시 연결을 6개 정도로 제한한다. 탭마다 SSE 를 하나씩 열면 탭이 많을 때 다른 요청이 막힐 수 있다. HTTP/2 로 서비스하면 이 문제는 사실상 없다
앞단 프록시 설정 (nginx)
nginx 는 기본으로 응답을 버퍼링해서, 이벤트가 바로 안 오고 몰아서 오거나 아예 안 온다. 응답 헤더 X-Accel-Buffering: no 를 주거나(위 컨트롤러), 해당 경로의 설정에서 끈다.
location /api/sse/ {
proxy_pass http://app;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 1h; # 하트비트가 있으면 기본 60초여도 되지만 여유를 둔다
}
기타 메모
- 스레드:
SseEmitter는 비동기 요청이라 연결이 열려 있는 동안 요청 스레드를 붙잡지 않는다. 동시 연결 수의 한계는 스레드 수보다 톰캣의 연결 수(server.tomcat.max-connections, 기본 8192)다. Spring Boot 3.2 이상에서spring.threads.virtual.enabled=true로 가상 스레드를 켜도 SSE 연결 수 자체가 늘어나지는 않는다 - Spring Security 6: 인가 필터는 요청(REQUEST)뿐 아니라 이후 디스패치에도 적용된다. emitter 가 끝날 때 마지막
ASYNC디스패치가 일어나는데, 이때 세션이 이미 없으면AccessDeniedException이나 “응답이 이미 커밋됐다”는 로그가 남을 수 있다. 그런 로그가 보이면dispatcherTypeMatchers(DispatcherType.ASYNC, DispatcherType.ERROR).permitAll()을 검토한다 - WebFlux 가 나은 경우: 애플리케이션 전체가 리액티브이거나 연결 수가 아주 많다면 WebFlux 에서
Flux<ServerSentEvent<T>>를 반환하는 편이 자연스럽다. Spring MVC 에서도text/event-stream으로Flux를 반환할 수 있지만, 응답 쓰기는 여전히 블로킹이고 별도 스레드에서 처리된다. 기존 MVC 앱에 알림 하나 붙이는 정도라면SseEmitter로 충분했다 - 양방향 통신이나 하트비트가 기본으로 필요한 경우라면 WebSocket(STOMP)도 선택지다
참고: Spring Framework — Asynchronous Requests (HTTP Streaming, SSE), ResponseBodyEmitter Javadoc
다시 쓰며 바로잡은 점
- 원문은 AI 와 나눈 문답을 그대로 옮긴 형태였다. 문제·원인·해결 순서로 다시 쓰고, 코드는 Spring Boot 3(
jakarta.*) 기준으로 새로 정리했다 - 원문 코드는
send()에서IOException이 나면completeWithError()를 불렀다. Spring 문서는 이 경우 애플리케이션이complete/completeWithError를 부르지 말라고 한다(컨테이너가 오류 처리를 시작한다). 맵에서 제거만 하도록 고쳤다 - 원문 코드에
emitters.remove(emitter, emitter)처럼 키 자리에 emitter 를 넣은 실수가 있었다. 키·값을 정확히 넣도록 고쳤다 - “서버가 보내는 하트비트도 세션을 연장한다”는 설명이 있었는데, 하트비트는 열린 응답에 쓰는 것이라 새 요청이 아니다. 세션을 늘린 것은 타임아웃 뒤의 재연결이다
- 시간 제한 없는 emitter 는 세션이 끝나도 남는다. 세션 종료 때 연결을 닫는 부분, nginx 버퍼링, 브라우저 연결 수 제한을 보탰다
- 클라이언트 ID 를
localStorage에 두면 탭끼리 공유돼 “연결마다 따로” 라는 원래 의도가 깨진다. 서버가 연결 ID 를 만들도록 바꿨다