웹 소켓, 채팅 기능

oYJo·2025년 3월 14일

Spring

목록 보기
29/29
post-thumbnail

웹 소켓

WebSocketHandler 인터페이스

void afterConnectionEstablished(WebSocketSession session)
// 클라이언트와 연결이 완료되고, 통신할 준비가 되면 실행
    
void handlerMessage(WebSocketSession session, WebSocketMessage message)
// 클라이언트로부터 메시지가 도착하면 실행
    
void afterConnectionClosed(WebSocketSession session, CloseStatus closeStatus)
// 클라이언트와 연결이 종료되면 실행

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

TextWebSocketHandler

handlerTextMessage(WebSocketSession session, TextMessage message)
// 클라이언트로부터 텍스트 메시지를 받았을 때 실행

채팅 기능

디스코드는 음성을 왔다갔다
채팅은 text 문자로 왔다갔다


누가 / 몇 번 채팅방 / 어떤 메시지 / 받는 사람
중복되면 안되니깐 set으로
synchronizedSet = 동기화하는 set, 객체를 반환하는 set
→ 줄서서 가져가는 것
→ 의도치 않은 충돌 방지를 위해 동기화 방법으로 진행

alt+shift+s 기본생성자 선택testSock 주소로 요청하면 testHandler가 처리한다
그리고 testHandler는 class= ~~

악수하면서 소매넣기 하는 방법
sockjs라는 라이브러리를 사용

1️⃣ Xml

✔️pom.xml

...
<!-- Jackson-databind -->
<!-- https://mvnrepository.com/artifact/com.fasterxml.jackson.core/jackson-databind -->
<dependency>
  <groupId>com.fasterxml.jackson.core</groupId>
  <artifactId>jackson-databind</artifactId>
  <version>2.14.2</version>
</dependency>

...

<!-- Spring WebSocket -->
<!-- https://mvnrepository.com/artifact/org.springframework/spring-websocket -->
<dependency>
  <groupId>org.springframework</groupId>
  <artifactId>spring-websocket</artifactId>
  <version>${org.springframework-version}</version>
</dependency>  

<!--Java에서 JSON을 쉽게 다룰 수 있게 해주는 구글 라이브러리 -->
<!-- GSON 라이브러리 -->
<!-- https://mvnrepository.com/artifact/com.google.code.gson/gson -->
<dependency>
  <groupId>com.google.code.gson</groupId>
  <artifactId>gson</artifactId>
  <version>2.9.0</version>
</dependency>
...

