Backend/spring

Spring WebSocket

dddzr 2024. 3. 1. 19:08

Spring WebSocket 사용 방법

1. 의존성 추가

Maven

pom.xml 파일에 다음과 같이 의존성을 추가합니다:

<dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-messaging</artifactId>
</dependency>

 

Gradle

build.gradle 파일에 다음과 같이 의존성을 추가합니다:

implementation 'org.springframework:spring-messaging'

 

2. WebSocket 설정

WebSocket 엔드포인트를 등록하는 설정 클래스를 작성.

WebSocketConfigurer 인터페이스를 구현하거나 @EnableWebSocket을 사용하여 WebSocket 활성화

*@EnableWebSocket을 사용하면 WebSocket 관련 빈들이 자동으로 등록됨.

*WebSocketConfigurer을 implement + override 하여 메서드를 customize.

 

3. WebSocketHandler 구현

WebSocketHandler 인터페이스를 구현.

WebSocket 연결 이벤트를 처리하고 메시지를 처리할 로직을 구현해야 합니다.

 

*TextWebSocketHandler

text message를 처리할 수 있는 handler 클래스

public class WebSocketHandler extends TextWebSocketHandler {}

아래 메서드를 재정의하여 필요한 작업을 수행

- handleTextMessage(): 클라이언트로부터 텍스트 메시지를 수신했을 때 호출됨.

- afterConnectionEstablished(): connection이 성립된 후 작동되는 메서드.

 

4. 메시지 브로커 설정(Optional)

STOMP(Message Broker over WebSocket) 프로토콜을 사용하여 브로커를 설정

메시지 브로커를 설정하려면 Spring의 MessageBrokerConfigurer 인터페이스를 구현하고, @EnableWebSocketMessageBroker 어노테이션을 사용하여 활성화합니다.

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

    @Override
    public void configureMessageBroker(MessageBrokerRegistry config) {
        // 메시지 브로커를 구성합니다.
        config.enableSimpleBroker("/topic"); // "/topic" 프리픽스를 사용하는 대상으로 메시지를 브로드캐스트합니다.
        config.setApplicationDestinationPrefixes("/app"); // "/app" 프리픽스를 사용하는 목적지로 메시지를 라우팅합니다.
    }

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        // STOMP 엔드포인트를 등록합니다.
        registry.addEndpoint("/chat").withSockJS(); // "/chat" 엔드포인트를 SockJS와 함께 등록합니다.
    }
}

*메세지 브로커 사용 이유

  • 메시지 라우팅 및 브로드캐스트: 메시지가 특정 주제(topic)로 라우팅됩니다. 클라이언트가 해당 주제를 구독하면 브로커에서 해당 주제로 전송된 모든 메시지를 수신합니다. 이를 통해 메시지의 브로드캐스트와 특정 주제에 대한 메시지 필터링이 가능합니다. 반면에 메시지 브로커를 사용하지 않는 경우에는 클라이언트와 서버 간의 단순한 점대점 통신만 가능하며, 메시지의 브로드캐스트 및 주제별 메시지 라우팅을 직접 구현해야 합니다.
  • 확장성: 메시지 브로커는 대용량의 메시지를 처리하고 다수의 클라이언트와의 연결을 관리할 수 있도록 설계되어 있습니다. 반면에 메시지 브로커를 사용하지 않는 경우에는 클라이언트와 서버 간의 직접적인 통신으로 인해 확장성이 제한될 수 있습니다.
  • 메시지 전송 보장: 메시지 브로커는 메시지를 수신한 후에 클라이언트에게 확인 응답을 보내거나, 메시지를 일시적으로 저장하여 클라이언트가 오프라인 상태인 경우에도 메시지를 보관할 수 있습니다. 이를 통해 메시지의 신뢰성과 내구성을 보장할 수 있습니다.
  • 중앙 집중화: 메시지 처리와 관리가 중앙 집중화되어 있습니다. 이는 메시지 브로커를 통해 메시지 라우팅, 필터링, 변환, 저장 등의 작업을 효율적으로 처리할 수 있게 해줍니다. 반면에 메시지 브로커를 사용하지 않는 경우에는 클라이언트와 서버 간의 모든 메시지 처리가 분산되어 있을 수 있습니다.

 

5. 클라이언트 구현

WebSocket API를 사용하여 서버와 연결하고 메시지를 주고받습니다.

 

예시 코드 - 채팅 어플

- 서버 (메세지 브로커 사용x)

