Programming/기타

[Spring] WebSocket , SockJS 를 이용한 채팅 서비스

엥재 2023. 8. 17. 00:17
이번 포스팅에서는 WebSocket 라이브러리와 SockJS 라이브러리를 이용해 스프링 채팅 서비스를 구현해보겠습니다.

 

WebSocket ? 

: 웹 버전의 TCP 또는 Socket 이라고 이해하면 됩니다. WebSocket은 서버와 클라이언트 간 Socket Connection을 유지해서 언제든 양방향 통신 또는 데이터 전송이 가능하도록 하는 기술입니다.

 

ex) SNS 애플리케이션 , LoL 같은 멀티플레이어 게임, 증권 거래 , 화상채팅 등

 

WebSocket 의 동작 과정

1. TCP/IP 접속 요청 (클라이언트)

2. TCP/IP 접속 수락 (서버)

3. 웹소켓 열기 핸드쉐이크 요청 (클라이언트)

4. 웹소켓 열기 핸드쉐이크 수락 (서버)

5. 웹소켓 데이터 송, 수신 (클라이언트, 서버)

 

 

 

SockJS ? 

  • 순수 WebSocket 만으로 채팅을 구현하게 되면, Firefox, Chrome, Egde , Whale 에서 동작합니다.
  • IE와 같이 일부 브라우저에서는 동작하지 않는 문제가 발생하게 됩니다.
  • 해결방법으로 WebSocket Emulation을 이용하면 됩니다.
  • WebSocket Emulation이란 우선 WebSocket을 시도하고 실패할 경우 HTTP Streaming , Long-Polling 같은 HTTP 기반의 다른 기술로 전환해 다시 연결을 시도하는 것을 말합니다.
  • 이때 Spring을 사용한다면 보통 SockJS 라이브러리를 이용하는 것이 일반적입니다.

 

 

 

채팅 서비스 구현

 

의존성 추가 - build.gradle

implementation 'org.springframework.boot:spring-boot-starter-websocket'
implementation 'org.webjars:sockjs-client:1.1.2'

 

 

WebSocketHandler

@Slf4j
@Component
public class WebSocketHandler extends TextWebSocketHandler {

    private static final ConcurrentLinkedQueue<WebSocketSession> sessions= new ConcurrentLinkedQueue<>();

    // 메세지 처리하는 메소드
    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
        String payload = message.getPayload();
        log.info("payload : " + payload);

        for(WebSocketSession sess: sessions) {
            sess.sendMessage(message);
        }
    }

    // client 접속 시 호출되는 메서드
    @Override
    public void afterConnectionEstablished(WebSocketSession session) {
        sessions.add(session);
        log.info(session + " 클라이언트 접속");
    }

    // client 접속 해제 시 호출되는 메서드드
    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) {
        log.info(session + " 클라이언트 접속 해제");
        sessions.remove(session);
    }
}
  • WebSocketSession 객체들을 저장하는 ConcurrentLinkedQueue를 선언해준다. 이는 각 클라이언트와의 연결 정보를 담고 있습니다.
  • 메세지가 도착했을 때 호출되는 handleTextMessage() 메소드를 구현하여 메세지를 받아와 모든 연결된 클라이언트에게 브로드캐스팅 합니다.
  • 클라이언트가 연결되었을 때 호출되는 afterConnectionEstablished() 메소드를 구현하여 새로운 클라이언트가 연결되면 해당 세션을 sessions 큐에 추가합니다.
  • 클라이언트가 연결을 종료했을 때 호출되는 afterConnectionClosed() 메소드를 구현하여 해당 세션을 sessions 큐에서 제거합니다.
ConcurrentLinkedQueue 를 사용한 이유
:ConcurrentLinkedQueue는 멀티스레드 환경에서 안전하게 사용할 수 있는 큐 자료구조입니다. 이 코드에서 sessions 변수는 서버에 연결된 클라이언트 세션을 저장하는 역할을 하고, 이런 상황에서는 여러 클라이언트가 동시에 접속하거나 연결을 해제할 수 있기 때문에 멀티스레드 환경에서 안전한 자료구조를 사용하는 것이 좋습니다.

 

WebSocketConfig

  • 이전에 구현한 WebSocketHandler을 이용해 WebSocket을 활성화하기 위한 Config 입니다.
  • @EnableWebSocket 애노테이션을 사용하여 WebSocket을 활성화 하도록 합니다.
  • WebSocket에 접속하기 위한 Endpoint/ws/chat으로 설정합니다.
  • 도메인이 다른 서버에서 접속 가능하도록 CORS : setAllowedOrigins("*");를 추가해줍니다.
