WebSocket, SockJs, STOMP를 이용한 웹 채팅 구현 (2) - chat.js

메밀·2022년 11월 14일
0

5. chat.js

1) 전체 코드

$(document).ready(function () {
	"use strict";
	
	// $(".chat-sidebar-list-wrapper ul li").on("click", function () {
	// 동적으로 추가된 요소에 이벤트가 동작하지 않으므로
	// 아래처럼 조건을 바꾸어 이벤트를 선택자가 아니라 document에 위임
	$(document).on("click",".chat-sidebar-list-wrapper ul li",function(){
		// 메시지 출력 부분의 하위 요소와 메시지 입력창 초기화
		$("#msgArea").empty();
		$("#msg").val(null);
		
		/*
        생략
        */
	});
	
	$(document).on("click","#chat-list li", function(){
		console.log("chatlist selected");
		
		let login = $("#login").val();
		let workMemberName = $("#workMemberName").val();
		// 자식요소 중 class 이름이 chatRoomNo인 요소를 찾는다.
		let chatRoomNo = $(this).find('.chatRoomNo').val();
		
		let sockJs = null;
		let stomp = null;
		
		
		// ajax로 방 정보와 기존 채팅 메시지 목록 받아오기
		function chatRoomInfo(){
			return new Promise(function(resolve, reject){
				$.ajax({
					type : 'get',
					url : '/member/chat/' + chatRoomNo,
					// 채팅 방 번호와 자기 자신의 workMemberNo를 전송
					data: { chatRoomNo : chatRoomNo},
					success: function(json){
						resolve(json);
					}
				}); // end for ajax
			})
		}
		
		// return 받은 json을 메시지 영역에 append
		function append(json){
			return new Promise(function(resolve, reject){
				$(json).each(function(index, item){
					$('.chat-container').scrollTop(0);
					chatMemberNo = item.chatMemberNo;
					
					for(let i = 0; i < item.msgList.length; i++){
						let str = "";
						let msgTime = timeForToday(item.msgList[i].createDate);
						
						// 접속자의 이름과 메시지 보낸 이의 이름이 같은 경우
						if(workMemberName === item.msgList[i].workMemberName){
		                    str = '<div class="chat">'
		                    str += '<div class="chat-avatar">'
		                    	str += '<div class="avatar avatar-busy m-0 mr-50 bg-info">'
									str += '<span class="fa fa-user"></span>'
								str += '</div>' // end for avatar
		                    	str += '<p>' + item.msgList[i].workMemberName + '<p>'
		                    str += '</div>' // end for chat-avatar
		                    	str += '<div class="chat-body">'
		                    		str += '<div class="chat-message">'
		                        		str += '<p>' + item.msgList[i].chatMsg + '</p>'
		                        		str += '<span class="chat-time">' + msgTime + '</span>'
		                    		str += '</div>'
		                		str += '</div>' 
		            		str += '</div>';
		                } else {
		                    str = '<div class="chat chat-left">'
		                    str += '<div class="chat-avatar">'
		                        str += '<div class="avatar avatar-busy m-0 mr-50 bg-info">'
									str += '<span class="fa fa-user"></span>'
								str += '</div>'
		                    	str += '<p>' + item.msgList[i].workMemberName + '<p>'
		                    str += '</div>'
		                    	str += '<div class="chat-body">'
		                    		str += '<div class="chat-message">'
		                       str += '<p>' + item.msgList[i].chatMsg + '</p>'
		                        str += '<span class="chat-time">' + msgTime + '</span>'
		                    str += '</div>'
		                	str += '</div>'
		            		str += '</div>';
		                }
						
						$("#msgArea").append(str);
					} // end for 반복문
				}) // end for json 함수
				
				// 자동 스크롤
				chatContainer.animate({
					scrollTop: chatContainer[0].scrollHeight
				}, 400)				
				
				resolve(chatMemberNo);
			})
		}
		
		// STOMP 통신 시작
		function stompConnection(chatMemberNo){
			sockJs = new SockJS("/stomp/chat"); 
			stomp = webstomp.over(sockJs);
		
			// 2. connection 성공 시 콜백함수
			stomp.connect({}, function(){
				
				//3. subscribe(path, callback)으로 메세지를 받을 수 있음
	            stomp.subscribe("/sub/chat/" + chatRoomNo, function (chat) {
	                let content = JSON.parse(chat.body);
	                let chatMemberEmail = content.chatMemberEmail;
	                let msg = content.chatMsg;
	                let str = '';
	                
	                let msgTime = timeForToday(content.time);
	                
	                if(content.chatMemberEmail == null){
	                    str = '<div class="badge badge-pill badge-light-secondary my-1">' + msg + '</div>';
	                } else if(chatMemberEmail === login){
	                    str = '<div class="chat">'
	                    str += '<div class="chat-avatar">'
	                    	str += '<div class="avatar avatar-busy m-0 mr-50 bg-info">'
								str += '<span class="fa fa-user"></span>'
							str += '</div>'
	                    	str += '<p>' + content.workMemberName + '<p>'
	                    str += '</div>'
	                    	str += '<div class="chat-body">'
	                    		str += '<div class="chat-message">'
	                        str += '<p>' + msg + '</p>'
	                        str += '<span class="chat-time">'+ msgTime + '</span>'
	                    str += '</div>'
	                	str += '</div>'
	            		str += '</div>';
	                } else {
	                    str = '<div class="chat chat-left">'
	                    str += '<div class="chat-avatar">'
	                    	str += '<div class="avatar avatar-busy m-0 mr-50 bg-info">'
								str += '<span class="fa fa-user"></span>'
							str += '</div>'
	                    	str += '<p>' + content.workMemberName + '<p>'
	                    str += '</div>'
	                    	str += '<div class="chat-body">'
	                    		str += '<div class="chat-message">'
	                        str += '<p>' + msg + '</p>'
	                        str += '<span class="chat-time">' + msgTime + '</span>'
	                    str += '</div>'
	                	str += '</div>'
	            		str += '</div>';
	                }
	                
			           $("#msgArea").append(str);
			           str = '';
			           
			           // 자동스크롤
			          chatContainer.animate({
							scrollTop: chatContainer[0].scrollHeight
						}, 400)	
					});
					
			        $("#button-send").off("click").on("click", function(e){
		                var msg = $("#msg").val();
		               	
		               	if(msg == "" || msg == null){
							return;
						}
						
						if (e.isComposing || e.keyCode === 229) {
							console.log("e.isComposing || e.keyCode === 229");
							return;
						}
		                
		                stomp.send('/pub/chat/message', JSON.stringify({chatRoomNo: chatRoomNo, chatMemberNo: chatMemberNo, chatMsg: msg, workMemberName: workMemberName, chatMemberEmail: login}));
		                $("#msg").val(null);
			        });
			        
			        // 엔터키를 누르면 submit 버튼이 눌리도록
			        $("#msg").keyup(function(event) {
	    				if (event.which === 13) {
							// console.log("enter key pressed!");
	        				$("#button-send").click();
	    				}
	    			});
				}); // end for stomp connect
		} // end for function stompConnection
		
		// 메시지 시간 계산 함수
		function timeForToday(value) {
	        const today = new Date();
	        const timeValue = new Date(value);
	        const betweenTime = Math.floor((today.getTime() - timeValue.getTime()) / 1000 / 60);
	        
	        if (betweenTime < 1) return '방금전';
	        
	        if (betweenTime < 60) {
	            return `${betweenTime}분전`;
	        }
	
	        const betweenTimeHour = Math.floor(betweenTime / 60);
	        
	        if (betweenTimeHour < 24) {
	            return `${betweenTimeHour}시간전`;
	        }
	
	        const betweenTimeDay = Math.floor(betweenTime / 60 / 24);
	        
	        if (betweenTimeDay < 365) {
	            return `${betweenTimeDay}일전`;
	        }
	
	        return `${Math.floor(betweenTimeDay / 365)}년전`;
 		}
		
		// 체이닝!!!!
		// 채팅방 정보와 메시지 리스트를 받아온 다음
		// 메시지를 특정 영역에 append 한 뒤
		// STOMP 연결을 시작한다.
		chatRoomInfo()
		.then(append)
		.then(stompConnection);
	}); // end for chat-list click
});



