본문 바로가기
Projects,Activity/DizzyCode(React)

WebSocket+STOMP: 개념 이해부터 구현까지

by 그냥하는거지뭐~ 2024. 5. 29.
[목차]
- 프롤로그
- STOMP
- STOMP의 Prefix
- 채팅 흐름을 이해해 보자 

 

0. 프롤로그

우선, 웹소켓이 처음이면 아래 글을 참고하자! 

 

[Network] Web Socket

목차0. 프롤로그1. 다양한 통신 방법2. WebSocket 통신 원리 3. HTTP vs WebSocket4. WebSocket의 한계와 해결 방법 0. 프롤로그지금까지 프로젝트를 하면서 사용했던 통신은 클라이언트가 먼저 요청을 보내

hwanheejung.tistory.com

 
프로젝트를 시작하기 전 구글링으로 다른 사람들의 채팅 프로젝트를 구경했는데 프로토콜, 라이브러리에 대한 전반적인 이해 없이는 깔끔하게 코드를 관리하기가 힘들겠다는 생각이 들었다. STOMP, 메시지브로커, 메시지큐... 등등 알아들을 수 없는 말들이 너무 많았다. 코드를 치기 전에 전반적인 틀과 동작 흐름을 이해하고 시작해야겠다는 생각에 공부를 시작했다. 
 
웹소켓에 대한 공부를 하면서 웹소켓은 텍스트와 바이너리 데이터를 전송할 수 있지만, 주고받는 메시지의 형식에 대한 표준은 없다는 것을 배웠다. 데이터가 너무 날것이라 주고받을 데이터의 구조와 타입 등을 미리 약속하지 않으면 프로젝트가 커짐에 따라 중구난방하게 관리되고, 유지보수하기가 너무 힘들어진다. 이를 도와주는 것이 바로 STOMP이다. 


1. STOMP over WebSocket

STOMP는 클라-서버 메시지 통신을 위한 프로토콜, 즉 하나의 약속이다. 간단한 텍스트를 기반으로 하며 주로 pub/sub 패턴을 구현하는 데 사용된다. STOMP는 표준화된 메시지 형식(ex. SEND, SUBSCRIBE, UNSUBSCRIBE, MESSAGE,...)을 지원하며, 메시지 브로커와 쉽게 통합할 수 있다. 

**메시지 브로커란?
메시지 브로커는 메시지를 송신자로부터 수신하여 이를 수신자에게 전달하는 중간 매개체이다. 메시지를 큐에 저장하고 관리하며, pub/sub 패턴을 지원해서 메시지를 효율적으로 전달한다. 
ex) Apache ActiveMQ, RabbitMQ, Apache Kafka

메시지 브로커와 STOMP는 상호 보완적 관계인데, 클라이언트는 STOMP를 사용하여 표준화된 형식으로 메시지를 브로커에게 전달하고, 브로커는 이를 적절한 수신자에게 전달한다. 메시지 브로커는 메시지를 저장하고 관리하며, 라우팅, 큐잉, 우선순위 관리 등의 기능을 제공한다. 

 
STOMP는 text frame으로 메시지를 주고받으며, 각 프레임은 command, headers, body로 구성된다. 

Command
: frame의 시작으로, 주요 command로는 CONNECT, SEND, SUBSCRIBE, UNSUBSCRIBE, ACK, NACK, BEGIN, COMMIT, ABORT, DISCONNECT 등이 있다. 

Headers
: 각 프레임에는 0개 이상의 헤더가 포함된다. key-value 쌍으로 구성되어 메시지의 메타데이터를 전달한다. ex) destination 

Body
: 실제 데이터. (text or binary)

 
어떤 형태로 데이터를 주고받는지 알아보자. 
 
연결 설정 (CONNECT, CONNECTED)
클라이언트는 CONNECT 프레임을 전송해서 서버에 연결을 요청한다. 

CONNECT
accept-version:1.2
host:stomp.github.org

^@

 
서버는 CONNECTED 프레임으로 응답하여 연결이 성공적으로 설정되었음을 알린다. 

CONNECTED
version:1.2

