프로젝트를 하면서 실시간 알림을 구현해야 하는 일이 생겼습니다. 이때 WebSocket
을 사용할 수 있지만 서버에서 클라이언트로 일방적으로 데이터를 전송하는 환경에 양방향 통신인 WebSocket
을 쓰기엔 리소스 낭비가 커보였습니다.
이때 Server-Send Event
의 약자인 SSE
를 활용하면 WebSocket 보다 가벼운 단방향 통신을 할 수 있다는 것을 알게되었습니다.
이를 활용하여 서버 - 클라이언트간 연결을 유지하면서 서버에서 받은 데이터가 있을 경우에 클라이언트에서 이벤트를 발생시키는 SSE
에 대해서 알아보고 구현하는 방법을 설명하겠습니다.
각 컨텐츠는 알림을 받을 시간이 설정되어 있습니다. -> 매 10분마다 현재 시간과 비교하여 알림을 보낼 데이터를 조회 -> 조회된 데이터마다 SSE 를 통해 데이터 전송
먼저 특정 시간에 조건에 맞는 알림을 보내기 위해 일정한 시간마다 보낼 데이터가 있는지 확인을 해야 합니다. 이를 사용하게 위해 Spring Scheduler
를 이용하여 특정 시간마다 Task 를 실행할 수 있습니다.
이때 Spring Scheduler 를 사용하기 위해 다음과 같은 기술도 고려해볼 수 있습니다.
이때 spring batch 는 특정 시간에 처리하기에 거리가 멀어 해당 사항이 없었고 , quartz scheduler 는 스케쥴링의 세밀한 제어를 하거나 클러스터링이 필요할 때 유용하게 사용할 수 있습니다. 때문에 구현이 복잡하여 , 단순 Scheduling 을 하고 싶다면 Spring scheduler 이 가장 좋습니다.
먼저 Spring Scheduler를 사용하기 위해 @EnableScheduling
를 설정해주어야 합니다. Spring Boot 기준으로 Application 파일에 @EnableScheduling
어노테이션을 추가해주어야 합니다.
해당 어노테이션은 따로 외부라이브러리를 설치하지 않아도 됩니다.
@EnableJpaAuditing @EnableScheduling // 주목 @SpringBootApplication public class Application { public static void main(String[] args) { SpringApplication.run(Application.class, args); } }
이제 어노테이션을 추가했다면 바로 Scheduler 를 구현할 수 있는데 @Scheduled
어노테이션을 작업하고자 하는 메서드 위에 선언해주면 됩니다.
@Scheduled(cron = "0 0/10 * * * *") public void sendPlaylistAtSpecificTime() { List<AlertResponse> result = playlistRepository.findAllPlaylistsByAlertTime(LocalTime.now()); for(AlertResponse playlist : result) { long id = playlist.getUserId(); SseEmitter sseEmitter = session.get(id); sendToClient(sseEmitter , id , playlist); } }
다음과 같이 @Scheduled
어노테이션을 선언하면 특정 시간에 따라 반복되는 로직이 완성됩니다.
이때 CRON 은 언제 스케쥴러가 실행될건지 설정할 수 있는 CRON 표현식은 밑의 챕터에서 설명합니다.
필드 | 허용하는 값 | 허용하는 특수 문자 |
---|---|---|
초 | 0-59 | * , - |
분 | 0-59 | * , - |
시 | 0-59 | * , - |
일 | 1-31 | * , - ? L W |
월 | 1-12 or JAN-DEC | * , - |
요일 | 0-6 or SUN-SAT | * , - ? L # |
년도 | 1970-2099 | * , - |
15W
일때 15일이 토요일이라면 금요일에 실행하고 일요일이라면 월요일에 실행합니다.1-5
사이의 숫자가 와야합니다. 5 # 3 매월 세번째 금요일예제
1) 매월 10일 오전 11시
0 1 1 10 * * *
2) 매일 오후 2시 5분 0초
0 5 14 * * *
3) 10분마다 실행
0 0/10 * * * *
4) 조건부 실행 ( 10분 0초 , 11분 0초 ~ 15분 0초 까지 실행 )
0 10-15 * * * *
5) 매월 마지막 금요일 오전 10 시 15분
0 15 10 ? 6L
6) 2014년부터 2017년까지 매월 마지막 금요일 오전 10시 15분
0 15 10 ? * 6L 2014-2017
만약 표현식을 작성하기 어렵다면 해당 사이트를 이용해서 원하는 표현식을 만들 수 있습니다. http://www.cronmaker.com/
SSE 는 Server Send Events 의 약자로 서버에서 클라이언트의 한 방향으로 흐르는 단방향 통신 채널입니다. 한번의 HTTP 연결을 통해 서버에서 클라이언트로 데이터를 보낼 수 있습니다.
이와 비슷한 방식으로 Polling
이 있는데 이는 클라이언트가 일정한 주기로 서버에 자원을 요청하는 방법으로 지속적인 HTTP 요청이 발생하기 때문에 리소스 낭비가 발생할 수 있습니다.
웹 소켓은 실시간 양방향 데이터 통신으로 서버와 클라이언트가 지속적인 TCP 연결을 통해 데이터를 주고받는 HTML5 사양입니다. 채팅 , 게임 , 주식등에 많이 사용됩니다.
주기적으로 데이터를 요청하는 Polling과는 달리 , WebSocket은 연결을 유지하여 서버와 클라이언트간 양방향 통신을 합니다.
분류 | 통신 방향 | HTTP 요청 횟수 |
---|---|---|
SSE | 단방향 | 한번 |
Socket | 양방향 | 한번 |
Polling | 단방향 | 매 요청 |
SSE 통신을 하기 위해서 처음 클라이언트는 서버와 연결이 필요합니다. SSE 연결 요청을 하기 위해서 JavaScript는 EventSource
를 제공합니다.
const eventSource = new EventSource(`/alert/subscribe/` + id);
eventSource.addEventListener("sse", function (event) {
console.log(event.data);
const data = JSON.parse(event.data);
}
구현은 되게 간단하며 서버에서 sse
이름의 데이터를 전송하면 EventListener 를 통해 console 이 출력됩니다.
서버에서 EventSource
로 요청되는 데이터를 처리해야 할 컨트롤러가 필요합니다. SSE 통신을 하기 위해서 MIME 타입을 text/event-stream
으로 받아야 합니다. 이는 @GetMapping
어노테이션 안에 제공하는 produces
인자 값을 이용합니다. MediaType.TEXT_EVENT_STREAM_VALUE
대신에 직접 문자열 text/event-stream
를 기입하셔도 무방합니다.
@GetMapping(value = "/alert/subscribe/{id}" , produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public ResponseEntity subscribeAlert(@PathVariable long id) {
SseEmitter sseEmitter = alertService.subscribeAlert(id);
return ResponseEntity.ok().body(sseEmitter);
}
추가적으로 Last-Event-Id 라는 헤더가 존재하는데 , 해당 헤더는 SSE 연결이 끊어졌을 때 유실된 데이터가 존재할 수 있습니다. ( 이는 서버는 클라이언트와 연결이 끊어진지 모르고 데이터를 전송했을 수 있기 때문입니다. )
이를 해결하기 위해 사용되며 이 헤더는 마지막으로 클라이언트가 받은 id 값을 의미하며 이를 이용하여 유실된 데이터를 제전송할 수 있습니다.
@RequestHeader(value = "Last-Event-ID", required = false, defaultValue = "")
컨트롤러 매개변수에 다음과 같은 데이터를 추가하여 가져올 수 있습니다.
private static final Long DEFAULT_TIMEOUT = 60L * 1000 * 60; // 60 second private Map<Long , SseEmitter> session = new HashMap<>(); public SseEmitter subscribeAlert(long id) { SseEmitter emitter = new SseEmitter(DEFAULT_TIMEOUT); if(session.containsKey(id)) { session.remove(id); } session.put(id , emitter); sendToClient(emitter , id , "EventStream Created. [userId = " + user.getId() + " ]"); return emitter; } private void sendToClient(SseEmitter emitter, long id, Object data) { try { emitter.send(SseEmitter.event() .id(Long.toString(id)) .name("sse") .data(data)); } catch (IOException exception) { throw new RuntimeException("SSE 연결을 실패했습니다."); } }
컨트롤러에서 subscribeAlert 를 호출하고 있습니다. 메서드를 살펴보면 SseEmitter
객체를 생성하고 있습니다. 이때 60초간 응답이 없다면 TIMEOUT 오류가 발생합니다.
이때 여기서 연결이 되었을 때 sendToClient
를 통해 메세지를 전송하는데 이는 SSE 연결 후 데이터를 전송하지 않으면 TIMEOUT 이 발생할 수 있기 때문에 더미 데이터를 전송해 주어야합니다.
그외 Map 자료구조에 session
을 관리하고 있으며, 새로운 세션이 들어왔을 때 등록해주고 있습니다. 이 때 SSE는 브라우저가 닫혀도 서버에서는 모르기 때문에 이미 존재할 수 있는 세션을 제거 후 다시 등록합니다.
이때 중요한 것은 SseEmitter 객체를 반환하는데 이는 클라이언트에서 SSE 정보를 보관해야 하기 때문에 무조건 반환을 해주어야합니다. 만약 반환해주지 않는다면 sse eventsource's response has a mime type ("text/plain") that is not "text/event-stream". aborting the connection.
오류가 발생할 수 있습니다.
해당 메서드는 단순한데 그저 클라이언트와 연결되어있는 SseEmitter 객체를 받아 send 메서드를 활용하여 데이터를 보내는 작업을 말합니다.
필자는 유실된 데이터를 따로 가공할 필요가 없기 때문에 Last-Event-Id
헤더를 활용하지 않았는데, 만약 클라이언트와 연결이 끊겼을 때 유실된 데이터를 다시 보내고 싶은 경우에 해당 포스팅을 참고해 주세요.
참고 블로그 1 : https://m.blog.naver.com/deeperain/221609802306
참고 블로그 2 : https://velog.io/@yeonii/Spring-%EC%8A%A4%ED%94%84%EB%A7%81-%EC%8A%A4%EC%BC%80%EC%A5%B4%EB%9F%AC-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0
참고 블로그 3 : https://velog.io/@max9106/Spring-SSE-Server-Sent-Events%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%9C-%EC%8B%A4%EC%8B%9C%EA%B0%84-%EC%95%8C%EB%A6%BC#-sse-in-spring