2) click 이벤트

$(document).on("click",".chat-sidebar-list-wrapper ul li",function(){
		// 메시지 출력 부분의 하위 요소와 메시지 입력창 초기화
		$("#msgArea").empty();
		$("#msg").val(null);
		
		/*
        생략
        */
});

- 트러블 슈팅: click 이벤트 바인딩

채팅 페이지에 진입하면 DB에 저장된 채팅방 목록과 해당 워크스페이스 멤버 리스트 정보를 사이드바에 띄운다. 이때 그 페이지에서 새로운 채팅방을 만들면 리로드 없이 새 채팅방 정보를 사이드에 append하는데, 이때 새로 추가한 채팅방에 대해서는 click 이벤트가 동작하지 않는 문제가 발생했다.

동적으로 추가된 요소에 이벤트가 바인딩 되지 않아 생기는 문제다.
이에 $(".chat-sidebar-list-wrapper ul li").on("click", function () { ... }로 시작하던 기존 코드를 $(document).on("click",".chat-sidebar-list-wrapper ul li",function(){ ... }로 수정하였다. 이벤트를 선택자가 아니라 document에 위임하여 해결한다.

- $("#msgArea").empty(), $("#msg").val(null)

사이드바의 요소를 클릭하면 $("#msgArea").empty()를 통해 기존 채팅 메시지 정보를, $("#msg").val(null)를 통해 메시지 입력창의 입력 정보를 비운다.

'생략' 부분은 css 수정 코드다.



3) 체이닝

내가 구현한 웹 메신저의 흐름은 다음과 같다.

유저가 사이드바에서 멤버 프로필이나 이미 개설된 채팅방을 클릭하면, ajax로 해당 채팅방의 정보와 기존 채팅 메시지 목록을 받아온다. (기존에 개설된 채팅방이 없는 경우 chatRoom을 개설한 뒤 사이드바에 append 해준다) 이후 반환받은 json을 가공하여 채팅 방 정보와 기존 메시지를 화면에 뿌려준다. 이후 ajax로 받아온 정보를 활용하여 STOMP 통신을 시작한다.

그런데 기존 코드에선 같은 메시지가 두 번 append되는 등의 요상한(?) 오류가 발생했다. (실제로 publish가 두 번 발생했다) 알고보니 STOMP 통신을 위해선 ajax로 받아온 채팅방 정보 데이터 중 chatMemberNo가 필요한데, 해당 데이터가 도착하기 전에 STOMP 연결을 시도하기 때문에 발생하는 오류였다.

- 트러블 슈팅: 함수 호출 순서의 문제와 Promise

Promise

chatRoomInfo()
.then(append)
.then(stompConnection);

chatRoomInfo()의 ajax 성공여부를 확인한 뒤에 두번째 함수 append()를 실행하고, append()가 완료된 후에 stompConnection()을 실행하기 위해 Promise를 사용하였다.

Promise의 상태와 resolve, reject, then()

function chatRoomInfo(){
	return new Promise(function(resolve, reject){
		$.ajax({
			type : 'get',
			url : '/member/chat/' + chatRoomNo,
			// 채팅 방 번호와 자기 자신의 workMemberNo를 전송
			data: { chatRoomNo : chatRoomNo},
			success: function(json){
				resolve(json);
			}
		}); // end for ajax
	})
}

Promise는 자바스크립트 비동기 처리에 사용되는 객체다. 프로미스의 상태(state)로는 1) Pending(대기), 2) Fulfilled(이행), 3) Rejected(실패)가 있다. new Promise() 메서드를 호출하면 Promise는 대기 상태가 되고, 함께 선언한 콜백함수의 인자인 resolve()를 호출하면 이행 상태가 된다. 이행 상태가 되면 then()을 이용해 결과값을 받아올 수 있다.

