[토이프로젝트]당근마켓 구현하기 - WebSocket 실시간 채팅 구현

gamja·2022년 12월 18일
0
post-thumbnail

학교 시험 기간이랑 겹쳐서 채팅 구현이 매우 늦어졌다.. 학교 공부랑 취업 준비를 동시에 하면서 프로젝트 진행이 더뎌지는 게 느껴졌지만 조금씩 꾸준히 해나가서 이제라도 채팅 구현을 할 수 있게 됐다. (긍정 회로 돌리기😇)

진행하기에 앞서 웹소켓이 무엇인지 알아보는 시간을 가졌다.

🚀웹소켓이란?

소켓

소켓(socket)은 통신의 극점(EndPoint)를 의미한다. 두 프로세스각 네트워크 상에서 통신을 하려면 양 프로세스마다 하나씩 총 두 개의 소켓이 필요하다.

웹소켓

Transport protocol의 일종으로 쉽게 이야기 하면 웹버전의 TCP 또는 Socket이라고 할 수 있다. WebSocket은 서버와 클라이언트 간에 Socket Connection을 유지해서 언제든 양방향 통신 또는 데이터 전송이 가능하도록 하는 기술이다.

웹소켓 사용 이유?

기존의 서버와 클라이언트 간의 통신은 대부분 HTTP를 통해 이루어졌으며, HTTP는 Request/Response 기반의 Stateless protocol이다.
즉, 서버와 클라이언트 간의 Socket Connection 같은 영구적인 연결이 되어 있지 않고 클라이언트가 필요할 때 Request 요청을 해야지만 서버가 Response를 하는 방식으로 통신이 진행되는 한방향 통신이다.
위와 같은 통신은 서버가 업데이트 되더라도 클라이언트 쪽에는 화면이 업데이트되지 않고, Refresh 해야지만 업데이트된 데이터를 보여준다. 즉, 실시간으로 업데이트 내용을 알 수 없다는 문제점이 존재한다.
Web Socket은 Stateful Protocol이기 때문에 클라이언트와 한 번 연결이 되면 계속 같은 라인을 사용해서 통신하기 때문에 HTTP 사용시 필요없이 발생되는 HTTP와 TCP연결 트래픽을 피할 수 있다.

작동원리

서버와 클라이언트 간의 WebSocket 연결은 HTTP 프로토콜을 통해서 이루어진다. 만약 연결이 정상적으로 이루어진다면 서버와 클라이언트 간에 WebSocket연결이 이루어지고 일정 시간이 지나면 HTTP연결은 자동으로 끊어진다. 즉, 접속까지는 HTTP 프로토콜을 이용하고, 그 이후 통신은 자체적인 WebSocket 프로토콜로 통신하게 된다. 일반 socket 통신과 달리 HTTP 80 Port를 사용하므로 방화벽에 제약이 없다는 장점이 있다.

TCP/IP, HTTP, SMTP와 같이 WebSocket을 사용하기 위한 프로토콜이 존재하는데, 이를 ws 프로토콜이라 한다. 이는 비동기적으로 클라이언트와 서버 사이를 지속적 양방향 연결 Stream 해준다.

참고 블로그
https://choseongho93.tistory.com/266
https://3dmpengines.tistory.com/1904

💻구현단계

Endpoint : endpoint의 일반적인 의미는 핸드폰, PC 등 네트워크 서비스의 끝 자락에 있는 유저들을 의미한다. WebSocket에서 EndPoint는 Endpoint 클래스나 객체를 의미하기도 하고, URI의 끝자락을 의미하기도 한다.
URI WebSocket을 사용하려면 Endpoint 클래스를 extends해서 onOpen(), onClose(), onError() 메소드 등을 구현해야 한다. Endpoint는 text, binary와 pong만을 다룰 수 있고 하나의 메시지는 하나의 타입만을 갖는다.

ChatMessage

package daangnmarket.daangntoyproject.chat.domain;

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

import java.time.LocalDateTime;

@Getter
@Setter
@ToString
public class ChatMessage {
    private int roomId;
    private String message;
    private String userId;
    private String imgUrl;
    private boolean result; // db에 저장 성공 여부
}

메시지를 전달한 사용자의 정보를 담아주기 위해서 객체를 따로 만들어서 전달해준다.


ChatHandler

package daangnmarket.daangntoyproject.config;

import com.fasterxml.jackson.databind.ObjectMapper;
import daangnmarket.daangntoyproject.chat.domain.ChatMessage;
import daangnmarket.daangntoyproject.chat.repository.ChatContentRepository;
import daangnmarket.daangntoyproject.chat.service.ChatService;
import lombok.extern.log4j.Log4j2;
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 java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

