Web Socket & STOMP

Future·2024년 7월 12일
1

CS-네트워크

목록 보기
2/2

Web Socket

웹 서버와 브라우저가 실시간 메세지를 교환하는 데 사용되는 프로토콜이다.
웹 소켓 자체는 메시징 구조나 규칙에 대해 정의하고 있지 않다. 따라서, STOMP와 같은 메시징 프로토콜이 사용된다.
Http 핸드쉐이크를 통해 통신 프로토콜을 Http에서 WS로 변경하면, 이후에는 실시간 양방향 연결이 지속된다.

Opening handshake

클라이언트와 서버가 웹 소켓 연결을 설정하는 초기 단계이다.
클라이언트는 서버에 HTTP 요청을 보내고, 이 요청에 다음과 같은 헤더를 포함하여 웹 소켓 연결을 요청한다.

  • 클라이언트 요청 예시
GET /chat HTTP/1.1
Host: localhost:8080
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
  • 서버 응답 헤더 예시
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

UpgradeConnection 헤더를 통해 웹 소켓으로 프로토콜을 전환하고
Sec-WebSocket-Key Sec-WebSocket-Accept 헤더를 통해 무결성을 확인한다.

WebSocket 통신

웹소켓 연결이 되면 클라이언트와 서버는 '메세지' 라는 데이터를 주고받는다.
이때, 메세지는 프레임의 모임이다.
메세지 크기가 크다면 메세지를 여러개의 프레임으로 나누어 전송한다.

  • FIN (1 bit) : 메세지의 마지막 프레임인지 여부를 나타낸다. 1(true) / 0(false)
  • opcode (4 bits) : 프레임 유형을 나타낸다. 텍스트 / 바이너리 / 종료
  • Mask (1 bit) : 데이터가 마스킹 되었는지 여부를 나타낸다. 클라이언트 -> 서버 전송의 경우 무조건 마스킹되어 있다. 1(true) / 0(false)
  • Payload len : 데이터의 길이
  • Masking-key (32 bits) : 데이터가 마스킹된 경우, 마스킹 키
  • Payload Data : 실제 전송되는 데이터

예시

Hello, this is a long message! 메세지를 여러 프레임으로 나누어 전송하는 예시

클라이언트 -> 서버
1. 첫 번째 프레임 (텍스트 메시지의 시작)
FIN: 0 (메시지가 끝나지 않음)
Opcode: 0x1 (텍스트 프레임)
Mask: 1 (마스킹됨)
Payload Length: 5 (첫 번째 프레임의 길이)
Masking Key: 4바이트 랜덤 키
Payload Data: "Hello"

2. 중간 프레임 (메시지의 중간 부분)
FIN: 0 (메시지가 끝나지 않음)
Opcode: 0x0 (연속 프레임)
Mask: 1 (마스킹됨)
Payload Length: 19 (두 번째 프레임의 길이)
Masking Key: 4바이트 랜덤 키
Payload Data: "this is a long message"

3. 마지막 프레임 (메시지의 끝)
FIN: 1 (메시지의 마지막 프레임)
Opcode: 0x0 (연속 프레임)
Mask: 1 (마스킹됨)
Payload Length: 1 (마지막 프레임의 길이)
Masking Key: 4바이트 랜덤 키
Payload Data: "!"

웹 소켓은 TCP 위에서 동작하기 때문에 프레임의 순서가 보장된다.

Close

클라이언트, 서버 중 하나가 연결을 종료할 때, opcode를 종료 프레임(opcode 0x8)으로 세팅하여 종료 프레임을 전송한다. 종료 프레임을 수신한 쪽은 이에 응답하는 종료 프레임을 전송한다. 서버, 클라이언트 양 쪽이 모두 종료 프레임을 주고 받으면 연결이 종료된다.

웹 소켓 특징

  • 양방향 통신 : 클라이언트와 서버 모두 양방향으로 메세지를 전송할 수 있다.
  • 연결지향적 : 처음 웹소켓 연결이 설정되면 연결이 지속된다.
  • 낮은 오버헤드 : 초기 핸드쉐이크에서 HTTP 요청을 전송한 이후에는 HTTP보다 전송하는 데이터 양이 적다.

STOMP (Simple Text Oriented Messaging Protocol)

텍스트 기반의 메시징 프로토콜로, 웹소켓 위에서 동작한다.
Pub/Sub (Publish/Subscribe) 모델을 사용한다.

