본문 바로가기
실습/리눅스 서버 + 스프링 부트

SSE를 사용해 알람 구현

by 이민우 2024. 10. 29.
728x90
반응형

비슷하지만 다른 웹 어플리케이션 두 개를 우리 팀과 다른 팀에서 각자 구현중이다.

 

정말 상세하게 요구사항을 하나 하나 짚어보면 엄연히 다른 웹 어플리케이션이지만, 어느정도 비슷한 어플리케이션 이니만큼 공통적인 부분은 존재한다.

그리고 다른 팀은 우리 팀에 비해 인원이 월등히 많기 때문에 개발 속도가 현저히 빠르고, 이로 인해 공통적인 부분은 해당 팀의 소스코드를 참고해서 개발을 수행중이다.

 

어쨌든 공통적인 부분 중에는 알람 기능도 존재한다. 별 건 아니고 배치 모듈에서 이상사항을 발견하고 알람을 DB에 Insert하면, 이를 로그인한 사용자들의 화면에 띄워주는 것이다.

알람 기능 역시 다른 팀이 먼저 개발을 해놓았기에 이를 참고해서 개발을 했다. 그리고 그 과정에서 SSE라는 새로운 개념을 알게 되었다.

 

오늘은 새롭게 알게된 SSE가 뭔지와, 어떻게 사용하는지를 포스팅해볼까 한다.

 

SSE (Server-Sent-Event)

알람이란 뭘까?

 

단순하게 서버에서 전송하는 데이터를 클라이언트가 받아 출력을 하는 기능이다.

 

그러면 이를 구현하기 위해 서버와 클라이언트가 어떤 방식으로 데이터를 주고받는 것이 좋을까?

 

가장 보편적으로 사용자 브라우저가 웹 서버와 통신하는데는 HTTP, 즉 TCP가 주로 사용된다.

 

사용자가 서버에 Request를 보내면, 서버가 그에 맞는 Response를 보내는 아주 기본적이고 간단한 방식이다.

https://ko.wikipedia.org/wiki/%EC%A0%84%EC%86%A1_%EC%A0%9C%EC%96%B4_%ED%94%84%EB%A1%9C%ED%86%A0%EC%BD%9C

 

그러면 알람을 구현하는 데 TCP를 사용하면 될까?

 

물론 그래도 된다. 브라우저 단에서 JS를 사용해 주기적으로 서버에 Request를 보내 신규 알람이 있는지 확인하고, 있으면 가져오면 된다.

 

아래는 JS에서 스케줄링처럼 주기적으로 특정 코드를 실행하는 방법이다.

https://123okk2.tistory.com/search/timeout

 

저장소

 

123okk2.tistory.com

 

 

하지만 이 방식은 어찌됐던 양방향 통신이기 때문에 클라이언트가 계속해서 서버에 요청을 보내고, 이로 인해 네트워크 트래픽이 증가하게 된다. 만약 사용자가 많다면 당연히 그만큼 서버에 부하가 오게될 것이다.

 

그렇다면 웹 소켓은 어떨까?

 

웹 소켓은 연결만 유지되면 서버와 클라이언트가 계속해서 통신할 수 있는 통신 방식이다.

https://ko.wikipedia.org/wiki/%EC%9B%B9%EC%86%8C%EC%BC%93

 

하지만 이 또한 결국은 TCP와 마찬가지로 양방향 통신이다.

 

알람이라는 게 서버에서 클라이언트로 내려주기만 하면 되는 일방향 통신인데, 굳이 양방향 통신을 고집해서 네트워크 트래픽을 늘려야할 이유는 없다.

 

그런 의미에서 SSE는 알람 기능 구현에 사용하기에 최적화된 기술이다.

 

SSE는 클라이언트가 최초로 연결을 요청하고 성립되면, 서버가 일방적으로 계속해서 데이터를 내려주는 일방향 통신 방식이다.

한 번 연결이 성립되면 서버단에서 계속해서 데이터를 전송하는 SSE

 

아래는 위키피디아에서 긁어온 SSE에 대한 설명이

SSE (Server-Sent Events)

