SSE(Server-Sent-Event)
관련 글
- Websocket
- Websocket + 부가기능
- Websocket (채팅 기록 json 파일 저장하기)
- Sse 👈🏻
- Sse 문제점
- Websocket + jwt
- Websocket test
- Jmh - 채팅 파일 refactoring
이 전에 websocket 재 연결에 대해 잠시 헤매고 있을 때(stomp 적용 전)
SSE를 사용하는 것에 대해 들어봤었다. 물론 그때는 채팅이기때문에 적용을 하지 않았고
채팅이 끝나고 알림기능을 구현할 때 적용해봐야지 생각이 들었다.
그리고 이번에 알림기능을 적용하면서 SSE에 대해서 공부하게 되었다.
SSE에 대해서 이론을 어느정도 파악하고 코드를 작성하는 방법에 대해서 찾아보니
간단한 코드도 있고 조금 설정을 해야하는 코드들도 있었다. 나는 간단한 방법으로 작성했다.
front 참고
[Spring] Websocket / Sock js를 사용한 실시간 알림전송 기능 구현
전체적인 코드 참고
Spring에서 Server-Sent-Events 구현하기
그 외
알림 기능을 구현해보자 - SSE(Server-Sent-Events)!
[Spring + SSE] Server-Sent Events를 이용한 실시간 알림
*이론 관련 reference는 하단에 적어뒀다.
CLIENT - SERVER
HTTP 프로토콜의 특징 중 중요한 부분 중 하나는 비연결성이다.
HTTP는 비연결성이라는 특징을 가지고 있어서 연결을 끊어버린다.
이를 해결하는 방식이 Polling(폴링) 이다.
Polling
클라이언트가 주기적으로 서버에 요청을 보내는 방법이다.
일정 시간마다 서버에 요청을 보내 데이터가 갱신되었는지 확인하고 만약 갱신되었다면 데이터를 응답받는다.
클라이언트와 서버 모두 구현이 단순해서 서버가 요청에 대한 부담이 크지 않고 요청 주기를 넉넉하게 잠아도 될 정도로
실시간성이 중요하지 않다면 고려해 볼만한 방법이다.
그러나 클라이언트가 계속 Request를 보내면서 서버의 부담은 점점 늘어난다.
그리고 Connection을 맺고 끊는 것에 대한 비용 부담이 커지기 때문에 좋지 않을 뿐더러
이러한 방식이 진짜 실시간으로 동작한다고 보기에도 어려운 점이 존재한다.
(주기적으로 보내는 것이기에 바로 응답한다는 것에 대한 보장이 없다.)
다른 방법으로 Long Polling(긴 폴링)이 있다.
Long Polling
요청을 보내고 서버에서 변경이 일어날 때까지 대기하는 방법이다.
즉, Connection을 계속 열어두고 요청이 온다면 해당 요청을 처리하는 것이다.
처음에 긴 Connection을 가질 수 있게 Request를 보낸다.
그리고 지속되고 있는 Connection 시간동안 어떤 이벤트가 발생했을 때 그 이벤트에 대한 결과 값을 지속되고 있는 Connection을 통해 보내면 된다.
그리고 다시 긴 Connection을 가질 수 있게 Request를 보낸다. 위의 방식을 반복하게 된다.
폴링 방식과 달리 계속적으로 Connection을 열어두고 있다가 바로 그에 대한 결과값을 받기에 실시간성이 보장될 것이다.
실시간 메시지 전달이 중요하지만 서버의 상태가 빈번하게 변하지 않는 경우에 적합하다.
하지만 시간 간격이 좁다면 사실상 기존 폴링과 큰 차이가 없게되고 지속적으로 연결되어 있기 때문에
다수의 클라이언트에게 동시에 이벤트가 발생될 경우
Response를 보내고 Request를 다시 보내야하기 때문에 순간적인 부담이 급증하게 된다.
streaming
Long polling과 마찬가지로 클라이언트에서 서버로 일단 http request를 날린다.
서버에서 클라이언트로 이벤트를 전달할때 해당 요청을 끊지 않고 필요한 메시지만 보내기를(flush) 반복하는 방식이다.
long polling에 비해 서버에서 메시지를 보내고도 다시 http request 연결을 하지 않아도 되어 부담이 경감될것으로 보인다.
long polling, streaming 방식의 경우 서버에서 클라이언트로 메시지를 보낼 수 는 있으나
클라이언트에서 서버로 메시지를 보내는것은 문제가 있을 수 있다.
위 방식 모두 HTTP를 통해 통신하기 때문에 Request/Response 모두 헤더가 불필요하게 크다.
이러한 단점들을 해소하기 위해 나온 것이 바로 웹 소켓(WebSocket)이다.
WebSocket
웹 소켓은 HTTP와 같은 프로토콜의 일종으로 클라이언트와 서버 간의 효율적인 양방향 통신을 실현하기 위한 구조이다.
웹 소켓은 양방향 통신으로 진행되고 최초 접속이 일반 Http 요청을 이용한 Handshaking으로 이루어진다.
(80, 443 포트로 접속하므로 추가로 방화벽을 열지 않고도 가능하다.
그렇기 때문에 HTTP 규격인 CORS 적용, 인증 등을 기존과 동일하게 보장받을 수 있다.)
Http와 같이 연결 후 끊어버리는 것이 아니라 연결 후 계속적으로 Connection을 지속하므로
연결하는데 필요한 불필요한 비용을 제거할 수 있다.
또한 위에서 HTTP 통신의 Request/Response 헤더가 불필요하게 크기에 문제가 생겼는데
웹소켓을 이용하면 최초 접속시에만 헤더 정보를 보내고 더 이상 보내지 않으므로 이를 처리해줄 수 있다.
그리고 기존 Http 요청과 달리 웹소켓 포트에 접속해 있는 모든 클라이언트에게 이벤트 방식으로 응답한다.
변경 사항의 빈도가 자주 일어나지 않고, 데이터의 크기가 작은 경우 Ajax, Streaming, Long polling 이 더 효과적일 수 있다.
실시간성을 보장해야 하고, 변경 사항의 빈도가 잦다면, 또는 짧은 대기 시간, 고주파수, 대용량의 조합인 경우 WebSocket이 좋은 해결책이 될 수 있다.
SSE
SSE는 웹소켓과 달리, 클라이언트는 서버로부터 데이터만 받을 수 있게 된다.
SSE는 웹소켓과 달리 별도의 프로토콜을 사용하지 않고 HTTP 프로토콜만으로 사용이 가능하기에 훨씬 가볍다.
접속에 문제가 있으면 자동으로 재연결을 시도하지만 클라이언트가 페이지를 닫아도 서버에서 감지하기가 어렵다.
또 다른 특징으로 HTTP/1.1의 경우 브라우저당 6개의 접속만을 허가하며 HTTP/2에서는 100개까지의 접속을 허용한다.
Spring Framework는 4.2(2015년)부터 SseEmitter 클래스를 제공하여 서버 사이드에서의 SSE 통신 구현이 가능해졌다.
JS에서는 EventSource를 이용하여 연결 생성 및 전송된 이벤트에 대한 제어가 가능하다.
EventSource를 이용하여 연결 생성 요청을 서버에 보낸다면 서버는 이를 처리해 연결을 진행해주어야한다.
그렇기 위해서는 서버에서 이 요청을 처리해줄 수 있는 부분을 구현해야한다.
Socket | Server-Sent-Event | |
---|---|---|
브라우저 지원 | 대부분 브라우저에서 지원 | 대부분 모던 브라우저 지원(polyfills 가능) |
통신 방향 | 양방향 | 일방향(서버 -> 클라이언트) |
리얼타임 | Yes | Yes |
데이터 형태 | Binary, UTF-8 | UTF-8 |
자동 재접속 | No | Yes(3초마다 재시도) |
최대 동시 접속 수 | 브라우저 연결 한도는 없지만 서버 셋업에 따라 다름 | HTTP를 통해서 할 때는 브라우저당 6개 까지 가능 / HTTP2로는 100개가 기본 |
프로토콜 | websocket | HTTP |
베터리 소모량 | 큼 | 작음 |
Firewall 친화적 | Nope | Yes |
WebSocket은 양방향 통신에 적합하다.
채팅에서와 같이 클라이언트와 서버가 양방향 통신이 필요한 부분에서는 좋은 선택이겠지만
알림 서비스는 단지 알림을 받는 사람 입장에서는 전혀 요청을 하지 않고 서버에서만 응답을 받는 단방향 시스템이므로
알림 기능만을 고려했을 때, 웹소켓보다 가벼운 SSE를 선택하는 것이 더 나은 선택으로 느껴졌다.
그래서 초반에는 초기에 작성했던 websocket 코드를 사용해서 할까 싶었지만
SseEmitter를 활용해서 구현하고 싶어졌다.
Code
- 클라이언트에서 SSE 연결 요청을 보낸다.
- 서버에서는 클라이언트와 매핑되는 SSE 통신 객체를 만든다.
- 서버에서 이벤트가 발생하면 해당 객체를 통해 클라이언트로 데이터를 전달한다.
Client
1
const eventSource = new EventSource(`/room/subscribe/?id=${username}`);
클라이언트에서는 EventSource라는 인터페이스로 SSE 연결 요청을 할 수 있다.
1
2
3
4
5
6
7
8
9
10
11
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)
}
};
서버에서 데이터를 푸쉬하면, message가 실행되고, e.data에서 데이터를 가져올 수 있다.
data:
행 다음에 메시지가 오고, 스트림 맨 마지막에는 \n
문자가 두개 있다면 스트림이 끝난 것으로 간주한다.
메시지가 길어서 여러줄을 보내야 한다면, data:
행을 사용하여 메시지를 분할하면 된다.
\n
으로 하나만 줄바꿈이 되어 있다면, message이벤트는 하나만 발생한다.
Server
spring framework 4.2부터 SSE 통신을 지원하는 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 { // id는 nickname으로 지정
SseEmitter emitter = new SseEmitter(DEFAULT_TIMEOUT);
}
생성자를 통해 만료시간을 설정할 수 있다.
springboot의 내장 톰캣을 사용하면 30초로 설정된다.
만료 시간이 되면 브라우저에서 자동으로 서버에 재연결 요청을 보낸다.
이때 생성된 SseEmitter 객체는 향후 이벤트가 발생했을 때,
해당 클라이언트로 이벤트를 전송하기 위해 사용되므로 서버에서 저장하고 있어야한다.
1
CLIENTS.put(id, emitter);
Emitter를 생성하고 나서 만료 시간까지 아무런 데이터도 보내지 않으면 재연결 요청시 503 Service Unavailable 에러가 발생할 수 있다.
따라서 처음 SSE 연결 시 더미 데이터를 전달해주는 것이 안전하다.
1
2
3
emitter.send(SseEmitter.event()
.name("connect") // 해당 이벤트의 이름 지정
.data("connected!")); // 503 에러 방지를 위한 더미 데이터
SseEmitter를 생성할 때는 비동기 요청이 완료되거나 타임아웃 발생 시 실행할 콜백을 등록할 수 있다.
타임아웃이 발생하면 브라우저에서 재연결 요청을 보내는데,
이때 새로운 Emitter 객체를 다시 생성하기 때문에(Controller의 subscribe()
참조) 기존의 Emitter를 제거해주어야 한다.
따라서 onCompletion 콜백에서 자기 자신을 지우도록 등록한다.
1
2
emitter.onTimeout(() -> CLIENTS.remove(id));
emitter.onCompletion(() -> CLIENTS.remove(id));
주의할 점은 이 콜백이 SseEmitter를 관리하는 다른 스레드에서 실행된다.
따라서 thread-safe한 자료구조를 사용하지 않으면 ConcurrnetModificationException이 발생할 수 있다.
여기서는 thread-safe한 자료구조인 ConcurrentHashMap을 사용했다. (etc. CopyOnWriteArrayList)
ConcurrentHashMap은 thread-safe하기 때문에, Multi-Thread 환경에서 사용할 수 있다.
이제 서버에서 무언가 변경 사항이 생겼을 때 클라이언트의 요청이 없어도 데이터를 전송할 수 있다.
누군가 /room/publish
를 호출하면 서버에서 admin과 user를 구분하는 message를
SSE Connection이 열려있는 모든 client에게 전달한다.
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
13
@GetMapping("/room/publish")
public void publish(String sender, String roomId) {
Set<String> deadIds = new HashSet<>();
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);
}
});
}
채팅을 완료한 후에 알림을 보내는 식이면 엄청 오래걸리는 작업이 존재할 때 그만큼 알림도 늦게 간다.
따라서 위 코드에서 @Async
를 활용해서 비동기적으로 처리하게 했다.
랜덤채팅에서 구현한 비동기 설정을 활용했다.
reference
mdn - Server-Sent Events 사용하기
이론
Spring에서 Server-Sent-Events 구현하기
알림 기능을 구현해보자 - SSE(Server-Sent-Events)!
48일차-7/24 토 -항해99
코드
[Spring] Server Sent Event(SSE)
[Spring] Websocket / Sock js를 사용한 실시간 알림전송 기능 구현
서버 사이드 이벤트 (Server Side Events, SSE)
[NODE] 📚 Server Sent Events 💯 정리 (+사용법)
webSocket 으로 개발하기 전에 알고 있어야 할 것들