@Component
@Log4j2
public class ChatHandler extends TextWebSocketHandler { // text 기반의 채팅을 구현할 것 -> textwebsocketHandler 상속
    @Autowired
    private ChatService chatService;
    private static List<HashMap<String, Object>> roomSessionList = new ArrayList<>(); // 웹소켓 세션을 담아둘 리스트 - 여기 저장된 사용자들에게만 메시지를 전달한다.(room과 session저장)
    private final ObjectMapper objectMapper = new ObjectMapper();   // json -> object

    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
        String msg = message.getPayload();

        // Json 객체 -> Java 객체
        ChatMessage chatMessage = objectMapper.readValue(msg, ChatMessage.class);
        log.info("chatMessage 객체 : " + chatMessage);
        String rn = String.valueOf(chatMessage.getRoomId()); // chat 메시지가 들어왔을 때 roomId가 어디인지

        HashMap<String, Object> temp = new HashMap<String, Object>();
        if(roomSessionList.size() > 0){
            for(int i =0; i<roomSessionList.size(); i++){
                String roomNumber = (String) roomSessionList.get(i).get("roomNumber");  // session리스트의 저장된 방번호를 가져와서
                if(roomNumber.equals(rn)){    // 같은 값의 방이 존재한다면
                    temp = roomSessionList.get(i);  // 해당 방번호의 session리스트의 존재하는 모든 object 값을 가져온다.
                    break;
                }
            }

            // 채팅 메시지 DB 저장
            chatService.saveChatContent(chatMessage);
            log.info("DB 저장 후 chatMessage : " + chatMessage);

            // 전달 내용
            TextMessage textMessage = new TextMessage(chatMessage.getRoomId() + "," + chatMessage.getMessage() + "," + chatMessage.getUserId() + "," + chatMessage.getImgUrl());

            // 해당 방의 세션들만 찾아서 메시지를 발송해준다.
            for(String k:temp.keySet()){
                if(k.equals("roomNumber")){ // key값이 roomNumber와 같다면
                    continue;
                }

                WebSocketSession wss = (WebSocketSession) temp.get(k);
                if(wss != null){
                    try {
                        wss.sendMessage(textMessage);
                    }catch (IOException e){
                        e.printStackTrace();
                    }
                }
            }
        }
    }

    /* Client가 접속 시 호출되는 메서드 */
    @SuppressWarnings("unchecked")
    @Override
    public void afterConnectionEstablished(WebSocketSession session) throws Exception {

        // 소켓 연결
        super.afterConnectionEstablished(session);
        boolean flag = false;      // 방이 이미 존재하는지 확인용
        String url = session.getUri().toString();
        log.info("소켓 연결 sessionUrl : " + url);

        String roomNumber = url.split("/chat/")[1]; // script에서 방번호 넘겨줌
        int idx = roomSessionList.size();
        if(roomSessionList.size() > 0){
            for(int i =0; i < roomSessionList.size(); i++){
                String rN =(String)roomSessionList.get(i).get("roomNumber");
                if(rN.equals(roomNumber)){
                    flag = true;    // 해당 room이 이미 존재한다.
                    idx = i;        // 몇 번 인덱스에 존재하는지 저장
                    break;
                }
            }
        }
        if(flag){   // 만약 방이 존재한다면 session만 추가
            HashMap<String, Object> map = roomSessionList.get(idx);
            map.put(session.getId(), session);
        }else {     // 방이 존재하지 않는다면 방번호와 session 추가
            HashMap<String, Object> map = new HashMap<String, Object>();
            map.put("roomNumber", roomNumber);  // 방번호 추가
            map.put(session.getId(), session);  // session 추가
            roomSessionList.add(map);
        }

        // session 등록이 끝나면 발급 받은 sessionId 값의 메시지를 발송한다.

        log.info(session + " 클라이언트 접속 -> session 추가");
    }

    /* Client가 접속 해제 시 호출되는 메서드 */

    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
        // 소켓 종료
        if(roomSessionList.size() > 0){ // 소켓이 종료되면 해당 세션값들을 찾아서 지운다.
            for(int i=0; i< roomSessionList.size(); i++){
                roomSessionList.get(i).remove(session.getId());
            }
        }
        super.afterConnectionClosed(session, status);
        log.info(session + " 클라이언트 접속 해제 -> session 제거 완료");
    }
}

afterConnectionsEstablished

  • chatHandler에서는 채팅 내용이 들어오면 Json객체를 Java 객체로 변환해준 다음에 해당 roomId가 session에 만들어졌는지 확인을 해준다.
  • 만약 같은 값의 방이 이미 존재한다면 메시지를 전달한 사용자의 정보를 저장한 Map을 해당 방번호에 Map을 추가해준다.
  • 반면 해당 방이 존재하지 않는다면 List에 roomId를 add 해준다.

afterConnectionClosed

  • 소켓을 종료해준다. 소켓이 종료되면 저장해둔 세션값들을 찾아서 지워준다.