// 서버 - 메세지 브로커 사용x
@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {

    // registerWebSocketHandlers 메서드를 오버라이드하여 WebSocket 핸들러 등록
    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        // "/chat" 엔드포인트에 WebSocket 핸들러 등록
        registry.addHandler(new WebSocketHandler(), "/chat");
        	.setAllowedOrigins("*") //(CORS) 허용할 uri를 지정. 생략가능.(default는 same-origin만 허용)
    }
}

public class WebSocketHandler extends TextWebSocketHandler {
    // 연결된 WebSocket 세션을 관리하기 위한 리스트를 생성
    private static final List<WebSocketSession> sessions = new CopyOnWriteArrayList<>();

    // 클라이언트로부터 메시지를 받았을 때 호출되는 메서드
    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
        // 연결된 모든 세션에게 메시지를 전송합니다.
        for (WebSocketSession s : sessions) {
            s.sendMessage(message);
        }
    }

    // WebSocket 연결이 설정되고 클라이언트와 연결되었을 때 호출되는 메서드
    @Override
    public void afterConnectionEstablished(WebSocketSession session) throws Exception {
        // 새로운 세션을 세션 리스트에 추가합니다.
        sessions.add(session);
    }

    // WebSocket 연결이 닫히고 클라이언트와 연결이 종료되었을 때 호출되는 메서드
    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
        // 종료된 세션을 세션 리스트에서 제거합니다.
        sessions.remove(session);
    }
}

 

- 서버 (메세지 브로커 사용)

//서버 - STOMP 사용
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

    // 메시지 브로커를 구성합니다.
    @Override
    public void configureMessageBroker(MessageBrokerRegistry config) {
        // "/topic" 프리픽스를 사용하는 대상으로 메시지를 브로드캐스트합니다.
        config.enableSimpleBroker("/topic");
        // "/app" 프리픽스를 사용하는 목적지로 메시지를 라우팅합니다.
        config.setApplicationDestinationPrefixes("/app");
    }

    // STOMP 엔드포인트를 등록합니다.
    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        // "/chat" 엔드포인트를 SockJS와 함께 등록합니다.
        registry.addEndpoint("/chat").withSockJS()
                .setAllowedOrigins("*"); // CORS 허용 설정
    }
}

 

- Spring Boot 테스트 코드

//테스트 코드
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.boot.web.server.LocalServerPort;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.ResponseEntity;
import org.springframework.web.socket.client.WebSocketClient;
import org.springframework.web.socket.client.standard.StandardWebSocketClient;
import org.springframework.web.socket.handler.TextWebSocketHandler;

import java.net.URI;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;

import static org.assertj.core.api.Assertions.assertThat;

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class WebSocketTest {

    @LocalServerPort
    private int port;

    @Autowired
    private TestRestTemplate restTemplate;

    @Test
    public void testWebSocketConnection() throws Exception {
        CountDownLatch latch = new CountDownLatch(1);
        WebSocketClient webSocketClient = new StandardWebSocketClient();
        
        // WebSocket 연결 설정
        webSocketClient.doHandshake(new TextWebSocketHandler() {
            @Override
            public void afterConnectionEstablished(org.springframework.web.socket.WebSocketSession session) throws Exception {
                latch.countDown(); // 연결 성공 시 CountDownLatch 감소
            }
        }, new URI("ws://localhost:" + port + "/chat"));
        
        // 연결 시간 제한 설정 (10초)
        assertThat(latch.await(10, TimeUnit.SECONDS)).isTrue(); // 10초 이내에 연결되지 않으면 테스트 실패
    }

    // 웹 소켓 메시지 전송 테스트
    @Test
    public void testWebSocketMessageSending() throws Exception {
        WebSocketClient webSocketClient = new StandardWebSocketClient();
        TestWebSocketHandler webSocketHandler = new TestWebSocketHandler();
        
        // WebSocket 연결 및 메시지 전송
        webSocketClient.doHandshake(webSocketHandler, new URI("ws://localhost:" + port + "/chat"));
        webSocketHandler.getSession().sendMessage("Test Message");
        
        // 특정 메시지 수신 확인
        assertThat(webSocketHandler.getReceivedMessage()).isEqualTo("Test Message");
    }

    // 테스트용 WebSocketHandler 클래스
    private static class TestWebSocketHandler extends TextWebSocketHandler {
        private String receivedMessage;
        private org.springframework.web.socket.WebSocketSession session;

        @Override
        public void afterConnectionEstablished(org.springframework.web.socket.WebSocketSession session) throws Exception {
            this.session = session;
        }

        @Override
        protected void handleTextMessage(org.springframework.web.socket.WebSocketSession session, org.springframework.web.socket.TextMessage message) throws Exception {
            this.receivedMessage = message.getPayload();
        }

        public String getReceivedMessage() {
            return receivedMessage;
        }

        public org.springframework.web.socket.WebSocketSession getSession() {
            return session;
        }
    }
}

 

 

