[Spring/React] WebSocket, STOMP를 활용한 실시간 채팅 구현

2024. 12. 15. 00:10·spring

1. 서론

국비에 다니는 친구가 프로젝트에서 스프링과 리액트 간에 실시간 통신을 위해 소켓을 사용해야 하는데 잘 모른다고 저한테 가르쳐달라고 하여 급하게 공부하였고 해당 내용을 블로그에 작성하려고 합니다.


2. WebSocket, SockJS, STOMP란?

WebSocket

  • WebSocket은 브라우저와 서버 간의 양방향 통신을 지원하는 프로토콜입니다. HTTP 요청과는 달리, WebSocket은 연결이 한 번 설정되면 클라이언트와 서버 간에 데이터를 자유롭게 주고받을 수 있습니다.

SockJS

  • SockJS는 WebSocket을 지원하지 않는 브라우저 환경에서도 WebSocket처럼 동작할 수 있도록 해주는 폴리필 라이브러리입니다.
  • WebSocket 연결이 실패하면 HTTP 기반의 다른 메커니즘(Long Polling 등)을 사용하여 비슷한 동작을 제공합니다.

STOMP

  • STOMP는 WebSocket 상단에서 동작하는 간단한 텍스트 기반 프로토콜입니다.
  • Pub/Sub 구조를 지원하여 클라이언트가 특정 주제를 구독하고, 서버가 해당 주제로 메시지를 발행할 수 있게 해줍니다.
  •  

Http 통신과 WebSocket 통신의 차이


3. Spring에서 WebSocket 서버 구현

WebSocket 설정 클래스

Spring에서는 @EnableWebSocketMessageBroker를 사용하여 WebSocket 메시징을 설정합니다. 아래 코드는 WebSocket 설정 클래스입니다.

 

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
    @Override
    public void configureMessageBroker(MessageBrokerRegistry config) {
        // 클라이언트가 구독하는 엔드포인트 설정
        config.enableSimpleBroker("/topic", "/queue");
        // 클라이언트가 메시지를 보낼 때 사용하는 prefix 설정
        config.setApplicationDestinationPrefixes("/app");
    }

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        // WebSocket 연결 엔드포인트 설정
        registry.addEndpoint("/ws") // 클라이언트는 ws://localhost:8080/ws로 연결
                .setAllowedOriginPatterns("*") // CORS 허용
                .withSockJS() // SockJS를 사용하여 WebSocket이 지원되지 않는 환경에서도 작동
        ;
    }
}

설명

  • enableSimpleBroker("/topic", "/queue"): 클라이언트가 메시지를 받을 때 사용하는 경로를 정의합니다. 예: /topic/public
  • setApplicationDestinationPrefixes("/app"): 클라이언트가 메시지를 보낼 때 사용하는 prefix를 정의합니다. 예: /app/chat/send
  • addEndpoint("/ws"): WebSocket 연결 엔드포인트입니다. SockJS를 추가로 지원합니다.

WebSocket 메시지 컨트롤러

다음은 채팅 메시지를 처리하는 Spring 컨트롤러입니다.

@Controller
public class ChattingController {
    @MessageMapping("/chat/send") // 클라이언트가 메시지를 보낼 때 사용하는 경로 (/app/chat/send)
    @SendTo("/topic/public") // 클라이언트가 구독하는 주제 (/topic/public)
    public SocketMessageDto sendMessage(SocketMessageDto socketMessageDto) {
        System.out.println("Received message: " + socketMessageDto);
        return socketMessageDto; // 모든 구독자에게 메시지 전달
    }
}

설명

  • @MessageMapping("/chat/send"): 클라이언트가 메시지를 전송할 때 사용하는 경로입니다.
  • @SendTo("/topic/public"): 이 경로를 구독한 모든 클라이언트에게 메시지를 전송합니다.
  • @DestinationVariable: 이 어노테이션을 사용하여 @PathVariable과 같은 동적주소로로 설정 가능합니다.

 


4. React 클라이언트에서 WebSocket 구현

StompProvider.jsx 작성

React에서는 @stomp/stompjs와 sockjs-client를 사용하여 WebSocket 연결을 관리합니다. 아래는 context api를 활용하여 provide로 감싼 영역에서는 1개의 connection만 갖게하는 코드입니다

import { Stomp } from "@stomp/stompjs";
import SockJS from "sockjs-client";

// Context 생성
const StompContext = createContext(null);