chatRoomInfo()는 ajax가 완료되면 리턴받은 json을 다음에 실행할 함수 append()로 넘겨준다.

function append(json){
	return new Promise(function(resolve, reject){
      	let chatMemberNo;
      
		$(json).each(function(index, item){
			$('.chat-container').scrollTop(0);
			chatMemberNo = item.chatMemberNo;
			
			for(let i = 0; i < item.msgList.length; i++){
                 /* html append할 문자열을 만드는 부분 -- 생략 */
              	$("#msgArea").append(str);
			} // end for 반복문
		}) // end for json 함수
				
		// 자동 스크롤
		chatContainer.animate({
			scrollTop: chatContainer[0].scrollHeight
		}, 400)				
				
		resolve(chatMemberNo);
	})
}

마찬가지로 두 번째 함수가 동작을 마치면 resolve()를 호출하여 Promise의 상태를 fulfilled로 만들어주고, 다음 함수인 stompConnection()을 위해 필요한 데이터인 chatMemberNo를 resolve의 파라미터로 넘겨준다.

cf) $('.chat-container').scrollTop(0)
스크롤의 위치를 가장 상단으로 올린다. 한 채팅방에서 다음 채팅방으로 이동했을 때, 전 채팅방에서 로드된 메시지 창의 스크롤보다 다음 채팅방의 스크롤이 짧은 경우 충분히 위로 올라가지 않는 현상 때문에 붙인 코드다.

4) stompConnection()

체이닝의 마지막 단계인 stompConnection() 함수다.

