[SpringBoot] TIL 083 - 23.11.23

유진·2023년 11월 22일
0

SpringBoot : 웹소켓

chatting.html

<!DOCTYPE html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
	
    <title>채팅방</title>

    <link rel="stylesheet" th:href="@{/css/main-style.css}">
    <link rel="stylesheet" th:href="@{/css/board/boardDetail-style.css}">
	<link rel="stylesheet" th:href="@{/css/chatting/chatting-style.css}">

    <script src="https://kit.fontawesome.com/a2e8ca0ae3.js" crossorigin="anonymous"></script>
</head>

<body>
	<main>

		<th:block th:replace="~{/common/header}">header.html</th:block>

		<button id="addTarget">추가</button>

		<div id="addTargetPopupLayer" class="popup-layer-close">  
			<span id="closeBtn">&times</span>

			<div class="target-input-area">
				<input type="search" id="targetInput" placeholder="닉네임 또는 이메일을 입력하세요" autocomplete="off">
			</div>

			<ul id="resultArea">
				<!-- <li class="result-row" data-id="1">
					<img class="result-row-img" src="/images/user.png">
					<span> <mark>유저</mark>일</span>
				</li>
				<li class="result-row"  data-id="2">
					<img class="result-row-img" src="/images/user.png">
					<span><mark>유저</mark>이</span>
				</li>

				<li class="result-row">일치하는 회원이 없습니다</li> -->
			</ul>
		</div>
	
		<div class="chatting-area">
			<ul class="chatting-list">
				<th:block th:each="room : ${roomList}">

					<li class="chatting-item" th:chat-no="${room.chattingNo}" th:target-no="${room.targetNo}">
						<div class="item-header">

							<img th:if="${room.targetProfile}" class="list-profile" th:src="${room.targetProfile}">

							<img th:unless="${room.targetProfile}" class="list-profile" src="/images/user.png">
							
						</div>
						<div class="item-body">
							<p>
								<span class="target-name" th:text="${room.targetNickName}">상대방 이름</span>
								<span class="recent-send-time" th:text="${room.sendTime}">메세지 보낸 시간</span>
							</p>
							<div>
								<p class="recent-message" th:utext="${room.lastMessage}">메세지 내용</p>

								<p th:if="${room.notReadCount > 0}" class="not-read-count" th:text="${room.notReadCount}"></p>
							</div>
						</div>
					</li>

				</th:block>

			</ul>

			<div class="chatting-content">
				<ul class="display-chatting">
					<!-- <li class="my-chat">
						<span class="chatDate">14:01</span>
						<p class="chat">가나다라마바사</p>
					</li>

					<li class="target-chat">
						<img src="/images/user.png">

						<div>
							<b>이번유저</b>	<br>
							<p class="chat">
								안녕하세요?? 반갑습니다.<br>
								ㅎㅎㅎㅎㅎ
							</p>
							<span class="chatDate">14:05</span>
						</div>
					</li> -->
				</ul>	
			
				<div class="input-area">
					<textarea id="inputChatting" rows="3"></textarea>

					<!-- 이미지 보내기 버튼 추가 -->
					<input type="file" name="sendImage" id="sendImage" accept="image/*">
					<label for="sendImage">이미지</label>

					<button id="send">보내기</button>
				</div>
			</div>
		</div>
	</main>

	<th:block th:replace="~{/common/footer}">header.html</th:block>

	<div class="modal">
        <span id="modalClose">&times;</span>
        <img id="modalImage">
    </div>

	<!--------------------------------------- sockjs를 이용한 WebSocket 구현을 위해 라이브러리 추가 ---------------------------------------------->
	
	<!-- https://github.com/sockjs/sockjs-client -->
	<script src="https://cdn.jsdelivr.net/npm/sockjs-client@1/dist/sockjs.min.js"></script>
	<script th:inline="javascript">
		// 로그인한 회원 번호
		const loginMemberNo = /*[[${loginMember.memberNo}]]*/ '로그인회원번호';
	</script>

	<script th:src="@{/js/chatting/chatting.js}"></script>
</body>
</html>


-> Refresh !


-> dto 추가해주기 !

ChattingServiceImpl.java

package edu.kh.project.chatting.model.service;

import java.util.List;
import java.util.Map;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import edu.kh.project.chatting.model.dao.ChattingMapper;
import edu.kh.project.chatting.model.dto.ChattingRoom;
import edu.kh.project.chatting.model.dto.Message;
import edu.kh.project.common.utility.Util;
import edu.kh.project.member.model.dto.Member;