Pub/Sub

  • Publisher : 메시지를 생성하고 발행한다. STOMP의 SEND 프레임을 이용한다.
  • Subscriber : 주제(topic)를 구독하여 해당되는 메시지를 받는다. SUBSCRIBE 프레임을 이용한다.
  • Broker : Publisher와 Subscriber 사이에서 메시지를 전송한다.
    Publisher에게 메시지를 받아서 Subscriber에게 전달한다. MESSAGE 프레임을 이용한다.

STOMP의 프레임 구조

COMMAND
header1:value1
header2:value2
body^@
  • Command : 프레임의 종류를 나타내는 명령어
    ex) CONNECT, SEND, SUBSCRIBE 등
  • Headers : key-value 쌍으로 이루어진 메타데이터
    ex) destination, content-type 등
  • Body : 실제 메시지 데이터 (텍스트 형식)

STOMP와 웹소켓의 관계

앞서, STOMP가 웹소켓 위에서 동작한다고 했다.
결론부터 말하면 STOMP 프레임은 웹소켓 프레임에 감싸져 전송된다.

ex) 클라이언트가 서버로 "Hello" 메시지를 전송하려고 한다.

1. STOMP 프레임을 생성한다.

SEND
destination:/topic/chat
Hello, Server!\0

2. 웹소켓 프레임의 Payload에 STOMP 프레임이 포함된다.

1000 0001 (FIN=1, Text frame)
<Payload length>
SEND\ndestination:/topic/chat\n\nHello, Server!\0

3. 서버가 웹소켓 프레임을 수신하면 Payload에서 STOMP 프레임을 추출하여 분석하고 해당 작업을 수행한다.

스프링에서 STOMP를 활용하여 채팅 구현하기

  • 의존성 추가
implementation 'org.springframework.boot:spring-boot-starter-websocket'
  • WebSocket 설정 - 스프링 내장 메시지 브로커 사용
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {

        registry.addEndpoint("/ws")
                .setAllowedOriginPatterns("*")
                .withSockJS();
    }

    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {

        registry.setApplicationDestinationPrefixes("/pub");
        registry.enableSimpleBroker("/sub");
    }

}
  • 메시지 핸들러
@Controller
@RequiredArgsConstructor
public class ChatController {

    private final SimpMessagingTemplate messagingTemplate;

    @MessageMapping("/send")
    public void message(ChatMessageRequest messageRequest){

        Long chatRoomId = messageRequest.getChatRoomId();

        messagingTemplate.convertAndSend("/sub/chat/" + chatRoomId, messageRequest);
    }
}
  • Message DTO
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class ChatMessageRequest {

    private MessageType messageType;
    private Long chatRoomId;
    private Long sendMemberId;
    private String message;

}
  • 클라이언트 측 코드
<!DOCTYPE html>
<html>
<head>
    <title>Chat Application</title>
    <script src="https://cdn.jsdelivr.net/npm/sockjs-client/dist/sockjs.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/stompjs/lib/stomp.min.js"></script>
    <script>
        var stompClient = null;

        function connect(chatRoomId) {
            var socket = new SockJS('/ws');
            stompClient = Stomp.over(socket);
            stompClient.connect({}, function (frame) {
                console.log('Connected: ' + frame);
                stompClient.subscribe('/sub/chat/' + chatRoomId, function (messageOutput) {
                    showMessageOutput(JSON.parse(messageOutput.body));
                });
            });
        }

        function sendMessage() {
            var message = {
      			messageType: document.getElementById('messageType').value,
      			chatRoomId: document.getElementById('chatRoomId').value,
                sendMemberId: document.getElementById('sendMemberId').value,
                message: document.getElementById('message').value
            };
            stompClient.send("/pub/send", {}, JSON.stringify(message));
        }

        function showMessageOutput(messageOutput) {
            var response = document.getElementById('response');
            var p = document.createElement('p');
            p.style.wordWrap = 'break-word';
            p.appendChild(document.createTextNode(messageOutput.sender + ": " + messageOutput.content));
            response.appendChild(p);
        }
    </script>
</head>
<body>
    <div>
        <input type="text" id="messageType" placeholder="messageType"/>
        <input type="text" id="chatRoomId" placeholder="chatRoomId"/>
        <input type="text" id="sendMemberId" placeholder="sendMemberId"/>
        <input type="text" id="message" placeholder="Message"/>
        <button onclick="connect(document.getElementById('chatRoomId').value)">Connect</button>
        <button onclick="sendMessage()">Send</button>
    </div>
    <div id="response"></div>
</body>
</html>

클라이언트 코드에서 중요한 부분은
function connect(chatRoomId) 의 stompClient.subscribe('/sub/chat/' + chatRoomId) 부분과
function sendMessage() 의 stompClient.send("/pub/send") 부분이다

profile
Record What I Learned

0개의 댓글