Spring Boot - WebSocket

진경천·2024년 11월 22일

WebSocket이란?

Ws 프로토콜 기반으로 클라이언트와 서버 사이에 소켓 커넥션을 유지하며 지속적인 완전 양방향 연결 스트림을 만들어주는 기술

  • OSI 7계층에 속함
  • HTTP Port(80, 443)에서 동작하도록 설계
  • HTTP보다 낮은 오버헤드와 빠른 속도 제공
  • HTTP upgrade 헤더 사용

Request Header

GET /spring-websocket-portfolio/portfolio HTTP/1.1
Host: localhost:8080
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: Uc9l9TMkWGbHFD2qnFHltg==
Sec-WebSocket-Protocol: v10.stomp, v11.stomp
Sec-WebSocket-Version: 13
Origin: http://localhost:8080

Upgrade: websocket : websocket upgrade 헤더를 선언
Connection: Upgrade : 업그레이드된 연결을 사용한다는 의미
sec-web-key : 클라이언트가 랜덤으로 생성한 값을 Base64로 인코딩한 문자열

Response Header

HTTP/1.1 101 Switching Protocols 
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: 1qVdfYHU9hPOl4JYYNXF623Gzn0=
Sec-WebSocket-Protocol: v10.stomp

HTTP/1.1 101 Switching Protocols : 기존의 HTTP 프로토콜이 아닌 WebSocket 프로토콜로 변경된다는 의미
Sec-WebSocket-Accept : 받은 key에 GUID를 붙이고 SHA-1 해시로 계산해 base64로 인코딩한 것

WebSocket 동작 과정

  1. TCP/IP 접속 요청 (클라이언트)
  2. TCP/IP 접속 수락 (서버)
  3. 웹소켓 열기 핸드쉐이크 요청 (클라이언트)
  4. 웹소켓 열기 핸드쉐이크 수락 (서버)
  5. 웹소켓 데이터 송, 수신 (클라이언트, 서버)

TCP Socket VS Web Socket

차이점 TCP Socket Web Socket
추상화 정도 저수준 추상화되어 있음
protocol 4계층(전송계층)에서 동작 7계층(애플리케이션 계층)에서 동작
Data 전송방법 바이트스트림을 통한 데이터 전송 구조화된 메시지 형식의 데이터 전송
방화벽 방화벽에 의해 차단될 수 있는 새 TCP 포트를 열어야 함 방화벽을 사용하여 웹이 아닌 인터넷 연결을 차단하는 환경에 유용

HTTP VS WebSocket

HTTP와 REST에서 어플리케이션은 많은 URL들로 설계된다.
클라이언트는 이러한 URL들에 요청을하고 서버는 HTTP URL, method, 헤더를 기반으로 적절한 핸들러를 이용해 요청을 처리한다.

WebSocket은 일반적으로 첫 연결을 위한 하나의 URL이 존재한다.
이후, 모든 어플리케이션 메시지는 같은 TCP 연결을 통해 전송된다.

위 그림과 같이 HTTP는 모든 통신을 요청과 응답을 한번씩 주고 받지만, WebSocket은 단 한번의 WebSocket 요청과 수락 이후에 연결을 끊기 전까지 자유롭게 양방향으로 데이터를 주고받을 수 있다.

WebSocket은 low-level 프로토콜이지만 WebSocket 클라이언트와 서버는 Sec-WebSocket-Protocol을 통해 STOMP와 같은 high-level 프로토콜을 사용할 수 있다.

Spring WebSocket

Spring에서 WebSocket의 기능을 직접 구현해본다.

WebSocketConfig

WebSocketHandler를 이용해 WebSocket을 활성화하기 위한 설정

@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        registry.addHandler(webSocketHandler(), "/websocket-endpoint")
                .setAllowedOriginPatterns("*")
                .withSockJS();
    }

    public WebSocketHandlerImpl webSocketHandler(){
        return new WebSocketHandlerImpl();
    }
}
  • WebSocketHandlerRegistry : WebSocketHandler를 등록하기 위한 인터페이스
  • addHandler(WebSocketHandler webSocketHandler, String... paths) : WebSocketHandler와 endPoint 경로를 등록하는 메서드
    • endPoint: API가 서버에서 리소스에 접근할 수 있도록 하는 URL
  • setAllowedOriginPatterns : 도메인이 다른 서버에서 접속할 수 있는 범위를 설정
    • "*"는 모든 서버에서 접속을 허용한다는 의미
  • withSockJS() : SockJS 라이브러리를 사용하는 것을 의미

    SockJS란?
    WebSocket이 지원되지 않는 브라우저에서도 양방향 통신을 가능하게 하는 라이브러리

