14) 웹 소켓(Web Socket)을 활용한 실시간 알림 - Spring Service(OTT Service)

삽질/사이드 프로젝트 2023. 3. 2. 17:18
728x90
728x90
해당 프로젝트는 2023/01/25 ~ 2023/03/12 내에 진행되는
아카데미 내 수강생들끼리 팀을 나누어 진행한 모의 프로젝트입니다.
팀원은 5명이었으며, 프로젝트 리더를 맡았습니다.

 

 

이전 글 목록

1) 주어진 RFP를 바탕으로 주제 선정 - Spring Project(OTT 서비스)
2) ERD 설계 - Spring Project(OTT 서비스)
3) 회원 가입 기능 구현 - Spring Project (OTT 서비스)
4) 로그인, 로그아웃 기능 구현 - Spring Project (OTT 서비스)
5) 상세 페이지 및 회원 정보 수정 - Spring Project (OTT 서비스)
6) CRUD를 한번에 → 게시판 만들기(QNA게시판) - Spring Project(Mybatis) (OTT 서비스)
7) 게시판 페이징 처리 - Spring Project (OTT 서비스)
8) 카카오 지도 API 사용하기 - Spring Project (OTT 서비스)
9) (네아로) 네이버 로그인 API 활용 사이트 로그인 및 회원가입 - SPRING Project(OTT 서비스)
10) 카카오 로그인 API 사용하기(내 사이트 로그인 및 회원가입) - Spring Project(OTT Service)
11) 아임포트(포트원) API를 이용한 결제처리 - Spring Project(OTT Service)
12) 관리자 페이지 만들기(데이터 통계 및 chart.js, 유저 알고리즘) - Spring Project(OTT Service)
13) 관리자 페이지 (영상 정보 업로드 시 여러 테이블에 insert 및 update) - Spring Project(OTT Service)

 

 

포스팅 당시 영상 상세페이지의 대댓글이 구현되지 않은 상황이었기 때문에, 사용자의 질문에 대한 관리자의 답변이 달렸을 경우 알림을 우선적으로 구현하고, 차후에 대댓글이 구현된 후 댓글 알림을 구현 할 예정이다.

 

 

설정하기


pom.xml

웹소켓의 데이터 통신은 내부적으로 JSON을 사용한다. JSON라이브러리는 기존에 추가되어 있기 때문에 생략한다.

<!-- Spring-websocket -->
<dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-websocket</artifactId>
    <version>${org.springframework-version}</version>
</dependency>

 

servlet-context.xml

서블릿의 최상단의 beans:beans 내부에 아래의 코드를 추가하였고

xmlns:websocket="http://www.springframework.org/schema/websocket"	
xsi:schemaLocation="http://www.springframework.org/schema/websocket http://www.springframework.org/schema/websocket/spring-websocket-4.3.xsd

 

알림 소켓 생성 시 매핑 값을 /echo로 설정할 것이고, 해당 요청을 EchoHandler에서 처리 할 것이다.

<!-- 웹소켓 -->
<beans:bean id="echoHandler" class="com.test.test1.chat.util.EchoHandler" />        

<websocket:handlers>
    <websocket:mapping handler="echoHandler" path="/echo"/>
    <websocket:handshake-interceptors>
        <beans:bean class="org.springframework.web.socket.server.support.HttpSessionHandshakeInterceptor"/>
    </websocket:handshake-interceptors>
    <websocket:sockjs />
</websocket:handlers>

 

 

CDN추가(03-30 수정)

sockjs는 CDN을 추가하여 사용했다.

<script src="https://cdn.jsdelivr.net/npm/sockjs-client@1/dist/sockjs.min.js"></script>

 

 

사용하기


TextWebSocketHandler를 상속받아 아래의 메서드를 사용할 수 있다.

afterConnectionEstablished - 연결 성공 시
afterConnectionClosed - 연결 종료 시
handleTextMessage - 메세지 송수신 시

답변 등록 시 ALARM 테이블에 아래와 같이 데이터가 추가된다.

등록 시 ALARM 테이블에 데이터가 입력되는 과정은 생략하도록 하겠다. 이제 이정도는 껌이다

 

 

