다음 편: [스프링] 2. SSE 연결 관리와 동시성
웹 서비스에서 "새 댓글이 달렸습니다"라는 알림을 사용자에게 즉시 보여주려면 어떻게 해야 할까? 사용자가 새로고침을 누르기 전에, 서버가 먼저 데이터를 밀어넣는 방법이 필요하다. 이 편에서는 그 방법 중 하나인 SSE의 개념과 동작 원리를 다룬다.
실시간 통신이 필요한 순간
HTTP는 클라이언트가 요청하면 서버가 응답하는 구조다. 서버가 먼저 말을 걸 수는 없다.
그런데 알림, 실시간 피드, 주식 시세 같은 기능은 서버 쪽에서 새 데이터가 생겼을 때 즉시 클라이언트에 전달해야 한다. 클라이언트가 "혹시 새 데이터 있어?"라고 매번 물어보는 건 비효율적이다.
이 문제를 해결하기 위해 여러 기술이 등장했다. 각각의 방식이 어떤 문제를 어떻게 풀었는지 비교해 보자.
실시간 통신 기술 비교
Polling
가장 단순한 방법이다. 클라이언트가 일정 주기마다 서버에 요청을 보내서 새 데이터가 있는지 확인한다.
setInterval(async () => {
const response = await fetch('/api/notifications/new');
const data = await response.json();
if (data.length > 0) {
renderNotifications(data);
}
}, 3000); // 3초마다
간단하지만, 새 데이터가 없어도 요청은 계속 나간다. 서버 입장에서는 "아무것도 없는데요"라는 빈 응답을 끊임없이 보내야 한다. 사용자가 많아지면 이 빈 요청들이 서버에 상당한 부하를 준다.
Long Polling
Polling의 개선 버전이다. 클라이언트가 요청을 보내면, 서버가 새 데이터가 생길 때까지 응답을 보류한다. 데이터가 생기는 순간 응답을 보내고, 클라이언트는 응답을 받자마자 다시 요청을 건다.
빈 응답이 줄어드니 Polling보다 효율적이다. 하지만 매번 새 HTTP 연결을 맺어야 하는 오버헤드가 여전히 있고, 서버에서 대기 중인 연결을 관리하는 것도 부담이다.
SSE
SSE는 Server-Sent Events의 약자다. 클라이언트가 한 번 연결하면, 서버가 그 연결을 열어둔 채 데이터가 생길 때마다 밀어 보내는 방식이다.
핵심 특징을 정리하면:
- 단방향 스트림 : 서버 → 클라이언트 방향만 가능하다. 클라이언트에서 서버로 데이터를 보내려면 별도의 HTTP 요청을 쓴다.
- HTTP 기반 : 기존 HTTP 인프라를 그대로 쓸 수 있다. 프록시, 로드밸런서, 방화벽이 별도 설정 없이 통과된다.
- 자동 재연결 : 연결이 끊기면 브라우저가 자동으로 다시 연결을 시도한다. 개발자가 재연결 로직을 짤 필요가 없다.
- 텍스트 전용 : 바이너리 데이터는 전송할 수 없다. JSON 문자열이 주로 쓰인다.
WebSocket
WebSocket은 HTTP에서 시작하지만, 핸드셰이크 후 TCP 위에서 양방향 전이중 통신으로 전환된다. 클라이언트와 서버가 자유롭게 메시지를 주고받을 수 있다.
채팅처럼 양방향 통신이 빈번한 경우에 적합하다. 하지만 HTTP와 다른 프로토콜이라서 프록시 설정이나 방화벽 통과에 추가 작업이 필요할 수 있다.
어떤 기술을 선택하는가
필요한가?} Bidirectional -- "예 (채팅, 게임)" --> WebSocket[WebSocket] Bidirectional -- "아니오 (알림, 피드)" --> OneWay{서버에서
데이터를 밀어주는가?} OneWay -- "예" --> SSE[SSE] OneWay -- "아니오 (단순 조회)" --> Polling[Polling / Long Polling] style SSE fill:#e1f5fe,stroke:#01579b style WebSocket fill:#fff3e0,stroke:#e65100
| 기준 | Polling | Long Polling | SSE | WebSocket |
|---|---|---|---|---|
| 방향 | 클라이언트 → 서버 | 클라이언트 → 서버 | 서버 → 클라이언트 | 양방향 |
| 프로토콜 | HTTP | HTTP | HTTP | WS / TCP |
| 서버 부하 | 높음 | 중간 | 낮음 | 낮음 |
| 구현 난이도 | 쉬움 | 보통 | 쉬움 | 높음 |
| 자동 재연결 | 직접 구현 | 직접 구현 | 브라우저 내장 | 직접 구현 |
| 적합한 케이스 | 레거시 호환 | 제한적 실시간 | 알림, 피드, 시세 | 채팅, 게임 |
서버에서 클라이언트로 일방적으로 데이터를 보내는 케이스라면 SSE가 가장 적합하다. 알림 시스템이 정확히 이 케이스에 해당한다.
"양방향이 필요한가?"를 먼저 판단한다. 알림, 대시보드 갱신, 실시간 피드처럼 서버 → 클라이언트 단방향이면 SSE로 충분하다. WebSocket은 채팅, 실시간 협업 에디터, 멀티플레이 게임처럼 양쪽이 빈번하게 메시지를 주고받는 경우에 쓴다.
SSE 프로토콜 구조
SSE는 HTTP 응답의 Content-Type을 text/event-stream으로 설정한 뒤, 특정 형식에 맞춰 텍스트를 스트리밍하는 구조다. 별도의 프로토콜 업그레이드 없이 일반 HTTP 위 에서 동작한다.
data: {"name": "Alice"} Note right of S: (빈 줄로 메시지 구분) S->>C: id: 1
data: {"msg": "Hello!"} S->>C: : heartbeat (주기적 연결 확인용 주석)
메시지 형식
서버가 보내는 각 메시지는 네 가지 필드로 구성된다. 각 필드는 한 줄에 하나씩, 필드명: 값 형태로 작성한다. 메시지와 메시지 사이는 빈 줄(\n\n)로 구분한다.
event: notification
id: 42
data: {"title": "새 댓글", "message": "좋은 글이네요!"}
retry: 3000
각 필드의 역할은 다음과 같다:
- event : 이벤트 이름이다. 클라이언트에서
addEventListener('notification', ...)으로 수신한다. 생략하면message라는 기본 이벤트로 전달된다. - data : 실제 전송 데이터다. 여러 줄에 걸쳐 보내려면
data:줄을 여러 번 쓴다. - id : 이벤트 고유 식별자다. 재연결 시
Last-Event-ID헤더로 서버에 전달된다. - retry : 재연결 대기 시간이다. 밀리초 단위로, 브라우저가 이 값을 기억해두고 연결이 끊겼을 때 해당 시간만큼 기다렸다가 재연결한다.
자동 재연결과 Last-Event-ID
SSE의 가장 큰 장점 중 하나는 브라우저가 재연결을 알아서 처리한다는 것이다.
연결이 끊기면 브라우저는 다음 단계를 자동으로 수행한다:
retry에 지정된 시간만큼 대기한다. 기본값은 보통 3초다.- 마지막으로 받은
id값을Last-Event-ID요청 헤더에 담아서 서버에 재연결 요청을 보낸다. - 서버는 이 ID를 보고 "이 클라이언트가 어디까지 받았는지" 판단할 수 있다.
이 메커니즘 덕분에 네트워크가 잠시 불안정해도 클라이언트 코드에서 재연결 로직을 별도로 구현할 필요가 없다. 단, 서버 측에서 Last-Event-ID를 받아서 누락된 이벤 트를 재전송하는 로직은 직접 구현해야 한다. 이 부분은 개념 4편에서 상세히 다룬다.
(Last-Event-ID: 10) S-->>C: 연결 성공 S->>C: id: 11 (누락된 데이터 전송)
브라우저의 EventSource API
SSE를 클라이언트에서 사용하려면 EventSource 객체를 쓴다. 브라우저 네이티브 API라서 별도 라이브러리가 필요 없다.
연결과 이벤트 리스닝
const eventSource = new EventSource('/api/sse/connect?userId=user1');
URL에 연결하면 브라우저가 서버와 SSE 연결을 수립한다. 이후 서버가 보내는 이벤트를 addEventListener로 수신한다.
// 커스텀 이벤트 리스닝 (서버에서 event: notification으로 보낸 메시지)
eventSource.addEventListener('notification', (event) => {
const data = JSON.parse(event.data);
console.log('알림:', data);
});
// 기본 이벤트 (event 필드가 없는 메시지)
eventSource.onmessage = (event) => {
console.log('메시지:', event.data);
};
// 에러 처리
eventSource.onerror = (error) => {
console.error('SSE 에러:', error);
};
서버에서 event: notification으로 보내면 클라이언트에서 addEventListener('notification', ...)으로 받는다. event 필드 없이 보내면 onmessage로 받는다. 커스텀 이벤트 이름을 쓴다면 반드시 addEventListener를 써야 한다는 점을 기억하자.
연결 상태 관리
eventSource.readyState로 현재 연결 상태를 확인할 수 있다.
EventSource.CONNECTING(0) : 연결 중이거나 재연결 시도 중EventSource.OPEN(1) : 연결 활성 상태EventSource.CLOSED(2) : 연결 종료
eventSource.close();
close()를 호출하면 연결이 끊기고, 브라우저의 자동 재연결도 중단된다.
EventSource는 GET 요청만 지원한다. 커스텀 헤더도 설정할 수 없다. 인증 토큰을 헤더로 보내야 하는 상황이면 쿼리 파라미터에 담거나, event-source-polyfill이나 fetch 기반의 SSE 클라이언트 라이브러리를 사용해야 한다.
Spring Boot에서의 SseEmitter
Spring Framework는 SseEmitter라는 클래스로 SSE를 지원한다. 컨트롤러에서 SseEmitter 객체를 반환하면 Spring이 응답의 Content-Type을 text/event-stream으로 설정하고, 연결을 열어둔 채 데이터를 스트리밍할 수 있게 해준다.
SseEmitter 기본 구조
@GetMapping(value = "/connect", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter connect() {
SseEmitter emitter = new SseEmitter(3600000L);
emitter.send(
SseEmitter.event()
.name("connect")
.data("연결 성공")
);
return emitter;
}
SseEmitter 객체를 생성할 때 타임아웃을 밀리초 단위로 지정한다. 위 예시는 1시간이다. 이 시간이 지나면 서버가 자동으로 연결을 종료한다.
SseEmitter.event()는 빌더 패턴으로 메시지를 구성한다:
.name(String)→ SSE의event필드.data(Object)→ SSE의data필드. 객체를 넘기면 JSON으로 직렬화된다..id(String)→ SSE의id필드
produces = MediaType.TEXT_EVENT_STREAM_VALUE는 응답의 Content-Type을 text/event-stream으로 명시한다.
타임아웃을 지정하지 않으면 Spring의 기본값이 적용되는데, 서블릿 컨테이너에 따라 30초~60초 정도다. 실시간 알림에서 30초마다 연결이 끊기고 재연결되면 서버에 불필요한 부하가 생긴다.
생명주기 콜백
SseEmitter는 연결의 생명주기를 관리하기 위한 세 가지 콜백을 제공한다.
emitter.onCompletion(() -> {
// 연결이 정상 종료되었을 때
});
emitter.onTimeout(() -> {
// 타임아웃이 발생했을 때
});
emitter.onError((e) -> {
// 전송 중 에러가 발생했을 때
});
연결이 끊어진 SseEmitter에 메시지를 보내면 IOException이 발생한다. 그래서 연결이 끊긴 emitter는 즉시 정리해야 한다. 이 콜백들이 바로 그 정리 시점을 알려주는 역할을 한다. 개념 2편에서 이 콜백을 활용한 연결 관리 패턴을 다룬다.
자주 하는 실수
SSE는 서버 → 클라이언트 단방향 전용이다. 클라이언트에서 서버로 데이터를 보내야 한다면 별도의 REST API 호출을 사용해야 한다. 양방향 통신이 필요하면 WebSocket을 고려한다.
[!DANGER] EventSource의 onmessage로 커스텀 이벤트를 받으려 함
서버에서 event: notification처럼 이벤트 이름을 지정하면 onmessage로는 받을 수 없다. 반드시 addEventListener('notification', ...)을 써야 한다. onmessage는 이벤트 이름이 없는 메시지만 받는다.
[!DANGER] CORS 설정 누락
프론트엔드와 백엔드가 다른 도메인에서 동작한다면 SSE 연결에도 CORS 설정이 필요하다. EventSource는 쿠키를 기본으로 보내지 않으므로, withCredentials 옵션이 필요한 경우 폴리필 라이브러리를 검토해야 한다.