✔️project(chatting(model(websocket))) → ChattingWebsocketHandler.java

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
        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) {
            
                // WebSocketSession은 HttpSession의 속성을 가로채서 똑같이 가지고 있기 때문에
                // 회원 정보를 나타내는 loginMember도 가지고 있음
                
                // 로그인된 회원 정보 중 회원 번호 얻어오기
                int loginMemberNo = ((Member)s.getAttributes().get("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());
    }
    
}

✔️resources(spring(appServlet)) → servlet-context.xml

<!-- 웹 소켓 처리 클래스를 bean으로 등록 -->
<beans:bean id="testHandler"
            class="edu.kh.project.main.model.websocket.TestWebsocketHandler"/>

<!-- 어떤 주소로 웹소켓 요청이 오면 세션을 가로챌지 지정 -->
<websocket:handlers>

  <websocket:mapping handler="testHandler" path="/testSock/"/>

  <!-- 요청 클라이언트의 세션을 가로채서 WebSocketSession에 넣어 주는 역할 -->
  <websocket:handshake-interceptors>
    <beans:bean class="org.springframework.web.socket.server.support.HttpSessionHandshakeInterceptor"/>
  </websocket:handshake-interceptors>

  <!-- SockJS를 이용한 웹소켓 연결 요청임을 명시 -->
  <websocket:sockjs/>

</websocket:handlers>

<beans:bean id="chatHandler" class="edu.kh.project.chatting.model.websocket.ChattingWebsocketHandler" />
<websocket:handlers>
  <!-- websocket 매핑 주소 -->
  <websocket:mapping handler="chatHandler" path="/chattingSock" />

  <websocket:handshake-interceptors>
    <!-- interceptor : http통신에서 request, response를 가로채는 역할 handshake-interceptors 
    : HttpSession에 있는 값을 가로채서 WebSocketSession 넣어주는 역할 -->
    <beans:bean class="org.springframework.web.socket.server.support.HttpSessionHandshakeInterceptor" />
  </websocket:handshake-interceptors>

  <websocket:sockjs />
</websocket:handlers> 

servlet-cotext.xml에 websocket 처리 클래스 bean으로 등록하기


2️⃣ Visual Studio Code

✔️webapp(resources(js)) → main.js

// -------------------------------------------------------------------------------
// 웹소켓 테스트
// 1. SockJS 라이브러리 추가

// 2. SockJS를 이용해서 클라이언트용 웹소켓 객체 생성
let testSock = new SockJS("/testSock/");

function sendMessage(name, str){
  // 매개 변수를 JS에 저장

  let obj={}; // 비어 있는 객체

  obj.name= name; // 객체에 일치하는 key가 없다면 자동으로 추가
  obj.str= str;

  // console.log(obj);

  // 웹소켓 연결된 곳으로 메시지 전달
  testSock.send(JSON.stringify(obj));
  //JS객체 -> JSON
}

// 웹소켓 객체(testSock)가 서버로부터 전달받은 메시지가 있을 경우
testSock.onmessage = e => {
  // e : 이벤트 객체
  // e.data : 전달받은 메시지(JSON)

  let obj = JSON.parse(e.data); // JSON -> JS 객체

  console.log(`보낸 사람 : ${obj.name} / ${obj.str}`);
}

✔️webapp(WEB-INF(views(chatting))) → chatting.jsp

<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"  %>

<!DOCTYPE html>
<html lang=ko">
<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" href="/resources/css/main-style.css">
    <link rel="stylesheet" href="/resources/css/board/boardDetail-style.css">
   <link rel="stylesheet" href="/resources/css/chatting/chatting-style.css">

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

<body>
   <main>

      <jsp:include page="../common/header.jsp"></jsp:include>

      <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="/resources/images/user.png">
               <span> <mark>유저</mark>일</span>
            </li>
            <li class="result-row"  data-id="2">
               <img class="result-row-img" src="/resources/images/user.png">
               <span><mark>유저</mark>이</span>
            </li>

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

               <li class="chatting-item" chat-no="${room.chattingNo}" target-no="${room.targetNo}">
                  <div class="item-header">
                     <c:if test="${not empty room.targetProfile}">
                        <img class="list-profile" src="${room.targetProfile}">
                     </c:if>
                     <c:if test="${empty room.targetProfile}">
                        <img class="list-profile" src="/resources/images/user.png">
                     </c:if>
                  </div>
                  <div class="item-body">
                     <p>
                        <span class="target-name">${room.targetNickName}</span>
                        <span class="recent-send-time">${room.sendTime}</span>
                     </p>
                     <div>
                        <p class="recent-message">${room.lastMessage}</p>

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

            </c:forEach>

         </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="/resources/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>
               <button id="send">보내기</button>
            </div>
         </div>
      </div>
   </main>

   <jsp:include page="../common/footer.jsp"></jsp:include>

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

   <script src="/resources/js/chatting/chatting.js"></script>
</body>
</html>

✔️webapp(resources(js)) → chatting.jsp

const addTarget = document.querySelector("#addTarget"); // 추가 버튼

const addTargetPopupLayer = document.querySelector("#addTargetPopupLayer"); // 팝업 레이어

const closeBtn = document.querySelector("#closeBtn"); // 닫기 버튼

const targetInput = document.querySelector("#targetInput"); // 사용자 검색

const resultArea = document.querySelector("#resultArea"); // 검색 결과

let selectChattingNo; // 선택한 채팅방 번호
let selectTargetNo; // 현재 채팅 대상
let selectTargetName; // 대상의 이름
let selectTargetProfile; // 대상의 프로필

// 검색 팝업 레이어 열기
addTarget.addEventListener("click", e => {
  addTargetPopupLayer.classList.toggle("popup-layer-close");
  targetInput.focus();
});

// 검색 팝업 레이어 닫기
closeBtn.addEventListener("click", e => {
  addTargetPopupLayer.classList.toggle("popup-layer-close");
  resultArea.innerHTML = "";
});

// 사용자 검색(ajax)
targetInput.addEventListener("input", e => {

  const query = e.target.value.trim();

  // 입력된 값이 없을 때
  if(query.length == 0){
    resultArea.innerHTML = ""; // 이전 검색 결과 비우기
    return;
  }

  // 입력된 값이 있을 때
  if(query.length > 0){
    fetch("/chatting/selectTarget?query="+query)
      .then(resp => resp.json())
      .then(list => {
      //console.log(list);

      resultArea.innerHTML = ""; // 이전 검색 결과 비우기

      if(list.length == 0){
        const li = document.createElement("li");
        li.classList.add("result-row");
        li.innerText = "일치하는 회원이 없습니다";
        resultArea.append(li);
      }

      for(let member of list){
        // li요소 생성(한 행을 감싸는 요소)
        const li = document.createElement("li");
        li.classList.add("result-row");
        li.setAttribute("data-id", member.memberNo);

        // 프로필 이미지 요소
        const img = document.createElement("img");
        img.classList.add("result-row-img");

        // 프로필 이미지 여부에 따른 src 속성 선택
        if(member.profileImage == null) img.setAttribute("src", "/resources/images/user.png");
        else   img.setAttribute("src", member.profileImage);

        let nickname = member.memberNickname;
        let email = member.memberEmail;

        const span = document.createElement("span");
        span.innerHTML = `${nickname} ${email}`.replace(query, `<mark>${query}</mark>`);

        // 요소 조립(화면에 추가)
        li.append(img, span);
        resultArea.append(li);

        // li요소에 클릭 시 채팅방에 입장하는 이벤트 추가
        li.addEventListener('click', chattingEnter);
      }

    })
      .catch(err => console.log(err) );
  }
});

// 채팅방 입장 또는 선택 함수
function chattingEnter(e){
  console.log(e.target); // 실제 클릭된 요소
  console.log(e.currentTarget); // 이벤트 리스트가 설정된 요소

  const targetNo = e.currentTarget.getAttribute("data-id");

  fetch("/chatting/enter?targetNo="+targetNo)
    .then(resp => resp.text())
    .then(chattingNo => {
    console.log(chattingNo);

    selectRoomList(); // 채팅방 목록 조회

    setTimeout(()=>{ 
      // 만약 채팅방 목록 중 이미 존재하는 채팅방이 있으면 클릭해서 입장
      const itemList = document.querySelectorAll(".chatting-item")

      for(let item of itemList) {      
        if(item.getAttribute("chat-no") == chattingNo){
          item.focus();
          item.click();
          addTargetPopupLayer.classList.toggle("popup-layer-close");
          targetInput.value = "";
          resultArea.innerHTML = "";
          return;
        }
      }

    }, 200);
    // 0.2초마다 채팅방 목록 다시 불러오기

  })
    .catch(err => console.log(err));
}

// 비동기로 채팅방 목록 조회
function selectRoomList(){

  fetch("/chatting/roomList")
    .then(resp => resp.json())
    .then(roomList => {
    console.log(roomList);

    // 채팅방 목록 출력 영역 선택
    const chattingList = document.querySelector(".chatting-list");

    // 채팅방 목록 지우기
    chattingList.innerHTML = "";

    // 조회한 채팅방 목록을 화면에 추가
    for(let room of roomList){
      const li = document.createElement("li");
      li.classList.add("chatting-item");
      li.setAttribute("chat-no", room.chattingNo);
      li.setAttribute("target-no", room.targetNo);

      if(room.chattingNo == selectChattingNo){
        li.classList.add("select");
      }

      // item-header 부분
      const itemHeader = document.createElement("div");
      itemHeader.classList.add("item-header");

      const listProfile = document.createElement("img");
      listProfile.classList.add("list-profile");

      if(room.targetProfile == undefined)   
        listProfile.setAttribute("src", "/resources/images/user.png");
      else                        
        listProfile.setAttribute("src", room.targetProfile);

      itemHeader.append(listProfile);

      // item-body 부분
      const itemBody = document.createElement("div");
      itemBody.classList.add("item-body");

      const p = document.createElement("p");

      const targetName = document.createElement("span");
      targetName.classList.add("target-name");
      targetName.innerText = room.targetNickName;

      const recentSendTime = document.createElement("span");
      recentSendTime.classList.add("recent-send-time");
      recentSendTime.innerText = room.sendTime;

      p.append(targetName, recentSendTime);

      const div = document.createElement("div");

      const recentMessage = document.createElement("p");
      recentMessage.classList.add("recent-message");

      if(room.lastMessage != undefined){
        recentMessage.innerHTML = room.lastMessage;
      }

      div.append(recentMessage);

      itemBody.append(p,div);

      // 현재 채팅방을 보고 있지 않고, 읽지 않은 메시지 개수가 0개 이상인 경우 -> 읽지 않은 메시지 개수 출력
      if(room.notReadCount > 0 && room.chattingNo != selectChattingNo ){
        const notReadCount = document.createElement("p");
        notReadCount.classList.add("not-read-count");
        notReadCount.innerText = room.notReadCount;
        div.append(notReadCount);

      }else{

        // 현재 채팅방을 보고 있는 경우
        // 비동기로 해당 채팅방 메시지를 읽음으로 표시
        fetch("/chatting/updateReadFlag",{
          method : "PUT",
          headers : {"Content-Type": "application/json"},
          body : JSON.stringify({"chattingNo" : selectChattingNo, "memberNo" : loginMemberNo})
        })
          .then(resp => resp.text())
          .then(result => console.log(result))
          .catch(err => console.log(err));

      }

      li.append(itemHeader, itemBody);
      chattingList.append(li);
    }

    roomListAddEvent();
  })
    .catch(err => console.log(err));

}

// 채팅 메시지 영역
const display = document.getElementsByClassName("display-chatting")[0];

// 채팅방 목록에 이벤트를 추가하는 함수 
function roomListAddEvent(){

  const chattingItemList = document.getElementsByClassName("chatting-item");

  for(let item of chattingItemList){
    item.addEventListener("click", e => {

      // 클릭한 채팅방의 번호 얻어오기
      //const id = item.getAttribute("id");
      //const arr = id.split("-");

      // 전역변수에 채팅방 번호, 상대 번호, 상태 프로필, 상대 이름 저장
      selectChattingNo = item.getAttribute("chat-no");
      selectTargetNo = item.getAttribute("target-no");

      selectTargetProfile = item.children[0].children[0].getAttribute("src");
      selectTargetName = item.children[1].children[0].children[0].innerText;

      if(item.children[1].children[1].children[1] != undefined){
        item.children[1].children[1].children[1].remove();
      }

      // 모든 채팅방에서 select 클래스를 제거
      for(let it of chattingItemList) it.classList.remove("select")

      // 현재 클릭한 채팅방에 select 클래스 추가
      item.classList.add("select");

      // 비동기로 메시지 목록을 조회하는 함수 호출
      selectChattingFn();
    });
  }
}

// 비동기로 메시지 목록을 조회하는 함수
function selectChattingFn() {

  fetch("/chatting/selectMessage?"+`chattingNo=${selectChattingNo}&memberNo=${loginMemberNo}`)
    .then(resp => resp.json())
    .then(messageList => {
    console.log(messageList);

    // <ul class="display-chatting">
    const ul = document.querySelector(".display-chatting");

    ul.innerHTML = ""; // 이전 내용 지우기

    // 메시지 만들어서 출력하기
    for(let msg of messageList){
      //<li>,  <li class="my-chat">
      const li = document.createElement("li");

      // 보낸 시간
      const span = document.createElement("span");
      span.classList.add("chatDate");
      span.innerText = msg.sendTime;

      // 메시지 내용
      const p = document.createElement("p");
      p.classList.add("chat");
      p.innerHTML = msg.messageContent; // br 태그 해석을 위해 innerHTML

      // 내가 작성한 메시지인 경우
      if(loginMemberNo == msg.senderNo){ 
        li.classList.add("my-chat");

        li.append(span, p);

      }else{ // 상대가 작성한 메시지인 경우
        li.classList.add("target-chat");

        // 상대 프로필
        // <img src="/resources/images/user.png">
        const img = document.createElement("img");
        img.setAttribute("src", selectTargetProfile);

        const div = document.createElement("div");

        // 상대 이름
        const b = document.createElement("b");
        b.innerText = selectTargetName; // 전역변수

        const br = document.createElement("br");

        div.append(b, br, p, span);
        li.append(img,div);

      }

      ul.append(li);

      display.scrollTop = display.scrollHeight; // 스크롤 제일 밑으로
      // 최근에 온 메시지는 아래로 계속 쌓이니까!
    }

  })
    .catch(err => console.log(err));

}

// ----------------------------------------------------------------------------------------------------------------

// sockjs를 이용한 WebSocket 구현

// 로그인이 되어 있을 경우에만
// /chattingSock 이라는 요청 주소로 통신할 수 있는 WebSocket 객체 생성
let chattingSock;

if(loginMemberNo != ""){
  chattingSock = new SockJS("/chattingSock");
}

// 채팅 입력
const send = document.getElementById("send");

const sendMessage = () => {
  const inputChatting = document.getElementById("inputChatting");

  if (inputChatting.value.trim().length == 0) {
    alert("채팅을 입력해주세요.");
    inputChatting.value = "";

  } else {
    var obj = {
      "senderNo": loginMemberNo,
      "targetNo": selectTargetNo,
      "chattingNo": selectChattingNo,
      "messageContent": inputChatting.value,
    };

    console.log(obj)

    // JSON.stringify() : 자바스크립트 객체를 JSON 문자열로 변환
    chattingSock.send(JSON.stringify(obj));

    inputChatting.value = "";
  }
}

// 엔터 == 제출
// 쉬프트 + 엔터 == 줄바꿈
inputChatting.addEventListener("keyup", e => {
  if(e.key == "Enter"){ 
    if (!e.shiftKey) {
      sendMessage();
    }
  }
})

// WebSocket 객체 chattingSock이 서버로부터 메시지를 통지받으면 자동으로 실행될 콜백 함수
chattingSock.onmessage = function(e) {

  // 메소드를 통해 전달받은 객체값을 JSON객체로 변환해서 obj 변수에 저장
  const msg = JSON.parse(e.data);
  console.log(msg);

  // 현재 채팅방을 보고있는 경우
  if(selectChattingNo == msg.chattingNo){

    const ul = document.querySelector(".display-chatting");

    // 메시지 만들어서 출력하기
    //<li>,  <li class="my-chat">
    const li = document.createElement("li");

    // 보낸 시간
    const span = document.createElement("span");
    span.classList.add("chatDate");
    span.innerText = msg.sendTime;

    // 메시지 내용
    const p = document.createElement("p");
    p.classList.add("chat");
    p.innerHTML = msg.messageContent; // br 태그 해석을 위해 innerHTML

    // 내가 작성한 메시지인 경우
    if(loginMemberNo == msg.senderNo){ 
      li.classList.add("my-chat");

      li.append(span, p);

    }else{ // 상대가 작성한 메시지인 경우
      li.classList.add("target-chat");

      // 상대 프로필
      // <img src="/resources/images/user.png">
      const img = document.createElement("img");
      img.setAttribute("src", selectTargetProfile);

      const div = document.createElement("div");

      // 상대 이름
      const b = document.createElement("b");
      b.innerText = selectTargetName; // 전역변수

      const br = document.createElement("br");

      div.append(b, br, p, span);
      li.append(img,div);

    }

    ul.append(li)
    display.scrollTop = display.scrollHeight; // 스크롤 제일 밑으로
  }

  selectRoomList();
}

// 문서 로딩 완료 후 수행할 기능
document.addEventListener("DOMContentLoaded", ()=>{

  // 채팅방 목록에 클릭 이벤트 추가
  roomListAddEvent(); 

  // 보내기 버튼에 이벤트 추가
  send.addEventListener("click", sendMessage);
});

✔️webapp(resources(css(chatting))) → chatting-style.css

/* 채팅방 생성 버튼 */
#openChatRoom {
    width: 120px;
 }
 
 /*  채팅방 생성 모달 */
 #chat-modal {
    justify-content: center;
    align-items: center;
 }
 
 .modal-body {
    text-align: center;
    background-color: white;
    padding: 50px;
    border-radius: 20px;
 }
 
 #chatRoomTitle {
    width: 250px;
 }
 
 /* 추가 버튼 */
 #addTarget{
     margin: 30px 0 0 70px;
     width: 100px;
     height: 30px;
     background-color: #455BA7;
     border: none;
     border-radius: 10px;
     font-weight: bold;
     color: white;
     cursor: pointer;
 }
 
 /* 팝업 레이어 */
 #addTargetPopupLayer{
     position: fixed;
     margin: auto;
     top: 0;
     bottom: 0;
     left: 0;
     right: 0;
     z-index: 1200;
     background-color: #f5f5f5;
     border: 5px inset #455ba8;
     width: 450px;
     height: 600px;
 
 }
 
 .popup-layer-close{ display: none;  }
 
 #closeBtn{
     position: absolute;
    top: -15px;
     right: -30px;
     font-size: 30px;
     cursor: pointer;
 }
 
 .target-input-area{
    width: 100%;
    height: 40px;
    border-bottom: 2px solid black;
 }
 
 #targetInput{
    width: 100%;
     height: 100%;
     outline: none;
     border: none;
     padding: 3px 10px;
     font-size: 20px;
 }
 
 #resultArea{list-style: none;}
 
 .result-row{
    width: 100%;
    height: 50px;
    padding: 5px;
    cursor: pointer;
    display: flex;
    align-items: center;
 }
 
 .result-row:hover{   background-color: #dadada;}
 
 .result-row > *{ 
    margin-right: 10px;
    user-select: none;
 }
 
 .result-row-img{ width: 40px;}
 
 /* 채팅창 영역 */
 .chatting-area {
    margin: auto;
    height: 650px;
    width: 1000px;
    margin-top: 20px;
    margin-bottom: 50px;
    display: flex;;
 }
 
 /*  채팅 목록 */
 .chatting-list{
    width: 30%;
    border : 1px solid black;
    list-style: none;
    overflow: auto;
 }
 
 .chatting-item{
    height: 12%;
    display: flex;
    padding: 5px 0;
    border-bottom: 1px solid black;
    cursor: pointer;
 }
 
 .chatting-item * {
    pointer-events: none;
 }
 
 .chatting-item > div{ height: 100%; text-align: center;}
 .item-header{ width : 25%; }
 .item-body{ 
    width : 75%; 
    padding : 2px 0;
 }
 .item-body> p{ 
    display: flex;
    justify-content: space-between;
 }
 
 .chatting-item.select{
    background-color: #ddd;
 }
 
 .list-profile{ 
    max-width: 65px;
     max-height: 65px;
 }
 
 .target-name{
    font-size: 1.2em;
    font-weight: bold;
 }
 
 .recent-send-time{
    margin-right: 5px;
 }
 
 .item-body > div{
    display: flex;
    justify-content: space-between;
 }
 
 .recent-message{
    width: 180px;
 
    /* 여러 줄 말줄임 처리 */
    white-space: normal;
     overflow: hidden;
     display: -webkit-box;
     -webkit-line-clamp: 2;
     -webkit-box-orient: vertical;
 
    /*  줄바꿈 처리 */
    word-break: break-all;
 }
 
 .not-read-count{
    width: 25px;
     height: 25px;
     display: flex;
     justify-content: center;
     align-items: center;
     border-radius: 50%;
     background-color: red;
     color: white;
     font-size: 12px;
     margin: 10px 10px 0 0;
 }
 
 /* 채팅 내용 */
 .chatting-content{
    width: 70%;
 }
 
 .display-chatting {
    width: 100%;
    height: 570px;
    border: 1px solid black;
    overflow: auto;
    list-style: none;
    padding: 10px 10px;
 }
 
 .display-chatting > li{
    margin: 10px 0;
 }
 
 .target-chat{
    display: flex;
    align-items: flex-start;
 }
 .target-chat > img{
    width: 50px;
    margin-right: 5px;
 }
 
 .chat {
    display: inline-block;
    border-radius: 5px;
    padding: 5px;
    background-color: #eee;
    text-align: left;
    max-width: 500px;
    word-break: break-all;
    white-space: pre-wrap;
 }
 
 .input-area {
    height: 80px;
    width: 100%;
    display: flex;
 }
 
 #inputChatting {
    padding: 3px;
    font-size: 1.3em;
    width: 80%;
    resize: none;
 }
 
 #send {
    width: 20%;
 }
 
 .my-chat {
    text-align: right;
 }
 
 .my-chat>p {
    background-color: yellow;
 }
 
 .chatDate {
    font-size: 9px;
 }
 
 #exit-area {
    text-align: right;
    margin-bottom: 10px;
 }
 
 .exit {
    text-align: center;
 }
 
 .exit>p {
    background-color: rgba(0, 0, 0, 0.3);
 }
 
 .chat-exit {
    width: 100%;
    text-align: center;
    background-color: black;
    color: white;
 }

