SpringBoot : 웹소켓
<!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">×</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">×</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 추가해주기 !
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);
}
}
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);
}
<?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>
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)
- 클라이언트로부터 텍스트 메세지를 받았을때 실행
*/
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) {
}
}
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
}
}
[ 시크릿 모드 ]
[ 일반 창 ]
-> 채팅 주고 받을 수 있음 !