IDX : PK
USER_ID : 알림 받을 사용자의 PK
CODE : 알림 정보 - 대댓글 알림 시 NewComment 등 CODE로 조건을 주어 VIEW를 다르게 할 수 있게 설계
CHECKED : default X이며 사용자가 알림 확인 시 자동으로 삭제처리된다.
PREFIX : 알림이 어디서 왔는지. 단순히 DB에 텍스트 형태로 입력시켜 주었다.

 

EchoHandler

package com.test.test1.chat.util;
(...생략...)
//Dao DI를위해 사용. 없을 경우 Dao = null
// -> Bean으로 등록되어 있어서 따로 스프링의 객체라고 주입시켜줘야함.
@Component
@RequestMapping("/echo")
public class EchoHandler extends TextWebSocketHandler{
	
	@Autowired
	private AlarmDao alarmDao;
	
	public void setAlarmDao(AlarmDao alarmDao) {
		this.alarmDao = alarmDao;
	}

	
	private static final Logger logger = LoggerFactory.getLogger(WebSocketHandler.class);
	//로그인 한 인원 전체
	private List<WebSocketSession> sessions = new ArrayList<WebSocketSession>();
	
	//클라이언트가 웹 소켓 생성
	@Override
	public void afterConnectionEstablished(WebSocketSession session) throws Exception {
		logger.info("Socket 연결");
		//웹 소켓이 생성될 때마다 리스트에 넣어줌
		sessions.add(session);
	}
	
	//JS에서 메세지 받을 때.
	@Override
	protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {// 메시지
	
		for(WebSocketSession single : sessions) {
//			대댓글 때 사용
//			String msg = message.getPayload();
//			String[] str = msg.split(",");
//			//JS에서 원하는대로 send하여 해당 기능 별 알람 구현
//			//질문에 답변 달렸을 때(질문자 ID와 제목 들고옴)
//			if(str != null && str.length == 2) {
//				String id = str[0];
//				String q_subject = str[1];
//				int count = alarmDao.selectAlarmCount(id); //알람이 존재할 때
//			}		
			
			//세션아이디
			String hsid = (String) single.getAttributes().get("user_id");
			
			//세션값이 같을때, 알람보낼 것이 있을 때만 전송 -> 로그인 한 사용자가 처음으로 알람 받는 순간임
			//해당 sendMsg에 DB정보 넣어서 체크 안된 알람 전부 전송하기
			if(single.getAttributes().get("user_id").equals(session.getAttributes().get("user_id"))) {				//체크 안된 알림들만 담아서 View
				List<AlarmDto> dto = new ArrayList<>();
				dto = alarmDao.selectAlarm(hsid);
				for(AlarmDto alarm : dto) {
					int idx = alarm.getIdx();
					String prefix = alarm.getPrefix();
					String code = alarm.getCode();
					if(code.equals("NewPost")) {
						code = "답변이 등록되었습니다.";
					}
					TextMessage sendMsg = new TextMessage("("+idx+")" + prefix + "에 " + code);
					single.sendMessage(sendMsg);
				}
			}
		}
	}
	
	@Override
	public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {//연결 해제
		// TODO Auto-generated method stub
		logger.info("Socket 끊음");
		//웹 소켓이 종료될 때마다 리스트에서 뺀다.
		sessions.remove(session);
	}
}

 

alarm.js

/* 02.23 장재호 */
const alarmUL = document.querySelector("#alarmUL");
const alarmI = document.querySelector("#alarmI");
const alarmDiv = document.querySelector("#alarmDiv");
var sock = null;

$(document).ready(function(){
	connectWs();

});

//소켓
function connectWs(){
	var ws = new SockJS("http://localhost:8080/echo");
	sock = ws;

	ws.onopen = function() {
		console.log("연결완료");
 		ws.send($('#socketuserID').val());
	};

	ws.onmessage = function(event) {
		/* 받을 알람이 있을 때 */
		console.log(event.data);
		if(event.data.length>0){
			let newAlarm = '';
			newAlarm += '<li scope="col">' + event.data + "</li>"
			$('#alarmUL').append(newAlarm);
			alarmDiv.style.visibility = "visible";
		}
	};

	ws.onclose = function() {
	    console.log('close');
	};

};

/* 알람창 추가 */

alarmI.addEventListener('click', function(){
	alarmUL.classList.toggle('visible');
	$(this).stop(false, false);
});