✔️webapp(WEB-INF(views(common))) → header.jsp

...
<%-- 로그인 했을 때 채팅 보여짐 --%>
  <c:if test="${!empty loginMember}" >
    <li><a href="/chatting">채팅</a></li>
  </c:if>
...

3️⃣ Spring

✔️project(chatting(model(dto))) → ChattingRoom.java

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

import lombok.Getter;
import lombok.Setter;
import lombok.ToString;

@Getter
@Setter
@ToString
public class ChattingRoom {
    private int chattingNo; // 채팅방 번호
    private String lastMessage; // 최근 메시지
    private String sendTime; // 메시지 보낸 시간
    private int targetNo; // 받는 회원 번호
    private String targetNickName; // 받는 회원 닉네임
    private String targetProfile; // 받는 회원 프로필 사진
    private int notReadCount; // 읽지 않은 메시지 개수
    
}

✔️project(chatting(model(dto))) → Message.java

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

import lombok.Getter;
import lombok.Setter;
import lombok.ToString;

@Getter
@Setter
@ToString
public class Message {
    private int messageNo; // 메시지 번호
    private String messageContent; // 메시지 내용
    private String readFlag; // 읽음 여부
    private int senderNo; // 보낸 회원 번호
    private int targetNo; // 받는 회원 번호
    private int chattingNo; // 채팅방 번호
    private String sendTime; // 메시지 보낸 시간
}