chat-content.html

<div class="col-12 col-lg-7 col-xl-9">
	<div class="py-2 px-4 border-bottom d-none d-lg-block" style="min-height: 63px;">
		<div th:each="chatRoom : ${chatRooms}" th:if="${postId != null} and ${chatRoom.postId == postId}">
			<div class="d-flex align-items-center py-1">
            	<div class="position-relative">
                     <img th:if="${chatRoom.buyerId != session.login.userId}" th:src="${chatRoom.buyerUserDto.imgUrl}" class="rounded-circle mr-1" width="40" height="40">
                     <img th:if="${chatRoom.sellerId != session.login.userId}" th:src="${chatRoom.sellerUserDto.imgUrl}" class="rounded-circle mr-1" width="40" height="40">
                 </div>
                 <div class="flex-grow-1 pl-3" style="margin-left: 10px;">
                 	<input type="hidden" hidden th:value="${chatRoom.roomId}" id="roomId">
                    <strong th:if="${chatRoom.buyerId != session.login.userId}" th:text="${chatRoom.buyerUserDto.nickname}"></strong>
                    <strong th:if="${chatRoom.sellerId != session.login.userId}" th:text="${chatRoom.sellerUserDto.nickname}"></strong>
               	 </div>
           	</div>
          </div>
      </div>

      <div class="position-relative">
           <div class="chat-messages p-4" id="msgArea">
               <!-- 대화내용 이전 내용을 먼저 보여줘야 한다. -->
               <div th:if="${chatContents != null}" th:each="chatContent : ${chatContents}">
               		<div class="chat-message-right pb-4" th:if="${chatContent.userId == session.login.userId}">
                       <div class="flex-shrink-1 bg-light rounded py-2 px-3 mr-3 msg" th:text="${chatContent.chatContent}">
                                            지금 바로 거래 가능 한가요?
                   		</div>
                       <span class="text-muted small text-nowrap mt-2 spendTime">오후 4:30</span>
               		</div>

                    <div class="chat-message-left pb-4" th:if="${chatContent.userId != session.login.userId}">
                          <div>
                              <img th:src="@{${chatContent.userDto.imgUrl}}" class="rounded-circle mr-1" width="40" height="40">
                           </div>
                     <div class="flex-shrink-1 bg-light rounded py-2 px-3 ml-3 msg" th:text="${chatContent.chatContent}">
                                            네 가능 합니다.
                      </div>
                      <div class="text-muted small text-nowrap mt-2 spendTime">오후 4:30</div>
                  </div>
               </div>
            </div>
        </div>
       	<div class="flex-grow-0 py-3 px-4 border-top">
              <div class="input-group">
                   <input th:if="${postId != null}" type="text" class="form-control" id="input-msg" placeholder="메시지 입력하기">
                   <input th:if="${postId == null}" type="text" class="form-control" placeholder="채팅을 시작해보세요." disabled>
                   <button class="btn" id="button-send">전송</button>
               </div>
             </div>
         </div>

chat.js