alarmUL.addEventListener('click', function(e){
	var endIdx = e.target.textContent.indexOf(")");
	var idx = e.target.textContent.substr(1, endIdx-1);

	$.ajax({
		url : '/alarmDel',
		data : {"idx" : idx},
		type : 'post',
		success : function(data){
			console.log(data);
			alert("성공");
		}
	})
	
	$(e.target).remove();
	if(alarmUL.children.length == 0){
		alarmDiv.style.visibility = "hidden";
	}
	
})
/* *************************** */

 

 

실행되는 순서이다.

1. alarm.jsp를 include받은 페이지 접속 시 alarm.js에 설정된 소켓이 생성되며 EchoHandler의 아래 연결

@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
    logger.info("Socket 연결");
    //웹 소켓이 생성될 때마다 리스트에 넣어줌
    sessions.add(session);
}

2. 소켓이 생성 될 때 아래의 send메서드를 통해 세션에 등록되어 있는 USER_ID가 전송된다.(input hidden)

ws.onopen = function() {
    console.log("연결완료");
    ws.send($('#socketuserID').val());
};

전송이 되면 아래의 EchoHandler의 메서드 중 handleTextMessage로 간다.

굳이 세션ID를 보내지 않고, ws.send()처리를 해도 됐다. 핸들러의 메서드를 실행만 시키면 됐었다.

왜냐, 서블릿 설정 시 HttpSession을 사용할 수 있는 인터셉터 설정을 하였기 때문이다.

message.getPayload()를 사용하여 JS에서 보낸 메세지를 출력할 수 있다. 만약 인터셉터를 사용하지 않고 세션값을 전송했을 경우, 해당 메서드를 사용하여 세션값을 뽑아 사용하면될 것이다.

//JS에서 메세지 받을 때.
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {// 메시지

    for(WebSocketSession single : sessions) {

        //세션아이디
        String hsid = (String) single.getAttributes().get("user_id");

        //세션값이 같을때, 알람보낼 것이 있을 때만 전송 -> 로그인 한 사용자가 처음으로 알람 받는 순간임
        //해당 sendMsg에 DB정보 넣어서 체크 안된 알람 전부 전송하기
	if(single.getAttributes().get("user_id").equals(session.getAttributes().get("user_id"))) {        //체크 안된 알림들만 담아서 View
            List<AlarmDto> dto = new ArrayList<>();
            dto = alarmDao.selectAlarm(hsid);
            for(AlarmDto alarm : dto) {
                int idx = alarm.getIdx();
                String prefix = alarm.getPrefix();
                String code = alarm.getCode();
                if(code.equals("NewPost")) {
                    code = "답변이 등록되었습니다.";
                }
                TextMessage sendMsg = new TextMessage("("+idx+")" + prefix + "에 " + code);
                single.sendMessage(sendMsg);
            }
        }
    }
}

여튼 인터셉터를 사용했기 때문에, single에서 getAttribute를 통해 세션에 있는 유저 아이디를 가져올 수 있다.

해당 유저아이디를 바탕으로 조회하여 ALARM 테이블에 있는 정보를 가져와서 알람이 있을 경우 다시 JS를 통해 View에 출력 할 수 있다.

 

3. 전송된 무언가가 있을 경우

ws.onmessage = function(event) {
    /* 받을 알람이 있을 때 */
    if(event.data.length>0){
        let newAlarm = '';
        newAlarm += '<li scope="col">' + event.data + "</li>"
        $('#alarmUL').append(newAlarm);
        alarmDiv.style.visibility = "visible";
    }
};

전송된 무언가가 있을 경우, newAlarm 변수 안에 리스트 형태로 알람 값을 담아주었다.

alarmDiv는 알람 버튼이 있는 디비전을 말하며, 알람이 있을 경우 종 모양 아이콘이 화면에 나타난다.

종 모양 아이콘을 클릭할 경우 알림 내역들이 보일 수 있게 설계하였다.

 

 

예시로 관리자가 질문을 등록하고 관리자가 답변을 달았을 때 알림이 생기는 것을 담았다. 로그인 로그아웃 질문등록 이런 과정들이 시간이 길어 GIF가 깨져서 그러니 양해바란다.

 

 

갑자기 난이도가 훅 올라간 느낌이여서 어려웠지만 소켓을 다루다보니 너무 재밌었다. 조금 더 심도있게 공부해도 재밌을 것 같다

728x90
300x250
mag1c

mag1c

2년차 주니어 개발자.

방명록