✔️project(chatting(controller)) → ChattingController.java


@SessionAttributes({"loginMember"})
@Controller
public class ChattingController {
    
    @Autowired
    private ChattingService service;
    
    // 채팅 페이지
    @GetMapping("/chatting")
    public String chatting(@SessionAttribute("loginMember") Member loginMember, Model model) {
        
        // 현재 개설되어 있는 채팅방 목록 불러오기
        List<ChattingRoom> roomList = service.selectRoomList(loginMember.getMemberNo());
        model.addAttribute("roomList", roomList);
        
        return "chatting/chatting";
    }
    
    // 채팅 상대 검색
    @GetMapping(value="/chatting/selectTarget", produces="application/json; charset=UTF-8")
    @ResponseBody
    public List<Member> selectTarget(String query, @SessionAttribute("loginMember") Member loginMember){
    
       Map<String, Object> map = new HashMap<>();
       
       map.put("memberNo", loginMember.getMemberNo());
       map.put("query", query);
       
       return service.selectTarget(map);
    }
    
    // 채팅방 입장(없으면 생성)
    @GetMapping("/chatting/enter")
    @ResponseBody
    public int chattingEnter(int targetNo, @SessionAttribute("loginMember") Member loginMember) {
     
        Map<String, Integer> map = new HashMap<String, Integer>();
        
        map.put("targetNo", targetNo);
        map.put("loginMemberNo", loginMember.getMemberNo());
        
        int chattingNo = service.checkChattingNo(map);
        
        // 받는 회원과 생성된 채팅방이 없을 경우 채팅방 번호 새로 생성
        if(chattingNo == 0) {
            chattingNo = service.createChattingRoom(map);
        }
        
        return chattingNo;
    }
    