$(document).ready(function(){
    const username = $("#login-nickname")[0].value;
    const userId = $("#loginId")[0].value;
    const roomId = $("#roomId")[0].value;
    console.log(username);
    $("#disconn").on("click", (e) => {
        disconnect();
    });

    $("#button-send").on("click", (e) => {
        send();
        prepareScroll();
    });

    // focus 되고 enter눌렀을 경우
    $(document).keyup(function(e) {
        if ($("#input-msg").is(":focus") && e.key == "Enter") {
            // 호출할 함수나 기능 작성
            send();
            prepareScroll();
        }
    });




    const websocket = new WebSocket("ws://localhost:8080/ws/chat/" + roomId);

    websocket.onmessage = onMessage;
    websocket.onopen = onOpen;
    websocket.onclose = onClose;

    function send(){
        let msg = document.getElementById("input-msg");

        const data = {
            "roomId" : roomId,
            "message" : msg.value,
            "userId" : userId
        }
        let jsonData = JSON.stringify(data);

        websocket.send(jsonData);
        msg.value = '';
    }

    // 채팅창에서 나갔을 때
    function onClose(evt){
        var str = username + ": "+ username + "님이 나갔습니다.";
        console.log(str);
        // websocket.send(str);
    }

    // 채팅창에 들어왔을 때
    function onOpen(evt){
        var str = username + ": " + username + "님이 들어왔습니다.";
        console.log(str);
        // websocket.send(str);
    }

    // 메시지를 받은 경우(수신)
    function onMessage(msg){
        var data = msg.data;
        console.log("수신 message : " + data);
        // 데이터를 보낸 사람
        var sessionId = null;
        var message = null;
        let receive = data.split(",");

        const receiveData = {
            "roomId" : receive[0],
            "message" : receive[1],
            "userId" : receive[2],
            "imgUrl" : receive[3],
            "createDate" : receive[4]
        };


        // 현재 세션에 로그인 한 사람
        var cur_session = userId;

        // 데이터 변수에 저장
        sessionId = receiveData.userId;
        message = receiveData.message;
        imgUrl = receiveData.imgUrl;
        createDate = receiveData.createDate;


        console.log("imgUrl : " + imgUrl);
        console.log("createDate : " + createDate);
        console.log("sessionID : " + sessionId);
        console.log("message : " + message);
        console.log("cur_session : " + cur_session);

        // 현재 시간 구하기
        let today = new Date();
        let hours = today.getHours();
        let minutes = today.getMinutes();

        let cur_time = hours + ':' + minutes;

        // 로그인 한 클라이언트와 타 클라이언트를 분류하기 위함

        if(sessionId == cur_session){
            var str = "<div class='chat-message-right pb-4'>";
            str += "<div class='flex-shrink-1 bg-light rounded py-2 px-3 mr-3 msg'>" + message + "</div>"
            str += "<span class='text-muted small text-nowrap mt-2 spendTime'>" + cur_time + "</span></div>"
            $("#msgArea").append(str);
        }else {
            var str = "<div class='chat-message-left pb-4'>";
            str += "<div>";
            str += "<img src=" + imgUrl + " class='rounded-circle mr-1' alt='Chris Wood' width='40' height='40'>"
            str += "</div>"
            str += "<div class='flex-shrink-1 bg-light rounded py-2 px-3 ml-3 msg'>" + message + "</div>"
            str += "<span class='text-muted small text-nowrap mt-2 spendTime'>" + cur_time + "</span></div>"
            $("#msgArea").append(str);
        }
    }


    // 스크롤 관련 function
    prepareScroll();
    let spendBtn = $('#button-send')[0];

    // 준비 함수, 약간의 시간을 두어 scroll 함수를 호출하기
    function prepareScroll() {
        window.setTimeout(scrollUl, 50);
    }

    // scroll 함수
    function scrollUl() {
        // 채팅창 form 안의 ul 요소, (ul 요소 안에 채팅 내용들이 li 요소로 입력된다.)
        let msgArea = document.querySelector('#msgArea');
        msgArea.scrollTop = msgArea.scrollHeight; // 스크롤의 위치를 최하단으로
    }
})

receiveData 변수를 따로 만들어줘서 ChatHandler에서 전달받은 데이터를 받아준다.

ChatController

package daangnmarket.daangntoyproject.chat.controller;

import daangnmarket.daangntoyproject.chat.model.ChatContentDto;
import daangnmarket.daangntoyproject.chat.model.ChatRoomDto;
import daangnmarket.daangntoyproject.chat.service.ChatService;
import lombok.extern.log4j.Log4j2;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;

import java.util.List;

@Controller
@Log4j2
public class ChatController {

    private static final Logger logger =  LoggerFactory.getLogger(ChatController.class);
    @Autowired
    private ChatService chatService;

    @GetMapping(value = "/chat")
    public String chat (@RequestParam(value = "loginId") String loginId,                // sellerId는 postId를 통해서 알 수 있음
                        @RequestParam(value = "pId", required = false) String postId,   // 채팅 목록만 나오도록 하는 경우
                        Model model){
        logger.info("chatController(buyerId={}, postId={})", loginId, postId);

        if(postId != null){
            List<ChatContentDto> chatContentDtos = chatService.findChatContents(loginId, Integer.parseInt(postId));
            model.addAttribute("postId", Integer.parseInt(postId));
            model.addAttribute("chatContents", chatContentDtos);
        }

        List<ChatRoomDto> chatRoomDtos = chatService.findChatRooms(loginId);
        model.addAttribute("chatRooms", chatRoomDtos);
        return "/chat/chat-content";
    }

}

참고 블로그
https://compogetters.tistory.com/96
https://dev-gorany.tistory.com/212
https://m.blog.naver.com/PostView.naver?isHttpsRedirect=true&blogId=nakim02&logNo=221411769920
https://retrieverj.tistory.com/23
https://dalili.tistory.com/125
https://velog.io/@ihj0043/WebSocket-%EC%B1%84%ED%8C%85%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%A8-%EB%8B%A8%EC%88%9C-%EC%B1%84%ED%8C%85

특히 많이 참고한 블로그

https://velog.io/@ihj0043/WebSocket-%EC%B1%84%ED%8C%85%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%A8-%EC%B1%84%ED%8C%85%EB%B0%A9-%EB%A7%8C%EB%93%A4%EA%B8%B0-2

profile
눈도 1mm씩 쌓인다.

1개의 댓글

comment-user-thumbnail
2023년 3월 15일

github 주소 있으면 알 수 있을까욤

답글 달기