[개발일지7] 스프링, 소켓을 이용한 채팅 & 실시간 알림 구현

김희주·2022년 10월 25일
0

개발일지

목록 보기
6/10
post-thumbnail

1. 목표 구현 화면

실시간 채팅창과 실시간 쪽지보내기(받는 사람 화면의 경우 토스트 메시지 팝업 출현)을 구현하고자 했다.
한 3일동안 매일 4시간정도 붙들었나...
소켓 자체는 정해진 형식이 있어서 구글링에 어려움은 없었는데 스프링에 sockt과 sockjs 라이브러리를 추가하는게 정말 너무!!! 안되어서 애먹었다. 스프링 의존성 추가...널 의존하지 않으면 안되겠니😮

2. 환경 세팅

나를 가장 힘들게 했던 의존성 주입.
소켓 관련 거의 모든 블로그 글들을 따라해봤지만 계속 의존성 주입에 실패하다가 이 분의 블로그 글 덕에 성공했다. 정말 감사합니다...정말 정말 감사합니다...
그리고 전체적인 로직?클래스와 패키지, 메소드 구성은 이 글의 도움을 받았다. 감사합니다!

2-1. pom.xml 의존성 주입

<dependency>
	<groupId>com.fasterxml.jackson.core</groupId>
	<artifactId>jackson-databind</artifactId>
	<version>2.8.4</version>
</dependency>

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

2-2. servlet-config.xml

<beans:bean id="echoHandler" class="com.chat.config.EchoHandler"/>

<websocket:handlers>
	<websocket:mapping handler="echoHandler" path="/echo"/>
	<websocket:sockjs/>
</websocket:handlers>

path는 /echo-ws로 바꾸던지...jsp화면에서 sockjs를 어떻게 설정하는지에 따라 바꿔야함.

2-3.

package com.chat.config;

import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.handler.TextWebSocketHandler;

public class EchoHandler extends TextWebSocketHandler { 
	//로그인 한 전체 session 리스트
	List<WebSocketSession> sessions = new ArrayList<WebSocketSession>();
	// 현재 로그인 중인 개별 유저
	Map<String, WebSocketSession> users = new ConcurrentHashMap<String, WebSocketSession>();
	
	@Override
	public void afterConnectionEstablished(WebSocketSession session) throws Exception {
      String senderId = getMemberId(session); // 접속한 유저의 http세션을 조회하여 id를 얻는 함수
		if(senderId!=null) {	// 로그인 값이 있는 경우만
			log(senderId + " 연결 됨");
			users.put(senderId, session);   // 로그인중 개별유저 저장
		}
	}
	
	@Override
	protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
	String senderId = getMemberId(session);  

		// 특정 유저에게 보낼 메시지 내용 추출
		String msg = message.getPayload();
		if(msg != null) {
			String[] strs = msg.split(",");
			log(strs.toString());
			if(strs != null && strs.length == 4) {
				String type = strs[0];
				String target = strs[1]; // m_id 저장
				String content = strs[2];
				String url = strs[3];
				WebSocketSession targetSession = users.get(target);  // 메시지를 받을 세션 조회
				
				// 실시간 접속시
				if(targetSession!=null) {
					TextMessage tmpMsg = new TextMessage("<a target='_blank' href='"+ url +"'>[<b>" + type + "</b>] " + content + "</a>" );
					targetSession.sendMessage(tmpMsg);
				}
			}
		}
	}
	
    //연결이 끊어진 후
	@Override
	public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
		String senderId = session.getId();
		if(senderId!=null) {	// 로그인 값이 있는 경우만
			log(senderId + " 연결 종료됨");
			users.remove(senderId);
			sessions.remove(session);
		}
	}
	
	// 에러 발생시
		@Override
		public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
			log(session.getId() + " 익셉션 발생: " + exception.getMessage());

		}
		// 로그 메시지
		private void log(String logmsg) {
			System.out.println(new Date() + " : " + logmsg);
		}
		// 웹소켓에 id 가져오기
	    // 접속한 유저의 http세션을 조회하여 id를 얻는 함수
		private String getMemberId(WebSocketSession session) {
			Map<String, Object> httpSession = session.getAttributes();
			String m_id = (String) httpSession.get("id"); // 세션에 저장된 m_id 기준 조회
			return m_id==null? null: m_id;
		}
}

여기서 ConcurrentHashMap(클릭)이 뭔지 몰라서 해당 글을 참고했다. 쉽게 말하면 읽기 작업에는 여러 쓰레드가 동시에 읽을 수 있지만, 쓰기 작업에는 특정 세그먼트 or 버킷에 대한 Lock을 사용한다는 것이다.