    // 채팅방 목록 조회
    @GetMapping(value="/chatting/roomList", produces="application/json; charset=UTF-8")
    @ResponseBody
    public List<ChattingRoom> selectRoomList(@SessionAttribute("loginMember") Member loginMember) {
       return service.selectRoomList(loginMember.getMemberNo());
    }
    
    // 채팅 읽음 표시
    @PutMapping("/chatting/updateReadFlag")
    @ResponseBody
    public int updateReadFlag(@RequestBody Map<String, Object> paramMap) {
        return service.updateReadFlag(paramMap);
    }
    
    // 채팅방 번호에 해당하는 메시지 목록 조회
    @GetMapping(value="/chatting/selectMessage", produces="application/json; charset=UTF-8")
    @ResponseBody
    public List<Message> selectMessageList(@RequestParam Map<String, Object> paramMap) {
        return service.selectMessageList(paramMap);
    }
    
}

✔️project(chatting(model(service))) → ChattingService.java

public interface ChattingService {
    
    List<ChattingRoom> selectRoomList(int memberNo);

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

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

    int insertMessage(Message msg);

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

    List<Message> selectMessageList(Map<String, Object> paramMap);

   /** 채팅 상대 검색
    * @param map 
    * @return memberList
    */
   List<Member> selectTarget(Map<String, Object> map);

}