@Configuration
@RequiredArgsConstructor
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer  {

    private final WebSocketHandler webSocketHandler;
    
    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        registry.addHandler(webSocketHandler, "/ws/chat")
                .setAllowedOriginPatterns("*")
                .withSockJS();
    }
}
EndPoint 란?
: EndPoint는 API가 서버에서 리소스에 접근할 수 있도록 하는 URL 입니다.
CORS 란 ?
: 교차 출처 리소스 공유(Cross-Origin Resource Sharing, CORS)는 추가 HTTP 헤더를 사용하여 한 출처에서 실행 중인 웹 어플리케이션이 다른 출처의 선택한 자원에 접근할 수 있는 권한을 부여하도록 브라우저에 알려주는 체제입니다.

웹 애플리케이션은 리소스가 자신의 출처(domain, protocol, port)와 다를 시에 교차 출처 HTTP 요청을 실행합니다. 이에 대한 응답으로 서버는 Access-Control-Allow-Origin 헤더를 다시 보냅니다.

 

 

ChatController

@RequiredArgsConstructor
@RequestMapping("/chat")
@Controller
public class ChatRoomController {
    @GetMapping("/{username}")
    public String chat(@PathVariable String username, Model model){
        model.addAttribute("username", username);
        return "/chat";
    }
}
  • {username}을 통해 채팅방에 입장하는 자신의 닉네임을 지정하여 채팅방에 입장합니다.

 

 

Chat.html 

<!DOCTYPE html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
    <meta name="description" content="">
    <meta name="author" content="">
    <link href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0-beta/css/bootstrap.min.css" rel="stylesheet"
          integrity="sha384-/Y6pD6FV/Vv2HJnA6t+vslU6fwYXjCFtcEpHbNJ0lyAFsXTsjBbfaDjzALeQsN6M" crossorigin="anonymous">
    <link href="https://getbootstrap.com/docs/4.0/examples/signin/signin.css" rel="stylesheet" crossorigin="anonymous"/>
    <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css">
    <!-- SockJS 및 Stomp.js 스크립트 로드 -->
    <script src="https://cdn.jsdelivr.net/webjars/sockjs-client/1.0.2/sockjs.min.js"></script>
    <script src="https://cdn.jsdelivr.net/webjars/stomp-websocket/2.3.3/stomp.min.js"></script>
</head>
</head>
<body>
    ... body code
</body>
</html>
<script th:inline="javascript">
    document.addEventListener('DOMContentLoaded', function() {
        // 실행할 기능을 정의해주세요.
        let username = /*[[${username}]]*/ ''; // 모델의 username 값을 JavaScript 변수로 가져옴
        console.log('Username: ' + username);

        let msgArea = document.getElementById("msgArea");
        console.log("maxArea = ", msgArea);

        // 버튼 클릭 이벤트 설정
        let exitButton = document.getElementById("button-exit");
        exitButton.addEventListener("click", function (e) {
            disconnect();
        });

        let sendButton = document.getElementById("button-send");
        sendButton.addEventListener("click", function (e) {
            send();
        });

        let websocket = new SockJS("http://localhost:8080/ws/chat", null, {transports: ["websocket", "xhr-streaming", "xhr-polling"]});

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

        function send() {

            var msg = document.getElementById("msg");
            console.log(username + ":" + msg.value);
            websocket.send(username + ":" + msg.value);
            msg.value = '';
        }

        function disconnect() {
            let str = username + ": 님이 채팅을 종료했습니다.";
            websocket.send(str);
            websocket.close();
        }

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

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

        function onMessage(msg) {
            let data = msg.data;
            let sessionId = null;
            //데이터를 보낸 사람
            let message = null;
            let arr = data.split(":");

            for (let i = 0; i < arr.length; i++) {
                console.log('arr[' + i + ']: ' + arr[i]);
            }

            let cur_session = username;

            //현재 세션에 로그인 한 사람
            console.log("cur_session : " + cur_session);
            sessionId = arr[0];
            message = arr[1];

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

            //로그인 한 클라이언트와 타 클라이언트를 분류하기 위함
            if (sessionId == cur_session) {
                let div = document.createElement('div');
                div.classList.add('alert');
                div.classList.add('alert-secondary');
                let b = document.createElement('b');
                b.innerText = sessionId + " : " + message;
                div.appendChild(b);
                msgArea.append(div);
            } else {
                let div = document.createElement('div');
                div.classList.add('alert');
                div.classList.add('alert-warning');
                let b = document.createElement('b');
                b.innerText = sessionId + " : " + message;
                div.appendChild(b);
                msgArea.append(div);
            }
        }
    });
</script>

 

 

결과