export function StompProvider({
  children,
  brokerURL,
  connectHeaders,
  onConnectCallback,
  onDisconnectCallback,
  onCloseCallback,
  onErrorCallback,
  reconnectDelay = 5000,
}) {
  const stompClientRef = useRef(null);
  const [isConnected, setIsConnected] = useState(false);

  useEffect(() => {
    // brokerURL에 소켓 엔드포인트를 적어줍니다. 예: http://localhost:8080/ws
    // 아래처럼 SockJS 인스턴스를 생성하도록 webSocketFactory에 전달
    const client = new Client({
      // brokerURL: "스프링의 withSockJS가 아닐때 사용 ws://",
      webSocketFactory: () => new SockJS(brokerURL), // SockJS일때 사용
      connectHeaders,
      debug: (str) => {
        console.log("[STOMP DEBUG]:", str);
      },
      reconnectDelay,
      onConnect: () => {
        setIsConnected(true);
        console.log("setIsConnected(true);");
        onConnectCallback && onConnectCallback();
      },
      onStompError: (frame) => {
        console.error("Broker reported error: " + frame.headers["message"]);
        console.error("Additional details: " + frame.body);
        onErrorCallback && onErrorCallback(frame);
      },
      onDisconnect: () => {
        setIsConnected(false);
        onDisconnectCallback && onDisconnectCallback();
      },
      onWebSocketClose: () => {
        setIsConnected(false);
        onCloseCallback && onCloseCallback();
      },
    });

    client.activate();
    stompClientRef.current = client;

    return () => {
      if (stompClientRef.current) {
        stompClientRef.current.deactivate();
      }
    };
  }, [
    brokerURL,
    connectHeaders,
    onConnectCallback,
    onDisconnectCallback,
    onCloseCallback,
    onErrorCallback,
    reconnectDelay,
  ]);

  const subscribe = (destination, callback, headers = {}) => {
    if (isConnected && stompClientRef.current) {
      const subscription = stompClientRef.current.subscribe(
        destination,
        callback,
        headers
      );
      return () => subscription.unsubscribe();
    } else {
      console.warn("STOMP not connected, cannot subscribe yet.");
      return () => {};
    }
  };

  const sendMessage = (destination, body = {}, headers = {}) => {
    if (isConnected && stompClientRef.current) {
      stompClientRef.current.publish({
        destination,
        body: JSON.stringify(body),
        headers,
      });
    } else {
      console.warn("STOMP not connected, cannot send message now.");
    }
  };

  return (
    <StompContext.Provider value={{ isConnected, subscribe, sendMessage }}>
      {children}
    </StompContext.Provider>
  );
}
StompProvider.propTypes = {
  children: PropTypes.node.isRequired,
  brokerURL: PropTypes.string.isRequired,
  connectHeaders: PropTypes.object,
  onConnectCallback: PropTypes.func,
  onDisconnectCallback: PropTypes.func,
  onCloseCallback: PropTypes.func,
  onErrorCallback: PropTypes.func,
  reconnectDelay: PropTypes.number,
};

// Context를 쉽게 사용하기 위한 커스텀 훅
export function useStomp() {
  const context = useContext(StompContext);
  if (!context) {
    throw new Error("useStomp must be used within a StompProvider");
  }
  return context;
}

StompProvider 사용

main.js에서 url을 등록하여 설정합니다.

<StompProvider
brokerURL="http://localhost:8080/ws"
reconnectDelay={5000}
>
    <App />
</StompProvider>

 

구독과 발행은 다음과 같이 사용할 수 있습니다.

 const { isConnected, subscribe, sendMessage } = useStomp();
 
 
 	// 구독
   useEffect(() => {
    let unsubscribe = () => {};
    console.log("isConnected", isConnected);
    if (isConnected) {
      unsubscribe = subscribe("/topic/public", (message) => {
        const parsed = JSON.parse(message.body);
        console.log("Received message:", parsed);
        setMessages((prev) => [...prev, parsed]);
      });
    }

    return () => unsubscribe();
  }, [isConnected, subscribe]);


	// 발행
    <button
      type="button"
      className={styles.sendButton}
      onClick={() => {
        sendMessage("/app/chat/send", {
          username,
          message: messageInput,
        });
        setMessageInput("");
      }}
    >
      Send
    </button>

 


5. 동작 확인

 

소켓 동작 확인

6. 결론

이 글에서는 Spring과 React를 사용해 WebSocket 기반의 실시간 채팅 기능을 구현하는 방법을 다뤘습니다.
Stomp를 사용하여 손쉽게 소켓통신을 사용할 수 있었습니다. 다음에 소켓을 사용해야하는 경우가 있다면 이번 코드를 바탕으로 살을 덧붙여 사용해야겠습니다. 감사합니다.

'spring' 카테고리의 다른 글
  • [Spring] 이벤트 시스템으로 느슨한 결합 구현하기
  • [Spring] 날짜/시간 처리하기 (@DateTimeFormat vs @JsonFormat)
  • [Spring] DTO 유효성 검사(Validation) : @Valid로 처리하기
  • [Spring] profile 환경 분리하기
당훈이
당훈이
당훈이 님의 블로그 입니다.
  • 당훈이
    당훈IT
    당훈이
  • 전체
    오늘
    어제
    • 분류 전체보기 (40)
      • spring (7)
      • vue.js (8)
      • docker (1)
      • javascript (1)
      • aws (21)
      • database (1)
        • oracle (1)
      • nuxt (1)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    스프링 배포
    ec2 domain
    중복요청
    aws 스프링
    ec2 spring 배포
    배포
    aws dns
    aws route53
    스프링부트
    nuxt fetch
    aws domain
    ec2 nodejs
    elb
    nodejs 배포
    ec2 route53
    aws spring
    nuxt dedupe
    nuxt vue
    route53
    nuxt usefetch
    AWS
    Spring
    nuxt cache
    vue3
    Vue
    spring boot
    EC2
    AWS EC2
    스프링
    AWS ELB
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
당훈이
[Spring/React] WebSocket, STOMP를 활용한 실시간 채팅 구현
상단으로

티스토리툴바