SSE ( Server-Sent Events )는 클라이언트가 HTTP 연결을 통해 서버로부터 자동 업데이트를 수신할 수 있도록 하는 
서버 푸시 기술이며, 초기 클라이언트 연결이 설정되면 서버가 클라이언트로 데이터 전송을 시작하는 방법을 설명합니다. 이는 일반적으로 브라우저 클라이언트로 메시지 업데이트나 지속적인 데이터 스트림을 전송하는 데 사용되며, 클라이언트가 이벤트 스트림을 수신하기 위해 특정 URL을 요청하는 EventSource라는 JavaScript API를 통해 네이티브 크로스 브라우저 스트리밍을 향상하도록 설계되었습니다.

출처 : 위키피디아 (https://en.wikipedia.org/wiki/Server-sent_events)

 

그리고 다음과 같은 장점을 가지고 있기에 알람 구현에 유리하다.

 

  1. 간단한 단방향 연결 유지 : 엄밀히 말하자면 SSE도 HTTP 프로토콜이다. 다만 조그마한 차이점은, 클라이언트가 서버로 Request를 보내는 것이 아니라 서버가 클라이언트에 이벤트를 보내는 방식으로, 클라이언트가 서버에 데이터를 주기적으로 요청할 필요가 없다.
  2. 연결 자동 재연결 : SSE는 연결이 끊어졌을 때 클라이언트에서 자동으로 재연결을 시도하기에 안정성이 좋다.
  3. 낮은 서버 부하 : 양방향 소켓 연결이 필요하지 않기 때문에 트래픽이 줄어 서버 부하가 덜하다.

 

 

Spring에서 SSE 구현하기

이제 Spring에서 SSE를 구현해서 간단하게 알람 기능을 구현하는 방법을 알아보자.

 

우선 알람을 위해 아래와 같은 테이블을 생성했다.

CREATE TABLE IF NOT EXISTS UI_ALARM_SSE_TEST (
    ALARM_PK        INT AUTO_INCREMENT COMMENT '알람 식별자',
    READ_YN         BOOLEAN DEFAULT FALSE COMMENT '읽음 여부', -- TRUE : 읽음 / FALSE : 읽지않음
    ALARM_MSG       VARCHAR(1000) COMMENT '알람 메시지',
    ALARM_OCCR_DTM  TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '알람 발생일',
    PRIMARY KEY(ALARM_PK)
) COMMENT = '알람 테스트 테이블';

 

간단하게 어느 다른 어플리케이션이 알람을 INSERT하고, 자바 어플리케이션이 주기적으로 DB를 스캔해 READ_YN이 FALSE인 데이터를 발견하면, 이를 클라이언트들에게 전송해 알람을 출력하는 어플리케이션이라고 보면 될 것 같다.

 

이제 SpringBoot 프로젝트를 생성하자.

 

SSE는 org.springframework.web.servlet.mvc.method.annotation 안에 있기 때문에 Spring Web을 추가해서 프로젝트를 생성해야 한다.

추가로 DB에 접근하기 위해 MariaDB 드라이버와 JPA를, 웹 페이지 호스팅을 위해 Thymeleaf도 추가해주었다.

 

Thymeleaf와 JPA를 추가해주었으니, 그에 맞춰 application.properties 설정을 추가했다.

spring.thymeleaf.encoding=UTF-8
spring.thymeleaf.prefix= classpath:/templates/
spring.thymeleaf.suffix=.html

spring.datasource.url=jdbc:mariadb://127.0.0.1:3306/test?&characterEncoding=utf8
spring.datasource.driver-class-name=org.mariadb.jdbc.Driver
spring.datasource.username=root
spring.datasource.password=0000

 

이제 위 쿼리로 생성되는 테이블에 맞춰 간단하게 Domain 클래스와 Repository 인터페이스를 생성하자.

굳이 클래스에 대한 설명은 생략하겠다.

 

AlarmDomain.java

package com.example.demo;

import jakarta.persistence.*;
import java.time.LocalDateTime;

import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.ToString;

@Entity
@Table(name = "UI_ALARM_SSE_TEST")
@NoArgsConstructor
@AllArgsConstructor
@Getter
@Setter
@ToString
public class AlarmDomain {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "ALARM_PK")
    private Long alarmPk; // 알람 식별자

    @Column(name = "READ_YN", nullable = false)
    private Boolean readYn = false; // 읽음 여부 (기본값: false)

    @Column(name = "ALARM_MSG", length = 1000)
    private String alarmMsg; // 알람 메시지

    @Column(name = "ALARM_OCCR_DTM", updatable = false)
    private LocalDateTime alarmOccrDtm = LocalDateTime.now(); // 알람 발생일 (기본값: 현재 시간)
}

 