2-4. view 세팅

  • haeder.jsp (모든 유저에게 적용될)
<!-- sockJS -->
<script src="https://cdn.jsdelivr.net/npm/sockjs-client@1/dist/sockjs.min.js"></script>
<script>
// 전역변수 설정
var socket  = null;
$(document).ready(function(){
    // 웹소켓 연결
    var sock = new SockJS("/echo");
    socket = sock;

    // 데이터를 전달 받았을때 
    sock.onmessage = onMessage; // toast 생성

});

// toast생성 및 추가
function onMessage(evt){
    var data = evt.data;
    // toast
    let toast = "<div class='toast' role='alert' aria-live='assertive' aria-atomic='true'>";
    toast += "<div class='toast-header'><i class='fas fa-bell mr-2'></i><strong class='mr-auto'>알림</strong>";
    toast += "<small class='text-muted'>just now</small><button type='button' class='ml-2 mb-1 close' data-dismiss='toast' aria-label='Close'>";
    toast += "<span aria-hidden='true'>&times;</span></button>";
    toast += "</div> <div class='toast-body'>" + data + "</div></div>";
    $("#msgStack").append(toast);   // msgStack div에 생성한 toast 추가
    $(".toast").toast({"animation": true, "autohide": false});
    $('.toast').toast('show');
};	
</script>
<body>
...
    <div id="msgStack"></div>
</body>

원래 블로그 글은 이렇게 되어있었지만 좀 더 내 식(?)대로 만들기 위해 새 블로그 글을 참조해 토스트를 만들었다.
토스트 만들기는 이 글을 참조했다. 정말 감사합니다...


<!-- sockJS -->
<script src="https://cdn.jsdelivr.net/npm/sockjs-client@1/dist/sockjs.min.js"></script>
<script>
// 전역변수 설정
var socket  = null;
$(document).ready(function(){
    // 웹소켓 연결
    sock = new SockJS("<c:url value="/echo-ws"/>");
    socket = sock;

    // 데이터를 전달 받았을때 
    sock.onmessage = onMessage; // toast 생성

//----------------------------------------------
  function fillWidth(elem, timer, limit) {
	if (!timer) { timer = 3000; }	
	if (!limit) { limit = 100; }
	var width = 1;
	var id = setInterval(frame, timer / 100);
		function frame() {
		if (width >= limit) {
			clearInterval(id);
		} else {
			width++;
			elem.style.width = width + '%';
		}
	}
};
  //-------------------------------------------
function onMessage(evt) {
    //받은 메시지 내용 추출
    var data = evt.data;
    //메시지가 떠있을 시간 설정
    var timer = 7000; 
   
    //토스트 노드 미리 만들어두기
	var $elem = $("<div class='toastWrap'><span class='toast'>" + data + "<b></b><div class='timerWrap'><div class='timer'></div></div></span></div>");
  	//토스트 띄우기
	$("#toast").append($elem); //top = prepend, bottom = append
  //토스트 창이 나타난 후 벌어질 상황 설계
	$elem.slideToggle(100, function() {
        //남은 시간 알려주며 줄어드는 바 표시
		$('.timerWrap', this).first().outerWidth($elem.find('.toast').first().outerWidth() - 10);

			fillWidth($elem.find('.timer').first()[0], timer);
			setTimeout(function() {
				$elem.fadeOut(function() {
					$(this).remove();
				});
			}, timer);			

	});
}
  });



<body>
  <div id="toast"></div>
 </body>
  • 메세지 전송할 javascript
// notifySend
$('#notifySendBtn').click(function(e){
    let modal = $('.modal-content').has(e.target);
    let type = '70';
    let target = modal.find('.modal-body input').val();
    let content = modal.find('.modal-body textarea').val();
    let url = '${contextPath}/member/notify.do';
    // 전송한 정보를 db에 저장	
    $.ajax({
        type: 'post',
        url: '${contextPath}/member/saveNotify.do',
        dataType: 'text',
        data: {
            target: target,
            content: content,
            type: type,
            url: url
        },
        success: function(){    // db전송 성공시 실시간 알림 전송
            // 소켓에 전달되는 메시지
            // 위에 기술한 EchoHandler에서 ,(comma)를 이용하여 분리시킨다.
            socket.send("관리자,"+target+","+content+","+url);	
        }
    });
    modal.find('.modal-body textarea').val('');	// textarea 초기화
});

모달은 다시 설계해야겠다.
부트스트랩중 Varying modal content을 참고하면 좋을 것.













얼추 이정도로 하고! 플젝에서 실전으로 써먹을 때 글을 더 디벨롭하거나...머...그래야겠다. 후후.

profile
백엔드 개발자입니다 ☘

0개의 댓글