✔️project(chatting(model(service))) → ChattingServiceImpl.java


@Service
public class ChattingServiceImpl implements ChattingService{

    @Autowired
    private ChattingDAO dao;

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

    @Override
    public int createChattingRoom(Map<String, Integer> map) {
        return dao.createChattingRoom(map);
    }

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

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

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

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

✔️project(chatting(model(dao))) → ChattingDAO.java

@Repository
public class ChattingDAO {
    
    @Autowired
    private SqlSessionTemplate sqlSession;
    
    public List<ChattingRoom> selectRoomList(int memberNo) {
        return sqlSession.selectList("chattingMapper.selectRoomList", memberNo);
    }

    public int checkChattingNo(Map<String, Integer> map) {
        return sqlSession.selectOne("chattingMapper.checkChattingNo", map);
    }

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

    public int insertMessage(Message msg) {
        return sqlSession.insert("chattingMapper.insertMessage", msg);
    }

    public int updateReadFlag(Map<String, Object> paramMap) {
        return sqlSession.update("chattingMapper.updateReadFlag", paramMap);
    }

    public List<Message> selectMessageList(int chattingNo) {
       return sqlSession.selectList("chattingMapper.selectMessageList", chattingNo);
    }

   public List<Member> selectTarget(Map<String, Object> map) {
      return sqlSession.selectList("chattingMapper.selectTarget", map);
   }
}

✔️resources(mappers) → 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="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>

속성 마음대로 추가 가능
setTimeOut으로 동기식으로 바꿔준다
시간 차가 있기 때문에 동기식으로 해주는 것

3️⃣ Oracle

-- 채팅
CREATE TABLE "CHATTING_ROOM" (
   "CHATTING_NO"   NUMBER      NOT NULL,
   "CH_CREATE_DATE"   DATE   DEFAULT SYSDATE   NOT NULL,
   "OPEN_MEMBER"   NUMBER      NOT NULL,
   "PARTICIPANT"   NUMBER      NOT NULL
);

COMMENT ON COLUMN "CHATTING_ROOM"."CHATTING_NO" IS '채팅방 번호';
COMMENT ON COLUMN "CHATTING_ROOM"."CH_CREATE_DATE" IS '채팅방 생성일';
COMMENT ON COLUMN "CHATTING_ROOM"."OPEN_MEMBER" IS '개설자 회원 번호';
COMMENT ON COLUMN "CHATTING_ROOM"."PARTICIPANT" IS '참여자 회원 번호';

ALTER TABLE "CHATTING_ROOM" ADD CONSTRAINT "PK_CHATTING_ROOM" PRIMARY KEY (
   "CHATTING_NO"
);

ALTER TABLE "CHATTING_ROOM"
ADD CONSTRAINT "FK_OPEN_MEMBER"
FOREIGN KEY ("OPEN_MEMBER") REFERENCES "MEMBER";

ALTER TABLE "CHATTING_ROOM"
ADD CONSTRAINT "FK_PARTICIPANT"
FOREIGN KEY ("PARTICIPANT") REFERENCES "MEMBER";

DROP TABLE "MESSAGE";

CREATE TABLE "MESSAGE" (
   "MESSAGE_NO"   NUMBER      NOT NULL,
   "MESSAGE_CONTENT"   VARCHAR2(4000)      NOT NULL,
   "READ_FL"   CHAR   DEFAULT 'N'   NOT NULL,
   "SEND_TIME"   DATE   DEFAULT SYSDATE   NOT NULL,
   "SENDER_NO"   NUMBER      NOT NULL,
   "CHATTING_NO"   NUMBER      NOT NULL
);

COMMENT ON COLUMN "MESSAGE"."MESSAGE_NO" IS '메시지 번호';
COMMENT ON COLUMN "MESSAGE"."MESSAGE_CONTENT" IS '메시지 내용';
COMMENT ON COLUMN "MESSAGE"."READ_FL" IS '읽음 여부';
COMMENT ON COLUMN "MESSAGE"."SEND_TIME" IS '메시지 보낸 시간';
COMMENT ON COLUMN "MESSAGE"."SENDER_NO" IS '보낸 회원의 번호';
COMMENT ON COLUMN "MESSAGE"."CHATTING_NO" IS '채팅방 번호';

ALTER TABLE "MESSAGE" ADD CONSTRAINT "PK_MESSAGE" PRIMARY KEY (
   "MESSAGE_NO"
);

ALTER TABLE "MESSAGE"
ADD CONSTRAINT "FK_CHATTING_NO"
FOREIGN KEY ("CHATTING_NO") REFERENCES "CHATTING_ROOM";

ALTER TABLE "MESSAGE"
ADD CONSTRAINT "FK_SENDER_NO"
FOREIGN KEY ("SENDER_NO") REFERENCES "MEMBER";

-- 시퀀스 생성
CREATE SEQUENCE SEQ_ROOM_NO NOCACHE;
CREATE SEQUENCE SEQ_MESSAGE_NO NOCACHE;
profile
Hello! My Name is oYJo

0개의 댓글