*클라이언트 예시

- javaScript

Spring WebSocket은 자체적으로 WebSocket 프로토콜을 지원해서 브라우저와 직접 WebSocket을 통신할 수 있습니다. 라이브러리를 꼭 사용 안 해도 되는데 일반적으로 아래 이유로 이용합니다.

*SockJS: WebSocket이 지원되지 않는 브라우저에서 실시간 통신을 구현할 수 있음.

*STOMP (Simple (or Streaming) Text Oriented Messaging Protocol): 간단한 텍스트 기반의 메시징 프로토콜로, 메시지 브로커와 클라이언트 간의 상호 작용을 단순화. (메시지 전송, 구독, 발행 등의 작업을 간편하게 처리)

<!-- 클라이언트 -->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Spring WebSocket Chat Example</title>
    <!-- WebSocket 통신을 위한 SockJS와 STOMP 클라이언트 라이브러리를 가져옵니다. -->
    <script src="https://cdnjs.cloudflare.com/ajax/libs/sockjs-client/1.5.0/sockjs.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/stomp.js/2.3.3/stomp.min.js"></script>
    <script>
        // SockJS를 사용하여 WebSocket을 생성. '/chat' 경로를 통해 서버와 연결
        const socket = new SockJS('/chat');
        // STOMP 클라이언트를 생성하고 SockJS를 통해 연결된 WebSocket을 사용
        const stompClient = Stomp.over(socket);

        // STOMP 클라이언트가 서버와 연결될 때 실행될 콜백 함수를 정의
        stompClient.connect({}, () => {
            // 구독을 설정하여 서버에서 전달되는 메시지를 받습니다.
            stompClient.subscribe('/topic/messages', (msg) => {
                // 받은 메시지를 파싱하여 채팅 메시지를 화면에 추가
                const item = document.createElement('li');
                item.textContent = JSON.parse(msg.body).content;
                document.getElementById('messages').appendChild(item);
            });
        });

        // 메시지 전송 함수
        function sendMessage() {
            const message = document.getElementById('message').value;
            // 메시지는 JSON 형식으로 전송
            stompClient.send("/app/sendMessage", {}, JSON.stringify({content: message}));
            document.getElementById('message').value = '';
        }
    </script>
</head>
<body>
<!-- 채팅 메시지를 표시할 리스트 -->
<ul id="messages"></ul>
<!-- 메시지 입력 필드 -->
<input id="message" autocomplete="off">
<button onclick="sendMessage()">Send</button>
</body>
</html>

 

- react

import React, { useState, useEffect } from 'react';

const ChatApp = () => {
    const [messages, setMessages] = useState([]);
    const [messageInput, setMessageInput] = useState('');
    const [socket, setSocket] = useState(null);

    // 컴포넌트가 처음 렌더링될 때 WebSocket 연결을 설정합니다.
    useEffect(() => {
        const newSocket = new WebSocket('ws://localhost:8080/chat');

        newSocket.onopen = () => {
            console.log('WebSocket 연결이 열렸습니다.');
        };

        newSocket.onmessage = (event) => {
            const receivedMessage = JSON.parse(event.data);
            setMessages([...messages, receivedMessage]);
        };

        newSocket.onclose = () => {
            console.log('WebSocket 연결이 닫혔습니다.');
        };

        setSocket(newSocket);

        // 컴포넌트가 언마운트될 때 WebSocket 연결을 닫습니다.
        return () => {
            newSocket.close();
        };
    }, []); // [] 안의 값이 변경될 때만 이펙트가 실행됩니다.

    const handleMessageInputChange = (event) => {
        setMessageInput(event.target.value);
    };

    const sendMessage = () => {
        if (!socket || socket.readyState !== WebSocket.OPEN) {
            console.error('WebSocket 연결이 없거나 열려있지 않습니다.');
            return;
        }

        // 입력된 메시지를 WebSocket을 통해 서버로 전송합니다.
        socket.send(JSON.stringify({ content: messageInput }));
        setMessageInput('');
    };

    return (
        <div>
            <ul>
                {messages.map((msg, index) => (
                    <li key={index}>{msg.content}</li>
                ))}
            </ul>
            <input
                type="text"
                value={messageInput}
                onChange={handleMessageInputChange}
            />
            <button onClick={sendMessage}>Send</button>
        </div>
    );
};

export default ChatApp;