@Service
public class ChattingServiceImpl implements ChattingService{

	// alt + shift + r (+ Enter) ==> 전체변경
	
    @Autowired
    private ChattingMapper mapper;

    @Override
    public List<ChattingRoom> selectRoomList(int memberNo) {
        return mapper.selectRoomList(memberNo);
    }
    
    @Override
    public int checkChattingNo(Map<String, Integer> map) {
        return mapper.checkChattingNo(map);
    }

    @Override
    public int createChattingRoom(Map<String, Integer> map) {
    	
    	int result = mapper.createChattingRoom(map);
    	
    	if(result > 0) {
    		return (int)map.get("chattingNo");
    	}
    	
        return 0;
    }


    @Override
    public int insertMessage(Message msg) {
        msg.setMessageContent(Util.XSSHandling(msg.getMessageContent()));
//        msg.setMessageContent(Util.newLineHandling(msg.getMessageContent()));
        return mapper.insertMessage(msg);
    }

    @Override
    public int updateReadFlag(Map<String, Object> paramMap) {
        return mapper.updateReadFlag(paramMap);
    }

    @Override
    public List<Message> selectMessageList( Map<String, Object> paramMap) {
        System.out.println(paramMap);
        List<Message> messageList = mapper.selectMessageList(  Integer.parseInt( String.valueOf(paramMap.get("chattingNo") )));
        
        if(!messageList.isEmpty()) {
            int result = mapper.updateReadFlag(paramMap);
        }
        return messageList;
    }

    // 채팅 상대 검색
	@Override
	public List<Member> selectTarget(Map<String, Object> map) {
		return mapper.selectTarget(map);
	}

    

    
    
    

    
    
}

ChattingMapper.java

package edu.kh.project.chatting.model.dao;

import java.util.List;
import java.util.Map;

import org.apache.ibatis.annotations.Mapper;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Repository;

import edu.kh.project.chatting.model.dto.ChattingRoom;
import edu.kh.project.chatting.model.dto.Message;
import edu.kh.project.member.model.dto.Member;

@Mapper
public interface ChattingMapper {
    
    public List<ChattingRoom> selectRoomList(int memberNo);

    public int checkChattingNo(Map<String, Integer> map);

    public int createChattingRoom(Map<String, Integer> map);

    public int insertMessage(Message msg);

    public int updateReadFlag(Map<String, Object> paramMap);

    public List<Message> selectMessageList(int chattingNo);

	public List<Member> selectTarget(Map<String, Object> map);
}

