Scheduling 과 SSE 를 활용한 실시간 푸시 알림 구현

5tr1ker·2023년 9월 15일
2

Server

목록 보기
8/10
post-thumbnail

개요

프로젝트를 하면서 실시간 알림을 구현해야 하는 일이 생겼습니다. 이때 WebSocket을 사용할 수 있지만 서버에서 클라이언트로 일방적으로 데이터를 전송하는 환경에 양방향 통신인 WebSocket 을 쓰기엔 리소스 낭비가 커보였습니다.

이때 Server-Send Event 의 약자인 SSE 를 활용하면 WebSocket 보다 가벼운 단방향 통신을 할 수 있다는 것을 알게되었습니다.

이를 활용하여 서버 - 클라이언트간 연결을 유지하면서 서버에서 받은 데이터가 있을 경우에 클라이언트에서 이벤트를 발생시키는 SSE에 대해서 알아보고 구현하는 방법을 설명하겠습니다.

구현 목표

  • 사용자가 설정한 알림을 받을 시간에 SSE 를 통해 클라이언트에게 데이터를 전송합니다.

프로세스

각 컨텐츠는 알림을 받을 시간이 설정되어 있습니다. -> 매 10분마다 현재 시간과 비교하여 알림을 보낼 데이터를 조회 -> 조회된 데이터마다 SSE 를 통해 데이터 전송

Spring Scheduler

먼저 특정 시간에 조건에 맞는 알림을 보내기 위해 일정한 시간마다 보낼 데이터가 있는지 확인을 해야 합니다. 이를 사용하게 위해 Spring Scheduler 를 이용하여 특정 시간마다 Task 를 실행할 수 있습니다.

이때 Spring Scheduler 를 사용하기 위해 다음과 같은 기술도 고려해볼 수 있습니다.

  • spring batch : 여러 Job 을 순차적으로 처리
  • quartz scheduler : 특정 job 을 특정 시간에 처리
  • spring scheduler : 특정 job 을 특정 시간에 처리

이때 spring batch 는 특정 시간에 처리하기에 거리가 멀어 해당 사항이 없었고 , quartz scheduler 는 스케쥴링의 세밀한 제어를 하거나 클러스터링이 필요할 때 유용하게 사용할 수 있습니다. 때문에 구현이 복잡하여 , 단순 Scheduling 을 하고 싶다면 Spring scheduler 이 가장 좋습니다.

Spring Boot 구현

초기 설정

먼저 Spring Scheduler를 사용하기 위해 @EnableScheduling 를 설정해주어야 합니다. Spring Boot 기준으로 Application 파일에 @EnableScheduling 어노테이션을 추가해주어야 합니다.

해당 어노테이션은 따로 외부라이브러리를 설치하지 않아도 됩니다.

@EnableJpaAuditing
@EnableScheduling // 주목
@SpringBootApplication
public class Application {

	public static void main(String[] args) {
		SpringApplication.run(Application.class, args);
	}

}

Task 구현

이제 어노테이션을 추가했다면 바로 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 표현식은 밑의 챕터에서 설명합니다.

CRON 표현식

필드허용하는 값허용하는 특수 문자
0-59* , -
0-59* , -
0-59* , -
1-31* , - ? L W
1-12 or JAN-DEC* , -
요일0-6 or SUN-SAT* , - ? L #
년도1970-2099* , -
  • Dash ( - )
    범위를 지정합니다. 2012-2024 는 2012년 부터 2024년 까지를 의미합니다.
  • L ( Last )
    마지막을 말하며 요일에 6L 이라고 하면 매달 마지막 토요일을 의미합니다.
  • W
    주어진 요일에 가장 가까운 평일을 의미합니다. ( 월-금 )
    예시로 15W 일때 15일이 토요일이라면 금요일에 실행하고 일요일이라면 월요일에 실행합니다.
  • 몇번째 주에 실행할지 의미합니다.
    해당 문자 뒤에 1-5 사이의 숫자가 와야합니다. 5 # 3 매월 세번째 금요일
  • ?
    특정 값이 없습니다.
  • /
    빈도를 설정할 수 있습니다. */5 -> 5분마다
    • 모든 값

예제
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 )

SSE 는 Server Send Events 의 약자로 서버에서 클라이언트의 한 방향으로 흐르는 단방향 통신 채널입니다. 한번의 HTTP 연결을 통해 서버에서 클라이언트로 데이터를 보낼 수 있습니다.

Polling

이와 비슷한 방식으로 Polling 이 있는데 이는 클라이언트가 일정한 주기로 서버에 자원을 요청하는 방법으로 지속적인 HTTP 요청이 발생하기 때문에 리소스 낭비가 발생할 수 있습니다.

WebSocket

웹 소켓은 실시간 양방향 데이터 통신으로 서버와 클라이언트가 지속적인 TCP 연결을 통해 데이터를 주고받는 HTML5 사양입니다. 채팅 , 게임 , 주식등에 많이 사용됩니다.
주기적으로 데이터를 요청하는 Polling과는 달리 , WebSocket은 연결을 유지하여 서버와 클라이언트간 양방향 통신을 합니다.

분류통신 방향HTTP 요청 횟수
SSE단방향한번
Socket양방향한번
Polling단방향매 요청

React.js 구현

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 이 출력됩니다.

Spring Boot 구현

Controller 계층

서버에서 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 = "")
컨트롤러 매개변수에 다음과 같은 데이터를 추가하여 가져올 수 있습니다.

Service 계층 구현

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 메서드

컨트롤러에서 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. 오류가 발생할 수 있습니다.

sendToClient 메서드

해당 메서드는 단순한데 그저 클라이언트와 연결되어있는 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

profile
https://github.com/5tr1ker

0개의 댓글