Sse
SSE(Server-Sent-Event)
관련 글
- Websocket
- Websocket + 부가기능
- Websocket (채팅 기록 json 파일 저장하기)
- Sse 👈🏻
- Websocket + jwt
- Websocket test
- Jmh - 채팅 파일 refactoring
이전에 WebSocket 재연결 문제로 잠시 헤매고 있을 때 STOMP를 적용하기 전 단계에서 SSE라는 기술을 접한 적이 있다.
그때는 채팅 기능을 구현하고 있었기 때문에 양방향 통신이 필요한 WebSocket을 선택했고 SSE는 사용하지 않았다.
채팅 기능 구현이 끝난 뒤 알림 기능을 추가할 때 SSE를 적용해보면 좋겠다는 생각을 하게 되었다.
이번에 알림 기능을 구현하면서 SSE에 대해 본격적으로 공부하게 되었고 실제 코드 구현까지 정리해보았다.
SSE의 개념을 어느 정도 이해한 뒤 구현 방법을 찾아보니 매우 단순한 예제도 있었고 설정이 많이 필요한 방식도 있었다.
나는 비교적 간단한 방식으로 구현했다.
CLIENT - SERVER
HTTP 프로토콜의 중요한 특징 중 하나는 비연결성(Connectionless)이다.
HTTP는 요청과 응답이 끝나면 연결을 바로 끊어버린다.
이러한 특성 때문에 서버에서 발생한 이벤트를 클라이언트로 실시간 전달하는 데에는 한계가 있다.
이를 보완하기 위해 여러 가지 통신 방식이 등장했다.
Polling
Polling은 클라이언트가 주기적으로 서버에 요청을 보내는 방식이다.
일정 시간마다 서버에 요청을 보내 데이터가 변경되었는지 확인하고 변경되었다면 데이터를 응답받는다.
구현 방식이 단순하고 서버와 클라이언트 모두 부담이 적은 편이다.
실시간성이 크게 중요하지 않고 요청 주기를 넉넉하게 잡을 수 있다면 고려해볼 수 있는 방식이다.
하지만 클라이언트가 지속적으로 Request를 보내기 때문에 서버 부하는 점점 증가한다.
Connection을 반복적으로 맺고 끊는 비용 또한 무시할 수 없다.
주기적으로 요청을 보내는 구조이기 때문에 이벤트가 발생하자마자 즉시 응답된다는 보장이 없다.
이러한 이유로 진정한 실시간 통신이라고 보기는 어렵다.
Long Polling
Long Polling은 클라이언트가 요청을 보낸 뒤 서버에서 변경 사항이 발생할 때까지 대기하는 방식이다.
클라이언트는 서버로 요청을 보내고 서버는 Connection을 끊지 않은 채 이벤트 발생을 기다린다.
이벤트가 발생하면 해당 Connection을 통해 응답을 보내고 연결을 종료한다.
클라이언트는 응답을 받은 후 다시 새로운 Long Polling 요청을 보낸다.
이 과정을 반복하면서 실시간에 가까운 동작을 구현한다.
Polling과 달리 서버에서 이벤트가 발생하자마자 응답을 보내기 때문에 실시간성이 보장된다.
서버 상태 변경이 자주 발생하지 않는 경우에 적합하다.
하지만 이벤트 발생 빈도가 높아지면 기존 Polling 방식과 큰 차이가 없어질 수 있다.
다수의 클라이언트에게 동시에 이벤트가 발생하면 순간적으로 서버 부하가 급증한다.
Streaming
Streaming 방식 역시 클라이언트에서 서버로 HTTP 요청을 먼저 보낸다.
서버는 해당 요청을 유지한 상태에서 이벤트가 발생할 때마다 Connection을 끊지 않고 데이터를 flush 방식으로 전달한다.
메시지를 보내기 위해 매번 새로운 요청을 만들 필요가 없기 때문에 Long Polling보다 서버 부담이 줄어든다.
하지만 Streaming과 Long Polling 모두 서버에서 클라이언트로의 단방향 전송에는 적합하나
클라이언트에서 서버로 메시지를 보내는 구조에는 한계가 존재한다.
WebSocket
위에서 살펴본 방식들은 모두 HTTP 기반 통신이기 때문에 Request와 Response의 헤더가 불필요하게 크다.
이러한 단점을 해결하기 위해 등장한 것이 WebSocket이다.
WebSocket은 클라이언트와 서버 간의 효율적인 양방향 통신을 위해 설계된 프로토콜이다.
최초 연결 시에는 HTTP 요청을 이용한 Handshaking 과정을 거친다.
80 또는 443 포트를 사용하기 때문에 별도의 방화벽 설정 없이 사용 가능하다.
HTTP 기반이므로 기존의 CORS 정책이나 인증 방식도 그대로 활용할 수 있다.
WebSocket은 연결을 유지한 상태에서 지속적으로 데이터를 주고받기 때문에
Connection 생성과 해제에 드는 불필요한 비용을 제거할 수 있다.
최초 연결 시에만 헤더를 전송하고 이후에는 전송하지 않기 때문에 통신 효율이 높다.
또한 서버는 연결된 모든 클라이언트에게 이벤트 기반으로 데이터를 전송할 수 있다.
변경 사항이 자주 발생하지 않고 데이터 크기가 작은 경우에는
Ajax, Streaming, Long Polling 방식이 더 효과적일 수 있다.
하지만 변경 빈도가 높고 실시간성이 매우 중요하며
짧은 대기 시간과 높은 빈도의 데이터 전송이 필요하다면 WebSocket이 가장 적합하다.
SSE(Server-Sent Events)
SSE는 WebSocket과 달리 서버에서 클라이언트로의 단방향 통신만 지원한다.
별도의 프로토콜을 사용하지 않고 HTTP 프로토콜만으로 동작하기 때문에 매우 가볍다.
연결이 끊어질 경우 자동으로 재연결을 시도한다는 장점이 있다.
다만 클라이언트가 페이지를 닫았을 때 서버에서 이를 즉시 감지하기는 어렵다.
HTTP/1.1 환경에서는 브라우저당 최대 6개의 연결만 허용된다.
HTTP/2 환경에서는 기본적으로 최대 100개의 연결을 허용한다.
Spring Framework는 4.2부터 SSE 구현을 위한 SseEmitter 클래스를 제공한다.
JavaScript에서는 EventSource 인터페이스를 이용해 SSE 연결을 생성할 수 있다.
EventSource를 통해 서버로 연결 요청을 보내면
서버에서는 해당 요청을 처리하고 SSE Connection을 유지해야 한다.
Socket vs SSE 비교
| 구분 | WebSocket | Server-Sent Events |
|---|---|---|
| 브라우저 지원 | 대부분 지원 | 대부분 모던 브라우저 지원 |
| 통신 방향 | 양방향 | 단방향(서버 → 클라이언트) |
| 실시간성 | 가능 | 가능 |
| 데이터 형식 | Binary, UTF-8 | UTF-8 |
| 자동 재접속 | 지원 안 함 | 3초 간격 자동 재시도 |
| 최대 동시 접속 | 서버 설정에 따라 다름 | HTTP/1.1: 6개 / HTTP/2: 100개 |
| 프로토콜 | WebSocket | HTTP |
| 배터리 소모 | 큼 | 적음 |
| Firewall 친화성 | 낮음 | 높음 |
WebSocket은 양방향 통신이 필요한 채팅과 같은 기능에 적합하다.
하지만 알림 기능은 서버에서 클라이언트로 데이터를 전달하기만 하면 된다.
알림 서비스 관점에서는 WebSocket보다 가볍고 단순한 SSE가 더 적합하다고 판단했다.
그래서 기존 WebSocket 코드 대신 SseEmitter를 활용한 SSE 구현을 선택했다.
SSE 구현
1. 클라이언트에서 SSE 연결 요청을 보낸다.
2. 서버에서는 클라이언트와 매핑되는 SseEmitter 객체를 생성한다.
3. 서버에서 이벤트가 발생하면 해당 객체를 통해 클라이언트로 데이터를 전송한다.
Client
1
const eventSource = new EventSource(`/room/subscribe/?id=${username}`);
EventSource 인터페이스를 사용해 SSE 연결 요청을 보낼 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
const eventSource = new EventSource(`/room/subscribe/?id=${username}`);
eventSource.onopen = (e) => {
};
eventSource.onerror = (e) => {
};
eventSource.onmessage = (e) => {
let message = JSON.parse(e.data + "\n");
alarmForm(message);
};
서버에서 데이터를 push하면 onmessage 이벤트가 실행된다.
e.data를 통해 서버에서 전송한 데이터를 받을 수 있다.
data: 다음 줄에 메시지가 오며 스트림 마지막에 개행 문자 두 개가 있으면 하나의 이벤트로 처리된다.
여러 줄 메시지를 보내려면 data: 라인을 여러 번 사용하면 된다.
Server
Spring Framework 4.2부터 SseEmitter API를 통해 SSE 통신을 지원한다.
1
2
3
4
5
6
7
private static final Long DEFAULT_TIMEOUT = 60L * 1000 * 60;
private static final Map<String, SseEmitter> CLIENTS = new ConcurrentHashMap<>();
@GetMapping("/room/subscribe")
public SseEmitter subscribe(String id) throws IOException {
SseEmitter emitter = new SseEmitter(DEFAULT_TIMEOUT);
}
생성자를 통해 SSE Connection의 만료 시간을 설정할 수 있다.
Spring Boot 내장 톰캣을 사용할 경우 기본 타임아웃은 30초이다.
만료 시간이 되면 브라우저는 자동으로 재연결 요청을 보낸다.
생성된 SseEmitter 객체는 이후 이벤트 전송에 사용되므로
서버에서 클라이언트 식별자와 함께 저장해야 한다.
1
CLIENTS.put(id, emitter);
Emitter 생성 후 아무 데이터도 보내지 않으면
재연결 시 503 Service Unavailable 오류가 발생할 수 있다.
이를 방지하기 위해 최초 연결 시 더미 데이터를 전송한다.
1
2
3
4
5
emitter.send(
SseEmitter.event()
.name("connect") // 해당 이벤트의 이름 지정
.data("connected!") // 503 에러 방지를 위한 더미 데이터
);
SseEmitter는 타임아웃이나 완료 시 실행될 콜백을 등록할 수 있다.
타임아웃 발생 시 새로운 Emitter가 생성되므로 기존 객체는 제거해야 한다.
1
2
emitter.onTimeout(() -> CLIENTS.remove(id));
emitter.onCompletion(() -> CLIENTS.remove(id));
이 콜백은 별도의 스레드에서 실행된다.
따라서 thread-safe한 자료구조를 사용하지 않으면 ConcurrentModificationException이 발생할 수 있다.
ConcurrentHashMap은 멀티 스레드 환경에서도 안전하게 사용할 수 있다.
이벤트 전송
서버에서 변경 사항이 발생하면 클라이언트의 요청 없이도 데이터를 전송할 수 있다.
1
2
3
document.querySelector('#messageForm').addEventListener("submit", () => {
fetch(`/room/publish?sender=${username}&roomId=${roomId}`);
});
1
2
3
4
5
6
7
8
9
10
11
12
@GetMapping("/room/publish")
public void publish(String sender, String roomId) {
CLIENTS.forEach((id, emitter) -> {
try {
ChatMessage chatMessage = chatService.ringAlarm(sender, roomId);
emitter.send(chatMessage, MediaType.APPLICATION_JSON);
} catch (Exception e) {
CLIENTS.remove(id);
log.warn("disconnected id : {}", id);
}
});
}
채팅이 발생하면 SSE 연결이 유지 중인 모든 클라이언트에게 알림이 전송된다.
알림 전송이 오래 걸릴 수 있는 작업인 경우 @Async를 활용해 비동기 처리했다.
reference
Server-Sent Events 사용하기
Spring에서 Server-Sent-Events 구현하기
알림 기능을 구현해보자 - SSE(Server-Sent-Events)!
[Spring] Server Sent Event(SSE)
[NODE] 📚 Server Sent Events 💯 정리 (+사용법)



