[Spring] 웹소켓으로 실시간 채팅 구현

장선규·2023년 7월 1일
2

Spring

목록 보기
4/4

현재 만들고 있는 토이 프로젝트에서 채팅 기능을 구현하고자 하였다.

채팅 기능을 구현하기 위해서는 웹소켓에 대해서 알아야 했다.

웹소켓이란?

처음에 채팅 기능을 구현할 때 HTTP를 이용해서 구현하려고 했다.

하지만 HTTP는 요청과 응답이라는 구조로 통신이 이루어지므로, 실시간으로 바뀌는 정보에 대해서는 지속적으로 요청을 해야되는 불편함이 있다. 이런 식으로 매번 연결을 맺고 끊는 작업은 꽤나 많은 비용이 드는 작업으로 비효율적이다.

  • 처음 웹소켓 연결을 할 때는 핸드쉐이크 요청을 한다. 이때 HTTP 요청이 upgrade 된다.
  • 업그레이드 후에는 http://~가 아닌 ws://~로 요청을 보내야 한다.이후 웹소켓으로 연결되고 데이터를 주고받는다.
  • 웹소켓 커넥션을 종료시키기 위해서도 Closing 핸드쉐이크가 필요하다. 핸드쉐이크 이후 웹소켓 연결이 종료된다.

따라서 한 번 연결을 한 후, 클라이언트 간 지속적으로 연결을 유지하는 웹소켓을 이용하기로 결정하였다.

구현

gradle 추가

implementation 'org.springframework.boot:spring-boot-starter-websocket'
  • 스프링에서 웹소켓을 이용하기 위해 spring-boot-starter-websocket 라이브러리를 추가해준다

WebSocketConfig

package com.sunkyuj.douner.config;

import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.WebSocketHandler;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;

@Configuration
@EnableWebSocket
@RequiredArgsConstructor
public class WebSocketConfig implements WebSocketConfigurer {

    private final WebSocketHandler webSocketHandler;

    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        // endpoint 설정 : /api/v1/chat/{postId}
        // 이를 통해서 ws://localhost:9090/ws/chat 으로 요청이 들어오면 websocket 통신을 진행한다.
        // setAllowedOrigins("*")는 모든 ip에서 접속 가능하도록 해줌
        registry.addHandler(webSocketHandler, "/ws/chat").setAllowedOrigins("*");
    }
}
  • 웹소켓 통신을 하기 위해 엔드포인트를 설정한다.
  • WebSocketHandler를 선언한다. 이 핸들러가 웹소켓 통신을 처리해준다.
    • addHandler() 안에 첫번째 인자로 들어가는데, 여기에 new WebSocketChatHandler() 와 같이 사용자 정의 핸들러를 직접 넣어도 된다. 어차피 WebSocketHandler를 상속할 것이므로 상관없다.
  • WebSocketHandlerRegistry에 웹소켓 엔드포인트를 /ws/chat로 설정.
    이제 ws://주소:포트/ws/chat로 요청이 들어오면 웹소켓 핸드쉐이킹을 한다.
  • setAllowedOrigins("*")는 모든 cors 요청을 허용하는 것이다.

WebSocketChatHandler

package com.sunkyuj.douner.chat;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.sunkyuj.douner.chat.model.ChatMessageDto;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
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.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;


/*
* WebSocket Handler 작성
* 소켓 통신은 서버와 클라이언트가 1:n으로 관계를 맺는다. 따라서 한 서버에 여러 클라이언트 접속 가능
* 서버에는 여러 클라이언트가 발송한 메세지를 받아 처리해줄 핸들러가 필요
* TextWebSocketHandler를 상속받아 핸들러 작성
* 클라이언트로 받은 메세지를 log로 출력하고 클라이언트로 환영 메세지를 보내줌
* */
@Slf4j
@Component
@RequiredArgsConstructor
public class WebSocketChatHandler extends TextWebSocketHandler {
    private final ObjectMapper mapper;

    // 현재 연결된 세션들
    private final Set<WebSocketSession> sessions = new HashSet<>();

    // chatRoomId: {session1, session2}
    private final Map<Long,Set<WebSocketSession>> chatRoomSessionMap = new HashMap<>();

    // 소켓 연결 확인
    @Override
    public void afterConnectionEstablished(WebSocketSession session) throws Exception {
        // TODO Auto-generated method stub
        log.info("{} 연결됨", session.getId());
        sessions.add(session);
    }

    // 소켓 통신 시 메세지의 전송을 다루는 부분
    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
        String payload = message.getPayload();
        log.info("payload {}", payload);

        // 페이로드 -> chatMessageDto로 변환
        ChatMessageDto chatMessageDto = mapper.readValue(payload, ChatMessageDto.class);
        log.info("session {}", chatMessageDto.toString());

        Long chatRoomId = chatMessageDto.getChatRoomId();
        // 메모리 상에 채팅방에 대한 세션 없으면 만들어줌
        if(!chatRoomSessionMap.containsKey(chatRoomId)){
            chatRoomSessionMap.put(chatRoomId,new HashSet<>());
        }
        Set<WebSocketSession> chatRoomSession = chatRoomSessionMap.get(chatRoomId);