AlarmRepository.java

package com.example.demo;

import java.util.List;

import org.springframework.data.repository.CrudRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface AlarmRepository extends CrudRepository<AlarmDomain, Long> {

	/**
	 *  ReadYn을 기반으로 알람 찾기
	 *  ReadYn이 False라면 아직 조회되지 않은 알람 조회
	 * @param readYn
	 * @return
	 */
	public List<AlarmDomain> findAllByReadYn(Boolean readYn);
	
};

 

다음으로 서비스를 생성한다.

 

동시성 제어를 위해 CopyOnWriteArrayList로 사용자들의 SSE 를 저장해서 관리하고, 각 사용자의 요청 시 해당 리스트에 SSE를 저장하도록 설정했다.

CopyOnWriteArrayList

스레드 세이프 리스트로, 쓰기 작업이 발생할 때마다 내부 배열을 복사한다. 이는 여러 쓰레드에서 동시에 안전하게 접근할 수 있고, 쓰기 작업 중에도 읽기 작업이 자유롭게 수행될 수 있다는 장점이 있지만, 읽기 작업이 많은 환경에는 적합하나 쓰기 작업이 많은 환경에는 적합하지 않을 수 있다.


그리고 주기적으로 DB를 스캔해 새로 들어온 알람을 SSE내 사용자, 즉 현재 로그인 중인 사용자들의 웹 페이지에 내려주는 코드를 작성했다.

 

보다 자세한 설명은 주석으로 달아놓았다.

 

SSEService.java

package com.example.demo;

import java.io.IOException;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CopyOnWriteArrayList;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;

import lombok.extern.slf4j.Slf4j;

@Service
@Slf4j
public class SSEService {
	
	@Autowired AlarmRepository alarmRepo;

	// 여러 사용자의 SSE를 모아놓는 리스트
	// 동시성을 위해 CopyOnWriteArrayList 사용
	private final List<SseEmitter> emitters = new CopyOnWriteArrayList<>();
	// alarm 표시 시간
	private int SYSTEM_ALARM_DISPLAY_TIME = 180000;
	
	/**
	 * SSE 설립 함수
	 * 사용자의 Request를 받으면 SSE 연결을 실질적으로 수립
	 */
	public SseEmitter createEmitter() {
		
		SseEmitter emitter = new SseEmitter(60000L); // 60초 타임아웃 설정
		emitters.add(emitter);

		// 성공/타임아웃/에러 발생 시 SSE 리스트에서 에미터를 삭제하도록 설정
		emitter.onCompletion(() -> emitters.remove(emitter));
		emitter.onTimeout(() -> {
			emitters.remove(emitter);
		});
		emitter.onError(e -> emitters.remove(emitter));

		try {
			// Create Emitter 시 Alarm Popup에 대한 Config 전송
			// 일정 시간만 알람을 띄우기 위해 설정
			emitter.send(
					SseEmitter.event().name("alarmConfig")
					.data(
							new HashMap<String, Object>(){
								{put("systemAlarmDisplayTime", SYSTEM_ALARM_DISPLAY_TIME);}
							}, 
							MediaType.APPLICATION_JSON
					));
			
		} catch (IOException e) {
			// 이미 끊어진 연결이므로 제거
			emitters.remove(emitter);
			// 아래 complete은 연결 끊기를 의미
			emitter.completeWithError(e);
		}

		return emitter;
	}
	
