diff --git a/_posts/Spring/2024-06-04-[Web/socket].md b/_posts/Spring/2024-06-04-[Web/socket].md new file mode 100644 index 0000000..aba7b5d --- /dev/null +++ b/_posts/Spring/2024-06-04-[Web/socket].md @@ -0,0 +1,1204 @@ +웹소켓 기능을 개발하면서 나름 공부한 내용을 정리해 보았습니다! +궁금하거나 부연설명이 필요한 부분은 자유롭게 댓글 달아주세요! + +# 웹소켓이란? + +웹소켓(WebSocket)은 클라이언트와 서버 간의 실시간 양방향 통신을 가능하게 하는 프로토콜 + +기존의 HTTP 통신 방식은 클라이언트가 요청을 보내면 서버가 응답하는 요청-응답 모델을 따른다. + +반면, 웹소켓은 최초 연결 이후 클라이언트와 서버 간에 지속적인 연결을 유지하면서 상호 간에 데이터를 주고받을 수 있다. + +이는 실시간 기능이 필요한 애플리케이션(채팅, 실시간 알림, 게임) 등에 유용하게 사용된다. + +# 웹소켓의 특징 + +## 양방향 통신 + +클라이언트와 서버 모두 메시지를 보낼 수 있다. + +## 지속적인 연결 + +HTTP와 달리 웹소켓은 연결이 한번 성립되면 끊기지 않고 계속 유지된다 + +## 낮은 오버헤드 + +HTTP처럼 매번 요청 헤더를 보내지 않고, 한 번 연결되면 지속적으로 데이터를 주고받을 수 있어 오버헤드가 적다. + +## 지연 감소(실시간 데이터 전송) + +서버와 클라이언트 간의 실시간 데이터 전송이 가능하다 + +http처럼 핸드쉐이크하면서 연결하고 연결 해제하는 과정이 생략되어서 데이터 전송시 지연(Latency)가 크게 감소한다(빨라진다) + +# 웹소켓의 초기 연결 + +HTTP 프로토콜을 사용해 핸드셰이크를 해서 초기 연결을 설정한다 + +클라이언트가 HTTP 요청 헤더에 특별한 업그레이드 요청을 추가하여 웹소켓 프로토콜로 전환을 요청한다 + +서버가 이 요청을 수락하면 웹소켓 연결이 성립되고 이후부터는 HTTP가 아닌 웹소켓 프로토콜로 데이터가 전송된다 + +### 클라이언트의 요청 + +```java +GET /chat HTTP/1.1 +Host: example.com +Upgrade: websocket +Connection: Upgrade +Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ== +Sec-WebSocket-Version: 13 +``` + +### 서버의 응답 + +```java +HTTP/1.1 101 Switching Protocols +Upgrade: websocket +Connection: Upgrade +Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo= +``` + +서버는 클라이언트의 요청을 확인하고, `Sec-WebSocket-Accept` 헤더에 특별한 값을 포함해 응답하여 웹소켓 연결을 설정한다 + +HTTP/1.1 101 Switching Protocols 응답 코드가 이 전환을 나타낸다 + +# 웹소켓 통신 + +웹소켓 연결 이후에는 TCP 연결을 기반으로 양방향 통신이 이루어진다. + +클라이언트와 서버는 서로 메시지를 교환하며, 각 메시지는 프레임(frame) 단위로 전송된다. → 프레임 종류랑 다 예시들어봐 + +프레임은 텍스트, 바이너리 데이터, 제어 메시지 등을 포함할 수 있다. →하나하나 설명해봐 + +웹소켓은 기본적으로 자체적인 프레임 구조를 사용하여 데이터를 전송하지만 STOMP와 같은 상위 레벨 프로토콜을 사용할 수도 있다. + +→ STOMP 랑 SockJS 설명을 여기다 추가해줘 + +## 웹소켓의 메시지 형식(프레임 구조) + +웹소켓은 다음과 같은 프레임 구조를 가지고 데이터를 전송한다 + +| **FIN** | 메시지의 끝을 나타내는 플래그 | +| --- | --- | +| **RSV1, RSV2, RSV3** | 예약된 플래그(일반적으로 0으로 설정) | +| **OPCODE** | 메시지의 유형 (텍스트 메시지(1), 바이너리 메시지(2), 연결 종료(8), 핑(9), 퐁(10) 등) | +| **마스킹 키** | 클라이언트에서 서버로 전송되는 메시지에 대해 사용된다 | +| **페이로드 길이** | 전송되는 데이터의 크기 | +| **페이로드 데이터** | 실제 전송되는 데이터(텍스트 또는 바이너리) | + +```java +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; + +public class WebSocketFrameExample { + public static void main(String[] args) { + String message = "Hello, WebSocket!"; // 실제 전송할 데이터 + byte[] frame = createTextFrame(message); + + // 프레임 출력 + for (byte b : frame) { + System.out.printf("%02X ", b); + } + } + + public static byte[] createTextFrame(String message) { + byte[] payloadData = message.getBytes(StandardCharsets.UTF_8); + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + + // FIN(1) + OPCODE(1) 설정 + byte finRsvOpcode = (byte) 0x81; // FIN flag + 텍스트 메시지 OPCODE 0x1 + outputStream.write(finRsvOpcode); + + // 페이로드 길이 설정 + int payloadLength = payloadData.length; + if (payloadLength <= 125) { + outputStream.write((byte) payloadLength); // 길이가 125 이하인 경우 + } else if (payloadLength <= 65535) { + outputStream.write((byte) 126); // 길이가 126~65535 사이인 경우 + outputStream.write((payloadLength >> 8) & 0xFF); // 16비트 길이 + outputStream.write(payloadLength & 0xFF); + } else { + outputStream.write((byte) 127); // 길이가 65536 이상인 경우 + for (int i = 7; i >= 0; i--) { + outputStream.write((payloadLength >> (8 * i)) & 0xFF); // 64비트 길이 + } + } + + // 마스킹 키 설정 (4바이트, 예제에서는 단순히 0으로 설정) + byte[] maskingKey = {0x00, 0x00, 0x00, 0x00}; + outputStream.write(maskingKey); + + // 페이로드 데이터 설정 (마스킹 키 적용 없음) + outputStream.write(payloadData); + + return outputStream.toByteArray(); + } +} + +``` + +### 예시 데이터 출력 + +```java +"Hello, WebSocket!" +``` + +이 데이터를 전송한다면 다음과 같이 출력된다 + +```java +81 0E 00 00 00 00 48 65 6C 6C 6F 2C 20 57 65 62 53 6F 63 6B 65 74 21 +``` + +- 81: FIN(1) + 텍스트 메시지 OPCODE(0x1) +- 0E: 페이로드 길이 (14바이트, "Hello, WebSocket!") +- 00 00 00 00: 마스킹 키 (4바이트) +- 48 65 6C 6C 6F 2C 20 57 65 62 53 6F 63 6B 65 74 21: "Hello, WebSocket!"의 UTF-8 인코딩된 값 + +## 웹소켓의 레이어 + +웹소켓은 응용 계층(Application Layer) 프로토콜로, OSI 7계층 모델에서 7계층에 속한다. + +웹소켓이 TCP/IP 프로토콜 위에서 동작하며, 데이터를 어플리케이션 수준에서 처리된다는 뜻이다. + +아래 Layer에 TCP (전송 계층) 프로토콜이 있어서 웹소켓 연결이 유지되는 동안에는 TCP 연결이 지속된다. + +# 웹소켓의 연결 해제 + +### 1. 명시적 종료 + +클라이언트나 서버가 연결을 종료할 수 있다 + +일반적으로 close 프레임을 전송해서 이루어진다 + +이 프레임은 연결이 종료될 것임을 알리고, 종료 코드를 포함할 수 있다 + +```java +// 종료 프레임 예시 +byte[] closeFrame = {(byte) 0x88, (byte) 0x00}; // FIN + OPCODE(8), payload length(0) +outputStream.write(closeFrame); +``` + +### 2. 네트워크 문제 + +클라이언트나 서버 간의 네트워크 연결이 끊어지면 웹소켓 연결도 끊어진다 + +### 3. 타임아웃 + +서버가 일정 시간동안 아무 메시지도 받지 못하면 연결을 종료한다 +타임아웃 시간은 보통 서버 설정 파일에서 `keep-alive`나 `timeout`과 같은 속성으로 설정된다. + +### 4. 프로토콜 위반 + +클라이언트나 서버가 웹소켓 프로토콜을 위반하면 연결이 종료될 수 있다 + +예 ) 잘못된 OPCODE 사용이나 유효하지 않은 프레임 형식 + + + +# 웹소켓에서 SUBSCIBE한 메시지의 목적지들을 저장하는 방법 → 이거 예제코드를 하나씩 말해주라 + +## 1. Map을 사용하여 구독 관리 + +`Map` 자료구조를 활용하여, 구독 ID나 사용자 세션 ID를 키(key)로, 목적지(destination) 목록을 값(value)으로 저장 +`Map>` 형태로 세션 ID를 키로, 구독한 목적지들의 리스트를 값으로 저장 +구독 요청이 들어오면 새로운 목적지를 추가하고, 구독 해제 요청이 들어오면 해당 목적지를 제거하는 방식으로 관리 + +```java +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class SubscriptionManager { + private final Map> subscriptions = new HashMap<>(); + + // 구독 추가 + public void addSubscription(String sessionId, String destination) { + subscriptions.computeIfAbsent(sessionId, k -> new ArrayList<>()).add(destination); + } + + // 구독 해제 + public void removeSubscription(String sessionId, String destination) { + if (subscriptions.containsKey(sessionId)) { + subscriptions.get(sessionId).remove(destination); + if (subscriptions.get(sessionId).isEmpty()) { + subscriptions.remove(sessionId); // 구독 목록이 비어 있으면 제거 + } + } + } + + // 특정 세션의 구독 목록 조회 + public List getSubscriptions(String sessionId) { + return subscriptions.getOrDefault(sessionId, new ArrayList<>()); + } + + // 특정 목적지의 구독자 세션 ID 목록 조회 + public List getSessionsForDestination(String destination) { + List sessions = new ArrayList<>(); + subscriptions.forEach((sessionId, destinations) -> { + if (destinations.contains(destination)) { + sessions.add(sessionId); + } + }); + return sessions; + } +} + +``` + +## 2. Spring의 SimpMessagingTemplate 사용 + +### SimpMessagingTemplate와 메시지 전송 + +`SimpMessagingTemplate`은 Spring에서 STOMP 메시지를 목적지(destination)에 따라 전송할 수 있도록 지원하는 템플릿 +`SimpMessagingTemplate`은 `subscribe`된 특정 목적지로 메시지를 브로드캐스팅하는 데 활용되며, 구독 관리가 자동으로 이루어진다 +메시지 전송 시, `SimpMessagingTemplate`은 특정 목적지로 메시지를 전송하는 메서드를 제공(`convertAndSend(destination, payload)`). + +메시지가 목적지에 따라 전송될 때, Spring은 이 메시지를 해당 목적지를 구독한 클라이언트에게 자동으로 전달 + +```java +import org.springframework.context.annotation.Configuration; +import org.springframework.messaging.simp.config.MessageBrokerRegistry; +import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; +import org.springframework.web.socket.config.annotation.StompEndpointRegistry; +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; + +@Configuration +@EnableWebSocketMessageBroker +public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { + + @Override + public void configureMessageBroker(MessageBrokerRegistry config) { + config.enableSimpleBroker("/topic", "/queue"); // 내부 메시지 브로커 활성화 + config.setApplicationDestinationPrefixes("/app"); // 메시지 라우팅용 전송 경로 + } + + @Override + public void registerStompEndpoints(StompEndpointRegistry registry) { + registry.addEndpoint("/ws") // 웹소켓 엔드포인트 + .setAllowedOrigins("*") + .withSockJS(); // SockJS 폴백 사용 + } +} +``` + +```java +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.stereotype.Controller; + +@Controller +public class ChatController { + private final SimpMessagingTemplate messagingTemplate; + + public ChatController(SimpMessagingTemplate messagingTemplate) { + this.messagingTemplate = messagingTemplate; + } + + // 특정 목적지에 메시지 전송 + public void sendMessageToDestination(String destination, String message) { + messagingTemplate.convertAndSend(destination, message); + } +} +``` + +### Message Broker + +`enableSimpleBroker()` 메서드를 통해 내장 브로커나 외부 브로커(RabbitMQ, ActiveMQ)를 설정할 수 있다. + +이 브로커는 STOMP 프레임을 처리하고, 메시지의 목적지를 기반으로 라우팅한다. +Spring의 내장 브로커는 `/topic`과 `/queue` 등의 목적지 패턴을 미리 정의하여, 브로커가 메시지 경로를 해석하고 관리하도록 지원한다. + +### SimpUserRegistry를 통한 세션과 구독 관리 + +`SimpUserRegistry`는 각 사용자의 세션 ID, 사용자 ID, 그리고 이들이 구독한 목적지를 트래킹하는 역할을 수행한다 +클라이언트가 `SUBSCRIBE` 프레임을 통해 구독을 요청하면, Spring은 `SimpUserRegistry`에 이를 저장해두고, 메시지 전송 시 이 정보를 참조하여 각 사용자에게 구독된 메시지를 자동으로 라우팅한다 + +```java +import org.springframework.messaging.simp.user.SimpUserRegistry; +import org.springframework.stereotype.Service; + +@Service +public class SubscriptionService { + private final SimpUserRegistry userRegistry; + + public SubscriptionService(SimpUserRegistry userRegistry) { + this.userRegistry = userRegistry; + } + + public void showActiveSubscriptions() { + userRegistry.getUsers().forEach(user -> { + System.out.println("User: " + user.getName()); + user.getSessions().forEach(session -> { + session.getSubscriptions().forEach(subscription -> + System.out.println(" Subscription: " + subscription.getDestination()) + ); + }); + }); + } +} + +``` + +### STOMP 핸들러와 컨트롤러 연동 + +STOMP 핸들러는 Spring의 `@MessageMapping`을 통해 컨트롤러 메서드로 메시지를 라우팅한다 + +이렇게 처리된 메시지는 내부 브로커를 통해 구독된 목적지로 전달된다 + +이 구조 덕분에 Spring에서는 개발자가 목적지 구독을 수동으로 관리할 필요 없이 자동으로 구독과 메시지 전송이 이루어진다. + +## 3. SimpUserRegistry 활용 + +Spring WebSocket에서는 `SimpUserRegistry`를 통해 현재 연결된 사용자와 목적지 구독 정보를 조회할 수 있다. 이를 통해 특정 사용자 세션이 연결된 목적지 목록을 조회하여 관리할 수 있으며, 별도로 추가적인 Map 저장이 필요 없다. + +목적지를 구독한 사용자를 관리하고, 해당 목적지에 맞춰 메시지를 전송하는 시스템을 구성하면 효과적으로 구독 기반의 메시징 시스템을 구축할 수 있다. SockJS와 STOMP를 통해 목적지별 메시징을 구현할 때 이러한 구독 관리가 유용하게 사용된다. + +추가로 프로젝트에서 실제로 STOMP와 SockJS를 썼기 때문에 확실히 짚고 넘어가고자 찾아보았다 + +웹소켓의 상위레벨 프로토콜로는 STOMP와 SockJS가 있다. + +# STOMP : Simple Text Oriented Messaging Protocol + +메시지 기반의 프로토콜 + +브로커 기반의 메시지 전송을 위한 표준이다 + +웹소켓 위에서 구동할 수 있다 + +주로 Spring을 사용하는 애플리케이션에서 메시징에 사용된다 + +STOMP FRAME을 사용해서 텍스트 형식의 명령어로 전송된다 + +## STOMP FRAME의 종류들 + +### STOMP CONNECT FRAME + +클라이언트가 서버에 연결을 시작할 때 사용하는 프레임 +클라이언트가 STOMP 서버에 연결을 요청하고, 서버는 `CONNECTED` 프레임으로 응답 + +```java +CONNECT +accept-version:1.2 +host:localhost + +\0 + +``` + +- accept-version: 클라이언트가 지원하는 STOMP 버전 +- host: 연결할 서버의 호스트 + +### STOMP CONNECTED FRAME + +서버가 클라이언트의 연결 요청을 수락했음을 알리는 프레임 + +```java + +CONNECTED +version:1.2 + +\0 +``` + +- version: 연결에 사용된 STOMP 버전을 표시 + +### SEND FRAME + +클라이언트가 서버에 메시지를 전송할 때 사용 + +이 프레임은 일반적으로 특정 목적지로 메시지를 보낼 때 사용된다 + +```java +SEND +destination:/app/chat.sendMessage +content-type:application/json + +{"message": "Hello, world!"} +\0 +``` + +- destination: 메시지가 전송될 목적지(예: 채팅방) +- content-type: 메시지의 형식을 지정(JSON 데이터는 `application/json`) +- payload: 전송할 실제 메시지 내용 + +### SUBSCRIBE FRAME + +클라이언트가 특정 목적지의 메시지를 구독하기 위해 사용 + +클라이언트는 서버로부터 특정 주제의 메시지를 받는다 + +```java +SUBSCRIBE +id:sub-0 +destination:/topic/chat + +\0 +``` + +- id: 구독을 식별하는 고유 ID. 서버에서 특정 구독을 취소할 때도 사용된다. +- destination: 구독할 목적지, 예를 들어 채팅방의 주제(topic)이다. + +### UNSUBSCRIBE FRAME + +클라이언트가 특정 구독을 해지하기 위해 사용 + +```java +UNSUBSCRIBE +id:sub-0 + +\0 +``` + +id: 해지할 구독의 ID. `SUBSCRIBE` 프레임에서 사용한 ID와 일치해야 한다 + +### DISCONNECT FRAME + +클라이언트가 서버와의 연결을 종료할 때 사용하는 프레임 + +클라이언트가 더 이상 메시지를 주고받지 않겠다고 알리면서 연결을 안전하게 끊는 역할을 한다 + +```java +DISCONNECT + +\0 +``` + +### MESSAGE FRAME + +서버가 클라이언트에게 구독된 목적지의 메시지를 전송할 때 사용하는 프레임 + +서버에서 클라이언트로 발송 + +```java + +MESSAGE +subscription:sub-0 +message-id:007 +destination:/topic/chat +content-type:application/json + +{"message": "New message from the chat"} +\0 + +``` + +- subscription: 클라이언트가 설정한 구독 ID +- message-id: 서버에서 발행한 메시지의 ID +- destination: 메시지가 발송된 목적지 +- payload: 서버가 발송하는 메시지의 내용 + +### ACK (Acknowledgment) FRAME + +클라이언트가 서버로부터 받은 메시지를 성공적으로 처리했음을 알리기 위해 전송 + +메시지의 확인 여부를 서버에 알릴 수 있다 + +```java +ACK +id:007 + +\0 +``` + +id: 메시지의 ID. 서버 `MESSAGE` 프레임의 `message-id`와 일치해야 한다. + + + +### NACK (Negative Acknowledgment) FRAME + +클라이언트가 서버로부터 받은 메시지를 처리하지 못했음을 알리기 위해 전송 + +이를 통해 서버는 메시지를 다시 처리하도록 할 수 있다. + +# SockJS + +SockJS는 웹소켓 폴백(fallback) 프로토콜이다. + +웹소켓을 사용할 수 없는 환경에서도 비슷한 실시간 양방향 통신 기능을 제공하는 라이브러리이다 + +브라우저나 네트워크 설정으로 인해 웹소켓이 지원되지 않는 경우, SockJS는 HTTP를 사용해 폴백 방식으로 통신을 이어갈 수 있다. + +SockJS는 주로 실시간 채팅, 알림, 협업 도구 등 실시간 연결이 필요한 애플리케이션에서 안정적인 연결을 보장하기 위해 사용된다. + +## SockJS의 주요 기능 + +### 다양한 폴백 옵션 제공 + +SockJS는 웹소켓이 차단되거나 사용할 수 없는 경우 자동으로 롱 폴링(long polling), 스트리밍(streaming), XHR 등을 사용해 연결을 유지한다 +이러한 폴백 방식은 실시간으로 데이터를 주고받기 위해 웹소켓과 비슷한 인터페이스를 제공 + +### 브라우저 및 네트워크 호환성 개선 + +특정 네트워크 방화벽이나 프록시 서버가 웹소켓 연결을 차단하는 경우가 많습니다. SockJS는 이를 해결하기 위해 다양한 폴백 방법을 지원하여 브라우저나 네트워크 환경의 제약을 뛰어넘어 실시간 연결을 유지할 수 있게 해준다 + +### 일관된 API 제공 + +SockJS는 웹소켓의 `send`, `close`, `onmessage`, `onclose`와 같은 API와 유사한 인터페이스를 제공해서 이를 통해 SockJS와 웹소켓을 쉽게 교체할 수 있도록 해준다 + +## SockJS의 작동 방식 + +SockJS는 웹소켓 연결이 불가능할 때 폴백 메커니즘을 통해 다음과 같은 방식으로 작동한다 + +### WebSocket + +우선 웹소켓을 시도, 지원 가능하면 이를 사용해 연결을 유지 + +### XHR 스트리밍 + +웹소켓이 차단되면, XHR(비동기 HTTP 요청) 스트리밍 방식을 사용해 서버가 데이터를 클라이언트로 지속적으로 전송할 수 있게 한다 + +### Iframe 스트리밍 + +브라우저 호환성을 높이기 위해 iframe을 활용하여 지속적인 데이터를 전송하는 방식 + +### 롱 폴링 + +지속적으로 서버에 요청을 보내는 방식으로, 서버가 새 데이터를 보낼 준비가 되면 응답합니다. 폴링 방식은 효율적이진 않지만 대부분의 네트워크 환경에서 사용할 수 있다 + +## Spring에서 SockJS는 웹소켓과 STOMP와 함께 자주 사용된다 + +`StompEndpointRegistry`에 `withSockJS()` 메서드를 추가해 간단히 설정가능 + +```java + +import org.springframework.context.annotation.Configuration; +import org.springframework.messaging.simp.config.MessageBrokerRegistry; +import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; +import org.springframework.web.socket.config.annotation.StompEndpointRegistry; +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; + +@Configuration +@EnableWebSocketMessageBroker +public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { + + @Override + public void configureMessageBroker(MessageBrokerRegistry config) { + config.enableSimpleBroker("/topic", "/queue"); + config.setApplicationDestinationPrefixes("/app"); + } + + @Override + public void registerStompEndpoints(StompEndpointRegistry registry) { + registry.addEndpoint("/ws-chat") + .setAllowedOrigins("*") + .withSockJS(); // SockJS 폴백 활성화 + } +} + +``` + +위 설정을 통해 `/ws-chat` 경로로 웹소켓을 활성화하고, 브라우저나 네트워크에서 웹소켓을 지원하지 않으면 SockJS가 자동으로 폴백 + +## SockJS의 장점과 한계 + +### 장점 + +네트워크 환경에 상관없이 안정적인 연결을 유지할 수 있어, 웹소켓을 지원하지 않는 환경에서도 유용하게 사용할 수 있다. + +### 단점 + +폴백 방식들은 웹소켓보다 효율성이 낮기 때문에, 실시간성이나 데이터 전송 속도 면에서 웹소켓보다 느릴 수 있다. + +# 웹소켓이란? + +웹소켓(WebSocket)은 클라이언트와 서버 간의 실시간 양방향 통신을 가능하게 하는 프로토콜 + +기존의 HTTP 통신 방식은 클라이언트가 요청을 보내면 서버가 응답하는 요청-응답 모델을 따른다. + +반면, 웹소켓은 최초 연결 이후 클라이언트와 서버 간에 지속적인 연결을 유지하면서 상호 간에 데이터를 주고받을 수 있다. + +이는 실시간 기능이 필요한 애플리케이션(채팅, 실시간 알림, 게임) 등에 유용하게 사용된다. + +# 웹소켓의 특징 + +## 양방향 통신 + +클라이언트와 서버 모두 메시지를 보낼 수 있다. + +## 지속적인 연결 + +HTTP와 달리 웹소켓은 연결이 한번 성립되면 끊기지 않고 계속 유지된다 + +## 낮은 오버헤드 + +HTTP처럼 매번 요청 헤더를 보내지 않고, 한 번 연결되면 지속적으로 데이터를 주고받을 수 있어 오버헤드가 적다. + +## 지연 감소(실시간 데이터 전송) + +서버와 클라이언트 간의 실시간 데이터 전송이 가능하다 + +http처럼 핸드쉐이크하면서 연결하고 연결 해제하는 과정이 생략되어서 데이터 전송시 지연(Latency)가 크게 감소한다(빨라진다) + +# 웹소켓의 초기 연결 + +HTTP 프로토콜을 사용해 핸드셰이크를 해서 초기 연결을 설정한다 + +클라이언트가 HTTP 요청 헤더에 특별한 업그레이드 요청을 추가하여 웹소켓 프로토콜로 전환을 요청한다 + +서버가 이 요청을 수락하면 웹소켓 연결이 성립되고 이후부터는 HTTP가 아닌 웹소켓 프로토콜로 데이터가 전송된다 + +### 클라이언트의 요청 + +``` +GET /chat HTTP/1.1 +Host: example.com +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= + +``` + +서버는 클라이언트의 요청을 확인하고, Sec-WebSocket-Accept 헤더에 특별한 값을 포함해 응답하여 웹소켓 연결을 설정한다 + +HTTP/1.1 101 Switching Protocols 응답 코드가 이 전환을 나타낸다 + +# 웹소켓 통신 + +웹소켓 연결 이후에는 TCP 연결을 기반으로 양방향 통신이 이루어진다. + +클라이언트와 서버는 서로 메시지를 교환하며, 각 메시지는 프레임(frame) 단위로 전송된다. + +프레임은 텍스트, 바이너리 데이터, 제어 메시지 등을 포함할 수 있다. + +웹소켓은 기본적으로 자체적인 프레임 구조를 사용하여 데이터를 전송하지만 STOMP와 같은 상위 레벨 프로토콜을 사용할 수도 있다. + +## 웹소켓의 메시지 형식(프레임 구조) + +웹소켓은 다음과 같은 프레임 구조를 가지고 데이터를 전송한다 + +FIN 메시지의 끝을 나타내는 플래그 + +| **RSV1, RSV2, RSV3** | 예약된 플래그(일반적으로 0으로 설정) | +| --- | --- | +| **OPCODE** | 메시지의 유형 (텍스트 메시지(1), 바이너리 메시지(2), 연결 종료(8), 핑(9), 퐁(10) 등) | +| **마스킹 키** | 클라이언트에서 서버로 전송되는 메시지에 대해 사용된다 | +| **페이로드 길이** | 전송되는 데이터의 크기 | +| **페이로드 데이터** | 실제 전송되는 데이터(텍스트 또는 바이너리) | + +``` +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; + +public class WebSocketFrameExample { + public static void main(String[] args) { + String message = "Hello, WebSocket!";// 실제 전송할 데이터byte[] frame = createTextFrame(message); + +// 프레임 출력for (byte b : frame) { + System.out.printf("%02X ", b); + } + } + + public static byte[] createTextFrame(String message) { + byte[] payloadData = message.getBytes(StandardCharsets.UTF_8); + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + +// FIN(1) + OPCODE(1) 설정byte finRsvOpcode = (byte) 0x81;// FIN flag + 텍스트 메시지 OPCODE 0x1 + outputStream.write(finRsvOpcode); + +// 페이로드 길이 설정int payloadLength = payloadData.length; + if (payloadLength <= 125) { + outputStream.write((byte) payloadLength);// 길이가 125 이하인 경우 + } else if (payloadLength <= 65535) { + outputStream.write((byte) 126);// 길이가 126~65535 사이인 경우 + outputStream.write((payloadLength >> 8) & 0xFF);// 16비트 길이 + outputStream.write(payloadLength & 0xFF); + } else { + outputStream.write((byte) 127);// 길이가 65536 이상인 경우for (int i = 7; i >= 0; i--) { + outputStream.write((payloadLength >> (8 * i)) & 0xFF);// 64비트 길이 + } + } + +// 마스킹 키 설정 (4바이트, 예제에서는 단순히 0으로 설정)byte[] maskingKey = {0x00, 0x00, 0x00, 0x00}; + outputStream.write(maskingKey); + +// 페이로드 데이터 설정 (마스킹 키 적용 없음) + outputStream.write(payloadData); + + return outputStream.toByteArray(); + } +} + +``` + +### 예시 데이터 출력 + +``` +"Hello, WebSocket!" + +``` + +이 데이터를 전송한다면 다음과 같이 출력된다 + +``` +81 0E 00 00 00 00 48 65 6C 6C 6F 2C 20 57 65 62 53 6F 63 6B 65 74 21 + +``` + +- 81: FIN(1) + 텍스트 메시지 OPCODE(0x1) +- 0E: 페이로드 길이 (14바이트, "Hello, WebSocket!") +- 00 00 00 00: 마스킹 키 (4바이트) +- 48 65 6C 6C 6F 2C 20 57 65 62 53 6F 63 6B 65 74 21: "Hello, WebSocket!"의 UTF-8 인코딩된 값 + +## 웹소켓의 레이어 + +웹소켓은 응용 계층(Application Layer) 프로토콜로, OSI 7계층 모델에서 7계층에 속한다. + +웹소켓이 TCP/IP 프로토콜 위에서 동작하며, 데이터를 어플리케이션 수준에서 처리된다는 뜻이다. + +아래 Layer에 TCP (전송 계층) 프로토콜이 있어서 웹소켓 연결이 유지되는 동안에는 TCP 연결이 지속된다. + +# 웹소켓의 연결 해제 + +### 1. 명시적 종료 + +클라이언트나 서버가 연결을 종료할 수 있다 + +일반적으로 close 프레임을 전송해서 이루어진다 + +이 프레임은 연결이 종료될 것임을 알리고, 종료 코드를 포함할 수 있다 + +``` +// 종료 프레임 예시byte[] closeFrame = {(byte) 0x88, (byte) 0x00};// FIN + OPCODE(8), payload length(0) +outputStream.write(closeFrame); + +``` + +### 2. 네트워크 문제 + +클라이언트나 서버 간의 네트워크 연결이 끊어지면 웹소켓 연결도 끊어진다 + +### 3. 타임아웃 + +서버가 일정 시간동안 아무 메시지도 받지 못하면 연결을 종료한다 타임아웃 시간은 보통 서버 설정 파일에서 keep-alive나 timeout과 같은 속성으로 설정된다. + +### 4. 프로토콜 위반 + +클라이언트나 서버가 웹소켓 프로토콜을 위반하면 연결이 종료될 수 있다 + +예 ) 잘못된 OPCODE 사용이나 유효하지 않은 프레임 형식 + +> ❓공부하다보니 웹소켓에서 특정 구독 주소로 메시지를 다 날리는데 그런건 내부적으로 어떻게 저장하는지 궁금해졌다 +> + +# 웹소켓에서 SUBSCIBE한 메시지의 목적지들을 저장하는 방법 + +## 1. Map을 사용하여 구독 관리 + +Map 자료구조를 활용하여, 구독 ID나 사용자 세션 ID를 키(key)로, 목적지(destination) 목록을 값(value)으로 저장 Map> 형태로 세션 ID를 키로, 구독한 목적지들의 리스트를 값으로 저장 구독 요청이 들어오면 새로운 목적지를 추가하고, 구독 해제 요청이 들어오면 해당 목적지를 제거하는 방식으로 관리 + +``` +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class SubscriptionManager { + private final Map> subscriptions = new HashMap<>(); + +// 구독 추가public void addSubscription(String sessionId, String destination) { + subscriptions.computeIfAbsent(sessionId, k -> new ArrayList<>()).add(destination); + } + +// 구독 해제public void removeSubscription(String sessionId, String destination) { + if (subscriptions.containsKey(sessionId)) { + subscriptions.get(sessionId).remove(destination); + if (subscriptions.get(sessionId).isEmpty()) { + subscriptions.remove(sessionId);// 구독 목록이 비어 있으면 제거 + } + } + } + +// 특정 세션의 구독 목록 조회public List getSubscriptions(String sessionId) { + return subscriptions.getOrDefault(sessionId, new ArrayList<>()); + } + +// 특정 목적지의 구독자 세션 ID 목록 조회public List getSessionsForDestination(String destination) { + List sessions = new ArrayList<>(); + subscriptions.forEach((sessionId, destinations) -> { + if (destinations.contains(destination)) { + sessions.add(sessionId); + } + }); + return sessions; + } +} + +``` + +## 2. Spring의 SimpMessagingTemplate 사용 + +### SimpMessagingTemplate와 메시지 전송 + +SimpMessagingTemplate은 Spring에서 STOMP 메시지를 목적지(destination)에 따라 전송할 수 있도록 지원하는 템플릿 SimpMessagingTemplate은 subscribe된 특정 목적지로 메시지를 브로드캐스팅하는 데 활용되며, 구독 관리가 자동으로 이루어진다 메시지 전송 시, SimpMessagingTemplate은 특정 목적지로 메시지를 전송하는 메서드를 제공(convertAndSend(destination, payload)). + +메시지가 목적지에 따라 전송될 때, Spring은 이 메시지를 해당 목적지를 구독한 클라이언트에게 자동으로 전달 + +``` +import org.springframework.context.annotation.Configuration; +import org.springframework.messaging.simp.config.MessageBrokerRegistry; +import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; +import org.springframework.web.socket.config.annotation.StompEndpointRegistry; +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; + +@Configuration +@EnableWebSocketMessageBroker +public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { + + @Override + public void configureMessageBroker(MessageBrokerRegistry config) { + config.enableSimpleBroker("/topic", "/queue"); // 내부 메시지 브로커 활성화 + config.setApplicationDestinationPrefixes("/app"); // 메시지 라우팅용 전송 경로 + } + + @Override + public void registerStompEndpoints(StompEndpointRegistry registry) { + registry.addEndpoint("/ws") // 웹소켓 엔드포인트 + .setAllowedOrigins("*") + .withSockJS(); // SockJS 폴백 사용 + } +} + +``` + +``` +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.stereotype.Controller; + +@Controller +public class ChatController { + private final SimpMessagingTemplate messagingTemplate; + + public ChatController(SimpMessagingTemplate messagingTemplate) { + this.messagingTemplate = messagingTemplate; + } + +// 특정 목적지에 메시지 전송public void sendMessageToDestination(String destination, String message) { + messagingTemplate.convertAndSend(destination, message); + } +} + +``` + +### Message Broker + +enableSimpleBroker() 메서드를 통해 내장 브로커나 외부 브로커(RabbitMQ, ActiveMQ)를 설정할 수 있다. + +이 브로커는 STOMP 프레임을 처리하고, 메시지의 목적지를 기반으로 라우팅한다. Spring의 내장 브로커는 /topic과 /queue 등의 목적지 패턴을 미리 정의하여, 브로커가 메시지 경로를 해석하고 관리하도록 지원한다. + +### SimpUserRegistry를 통한 세션과 구독 관리 + +SimpUserRegistry는 각 사용자의 세션 ID, 사용자 ID, 그리고 이들이 구독한 목적지를 트래킹하는 역할을 수행한다 클라이언트가 SUBSCRIBE 프레임을 통해 구독을 요청하면, Spring은 SimpUserRegistry에 이를 저장해두고, 메시지 전송 시 이 정보를 참조하여 각 사용자에게 구독된 메시지를 자동으로 라우팅한다 + +``` +import org.springframework.messaging.simp.user.SimpUserRegistry; +import org.springframework.stereotype.Service; + +@Service +public class SubscriptionService { + private final SimpUserRegistry userRegistry; + + public SubscriptionService(SimpUserRegistry userRegistry) { + this.userRegistry = userRegistry; + } + + public void showActiveSubscriptions() { + userRegistry.getUsers().forEach(user -> { + System.out.println("User: " + user.getName()); + user.getSessions().forEach(session -> { + session.getSubscriptions().forEach(subscription -> + System.out.println(" Subscription: " + subscription.getDestination()) + ); + }); + }); + } +} + +``` + +### STOMP 핸들러와 컨트롤러 연동 + +STOMP 핸들러는 Spring의 @MessageMapping을 통해 컨트롤러 메서드로 메시지를 라우팅한다 + +이렇게 처리된 메시지는 내부 브로커를 통해 구독된 목적지로 전달된다 + +이 구조 덕분에 Spring에서는 개발자가 목적지 구독을 수동으로 관리할 필요 없이 자동으로 구독과 메시지 전송이 이루어진다. + +## 3. SimpUserRegistry 활용 + +Spring WebSocket에서는 SimpUserRegistry를 통해 현재 연결된 사용자와 목적지 구독 정보를 조회할 수 있다. 이를 통해 특정 사용자 세션이 연결된 목적지 목록을 조회하여 관리할 수 있으며, 별도로 추가적인 Map 저장이 필요 없다. + +목적지를 구독한 사용자를 관리하고, 해당 목적지에 맞춰 메시지를 전송하는 시스템을 구성하면 효과적으로 구독 기반의 메시징 시스템을 구축할 수 있다. SockJS와 STOMP를 통해 목적지별 메시징을 구현할 때 이러한 구독 관리가 유용하게 사용된다. + +추가로 프로젝트에서 실제로 STOMP와 SockJS를 썼기 때문에 확실히 짚고 넘어가고자 찾아보았다 + +웹소켓의 상위레벨 프로토콜로는 STOMP와 SockJS가 있다. + +# STOMP : Simple Text Oriented Messaging Protocol + +메시지 기반의 프로토콜 + +브로커 기반의 메시지 전송을 위한 표준이다 + +웹소켓 위에서 구동할 수 있다 + +주로 Spring을 사용하는 애플리케이션에서 메시징에 사용된다 + +STOMP FRAME을 사용해서 텍스트 형식의 명령어로 전송된다 + +## STOMP FRAME의 종류들 + +### STOMP CONNECT FRAME + +클라이언트가 서버에 연결을 시작할 때 사용하는 프레임 클라이언트가 STOMP 서버에 연결을 요청하고, 서버는 CONNECTED 프레임으로 응답 + +``` +CONNECT +accept-version:1.2 +host:localhost + +\\0 + +``` + +- accept-version: 클라이언트가 지원하는 STOMP 버전 +- host: 연결할 서버의 호스트 + +### STOMP CONNECTED FRAME + +서버가 클라이언트의 연결 요청을 수락했음을 알리는 프레임 + +``` +CONNECTED +version:1.2 + +\\0 + +``` + +- version: 연결에 사용된 STOMP 버전을 표시 + +### SEND FRAME + +클라이언트가 서버에 메시지를 전송할 때 사용 + +이 프레임은 일반적으로 특정 목적지로 메시지를 보낼 때 사용된다 + +``` +SEND +destination:/app/chat.sendMessage +content-type:application/json + +{"message": "Hello, world!"} +\\0 + +``` + +- destination: 메시지가 전송될 목적지(예: 채팅방) +- content-type: 메시지의 형식을 지정(JSON 데이터는 application/json) +- payload: 전송할 실제 메시지 내용 + +### SUBSCRIBE FRAME + +클라이언트가 특정 목적지의 메시지를 구독하기 위해 사용 + +클라이언트는 서버로부터 특정 주제의 메시지를 받는다 + +``` +SUBSCRIBE +id:sub-0 +destination:/topic/chat + +\\0 + +``` + +- id: 구독을 식별하는 고유 ID. 서버에서 특정 구독을 취소할 때도 사용된다. +- destination: 구독할 목적지, 예를 들어 채팅방의 주제(topic)이다. + +### UNSUBSCRIBE FRAME + +클라이언트가 특정 구독을 해지하기 위해 사용 + +``` +UNSUBSCRIBE +id:sub-0 + +\\0 + +``` + +id: 해지할 구독의 ID. SUBSCRIBE 프레임에서 사용한 ID와 일치해야 한다 + +### DISCONNECT FRAME + +클라이언트가 서버와의 연결을 종료할 때 사용하는 프레임 + +클라이언트가 더 이상 메시지를 주고받지 않겠다고 알리면서 연결을 안전하게 끊는 역할을 한다 + +``` +DISCONNECT + +\\0 + +``` + +### MESSAGE FRAME + +서버가 클라이언트에게 구독된 목적지의 메시지를 전송할 때 사용하는 프레임 + +서버에서 클라이언트로 발송 + +``` +MESSAGE +subscription:sub-0 +message-id:007 +destination:/topic/chat +content-type:application/json + +{"message": "New message from the chat"} +\\0 + +``` + +- subscription: 클라이언트가 설정한 구독 ID +- message-id: 서버에서 발행한 메시지의 ID +- destination: 메시지가 발송된 목적지 +- payload: 서버가 발송하는 메시지의 내용 + +### ACK (Acknowledgment) FRAME + +클라이언트가 서버로부터 받은 메시지를 성공적으로 처리했음을 알리기 위해 전송 + +메시지의 확인 여부를 서버에 알릴 수 있다 + +``` +ACK +id:007 + +\\0 + +``` + +id: 메시지의 ID. 서버 MESSAGE 프레임의 message-id와 일치해야 한다. + +> ❓ +> + +### NACK (Negative Acknowledgment) FRAME + +클라이언트가 서버로부터 받은 메시지를 처리하지 못했음을 알리기 위해 전송 + +이를 통해 서버는 메시지를 다시 처리하도록 할 수 있다. + +# SockJS + +SockJS는 웹소켓 폴백(fallback) 프로토콜이다. + +웹소켓을 사용할 수 없는 환경에서도 비슷한 실시간 양방향 통신 기능을 제공하는 라이브러리이다 + +브라우저나 네트워크 설정으로 인해 웹소켓이 지원되지 않는 경우, SockJS는 HTTP를 사용해 폴백 방식으로 통신을 이어갈 수 있다. + +SockJS는 주로 실시간 채팅, 알림, 협업 도구 등 실시간 연결이 필요한 애플리케이션에서 안정적인 연결을 보장하기 위해 사용된다. + +## SockJS의 주요 기능 + +### 다양한 폴백 옵션 제공 + +SockJS는 웹소켓이 차단되거나 사용할 수 없는 경우 자동으로 롱 폴링(long polling), 스트리밍(streaming), XHR 등을 사용해 연결을 유지한다 이러한 폴백 방식은 실시간으로 데이터를 주고받기 위해 웹소켓과 비슷한 인터페이스를 제공 + +### 브라우저 및 네트워크 호환성 개선 + +특정 네트워크 방화벽이나 프록시 서버가 웹소켓 연결을 차단하는 경우가 많습니다. SockJS는 이를 해결하기 위해 다양한 폴백 방법을 지원하여 브라우저나 네트워크 환경의 제약을 뛰어넘어 실시간 연결을 유지할 수 있게 해준다 + +### 일관된 API 제공 + +SockJS는 웹소켓의 send, close, onmessage, onclose와 같은 API와 유사한 인터페이스를 제공해서 이를 통해 SockJS와 웹소켓을 쉽게 교체할 수 있도록 해준다 + +## SockJS의 작동 방식 + +SockJS는 웹소켓 연결이 불가능할 때 폴백 메커니즘을 통해 다음과 같은 방식으로 작동한다 + +### WebSocket + +우선 웹소켓을 시도, 지원 가능하면 이를 사용해 연결을 유지 + +### XHR 스트리밍 + +웹소켓이 차단되면, XHR(비동기 HTTP 요청) 스트리밍 방식을 사용해 서버가 데이터를 클라이언트로 지속적으로 전송할 수 있게 한다 + +### Iframe 스트리밍 + +브라우저 호환성을 높이기 위해 iframe을 활용하여 지속적인 데이터를 전송하는 방식 + +### 롱 폴링 + +지속적으로 서버에 요청을 보내는 방식으로, 서버가 새 데이터를 보낼 준비가 되면 응답합니다. 폴링 방식은 효율적이진 않지만 대부분의 네트워크 환경에서 사용할 수 있다 + +## Spring에서 SockJS는 웹소켓과 STOMP와 함께 자주 사용된다 + +StompEndpointRegistry에 withSockJS() 메서드를 추가해 간단히 설정가능 + +``` +import org.springframework.context.annotation.Configuration; +import org.springframework.messaging.simp.config.MessageBrokerRegistry; +import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; +import org.springframework.web.socket.config.annotation.StompEndpointRegistry; +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; + +@Configuration +@EnableWebSocketMessageBroker +public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { + + @Override + public void configureMessageBroker(MessageBrokerRegistry config) { + config.enableSimpleBroker("/topic", "/queue"); + config.setApplicationDestinationPrefixes("/app"); + } + + @Override + public void registerStompEndpoints(StompEndpointRegistry registry) { + registry.addEndpoint("/ws-chat") + .setAllowedOrigins("*") + .withSockJS(); // SockJS 폴백 활성화 + } +} + +``` + +위 설정을 통해 /ws-chat 경로로 웹소켓을 활성화하고, 브라우저나 네트워크에서 웹소켓을 지원하지 않으면 SockJS가 자동으로 폴백 + +## SockJS의 장점과 한계 + +### 장점 + +네트워크 환경에 상관없이 안정적인 연결을 유지할 수 있어, 웹소켓을 지원하지 않는 환경에서도 유용하게 사용할 수 있다. + +### 단점 + +폴백 방식들은 웹소켓보다 효율성이 낮기 때문에, 실시간성이나 데이터 전송 속도 면에서 웹소켓보다 느릴 수 있다.