        // message 에 담긴 타입을 확인한다.
        // 이때 message 에서 getType 으로 가져온 내용이
        // ChatDTO 의 열거형인 MessageType 안에 있는 ENTER 과 동일한 값이라면
        if (chatMessageDto.getMessageType().equals(ChatMessageDto.MessageType.ENTER)) {
            // sessions 에 넘어온 session 을 담고,
            chatRoomSession.add(session);
        }
        if (chatRoomSession.size()>=3) {
            removeClosedSession(chatRoomSession);
        }
        sendMessageToChatRoom(chatMessageDto, chatRoomSession);

    }

    // 소켓 종료 확인
    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
        // TODO Auto-generated method stub
        log.info("{} 연결 끊김", session.getId());
        sessions.remove(session);
    }

    // ====== 채팅 관련 메소드 ======
    private void removeClosedSession(Set<WebSocketSession> chatRoomSession) {
        chatRoomSession.removeIf(sess -> !sessions.contains(sess));
    }

    private void sendMessageToChatRoom(ChatMessageDto chatMessageDto, Set<WebSocketSession> chatRoomSession) {
        chatRoomSession.parallelStream().forEach(sess -> sendMessage(sess, chatMessageDto));//2
    }


    public <T> void sendMessage(WebSocketSession session, T message) {
        try{
            session.sendMessage(new TextMessage(mapper.writeValueAsString(message)));
        } catch (IOException e) {
            log.error(e.getMessage(), e);
        }
    }
}

sessions: 현재 연결된 웹소켓 세션들을 담는 Set

  • 메모리 상에 현재 연결된 웹소켓을 담고, 세션이 추가되거나 종료되면 반영한다.
  • afterConnectionEstablished(), afterConnectionClosed()에서 반영

chatRoomSessionMap: 채팅방 당 연결된 세션을 담은 Map, Map<roomId, Session Set>의 형태로 세션을 저장한다.

  • 채팅 메세지를 보낼 채팅방을 찾고, 해당 채팅방에 속한 세션들에게 메세지를 전송한다.

handleTextMessage(): 웹소켓 통신 시 메세지 전송을 다루는 부분

  • TextWebSocketHandler 클래스의 handleTextMessage() 메소드를 Override하여 구현

  • 웹소켓 통신 메세지를 TextMessage로 받고, 이를 mapper로 파싱하여 ChatMessageDto로 변환

    • ChatMessageDto

      package com.sunkyuj.douner.chat.model;
      
      import lombok.*;
      
      @Builder
      @Getter
      @Setter
      @AllArgsConstructor
      @NoArgsConstructor
      public class ChatMessageDto {
          // 메시지  타입 : 입장, 채팅
          public enum MessageType{
              ENTER, TALK
          }
      
          private MessageType messageType; // 메시지 타입
          private Long chatRoomId; // 방 번호
          private Long senderId; // 채팅을 보낸 사람
          private String message; // 메시지
      }
    • 웹소켓 통신 시 보내는 양식

      {
      "messageType":"TALK", // ENTER, TALK
      "chatRoomId":1, // 채팅방 번호
      "senderId":100, // 메세지 전송자의 UserId
      "message":"hello" // 메세지 내용
      }
  • 채팅방 번호를 가져오고, 만약 메모리 상에 채팅방에 대한 세션이 없으면 만들어준다.

  • 메세지 ChatMessageDto의 메세지타입이 ENTER라면 채팅룸 세션에 웹소켓 클라이언트의 세션을 넣어준다.

  • 마지막에 sendMessageToChatRoom(chatMessageDto, chatRoomSession) 호출하여 해당 채팅방에 있는 모든 세션에 메세지 전송

ChatController

어쨋거나 채팅방에 대한 데이터와 채팅 메세지에 대한 데이터를 DB에 넣어줘야 한다.
DB에 데이터를 넣고, 가져오는 api를 제공하도록 구성하였다.

실행

크롬 확장 프로그램인 Web Socket Client를 사용해서 웹소켓 채팅이 잘 동작하는지 확인해보았다.

  1. 채팅방 생성
  • POST /api/v1/chat 게시물에 대한 채팅방을 생성
  • 채팅방 번호 12번이 부여된 것을 확인할 수 있다.
  1. 웹소켓 연결
    • URL에 설정한 엔드포인트인 ws://localhost:9090/ws/chat에 HTTP 요청을 하여 핸드쉐이크 -> ws 로 업그레이드
    • connect 버튼을 누르면 핸드쉐이크 요청
  2. 채팅방 입장
    • user A 채팅방 ENTER
    • 처음엔 user B가 핸드쉐이킹은 했지만, 채팅방에 입장하지 않았으므로 아무것도 뜨지 않는다.
    • user B 채팅 ENTER
    • user A에게도 채팅방에 입장한 것이 뜬다
  1. 채팅 메시지 전송
    • 채팅을 하면 서로 보낸 메세지가 즉각적으로 나타난다
  • 다른 채팅방에 들어간 경우
    • user A(채팅방 번호 12)에게는 다른 채팅방(채팅방 번호 10)에 입장한 사용자에 대한 정보가 뜨지 않는다 (당연한 사실이지만, 처음엔 오류가 있었음)

추후 개선 사항

  • 웹소켓 통신 시 STOMP 프로토콜 사용하기
  • 이미 만든 채팅방이 있는지 체크하기
  • 앱에서 잘 작동하는지 확인하기
  • 채팅 웹페이지 thymeleaf로 구현하기

Reference

https://terianp.tistory.com/142
https://www.youtube.com/watch?v=rvss-_t6gzg
https://everydayyy.tistory.com/87

profile
코딩연습

1개의 댓글

comment-user-thumbnail
2023년 7월 17일

답글 달기