	/**
	 * 3초마다 주기적으로 DB에서 신규로 삽입된 알람을 조회
	 * 만약 존재할 경우 사용자에게 알람 전송 
	 */
	@Scheduled(fixedDelay = 3000)
	public void sendToClients() {

		// 읽지 않은 알람 조회
		List<AlarmDomain> alarmList = alarmRepo.findAllByReadYn(false);

		// 알람이 존재하지 않으면 스킵
		if(alarmList.size() == 0) {
			return;
		}
		
		// SSE 연결을 수립한 사용자가 있을 경우 실행
		if(!emitters.isEmpty()) {

			// 각 SSE에 알람 메시지 전송
			for(SseEmitter emitter : emitters) {

				try {

					// 일정 시간만 알람을 띄우기 위해 Config 전송
					emitter.send(SseEmitter.event().name("alarmConfig")
							.data(
									new HashMap<String, Object>(){
										{put("systemAlarmDisplayTime", SYSTEM_ALARM_DISPLAY_TIME);}
									}, MediaType.APPLICATION_JSON
								)
							);

					for(AlarmDomain data : alarmList) {
						Thread.sleep(300); // 여러개의 alarmPopup이 순자적으로 뜨게끔 sleep
						emitter.send(SseEmitter.event().name("alarmMessage")
								.data(
										new HashMap<String, Object>() {
											{put("msg", data.getAlarmMsg());}
										},
										MediaType.APPLICATION_JSON
									)
								);
						
						// 읽음 여부 갱신
						data.setReadYn(true);
					}

				} catch (IOException e) {
					// 이미 끊어진 연결이므로 제거
					emitter.completeWithError(e);
					emitters.remove(emitter);
				} catch (InterruptedException e) {
					log.error(e.getMessage());
				}
			}
			
			// 읽음 여부 업데이트
			alarmRepo.saveAll(alarmList);
		}
	}
}

* Scheduling이 추가되었으니 메인 클래스에 @EnableScheduling 추가를 잊지 말자.

 

이제 사용자와 통신에 사용될 Controller를 작성한다.

 

간단하게 웹 페이지를 반환하는 컨트롤러와 SSE 연결을 위해 클라이언트의 요청을 받을 API만 포함했다.

 

SSEController.java

package com.example.demo;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;

import jakarta.servlet.http.HttpServletResponse;

@Controller
public class SSEController {

	@Autowired SSEService sseService;
	
	@GetMapping("/")
	public String homepage() {
		// Content-Type을 설정
		return "homepage";
	}
	
	@GetMapping("/subscribe")
	public SseEmitter subscribe(HttpServletResponse response) {
		// Content-Type은 아래와 같이 고정해서 설정
		response.setContentType("text/event-stream");
		return sseService.createEmitter();
	}
	
}

 

마지막으로 사용자에게 보여줄 HTML을 작성한다.

 

간단하게 다른 요소 없이 알림이 떠질 알람 섹션만 추가하고,

 

JS에서 SSE를 성립하고 주기적으로 서버로부터 데이터를 받아 위 알람 섹션에 추가하도록 작성했다.

 

자세한 설명은 마찬가지로 주석으로 작성해놓았다.

 

homepage.html

<!DOCTYPE html>
<html lang="ko"
	  xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">

