WebSocket 공부

Ada·2022년 11월 20일
0

항해TOL

목록 보기
41/63

WebSocket
HTTP 프로토콜과 호환되어 양방향 통신을 제공하기 위해 개발된 프로토콜로써 80port를 사용하여 방화벽에 제약이 없다.

기존의 HTTP를 사용한 통신은 클라이언트의 요청 -> 웹서버의 응답 -> 연결 종료
의 과정이였는데 WebSocket을 사용하면 웹 서버의 응답 요청 후에서 Connetion이 종료되지 않고 그대로 유지된다는 특징이 있다.
이때 프로토콜의 요청은 [ws://~] 로 시작한다.

사실 웹소켓 외에도 HTTP의 실시간 통신 방식에는 3가지 방식이 더 있다.

1. polling

클라이언트가 일정한 주기마다 서버에 HTTP 요청을 보내는 방식으로, 실시간 데이터의 업데이트 주기는 예측 불가능 하므로, 불필요한 요청에 따른 서버 및 네트워크의 부하가 늘어난다.

업데이트 주기 설정에 따라 서버의 부하가 올라가거나 실시간 성이 떨어지는 trade off 관계를 가진다.
실시간성이 떨어져도 되고 여러대의 클라이언트와 통신을 할 때 사용하기 적합하다.

2. Long polling

클라이언트에서 서버로 일단 HTTP Request를 요청하면 서버에서 해당 클라이언트로 전달할 이벤트가 있을때까지 대기상태로 있다가 이벤트 발생시 Response 메시지 전송 후 연결이 종료되며 다시 클라이언트가 HTTP Request를 요청하고 대기상태에 들어가는것을 반복하는 방식이다.

일반 polling 보다는 서버의 부담이 줄어들지만 업데이트 주기가 짧다면 polling 과 별 차이가 없게 되며 다수의 클라이언트에게 동시에 이벤트 발생 시 곧바로 다수의 클라이언트가 서버로 접속을 시도하면서 서버의 부담이 급증 하게된다.

3. streaming

Long polling과 마찬가지로 클라이언트에서 서버로 일단 HTTP Request를 보낸 후 서버에서 클라이언트로 이벤트를 전달할 때 해당 요청을 끊지 않고 필요한 메시지만 보내기(flush)를 반복하는 방식이다.

Long polling에 비해 서버에서 메시지를 보내고도 다시 HTTP Request 연결을 하지 않아도 되어 부담이 줄어들 수 있다.


WebSocket

다른 방법들을 제쳐두고 WebSocket을 사용해야 하는 이유는 바로
Connetion 유지 + 클라이언트의 요청 없이 데이터 전송 이 가능하다는 이점 때문이다.

WebSocket 이 기존의 일반 TCP Socket과 다른 점은 최초 접속이 일반 HTTP Request를 통해 이뤄지기 때문에 기존의 80, 443 포트로 접속하므로 추가적으로 방화벽을 열어주지 않고도 양방향 통신이 가능하며 HTTP 규격인 CORS 적용이나 인증등의 과정을 기존과 동일하게 가져갈 수 있다는 큰 장점이 있다.

변경 사항의 빈도가 길고, 데이터의 크기가 작은 경우 위의 기술들이 더 효과적일 수 있지만

실시간성의 보장, 변경 사항의 빈도가 짧음, 짧은 대기시간, 대용량 등의 조건일 때는 WebSocket을 사용하는것이 바람직하다.

WebSocket 접속 과정

웹소켓도 TCP/IP 위에서 동작하기 때문에 우선적으로 서버와 클라이언트는 TCP/IP 접속이 되어있어야 한다.

TCP/IP/접속이 완료된 후 서버와 클라이언트는 웹소켓 열기 HandShake 과정을 시작한다.

웹소켓 열기 HandShake

웹소켓 열기 HandShake는 클라이언트가 먼저 HandShake 요청을 보내면 서버가 이에 대한 응답을 보내는 구조이다.

서버와 클라이언트는 HTTP 1.1 프로토콜을 사용하여 요청과 응답을 보낸다.

다음은 Request와 Response의 예시이다.

HandShake Request

GET /chat HTTP/1.1
Host: server.gorany.org
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Origin: http://localhost:8080
Sec-WebSocket-Protocol: v10.stomp, v11.stomp, my-team-custom
Sec-WebSocket-Version: 13

Header NamedivDescription
GETRequire요청 명령어는 GET을 사용해야 하며, HTTP 버전은 1.1 이상이어야 한다.
HostRequire웹소켓 서버의 주소
UpgradeRequireWebSocket이라는 단어를 사용해야 한다. 대소문자는 구분 X
ConnectionRequireHTTP 사용 방식을 변경한다는 의미. Upgrade라는 단어를 사용해야 한다. 대소문자는 구분 X
Sec-WebSocket-KeyRequire보안을 위한 요청 키. 길이가 16Byte인 임의로 선택 된 숫자를 base64 인코딩한 값 이다.
OriginRequire클라이언트로 웹 브라우저를 사용하는 경우 필수항목으로, 클라이언트의 주소
Sec-WebSocket-VersionRequire13을 사용한다.
Sec-WebSocket-ProtocolOption클라이언트가 사용하고 싶은 하위 프로토콜 이름을 명시한다.
Sec-WebSocket-ExtensionsOption클라이언트가 사용하고 싶은 추가 옵션을 기술한다.

HandShake Response

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

Header NamedivDescription
HTTPRequireHTTP 버전은 1.1이며, 클라이언트로부터의 요청이 이상 없는 경우 101을 상태코드로 사용한다.
UpgradeRequireWebSocket이라는 단어를 사용해야 한다. 대소문자는 구분 X
ConnectionRequireUpgrade라는 단어를 사용해야 한다. 대소문자는 구분 X
Sec-WebSocket-AcceptRequire클라이언트로부터 받은 Sec-WebSocket-Key를 사용하여 계산된 값이다.
Sec-WebSocket-ProtocolOption서버에서 서비스하는 하위 프로토콜을 명시한다. 클라이언트가 요청하지 않는 하위 프로토콜을 명시하면 HandShake는 실패한다.
Sec-WebSocket-ExtensionsOption서버가 사용하는 추가 옵션을 기술한다. 클라이언트가 요청하지 않는 추가 옵션을 명시하면 HandShake는 실패한다.

위 테이블에 명시된 헤더 중 필수는 반드시 사용해야 하며, 특정한 값이 명시된 헤더는 그 값만 사용해야 한다.


WebSocket Emulation

모든 클라이언트의 브라우저에서 WebSocket을 지원한다는 보장이 없으며
서버와 클라이언트 사이의 Proxy가 Upgrade 헤더를 해석하지 못해 서버에 전달하지 못할 수도 있으며 유휴 상태에서 connection을 종료시킬수도 있다.

이를 해결하기 위핸 방법이 WebSocket Emulation를 사용하는 것이다.

WebSocket Emulation
우선 WebSocket을 시도하고 실패할 경우 다른 HTTP 기반의 기술로 전환해서 다시 연결을 시도하는 것

Spring에서는 주로 SockJS를 사용한다.

SockJS

SockJS는 어플리케이션이 WebSocket API를 사용하도록 허용하지만, 브라우저에서 WebSocket을 지원하지 않는 경우에 대안으로 어플리케이션의 코드를 변경할 필요 없이 런타임에 필요할 때 대체를 하는 것이다.

SockJS의 장점은 클라이언트와 서버 사이의 짧은 지연시간, 그리고 크로스 브라우징을 지원하는 API라는 것이다.

SockJS의 구성

  • SockJS Protocol
  • SockJS Javascript Client - 브라우저에서 사용되는 클라이언트 라이브러리
  • SockJS Server 구현 - Spring-websocket 모듈을 통해 제공
  • SockJS Java Client - Spring-websocket 모듈을 통해 제공 (Spring ver.4.1 ~ )

SockJS는 다양한 기술을 이용해 웹소켓을 지원하지 않는 브라우저에서 정상적으로 동작하도록 해준다. 전송 타입은 크게 다음의 3가지로 분류된다.

  • WebSocket
  • HTTP Streaming
  • HTTP Long Polling

WebSocket Emulation Process

SockJS Client는 서버의 기본 정보를 얻기 위해 GET /info를 호출하는데, 이는 서버가 WebSocket을 지원하는지, 전송 과정에서 Cookies 지원이 필요한지 여부 그리고 CORS를 위한 Origin 정보 등의 정보를 응답으로 전달받는다.

그 이후 SockJS는 어떤 전송 타입을 사용할 지 결정한다. 위의 순서대로 사용하려고 시도한다.

모든 전송 요청은 다음의 URL 구조를 갖는다

https://host:port/myApp/myEndpoint/{server-id}/{session-id}/{transport}
  • server-id: 클러스터에서 요청을 라우팅하는데 사용하나 이외에는 의미없음

  • session-id: SockJS session에 소속하는 HTTP 요청과 연관성 있음

  • transport: 전송 타입 (예 : websocket, xhr-streaming, xhr-polling)

WebSocket 전송은 WebSocket HandShaking을 위한 하나의 HTTP 요청을 필요로 한다.

모든 메세지들은 그 이후 사용했던 Socket을 통해 교환된다.
HTTP 전송은 보다 더 많은 요청을 필요로 한다.

Ajax/XHR Streaming

서버 -> 클라이언트로의 메세지들을 위해 하나의 Long-running 요청이 있고, 추가적인 HTTP POST 요청은 클라이언트 -> 서버로의 메세지를 위해 사용된다
Long Polling

서버 -> 클라이언트로의 응답 후 현재의 요청을 끝내는 것을 제외하고는 XHR Streaming과 유사하다.
SockJS는 메세지 Frame의 크기를 최소화하기 위해 노력한다.

예를 들어, 서버는

"o" (open frame)을 초기에 전송하고, 메세지는 ["msg1","msg2"]와 같은 JSON-Encoded 배열로써 전달되며,

"h" (hearbeat frame): 기본적으로 25초간 메세지 흐름이 없는 경우에 전송하고

"c" (close frame): 해당 세션을 종료한다.

HeartBeats

SockJS 프로토콜은 서버에 HeartBeats 메시지를 보내서 서버 부하를 방지한다.
Spring SockJS에는 heartBeatTime 이라는 속성이 있는데 이 속성을 이용해서 빈도수를 조절 할 수 있다. 기본적으로 이 속성은 어떠한 메시지가 연결에 보내지지 않았다는 가정 하에 25초이다.

WebSockt, SockJS를 사용하여 STOMP(다중 채널 연결시 필요한 프로토콜)를 사용할 때 만약 STOMP 클라이언트와 서버의 heartBeats 협약이 변경될 경우 SockJS heartBeats는 무시된다.


WebSocketHandler

org.springframework.web.socket 패키지에 있으며 WebSocketHandler 인터페이스를 구현한 bean을 등록해서 웹소켓요청을 처리해야한다. 주요 메소드는 다음과 같다.

  • afterConnectionEstablished(WebSocketSession session) : 클라이언트가 서버로 연결된 이후에 실행

  • handleMessage(WebSocketSession session, WebSocketMessage<?> message) : 클라이언트가 서버로 메세지를 전송했을 때 실행

  • afterConnectionClosed(WebSocketSession session, CloseStatus status) : 클라이언트가 연결을 끊었을 때 실행

  • handleTransportError(WebSocketSession session,Throwable exception) : 연결된 클라이언트에서 예외 발생 시 실행

웹소켓을 사용할 때는 보통 채팅용으로 많이 사용해서 그런지, WebSocketHandler 나 AbstractWebSocketHandler 를 사용하기 보다 이를 확장한 TextWebSocketHandler 를 많이 사용한다.

  • handleTextMessage(WebSocketSession session, TextMessage message) : handleMessage 와 비슷하지만 텍스트 형태를 주고받는다.

HandshakeInterceptor

org.springframework.web.socket.server 패키지에 있으며 HandshakeInterceptor 인터페이스를 구현한 bean은 ws 커넥션 과정의 Handshake 때 beforeHandshake()와 afterHandshake()를 수행한다. HandshakeInterceptor 는 HTTP 정보 (특히 쿠키-세션ID)를 옮길 때 많이 사용하는 듯하다. 구현체인 HttpSessionHandshakeInterceptor 에서는 HTTP.SESSION.ID라는 이름으로 JSESSIONID를 옮겨담는다. attributes.put("HTTP.SESSION.ID", session.getId());

spring security와 spring websocket을 같이 쓴다면 HandshakeInterceptor를 구현해서 유저의 인증을 손쉽게 처리할 수 있다.


Stomp(Simple Text Oriented Message Protocol)

만약 WebSocket 프로토콜만을 사용해서 채팅 서버를 구현한다면, 메시지 포맷 형식이나 메시지 통신 과정, 세션 등을 일일이 관리해야하는 번거로움이 있다. 따라서 메시징 처리에 최적화 시키기 위해 Stomp 프로토콜을 서브 프로토콜로 사용하게 된다.

STOMP는 메시지 송수신을 효율적으로 하기 위해 나온 프로토콜이며 WebSocket 프로토콜 위에서 동작한다.

기본적으로 pub/sub 구조로 되어있어 메시지 송신이나 수신 처리하는 부분이 확실히 정의되어 있기 때문에 개발자 입장에선 메시징 처리 할 때 Stomp 스펙의 규칙만 잘 지키면 된다는 이점이 있다.

Stomp의 주요 특징

  • @Controller → @MessageMapping으로 연결한 후 브로커에게 보내는데 브로커는 메모리, RabbitMQ, ActiveMq 모두 사용이 가능하다.

  • Spring은 브로커에 대한 TCP 연결을 유지하고 연결된 WebSocket 클라이언트에게 메시지를 전달한다.

  • 클라이언트는 메시지를 받고 또 메시지를 수신한다.

  • 클라이언트에서 메시지를 보내면 @MessageMapping에서 받아서 처리한다.

  • 메시지를 받을 endpoint는 /endpoint/..., /endpoint/** 등을 지원한다.

  • 서버의 모든 메시지는 특정 클라이언트 구독에 대한 응답이어야 하며 서버 메시지의 subscription-id 헤더는 클라이언트 구독의 id 헤더와 동일해야한다.

Stomp의 장점

  • raw WebSocket보다 더 많은 프로그래밍 모델을 지원

  • 여러 브로커(카프카, 등등)을 사용가능

  • WebSocket 기반으로 각 Connect(연결)마다 WebSocketHandler를 구현하는 것보다 @Controller 된 객체를 이용해 조직적으로 관리할 수 있다.

  • 즉, 메세지는 STOMP의 destination 헤더를 기반으로 @Controller 객체의 @MethodMapping 메서드로 라우팅 된다.

  • STOMP의 destination 및 message type을 기반으로 메세지를 보호하기 위해 스프링 시큐리티를 사용할 수 있다.

Stomp Frame

Stomp의 메시지 형식은

COMMAND
header1:value1
header2:value2

Body^@

위와 같으며 이러한 Frame 구조로 클라이언트와 서버간의 메시지 통신이 이루어진다.

클라이언트는 메시지를 발송할 때 COMMAND로 SEND 또는 SUBSCRIBE 등의 명령을 사용할 수 있다.

header:value 형식으로 누가 받을지에 대한 정보와 메시지 정보 등이 포함된다.

즉, 위 형식에 메시지 송수신에 필요한 정보를 담게되고, STOMP 플로토콜은 pub/sub 방식을 사용하여 Message Broker를 통해 특정 작업을 수행하거나 메시지를 보내게 된다.

아래는 stomp-specification 에서 참고한 예시들이다.



SEND
destination:/queue/a
receipt:message-12345

hello queue a^@

------

MESSAGE
foo:World
foo:Hello

^@

//The value of the foo header is just World.

-------

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

^@

------

// SEND Frame은 body가 있는 경우 content-length와 content-type 헤더를 반드시 가져야만 한다.
SEND
destination:/queue/a
content-type:text/plain

hello queue a
^@

Spring 에서 Stomp 사용 설정

Spring은 WebSocket / SockJS를 기반으로 STOMP를 위해 spring-messaging과 spring-websocket 모듈을 제공한다.

아래 예시와 같이 STOMP 설정을 할 수 있는데 기본적으로 커넥션을 위한 STOMP Endpoint를 설정해야만 한다.


@Configuration
@EnableWebSocketMessageBroker 
@RequiredArgsConstructor
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { // (1)

    private final StompHandler stompHandler;

    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) { // (2)
        registry.enableSimpleBroker("/sub"); // (3)
        registry.setApplicationDestinationPrefixes("/pub"); // (4)
    }

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) { // (5)
        registry.addEndpoint("/stomp/chat") // ex ) ws://localhost:9000/stomp/chat
                .setAllowedOriginPatterns("*").withSockJS(); 
    }

    @Override // (6)
    public void configureClientInboundChannel (ChannelRegistration registration){
        registration.interceptors(stompHandler);
    }
}

(1) WebSocketMessageBrokerConfigurer를 상속받아 STOMP로 메시지 처리 방법을 구성한다.

(2) configureMessageBroker에서는 메시지를 중간에서 라우팅할 때 사용하는 메시지 브로커를 구성한다.

(3) enableSimpleBroker에서는 해당 주소를 구독하는 클라이언트에게 메시지를 보낸다. 즉, 인자에는 구독 요청의 prefix를 넣고, 클라이언트에서 1번 채널을 구독하고자 할 때는 /sub/1 형식과 같은 규칙을 따라야 한다.

(4) setApplicationDestinationPrefixes에는 메시지 발행 요청의 prefix를 넣는다. 즉, /pub로 시작하는 메시지만 해당 Broker에서 받아서 처리한다.

(5) 클라이언트에서 WebSocket에 접속할 수 있는 endpoint를 지정한다.

(6) 사용자가 웹 소켓 연결에 연결 될 때와 끊길 때 추가 기능(인증, 세션 관리 등)을 위해 인터셉터를 걸어주었다. 인자에는 추가 기능을 구현한 StompHandler를 빈으로 등록하여 넣어주었다.

자바스크립트에서 Stomp 설정

SockJS로 브라우저에 연결하기 위해 sockjs-client를 이용할 수 있다.

최근에는 JSteunou/webstomp-client를 많이 사용한다.

var sock = new SockJS("/ws/chat");
var stomp = webstomp.over(sock);

stomp.connect({}, function(frame) {
}

/* WebSocket만 이용할 경우 */

var websocket = new WebSocket("/ws/chat");
var stomp = webstomp.over(websocket);

stomp.connect({}, function(frame) {
}

In-Memory 기반 Message Broker 문제점

사실 Spring에서 제공하는 Stomp를 활용하고도, 내장된 Simple Message Broker를 사용해 채팅 서버를 구현할 수 있다. 하지만 Simple Message Broker 같은 경우 스프링 부트 서버의 내부 메모리에서 동작하게 된다.

인메모리 기반 브로커만 사용하면 서버가 down되거나 재시작을 하게될때 Message Broker(메시지 큐)에 있는 데이터들은 유실될 수 있다.

(우리 프로젝트는 클라이언트의 메시지를 저장해야 하기 때문에 이 방법은 사용하지 못 할 것이라고 판단하였다.)


오늘은 여기까지만 공부하고 다음에는 외부 메시지 브로커에 대해 공부해야겠다.

참고
https://dev-gorany.tistory.com/212
https://rubberduck-debug.tistory.com/123
https://xzio.tistory.com/997
https://wedul.site/692
https://velog.io/@ohjinseo/WebSocket-Spring-Boot-stomp-Redis-PubSub-%EC%9D%B4%EC%9A%A9%ED%95%9C-%EC%B1%84%ED%8C%85-%EA%B5%AC%ED%98%84
https://velog.io/@yyong3519/%EC%8A%A4%ED%94%84%EB%A7%81%EB%B6%80%ED%8A%B8-%EC%9B%B9%EC%86%8C%EC%BC%93-STOMP

profile
백엔드 프로그래머

0개의 댓글