^@

 
메시지 전송 (SEND)
클라이언트는 SEND 프레임을 사용하여 특정 주제(destination)에 메시지를 발행한다. destination 헤더는 메시지가 전송될 주제를 지정한다.

SEND
destination:/queue/test

Hello, World!
^@

 
구독 및 구독 취소 (SUBSCRIBE / UNSUBSCRIBE)
클라이언트는 SUBSCRIBE 프레임을 사용하여 특정 주제를 구독한다. 서버는 해당 주제에 발행된 메시지를 클라이언트에게 전송한다. 또한  UNSUBSCRIBE 프레임을 사용하여 구독을 취소할 수 있다. 

SUBSCRIBE
id:sub-0
destination:/queue/test

^@
UNSUBSCRIBE
id:sub-0

^@

 
메시지 수신 (MESSAGE)
서버는 클라이언트가 구독한 주제에 메시지가 발행되면 MESSAGE 프레임을 클라이언트에게 전송한다. 메시지 프레임에는 destination, message-id, subscription 등의 헤더가 포함된다. 

MESSAGE
destination:/queue/test
message-id:007
subscription:sub-0

Hello, World!
^@

 
연결 종료 (DISCONNECT)

DISCONNECT

^@

 
웹소켓을 단독으로 사용했을 때와 비교했을 때 확실히 구조화된 형태로 데이터를 관리할 수 있다는 것이 느껴진다. 


2. STOMP의 Prefix

prefix는 주로 브로커의 설정과 관련된다. 브로커는 특정 경로를 사용해서 메시지를 라우팅하거나 특정 동작을 수행하는데, STOMP 프로토콜에서는 이러한 경로를 정의하기 위해 prefix를 사용한다. 대표적으로 /app, /topic, /queue가 있다. 
 

2.1. /topic

브로드캐스트 메시징, 즉 다수의 클라이언트가 같은 주제를 구독하는 pub/sub 모델에서 사용된다. 여러 클라이언트가 같은 주제를 구독(subscribe)하고, 특정 주제에 메시지가 발행(publish)될 때, 해당 주제를 구독한 모든 클라이언트가 메시지를 받는다. 

// client
const subscribe = () => {
    client.current.subscribe('/topic/room1', (message) => {
      const chatMessage = JSON.parse(message.body);
      setMessages((prevMessages) => [...prevMessages, chatMessage]);
      console.log(chatMessage.sender + ': ' + chatMessage.content);
    });
};

 
 

2.2. /app

클라이언트가 서버로 메시지를 전송할 때 사용된다. 서버 측에서 메시지를 처리하고, 특정 목적지로 메시지를 라우팅한다. 
 
처음에 app과 topic의 차이가 와닿지 않았는데 HTTP method의 get과 post로 비유하면 쉽다(같은 개념은 아님). /app은 POST와 비슷해서 클라이언트가 서버로 메시지를 보내고, 서버는 이를 처리한 후 결과를 다른 경로로 보낸다. /topic은 GET과 비슷한데, 서버가 클라이언트에게 메시지를 전달하기 위해 사용된다.

// client
client.current.publish({
  destination: '/app/chat.sendMessage',
  body: JSON.stringify({
   sender: 'UserA',
   content: message,
   type: 'CHAT'
  })
});
// server
// 서버에서 메시지를 처리하고 결과를 브로드캐스트
@MessageMapping("/chat.sendMessage")
@SendTo("/topic/public")
public ChatMessage sendMessage(ChatMessage chatMessage) {
    // 메시지 처리 (예: DB 저장)
    return chatMessage;
}

 
 

2.3. /queue

point-to-point 모델에서 사용된다. 특정 클라이언트 또는 서버가 큐를 생성하고, 큐에 전송된 메시지는 단일 수신자가 처리한다. 예를 들어 1:1 채팅방에서 유용하게 쓰일 수 있는데, 특정 큐에 연결된 특정 수신자에게만 메시지를 전송함으로써 정확히 한 번 메시지가 전달됨을 보장할 수 있다. 이때 각 사용자는 자신에게 전송되는 메시지를 받을 수 있도록 고유한 자신의 큐를 구독한다. 사용자 A, B가 있을 때, 
1. A는 큐 /queue/chat-userA를 구독
2. B는 큐 /queue/chat-userB를 구독
3. A가 B에게 메시지를 보낼 때, 메시지를 /queue/chat-userB로 전송
4. B는 자신의 큐(/queue/chat-userB)를 구독하고 있으므로, 해당 큐로 전송된 메시지를 수신하게 됨
 