// STOMP 통신 시작
function stompConnection(chatMemberNo){
  sockJs = new SockJS("/stomp/chat"); 
  stomp = webstomp.over(sockJs);

  // 2. connection 성공 시 콜백함수
  stomp.connect({}, function(){

    //3. subscribe(path, callback)으로 메세지를 받을 수 있음
    stomp.subscribe("/sub/chat/" + chatRoomNo, function (chat) {
      let content = JSON.parse(chat.body);
      let chatMemberEmail = content.chatMemberEmail;
      let msg = content.chatMsg;
      let str = '';

      let msgTime = timeForToday(content.time);

      if(chatMemberEmail === login){
        /*
        위에 선언한 변수를 활용하여 append할 문자열을 만드는 부분
        */
      } else {
        /*
        위에 선언한 변수를 활용하여 append할 문자열을 만드는 부분
        */
      }

      $("#msgArea").append(str);
      str = '';

      // 자동스크롤
      chatContainer.animate({
        scrollTop: chatContainer[0].scrollHeight
      }, 400)	
    });

    $("#button-send").off("click").on("click", function(e){
      var msg = $("#msg").val();

      if(msg == "" || msg == null){
        return;
      }

      if (e.isComposing || e.keyCode === 229) {
        console.log("e.isComposing || e.keyCode === 229");
        return;
      }

      stomp.send('/pub/chat/message', JSON.stringify({chatRoomNo: chatRoomNo, chatMemberNo: chatMemberNo, chatMsg: msg, workMemberName: workMemberName, chatMemberEmail: login}));
      $("#msg").val(null);
    });

    // 엔터키를 누르면 submit 버튼이 눌리도록
    $("#msg").keyup(function(event) {
      if (event.which === 13) {
        // console.log("enter key pressed!");
        $("#button-send").click();
      }
    });
  }); // end for stomp connect
} // end for function stompConnection

- SockJs, STOMP 연결

WebSocketMessageBrokerConfigurer를 구현한 StompWebSocketConfig 클래스의 registerStompEndpoints(StompEndpointRegistry registry) 메소드에 추가한 엔드포인트로 SockJs 객체를 생성한다. 그렇게 생성한 sockJs 객체를 webstomp.over()의 인자로 넘겨 stomp client 연결을 시도한다.

- stomp.connect({}, function(){ ... })

헤더, 콜백함수

- stomp.subscribe(path, callBack)

StompWebSocketConfig.configureMessageBroker()의 설정 정보를 참고하여 path 값을 넘겨준다. subscribe 함수의 콜백 함수로는 받은 메시지를 화면에 append할 코드를 적어주면 된다. subscribe 함수는

채팅방에서 클라이언트가 메시지를 입력하면 서버에서 topic('/sub/chat/chatRoomNo')로 메시지를 발행(publish)하는데, subscribe는 대기하고 있다가 발송된 메시지를 받아서 처리한다.

JSON.parse(chat.body)

JSON.parse() 메서드는 JSON 문자열의 구문을 분석하고, 그 결과에서 JavaScript 값이나 객체를 생성한다. 여기선 chat.body를 가공하여 화면에 뿌릴 데이터를 가공할 것이다.

-- 코드 설명

let content = JSON.parse(chat.body);
let chatMemberEmail = content.chatMemberEmail;
let msg = content.chatMsg;
let str = ''; // append할 html을 저장할 변수

// 시간 정보를 함수로 넘겨 5분 전, 3시간 전, 7일 전 등의 데이터로 가공한다
let msgTime = timeForToday(content.time); 

// 메시지 주인의 email이 세션에 저장된 login email과 같으면
// 즉 자신이 보낸 이메일이면 오른쪽 정렬
if(chatMemberEmail === login){
  /* 위에 선언한 변수를 활용하여 append할 문자열을 만드는 부분 */
} else {
  /* 위에 선언한 변수를 활용하여 append할 문자열을 만드는 부분 */
}

$("#msgArea").append(str);

stomp.send()

역시 StompWebSocketConfig.configureMessageBroker()의 설정 정보를 참고하여 path와 publish할 데이터를 넘긴다. 이때 JSON.stringify()는 parse()의 반대라고 보면 된다. JavaScript 값을 JSON으로 변환한다. send() 메서드가 호출되면 서버는 topic('/sub/chat/chatRoomNo')로 메시지를 발행(publish)하고, 기다리고 있던 subscribe() 메서드가 동작하게 된다.

참고
https://dev-gorany.tistory.com/224
https://lahuman.jabsiri.co.kr/202
https://lee-mandu.tistory.com/437
https://joshua1988.github.io/web-development/javascript/promise-for-beginners/

0개의 댓글