chatting-mapper.xml

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="edu.kh.project.chatting.model.dao.ChattingMapper">

	<resultMap type="ChattingRoom" id="chattingRoom_rm">
		<id property="chattingNo" column="CHATTING_NO" />

		<result property="lastMessage" column="LAST_MESSAGE" />
		<result property="sendTime" column="SEND_TIME" />
		<result property="targetNo" column="TARGET_NO" />
		<result property="targetNickName" column="TARGET_NICKNAME" />
		<result property="targetProfile" column="TARGET_PROFILE" />
		<result property="notReadCount" column="NOT_READ_COUNT" />
	</resultMap>

	<resultMap type="Message" id="message_rm">
		<id property="messageNo" column="MESSAGE_NO" />

		<result property="messageContent" column="MESSAGE_CONTENT" />
		<result property="readFlag" column="READ_FL" />
		<result property="senderNo" column="SENDER_NO" />
		<result property="chattingNo" column="CHATTING_NO" />
		<result property="sendTime" column="SEND_TIME" />
	</resultMap>

	
	<resultMap type="Member" id="member_rm">
		<id property="memberNo" column="MEMBER_NO"/>
		<result property="memberEmail" column="MEMBER_EMAIL"/>
		<result property="memberNickname" column="MEMBER_NICKNAME"/>
		<result property="profileImage" column="PROFILE_IMG"/>
	</resultMap>	


	<!--=========================================================================================-->

	<!-- 채팅방 목록 조회 -->
	<select id="selectRoomList" resultMap="chattingRoom_rm">
		SELECT CHATTING_NO
			,(SELECT MESSAGE_CONTENT FROM (
				SELECT * FROM MESSAGE M2
				WHERE M2.CHATTING_NO = R.CHATTING_NO
				ORDER BY MESSAGE_NO DESC) 
				WHERE ROWNUM = 1) LAST_MESSAGE
			,TO_CHAR(NVL((SELECT MAX(SEND_TIME) SEND_TIME 
					FROM MESSAGE M
					WHERE R.CHATTING_NO  = M.CHATTING_NO), CH_CREATE_DATE), 
					'YYYY.MM.DD') SEND_TIME
			,NVL2((SELECT OPEN_MEMBER FROM CHATTING_ROOM R2
				WHERE R2.CHATTING_NO = R.CHATTING_NO
				AND R2.OPEN_MEMBER = #{memberNo}),
				R.PARTICIPANT,
				R.OPEN_MEMBER
				) TARGET_NO	
			,NVL2((SELECT OPEN_MEMBER FROM CHATTING_ROOM R2
				WHERE R2.CHATTING_NO = R.CHATTING_NO
				AND R2.OPEN_MEMBER = #{memberNo}),
				(SELECT MEMBER_NICKNAME FROM MEMBER WHERE MEMBER_NO = R.PARTICIPANT),
				(SELECT MEMBER_NICKNAME FROM MEMBER WHERE MEMBER_NO = R.OPEN_MEMBER)
				) TARGET_NICKNAME	
			,NVL2((SELECT OPEN_MEMBER FROM CHATTING_ROOM R2
				WHERE R2.CHATTING_NO = R.CHATTING_NO
				AND R2.OPEN_MEMBER = #{memberNo}),
				(SELECT PROFILE_IMG FROM MEMBER WHERE MEMBER_NO = R.PARTICIPANT),
				(SELECT PROFILE_IMG FROM MEMBER WHERE MEMBER_NO = R.OPEN_MEMBER)
				) TARGET_PROFILE
			,(SELECT COUNT(*) FROM MESSAGE M WHERE M.CHATTING_NO = R.CHATTING_NO AND READ_FL = 'N' AND SENDER_NO != #{memberNo}) NOT_READ_COUNT
			,(SELECT MAX(MESSAGE_NO) SEND_TIME FROM MESSAGE M WHERE R.CHATTING_NO  = M.CHATTING_NO) MAX_MESSAGE_NO
		FROM CHATTING_ROOM R
		WHERE OPEN_MEMBER = #{memberNo}
		OR PARTICIPANT = #{memberNo}
		ORDER BY MAX_MESSAGE_NO DESC NULLS LAST
	</select>

	<!-- 채팅 확인 -->
	<select id="checkChattingNo" resultType="_int">
		SELECT NVL(SUM(CHATTING_NO),0) CHATTING_NO FROM CHATTING_ROOM
		WHERE (OPEN_MEMBER = #{loginMemberNo} AND PARTICIPANT = #{targetNo})
		OR (OPEN_MEMBER = #{targetNo} AND PARTICIPANT = #{loginMemberNo})
	</select>
	
	<!-- 채팅방 생성 -->
	<insert id="createChattingRoom" parameterType="map" useGeneratedKeys="true">
	
		<selectKey keyProperty="chattingNo" order="BEFORE" resultType="_int">
			SELECT SEQ_ROOM_NO.NEXTVAL FROM DUAL
		</selectKey>
	
		INSERT INTO CHATTING_ROOM
		VALUES(#{chattingNo}, DEFAULT, #{loginMemberNo}, #{targetNo})
	</insert>
	


	<!-- 채팅 메세지 삽입 -->
	<insert id="insertMessage">
		INSERT INTO "MESSAGE"
		VALUES(SEQ_MESSAGE_NO.NEXTVAL, #{messageContent}, DEFAULT, DEFAULT, #{senderNo}, #{chattingNo})
	</insert>
	
	
	<!-- 채팅 메세지 중 내가 보내지 않은 글을 읽음으로 표시 -->
	<update id="updateReadFlag">
		UPDATE "MESSAGE" SET
		READ_FL = 'Y'
		WHERE CHATTING_NO = #{chattingNo}
		AND SENDER_NO != #{memberNo}
	</update>

	<!-- 채팅방 메세지 조회 -->
	<select id="selectMessageList" resultMap="message_rm">
		SELECT MESSAGE_NO, MESSAGE_CONTENT, READ_FL, SENDER_NO, CHATTING_NO,
		TO_CHAR(SEND_TIME, 'YYYY.MM.DD HH24:MI') SEND_TIME 
		FROM MESSAGE
		WHERE CHATTING_NO  = #{chattingNo}
		ORDER BY MESSAGE_NO
	</select>


	<!-- 채팅 상대 검색 -->
	<select id="selectTarget" resultMap="member_rm">
		SELECT MEMBER_NO, MEMBER_EMAIL, MEMBER_NICKNAME, PROFILE_IMG  FROM "MEMBER"
		WHERE (MEMBER_EMAIL LIKE '%${query}%' OR MEMBER_NICKNAME LIKE '%${query}%')
		AND MEMBER_DEL_FL = 'N'
		AND MEMBER_NO != ${memberNo}
	</select>

</mapper>

ChattingWebsocketHandler.java

package edu.kh.project.chatting.model.websocket;

import java.text.SimpleDateFormat;
import java.util.Collections;
import java.util.Date;
import java.util.HashSet;
import java.util.Set;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
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;

import com.fasterxml.jackson.databind.ObjectMapper;
//import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.gson.Gson;

import edu.kh.project.chatting.model.dto.Message;
import edu.kh.project.chatting.model.service.ChattingService;
import edu.kh.project.member.model.dto.Member;
import jakarta.servlet.http.HttpSession;

@Component
public class ChattingWebsocketHandler extends TextWebSocketHandler{
    
    private Logger logger = LoggerFactory.getLogger(ChattingWebsocketHandler.class);
    
    
    @Autowired
    private ChattingService service;
   
    
    // WebSocketSession : 클라이언트 - 서버간 전이중통신을 담당하는 객체 (JDBC Connection과 유사)
    // 클라이언트의  최초 웹소켓 요청 시 생성
    private Set<WebSocketSession> sessions  = Collections.synchronizedSet(new HashSet<WebSocketSession>());
    // synchronizedSet : 동기화된 Set 반환(HashSet은 기본적으로 비동기)
    // -> 멀티스레드 환경에서 하나의 컬렉션에 여러 스레드가 접근하여 의도치 않은 문제가 발생되지 않게 하기 위해
    //    동기화를 진행하여 스레드가 여러 순서대로 한 컬렉션에 순서대로 접근할 수 있게 변경.
    
    // afterConnectionEstablished - 클라이언트와 연결이 완료되고, 통신할 준비가 되면 실행. 
    @Override
    public void afterConnectionEstablished(WebSocketSession session) throws Exception {
        // 연결 요청이 접수되면 해당 클라이언트와 통신을 담당하는 WebSocketSession 객체가 전달되어져 옴.
        // 이를 필드에 선언해준sessions에 저장
        sessions.add(session);
    
        //logger.info("{}연결됨", session.getId());
//      System.out.println(session.getId() + "연결됨");
    }
    
    
    //handlerTextMessage - 클라이언트로부터 텍스트 메세지를 받았을때 실행
    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
        
        // 전달받은 내용은 JSON 형태의 String
        logger.info("전달받은 내용 : " + message.getPayload());
        
        // Jackson에서 제공하는 객체
        // JSON String -> VO Object
        // 전달받은 내용 : {"senderNo":"4","targetNo":"11","chattingNo":"8","messageContent":"실시간"}
        ObjectMapper objectMapper = new ObjectMapper();
        
        Message msg = objectMapper.readValue( message.getPayload(), Message.class);
        // Message 객체 확인
        System.out.println(msg); 
        
        // DB 삽입 서비스 호출
        int result = service.insertMessage(msg);
        
        if(result > 0 ) {
            
            SimpleDateFormat sdf = new SimpleDateFormat("yyyy.MM.dd hh:mm");
            msg.setSendTime(sdf.format(new Date()) );
            
            // 전역변수로 선언된 sessions에는 접속중인 모든 회원의 세션 정보가 담겨 있음
            for(WebSocketSession s : sessions) {
                
            	// 가로챈 session 꺼내기
            	HttpSession temp = (HttpSession)s.getAttributes().get("session");
                
                // 로그인된 회원 정보 중 회원 번호 얻어오기
                int loginMemberNo = ((Member)temp.getAttribute("loginMember")).getMemberNo();
                logger.debug("loginMemberNo : " + loginMemberNo);
                
                // 로그인 상태인 회원 중 targetNo가 일치하는 회원에게 메세지 전달
                if(loginMemberNo == msg.getTargetNo() || loginMemberNo == msg.getSenderNo()) {
                    
                    s.sendMessage(new TextMessage(new Gson().toJson(msg)));
                }
            }
        }
    }
    
    
    
    
    // afterConnectionClosed - 클라이언트와 연결이 종료되면 실행된다.
    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
        sessions.remove(session);
        //logger.info("{}연결끊김",session.getId());
    }
    
}

/* WebSocket
- 브라우저와 웹서버간의 전이중통신을 지원하는 프로토콜이다
- HTML5버전부터 지원하는 기능이다.
- 자바 톰캣7버전부터 지원했으나 8버전부터 본격적으로 지원한다.
- spring4부터 웹소켓을 지원한다. 
(전이중 통신(Full Duplex): 두 대의 단말기가 데이터를 송수신하기 위해 동시에 각각 독립된 회선을 사용하는 통신 방식. 
대표적으로 전화망, 고속 데이터 통신)



WebSocketHandler 인터페이스 : 웹소켓을 위한 메소드를 지원하는 인터페이스
    -> WebSocketHandler 인터페이스를 상속받은 클래스를 이용해 웹소켓 기능을 구현


WebSocketHandler 주요 메소드
        
    void handlerMessage(WebSocketSession session, WebSocketMessage message)
    - 클라이언트로부터 메세지가 도착하면 실행
    
    void afterConnectionEstablished(WebSocketSession session)
    - 클라이언트와 연결이 완료되고, 통신할 준비가 되면 실행

    void afterConnectionClosed(WebSocketSession session, CloseStatus closeStatus)
    - 클라이언트와 연결이 종료되면 실행

    void handleTransportError(WebSocketSession session, Throwable exception)
    - 메세지 전송중 에러가 발생하면 실행 


----------------------------------------------------------------------------

TextWebSocketHandler :  WebSocketHandler 인터페이스를 상속받아 구현한 텍스트 메세지 전용 웹소켓 핸들러 클래스
 
    handlerTextMessage(WebSocketSession session, TextMessage message)
    - 클라이언트로부터 텍스트 메세지를 받았을때 실행
     

*/

ChattingHandshakeInterceptor.java

package edu.kh.project.common.interceptor;

import java.util.Map;

import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.http.server.ServletServerHttpRequest;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.WebSocketHandler;
import org.springframework.web.socket.server.HandshakeInterceptor;

import jakarta.servlet.http.HttpSession;

@Component
public class ChattingHandshakeInterceptor implements HandshakeInterceptor{

	// WebSocketHandler가 동작 하기 전
	@Override                  // ServerHttpRequest (부모) , ServletServerHttpRequest (자식)
	public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler,
			Map<String, Object> attributes) throws Exception {
		
		if(request instanceof ServletServerHttpRequest) {
			ServletServerHttpRequest servletRequest = (ServletServerHttpRequest)request;
			
			// 웹소켓에 접속한 클라이언트의 세션을 얻어옴
			HttpSession session = servletRequest.getServletRequest().getSession();
			
			// Map<String, Object> attributes
			// -> WebSocketHandler의 WebSocketSession에 담을 내용(값)을 세팅하는 Map
			attributes.put("session", session);
			
		}
		
		
		return true; // return은 꼭 true !
	}
	
	
	

	@Override
	public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler,
			Exception exception) {
		
	}

}

WebSocketConfig.java

package edu.kh.project.common.config;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
import org.springframework.web.socket.server.HandshakeInterceptor;

import edu.kh.project.chatting.model.websocket.ChattingWebsocketHandler;

@Configuration
@EnableWebSocket // 여기에서 WebSocket 사용함을 명시
public class WebSocketConfig implements WebSocketConfigurer{

	@Autowired
	private ChattingWebsocketHandler chattingWebsocketHandler;
	
	@Autowired
	private HandshakeInterceptor handshakeInterceptor;
	
	
	@Override
	public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
		
		registry.addHandler(chattingWebsocketHandler, "/chattingSock")
								.addInterceptors(handshakeInterceptor)
								.setAllowedOriginPatterns("http://localhost/", "http://127.0.0.1")
								.withSockJS();
		
		// CORS = Cross Origin Resorce Sharing
		// origin? http://localhost:8080
		
	}

}

header.html


[ 시크릿 모드 ]

[ 일반 창 ]

-> 채팅 주고 받을 수 있음 !

0개의 댓글