즉, 메시지를 보낼 때는 /app으로, 

const message = {
    senderId: userId,
    recipientId: recipientId,
    content: messageContent,
};

client.publish({
    destination: '/app/chat',
    body: JSON.stringify(message),
});

 
받을 때는 /queue를 사용하는 것이다. 

// 자신의 큐를 구독
stompClient.subscribe(`/queue/chat-${userId}`, (message) => {
    const receivedMessage = JSON.parse(message.body);
    setMessages(prevMessages => [...prevMessages, receivedMessage]);
});

 


3. 채팅 흐름을 이해해보자 

채팅방에 A, B, C가 속해있다. 

A가 채팅하기 위해 채팅방 화면에 들어감: 
1. 웹소켓 연결
2. STOMP 연결, 브로커 연결
3. 연결 성공 시 해당 채팅방 subscribe
4. 메시지 보낼 때 STOMP 클라이언트를 통해 메시지 전송 publish 

채팅방 화면을 나갈 때:
1. 웹소켓, STOMP 모든 연결 종료 (채팅방 구독 취소 포함)

 
이때 주의할 점은, 화면을 떠나있는 B와 C는 연결을 종료(구독 취소)한 상태이기 때문에 A가 채팅방에 메시지를 보내더라도 브로커는 B와 C에게 메시지를 전달하지 않는다. 따라서, A가 메시지를 보내면 서버가 DB에 저장한 후, B, C가 채팅방에 다시 들어왔을 때 서버에서 이전에 저장된 메시지를 클라이언트에게 전달해야 한다. 여기에 푸시 알림을 사용해 채팅방 화면에 없는 상태에서도 메시지를 확인할 수 있게 구현할 수 있다. 

import React, { useEffect, useRef, useState } from 'react';
import { Client } from '@stomp/stompjs';
import SockJS from 'sockjs-client';

const ChatComponent = () => {
  const [message, setMessage] = useState('');
  const [messages, setMessages] = useState([]);
  const client = useRef(null);

  useEffect(() => {
    // 웹소켓 및 STOMP 클라이언트 설정. 이때 브로커 URL 전달
    const socket = new SockJS('http://localhost:8787/ws');

    client.current = new Client({
      webSocketFactory: () => socket,
      onConnect: () => {
        console.log('Connected successfully');
        // 성공적으로 연결된 후 room1 구독
        subscribe();
      },
      debug: (str) => {
        console.log(str);
      },
    });

    // 클라이언트 활성화. 브로커와의 연결 시작 
    client.current.activate();

    // 컴포넌트 언마운트 시 웹소켓 및 STOMP 모든 연결 종료 (구독 취소 포함)
    return () => {
      if (client.current) {
        client.current.deactivate();
      }
    };
  }, []);

  const subscribe = () => {
    client.current.subscribe('/topic/room1', (message) => {
      const chatMessage = JSON.parse(message.body);
      setMessages((prevMessages) => [...prevMessages, chatMessage]);
      console.log(chatMessage.sender + ': ' + chatMessage.content);
    });
  };

  const sendMessage = () => {
    const messageContent = {
      sender: 'UserA',
      content: message,
      type: 'CHAT',
    };
    client.current.publish({
      destination: '/app/chat.sendMessage',
      body: JSON.stringify(messageContent),
    });
    setMessage(''); 
  };

  return (
    <div>
      <h2>Chat Room 1</h2>
      <div>
        {messages.map((msg, index) => (
          <div key={index}>
            <strong>{msg.sender}:</strong> {msg.content}
          </div>
        ))}
      </div>
      <input
        type="text"
        value={message}
        onChange={(e) => setMessage(e.target.value)}
        placeholder="Enter your message"
      />
      <button onClick={sendMessage}>Send</button>
    </div>
  );
};

export default ChatComponent;

 


Reference

- https://stomp-js.github.io/guide/stompjs/using-stompjs-v5.html