<head>
<meta charset="UTF-8">
<title>alarmTestPage</title>
</head>
<body>

	<!-- alarm이 표출될 zone -->
	<div id="alarm_display_zone" style="background: rgb(255, 128, 255); width: 500px; height: 1000px;">
	
		<div id="dummy_alarm_zone" style="display:none; width:500px; height:200px;">
			<span id="alarm_text">
			</span>
			<button type="button" id="close_alarm">
				<span class="for-a11y">X</span>
			</button>
		</div>
		
	</div>


	<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js"></script>
	<script th:inline="javascript">
		$(function(){
			
			// DOM 로딩이 완료되면 곧바로 SSE 연결 수립
			initializeSSE();
		})
		
		// SSE 연결 수립 함수
		const initializeSSE = function() {
			
			let systemAlarmDisplayTime;

			// 브라우저 세션스토리지에 SSE 연결을 저장할거라, 우선 연결이 존재하는지 확인
			if(
				sessionStorage.getItem('eventSourceInit') == null ||
				typeof sessionStorage.getItem('eventSourceInit') !== 'undefined'
			) {
				
				// SSE 연결 수립
				const eventSource = new EventSource("/subscribe");
				
				// 서버에서 .name("alarmConfig") 로 전송한 정보 가져오기
				eventSource.addEventListener("alarmConfig", function(event) {
					// Map으로 Return했기 때문에 JSON.parse 수행
					const alarmConfig = JSON.parse(event.data);
					systemAlarmDisplayTime = alarmConfig.systemAlarmDisplayTime;
				});

				// 서버에서 .name("alarmMessage") 로 전송한 정보 가져오기
				eventSource.addEventListener("alarmMessage", function(event) {
					// Map으로 Return했기 때문에 JSON.parse 수행
					const alarmData = JSON.parse(event.data);
					showAlarm(alarmData.msg, systemAlarmDisplayTime);
				});
		
				// 에러 시 sessionStorage에서 제거
				eventSource.onerror = function(event) {
					eventSource.close();
					sessionStorage.removeItem('eventSourceInit');
				};
		
				// SSE가 연결되었음을 저장
				sessionStorage.setItem('eventSourceInit', true);
			}
		};
		
		// 알람 수신 시 알람 표출
		const showAlarm = function(alarmData, systemAlarmDisplayTime = 5000) {

			const maxAlarms = 5;  // 최대 alarm 개수
			const alarmList = $('#alarm_display_zone');

			// 최대 알람 표출 개수에서 넘어서는지 확인
			if(alarmList.find('.alarm_display_item').length >= maxAlarms) {
				// 넘어서면 가장 처음 들어온 알람 삭제
				alarmList.find('.alarm_display_item').first().remove();
			}

			// 알람 표출을 위해 사전에 설정해놓은 정보 불러오기
			const dummyAlarm = $('#dummy_alarm_zone').clone();
			dummyAlarm.addClass("alarm_display_item");
			dummyAlarm.css('display', 'block');

			// 알람 메시지 표출
			dummyAlarm.find('#alarm_text').text(alarmData);

			// 닫기 버튼 설정
			dummyAlarm.find('#close_alarm').on('click', function(event) {
				event.preventDefault();
				dummyAlarm.remove();
			});

			// 실제로 표출해서 사용자에게 표출
			alarmList.append(dummyAlarm);

			// 일정 시간이 지나면 닫히도록 타임아웃 설정
			setTimeout(function() {
				dummyAlarm.remove();
			}, systemAlarmDisplayTime);

		}
	</script>

</body>
</html>

 

이제 어플리케이션을 실행시키고 8080 포트로 진입해보면 아래와 같이 정상적으로 화면이 출력됨을 확인할 수 있다.

 

화면이 출력됐다고 끝이 아니고, 알람까지 정상적으로 출력되어야 완성이므로 한 번 실험을 해보자.

또다른 어플리케이션이 알람을 DB에 추가한 것처럼 아래 쿼리를 순차적으로 실행시킨다.

INSERT INTO UI_ALARM_SSE_TEST (READ_YN, ALARM_MSG)
VALUES (FALSE, 'TEST 1');

INSERT INTO UI_ALARM_SSE_TEST (READ_YN, ALARM_MSG)
VALUES (FALSE, 'TEST 2');

INSERT INTO UI_ALARM_SSE_TEST (READ_YN, ALARM_MSG)
VALUES (FALSE, 'TEST 3');

INSERT INTO UI_ALARM_SSE_TEST (READ_YN, ALARM_MSG)
VALUES (FALSE, 'TEST 4');

INSERT INTO UI_ALARM_SSE_TEST (READ_YN, ALARM_MSG)
VALUES (FALSE, 'TEST 5');

INSERT INTO UI_ALARM_SSE_TEST (READ_YN, ALARM_MSG)
VALUES (FALSE, 'TEST 6');

 

이제 웹 페이지에서 추가한 알람들이 정상적으로 출력되는지 확인한다.

정상적으로 출력이 됨을 확인하며 SSE 개발방법 포스팅을 마치겠다.

728x90
반응형