WebSocketHandler

WebSocket의 메시지와 생명주기 이벤트를 관리하는 Handler

여기서는 커스텀 핸들러를 구현하기 위해 WebSocketHandler 인터페이스를 구현체를 작성하였다.

public class WebSocketHandlerImpl implements WebSocketHandler {
    @Override
    public void afterConnectionEstablished(WebSocketSession session) throws Exception {
        log.info("WebSocket 연결 성공");
    }

    @Override
    public void handleMessage(WebSocketSession session, WebSocketMessage<?> message) throws Exception {
        if(message instanceof TextMessage) {
            String payload = ((TextMessage) message).getPayload();
            log.info("Received message: {}", payload);
        }
    }

    @Override
    public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
        log.info("WebSocket 에러 발생");
    }

    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus closeStatus) throws Exception {
        log.info("WebSocket 연결 종료");
    }

    @Override
    public boolean supportsPartialMessages() {
        return false;
    }
}
  • afterConnectionClosed : WebSocket 연결이 성공적으로 닫히고 호출되는 메서드
  • afterConnectionEstablished : WebSocket 연결이 성공하고 열리고 나서 호출되는 메서드
  • handleMessage : 새로운 WebSocket 메시지가 왔을 때 호출되는 메서드
  • handleTransportError : WebSocket 메시지 전송에서 발생하는 에러를 처리
  • supportsPartialMessages() : 핸들러가 partial message를 처리하는지의 여부

STOMP

Simple Text Oriented Messaging Protocol
메시지 전송을 호율적으로 하기 위한 프로토콜

  • 클라이언트와 서버가 전송할 메세지의 유형, 형식, 내용들을 정의하는 매커니즘
    • 메세징 프로토콜과 메세징 형식을 개발할 필요가 없음
  • WebSocket을 기반으로 메시지 브로커와 publish-subscribe(발행-구독) 메커니즘을 지원
  • frame 기반 프로토콜

클라이언트는 send 나 subscribe 명령어를 통해 destination 헤더와 함께 메시지에 대한 전송이나 구독을 한다.

publish 송신 subscribe 수신

SUBSCRIBE

SUBSCRIBE
id:sub-1
destination:/topic/price.stock.*

^@

SEND frame

SEND
destination:/queue/trade
content-type:application/json
content-length:44

{"action":"BUY","ticker":"MMM","shares",44}^@

Message frame

destination:/topic/messages
content-type:application/json
subscription:sub-0
message-id:mi1yksot-244
content-length:51

{"from":"a","text":"aa","time":"13:37:38"}

동작 흐름

WebSocketStompConfig

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketStompConfig implements WebSocketMessageBrokerConfigurer {
    /*
    /chat 엔드포인트를 정의합니다.
    클라이언트는 이 엔드포인트에 연결해 WebSocket을 통해 STOMP 통신을 시작합니다.
    withSockJS(): SockJS를 사용하여 WebSocket을 지원하지 않는 환경에서도 동작하도록 합니다.
    */
    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {

        registry.addEndpoint("/chat").withSockJS();
    }

    /*
    메시지 브로커를 설정합니다.
    enableSimpleBroker("/topic", "/queue"):
    /topic과 /queue로 시작하는 메시지를 브로커가 처리합니다.
    setApplicationDestinationPrefixes("/app"):
    클라이언트가 서버에 메시지를 보낼 때 /app으로 시작하는 경로를 사용합니다.
    */
    @Override
    public void configureMessageBroker(MessageBrokerRegistry config) {
        config.enableSimpleBroker("/topic", "/queue");
        config.setApplicationDestinationPrefixes("/app");
    }
}

Controller

@Controller
public class WebSocketController {
    @MessageMapping("/chat")   // 클라이언트가 /app/chat 경로로 보낸 메시지를 처리
    @SendTo("/topic/messages") // 처리된 메시지를 /topic/messages 구독자들에게 전달
    public OutputMessageModel send(final MessageModel message) throws Exception {  // MessageModel 객체로 message 수신
        final String time = new SimpleDateFormat("HH:mm:ss").format(new Date());
        return new OutputMessageModel(message.getFrom(), message.getText(), time); // OutputMessageModel 객체로 변환하여 응답
    }
    MessageModel model;
}
profile
어중이떠중이

0개의 댓글