[Network] WebSocket & Sub Protocol (feat. STOMP)
📕 목차
1. Web Socket
2. Sub Protocol
3. STOMP(Simple Text Oriented Messaging Protocol)
1. Web Socket
여기서 이어지는 내용.
채팅 시스템 구현하다가, 우선 Web Socket 프로토콜에 대해서 명확하게 짚고 넘어가는 것이 좋다고 생각해서 작성.
Web Socket 내용은 IEFT에서 언급한 프로토콜을 기준으로 작성했습니다.
📌 What is Web Socket?
💡 WebSocket은 데이터 전송 방법에 대한 약속만을 정의한다!
- 웹 애플리케이션에서 실시간, 양방향 통신을 가능하게 하는 프로토콜. 2011년에 IETF에 의해 표준화 (RFC 6455)
- 단일 TCP 연결을 통해 전이중(full-duplex) 통신 채널을 제공하는 프로토콜
- HTTP로 hand-shake 수행한 후, socket 연결을 유지하면서 통신한다.
- 채팅같은 real-time 서비스를 제공해줘야 하는 기능을 구현할 때 사용
- HTTP/HTTPS와 동일하게 80, 443 포트를 사용하기 때문에 방화벽이 있는 환경에서도 잘 동작한다.
- 만약 다른 port를 사용했다면, 한 쪽에서 해당 port를 막아뒀을 때 연결이 안 됨.
📌 HTTP 프로토콜과의 차이
- HTTP
- 클라이언트가 요청을 보내면 서버가 응답하는 단방향(half-duplex) 통신
- 비연결성(응답 받으면 연결 종료), 무상태 지향
- 매 요청마다 헤더 정보 포함
- Web Socket
- 초기 연결 설정 후 양쪽에서 요청을 보낼 수 있음 (처음 연결 요청은 Client가 시작)
- 한 번 연결되면, 한 쪽에서 명시적으로 닫을 때까지 유지
- 초기 연결 후에는 최소한의 오버헤드로 데이터 전송
📌 Hand Shake
그렇다면 Client는 어떤 요청과 응답을 받아서 연결을 성립시킬까?
GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Origin: http://example.com
Sec-WebSocket-Protocol: chat,
superchat Sec-WebSocket-Version: 13
위 요청은 `{server.domain.name}/chat`으로 HTTP GET 요청을 보낼 때의 요청 정보를 의미한다.
`/chat`은 server에서 정한 end-point라서 바뀔 수 있다.
헤더 이름 | 필수 | 설명 |
GET | ✅ | 요청 명령어는 GET을 사용. HTTP 1.1 이상이어야 한다. |
Host | ✅ | 연결할 웹소켓 서버 주소 |
Upgrade | ✅ | WebSocket을 사용해야 한다. (대소문자 무관) |
Connection | ✅ | Upgrade를 사용해야 한다. (대소문자 무관) |
Sec-WebSocket-Key | ✅ | 길이가 16Byte인 임의로 선택된 숫자를 base64로 인코딩한 값 |
Origin | ✅ | 클라이언트의 주소. 웹 브라우저를 사용하는 경우 필수 항목 |
Sec-WebSocket-Version | ✅ | 연결할 Web Socket 버전. 여기선 13을 사용 |
Sec-WebSocket-Protocol | 클라이언트가 사용하고 싶은 하위 프로토콜 이름 명시 | |
Sec-WebSocket-Extensions | 클라이언트가 사용하고 싶은 추가 옵션 기술 |
Sub Protocol(하위 프로토콜)이 뭔지는 밑에서 다룰 예정이다.
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Sec-WebSocket-Protocol: chat
헤더 이름 | 필수 | 설명 |
HTTP | ✅ | 별도의 약속이 없다면, 성공 응답은 101 status code를 사용한다. |
Upgrade | ✅ | WebSocket 고정 |
Connection | ✅ | Upgrade 고정 |
Sec-WebSocket-Accept | ✅ | 클라이언트로부터 받은 Sec-WebSocket-Key를 사용해 계산된 값 |
Sec-WebSocket-Protocol | 서버에서 서비스하는 하위 프로토콜 명시 클라이언트가 요청하지 않은 하위 프로토콜이 명시되어 있으면 HandShake에 실패한다. |
|
Sec-WebSocket-Extensions | 서버가 사용하는 추가 옵션 기술 클라이언트가 요청하지 않은 추가 옵션이 명시되어 있으면 HandShake에 실패한다. |
📌 Frame
- FIN (1 bit): 메시지의 마지막 프레임인지? (기본적으로 TCP라서 Fregmentation이 수행됨)
- RSV1, RSV2, RSV3 (각 1 bit): 예약된 비트로, 일반적으로 0을 갖는다.
- Opcode (4 bits): 프레임의 타입을 의미한다.
- 0x0: 연속 프레임
- 0x1: 텍스트 프레임
- 0x2: 이진 프레임
- 0x8: 연결 종료
- 0x9: Ping
- 0xA: Pong
- Mask (1 bit): payload 데이터가 마스킹되었는지 표시
- Payload length (7 bits, 7+16 bits, 혹은 7+64 bits): 페이로드 길이를 의미
- Masking-key (0 혹은 4 bytes): 마스킹에 사용되는 키
- Payload data: 실제 전송되는 데이터 정보
✒️ Masking의 목적
Web Socket 프로토콜에서 Mask라는 이상한 필드가 추가되었다.
그런데 해당 필드는 권장 사항이 아니라 의무 사항이라고 하니, 궁금해서 조금 알아봤다.
중간자(ex. proxy server)가 Web Socket 트래픽을 악의적으로 조작하는 캐시 포이즈닝(cache poisoning) 공격과 같은 보안 위협을 방지하기 위해 사용한다. (브라우저에 캐싱된 값에 올바르지 않은 값을 흘려서 악의적인 데이터 캐싱하는 것들)
이를 위해선 클라이언트에서 서버로 전달되는 모든 프레임은 반드시 마스킹이 되어야 한다. (반대는 불필요)
뭔 소리야? ㅋㅋㅋㅋㅋ
우선 마스킹은 payload의 각 byte에 대해 masking-key를 사용해 XOR 연산을 사용하여 무작위 난수를 생성한다. (서버한테 masking-key를 같이 전달해서 한 번 더 XOR 연산을 하면 원본값을 복원할 수 있다.)
그런데 여기서 masking-key는 클라이언트가 생성하는 것이 아니라, 브라우저가 생성한다. (브라우저의 js 코드가 아님)
mask는 클라이언트가 악의적인 byte stream을 흘리는 것을 시도할 수 있음을 전제로 둔다.
이를 그대로 받으면 server가 장애를 일으킬 수도 있으므로, 공격자가 악의적인 byte stream을 생성해서 전달하더라도 제어권이 없는 masking-key에 의해 최종 byte stream은 다르게 전달됨을 의미한다.
웃긴 건 이게 실제로 가능함을 시험해보고 넣은 게 아니라, 그런 가능성이 존재하는 것만으로도 브라우저 공급 업체가 불안할 수 있기 때문에 가능성을 제거하기 위해 추가되었다.
근데 이거 그냥 WebSocket 라이브러리 가져와서 masking-key 수정하고 공격하면 되는 건데, 뭐 하러 이런 걸 만들었는지 모르겠다. 뭘 막겠다는 건지..
참고 [Stack OverFlow]
📌 Ping/Pong
Web Socket의 가장 중요한 내용 중 하나라고 생각하는데, 이걸 자세히 다루는 블로그가 많이 없는 것 같아서..
양방향 통신을 위해 socket을 마련해둔 것까진 좋은데, 문제는 한 쪽에서 연결을 끊겠다는 요청을 전달하지 않거나, 전달할 수 없는 상황이라면 어떻게 할 것인가?
이런 현상은 생각보다 자주 발생한다.
터널을 지나거나 지하로 들어가는 등의 특수한 경우도 있겠지만, collision이 자주 발생하는 사람이 밀집된 장소에서 무선 통신을 시도하거나, 단순히 네트워크 환경이 좋지 않을 수도 있다.
불안정한 핸드쉐이크가 발생하여 client에서 연결을 끊어버렸을 때, 서버 측에서도 더 이상 연결을 유지할 필요가 없으므로 socket을 삭제해야 하지만 이런 상황을 알 방도가 없다. (혹은 클라이언트가 아주 잠시 연결이 끊겼다가 다시 연결을 하는 경우일 수도 있다. 이럴 때마다 사용자 상태를 offline으로 전환했다가 online으로 되돌리는 건 과하다.)
문제는 일정 시간 트래픽이 없으면, 중간 장치(ex. proxy)에서 연결을 끊어버릴 수도 있다.
그렇다고 일정 수치 이상 지나면 알아서 연결을 끊도록 만들면, 이게 long-polling이랑 뭐가 달라지겠나.
이를 위해 router 간에 박동 신호를 전달하고, application의 health check를 하듯, connection 상태를 주기적으로 확인하는 박동 검사를 수행해야 하는데, 여기서 ping/pong을 사용한다.
한 쪽에서 ping을 보내면, 상대는 pong을 반드시 전달해야 한다. (Client나 Server 어느 쪽에서 ping을 보내도 무관하다) 이 과정을 주기적으로 수행하다가 일정 시간을 넘어도 ping/pong을 수신하지 못 하면 연결을 닫는다.
ping/pong을 비동기적으로 동작하도록 한다면, 다른 메시지의 송/수신을 방해하지 않도록 만들 수도 있다.
✒️ Ping의 주기는 어떻게 잡아야 할까?
IETF 문서를 읽어봐도 ping을 얼마나 자주 해야 하는지에 대한 지침이 없었다.
그러다 매우 예전에 작성된 stack overflow 질문을 읽어봤는데, 과거와 달리 현대의 JS websocket 라이브러리는 ping을 알아서 처리해주기 때문에, pong에 대한 핸들링만 처리하면 된다고 한다. (네트워크 상태에 따라 브라우저가 동적으로 주기를 조절하기 위해)
하지만 이건 내가 원한 답이 아니니, 다른 방식으로 접근해보자.
우선 하한선을 고려해야 한다.
client와 server 사이의 proxy 혹은 NAT router의 ttl이 몇 초인지 고민해봐야 한다.
그리고 이는 최소 60sec 이상으로 설정하는데, 그 말은 가장 짧은 주기를 가질 때 1분마다 값이 날아가므로 ping은 그보다 짧은 주기를 사용해야 안정적으로 연결을 유지할 수 있다.
보다 적절한 주기를 알아내보고 싶다면, 인지도 있는 WebSocket library들이 설정한 interval을 참고해보는 것도 좋을 것이다.
WebSocket-Node는 interval 간격을 20sec로 설정해주고 있다.
(`24.10.31) 나중에 알게 된 사실은 IEFT에서 권장으로 25sec로 잡았다고 한다. 그래서 스프링은 SockJS 설정할 때, 기본값을 25sec로 잡는다. 그런데 또 rabbitmq는 5~20sec를 권장함.
✒️ Ping으로 클라이언트와 서버 데이터 동기화
이건 그냥 개인적으로 떠오른 재밌는 아이디어를 적어둔 내용.
Client와 Server가 Web Socket이 연결되어 있음을 가정하고, 사용자가 Client에 input을 넣어 state가 바뀐 상황을 상상해보자.
해당 event를 Client가 곧장 Server로 보내지 않고 데이터를 동기화할 수는 없을까?
IETF 공식문서에서 ping(0x9), pong(0xA)은 애플리케이션 데이터가 포함될 수 있다고 명시하고 있다.
(ping/pong을 할 때, 개발자가 payload에 원하는 값을 담을 수 있도록 모두 비워놓았다. 의도적인 건진 모르겠지만, 딱히 막아두진 않았다.)
원칙 상 가장 최근에 처리된 Ping에 대해서만 Pong 프레임을 보내도록 선택하므로, Ping에 요청을 담으면 Pong에 응답을 담을 수 있다는 말이 된다.
그렇다면 event가 발생할 때마다 server로 보내는 게 아닌, "어차피 보내야 할 요청"인 ping에 데이터를 담아서 보내는 것도 이론적으로 가능하단 말이 된다.
ping의 주기는 약 20sec이므로, 사실상 real-time이나 다름없다.
하지만 실시간성이 매우매우 중요한 경우, 초 단위 차이로 문제가 심각해질 수 있는 critical한 프로그램에선 써먹어볼 수 없는 방법이다.
2. Sub Protocol
📌 Why use sub protocol?
Web Socket Protocol은 데이터 전송 방법에 대한 정의를 할 뿐, 데이터 형식이나 구조에 대한 내용은 명시되어 있지 않다.
메시지가 의미하는 내용, 클라이언트가 특정 시점에 기대할 수 있는 메시지 종류, 보낼 수 있는 메시지 종류 등은 결국 애플리케이션 레벨에서 처리해야 하며 별도의 합의가 필요하다.
하위 프로토콜은 이런 정보를 교환할 수 있도록 명시한 것이다.
처음 WebSocket을 연결할 때, 헤더에 Web-Socket-Protocol 필드가 존재했던 것을 잊지 않았다면, 사용할 protocol을 여기에 명시하면 된다는 것을 알 수 있다.
📌 WAMP (Web Application Messaging Protocol)
이게 원래는 IoT에서 사용하려고 나온 개념이라, 이를 제대로 다뤄볼 일이 잘 없다고 한다 ㅋㅋ
여튼 Sub Protocol의 필요성은 다음과 같다.
- 표준화된 메시지 형식
- WebSocket은 메시지 구조나 형식을 규정하지 않기 때문에 유연성을 제공하지만, 애플리케이션 개발자가 일관된 메시지 형식을 신경써야만 한다.
- 어떤 SubProtocol을 사용하기로 합의를 했다면, 일관된 메시지 구조와 형식을 보장할 수 있다.
- 메시지 라우팅 및 주소 지정
- WebSocket은 단순히 연결된 end-point 간의 channel만 제공하므로, 애플리케이션에서 필요한 메시지 라우팅과 같은 기능이 없다.
- Sub Protocol은 일반적으로 topic에 대한 pub/sub 모델을 제공한다. 즉, 메시지를 적절한 수신자나 핸들러로 라우팅할 수 있다.
- 연결 관리 간소화
- WebSocket은 클라이언트 세션을 관리하고, 상태를 추적하는 것을 개발자의 몫으로 남긴다.
- Sub Protocol에선 연결 설정, 유지, 종료에 대한 표준화된 절차를 제공한다. (예를 들어, STOMP에선 CONNECT, DISCONNECT 프레임을 정의해두었다.)
- 오류 처리
- WebSocket은 에러가 발생했을 때 메커니즘을 명시하지 않는다.
- Sub Protocol에서 오류 코드와 설명을 포함한 메커니즘을 포함시켜 개선한다.
- 상호 운용성 향상
- 표준화된 프로토콜을 사용하도록 하여, 다양한 클라이언트와 서버 간 호환성을 보장할 수 있다.
- 개발 생산성 향상
- 개발자가 더 이상 저수준의 통신 세부사항에 신경쓰지 않아도 된다. 그저 비지니스 로직에 전념하도록 만들 수 있다.
3. STOMP (Simple Text Oriented Messaging Protocol)
📌 What is STOMP
이름에도 Simple Text Oriented라고 나온 만큼, 간단한 텍스트를 기반으로 메시징을 하는 하위 프로토콜이다.
- 텍스트 기반: 사람이 읽을 수 있는 형식으로 데이터를 전달한다. (디버깅 용이)
- 단순성: 구현과 사용이 간단하다
- 상호운용성: 다양한 언어와 플랫폼에서 지원한다.
- 프레임 기반: 메시지는 명확한 구조를 가진 프레임으로 전송된다. (총 세 부분으로 프레임 끝은 NULL문자로 표시)
- COMMAND: 프레임의 유형 (ex. CONNECT, SEND)
- HEADERS: 키-값 쌍의 집합으로, 각 줄은 "key:value" 형식으로 구성된다. (이걸로 인증 처리도 수행할 수 있다)
- BODY: 선택적인 메시지 본문으로, 빈 줄로 헤더와 구분한다.
- Publish-Subscribe: 메시지를 공급하는 주체와 소비하는 주체를 분리해 제공하는 메시징 방법 (한 줄 요약이 어려워서 밑에서 다시 자세히 설명..)
📌 COMMAND
STOMP를 사용하여 연결을 하면, 실제로 위와 같은 로그가 출력된다. (난 이게 STOMP 프로토콜 프레임인 걸 이제 알았다 ㅎㅎ...)
- CONNECT: 클라이언트가 서버와의 STOMP 세션을 시작
- CONNECTED: 서버가 연결 성공을 확인
- SUBSCRIBE: 클라이언트가 특정 대상을 구독
- SEND: 메시지를 특정 대상으로 전송
- MESSAGE: 서버가 구독자에게 메시지를 전달
- UNSUBSCRIBE: 구독 취소
- DISCONNECT: STOMP 세션 종료
이 외에도 BEGIN, COMMIT, ABORT, ACK, NACK도 있다.
📌 Pub/Sub Model
채팅방 1, 2, 3이 있고, 채팅방 1에 사용자A, 사용자B, 채팅방 2에는 사용자B가 멤버로 들어가 있다고 가정하자.
B가 1번 채널에 메시지를 전송했을 때, 해당 채널의 다른 사용자들에게 메시지를 전달하려면 어떻게 해야할까?
가장 일반적인 방법으로는 Server가 1번 채널에 연결된 WebSocket Session들에 메시지를 전송하는 방법일 것이다.
하지만 위 방식은 상태 관리의 상당한 복잡성을 야기한다.
각 channel마다 연결된 session 정보들을 유지해야 하는 일은 만만찮은 일이다.
메시징 프로토콜이 채택하는 PUB/SUB 방식은 시스템을 보다 느슨하게 만들기 위해 도입되었다. (STOMP도 마찬가지)
우선, 각 채팅방은 고유한 topic(ex. `/topic/chatroom.1`, `/topic/chatroom.2`)을 갖도록 만든다.
그리고 클라이언트는 메시지를 받고 싶은 topic을 구독(sub)하고, 해당 topic에 메시지를 전달(pub)한다.
만약 topic에 메시지가 pub되면, Message Broker가 topic을 구독한 클라이언트들을 확인하고, STOMP 형식으로 메시지를 전달하는 라우터 역할을 수행하는 것이다.
더 이상 Server가 WebSocket Session을 관리하면서 Routing을 해주지 않아도 되므로, 시스템이 느슨해졌다고 표현하는 것이다.
띠옹. 근데 갑자기 Message Broker라는 애가 튀어나왔는데, 얜 또 뭔데?
📌 Message Broker
여기서부턴 이해하기가 많이 난해해진다.
우선 STOMP는 메시지 지향 미들웨어(MOM)를 위한 프로토콜이다.
Message Broker는 MOM의 핵심 구성 요소 중 하나인데, 다음과 같은 역할을 수행한다.
- 메시지 수신 및 저장
- 메시지 라우팅
- 구독자에게 메시지 전달
- 필요 시 메시지 변환
- 신뢰성 있는 메시지 전달 보장
이 때 STOMP는 내장 메시지 브로커를 사용하거나, 외부 브로커(ex. RabbitMQ, ActiveMQ)와 연동할 수 있다.
1️⃣ In-Memory Brocker
2️⃣ External Broker
Message Broker에 대한 설명은 이번 장 범위에서 벗어나므로 설명하지 않는다.
여기서 매우 중요한 것은 STOMP와 Message Broker의 차이를 명확하게 구분하는 것이다.
개인적으로 처음에 이걸 이해하는 게 너무 어려웠다.
STOMP이 제공하는 것은 pub/sub 모델을 구현하기 위한 명령어(Command)와 프레임 구조를 정의하는 것 뿐이다.
STOMP는 그저 프로토콜이기 때문에, SUBSCRIBE, SEND, MESSAGE, UNSUBSCRIBE라는 명령어를 제공해줄 뿐, 이런 명령을 받았을 때 실제로 어떤 일을 처리할 지는 다른 곳(여기선 Message Broker)에서 처리해야 한다는 의미다.
사용자1이 Server와 STOMP 하위 프로토콜 기반의 Web Socket 연결 이후, `chat.1`에 메시지를 발행하는 과정을 정리하면 다음과 같다.
- 사용자1이 STOMP 프로토콜 기반의 메시지를 웹소켓을 통해 서버로 전달 (CONNECT)
- 서버가 STOMP 메시지를 해석하고, 사용자 연결을 수락하여 응답 (CONNECTED). 여기서 사용자의 Session을 상태에 추가
- 사용자1이 `/topic/chat.1`을 구독 요청 (SUBSCRIBE)
- 서버가 `/topic/chat.1`을 라우팅하여 적절한 컨트롤러로 요청 전달
- 컨트롤러에서 Message Broker에게 STOMP 프로토콜 기반으로 사용자1이 `/topic/chat.1`을 구독함을 알림 (SUBSCRIBE)
- Message Broker가 STOMP 메시지를 해석하여, `/topic/chat.1`에 대해 사용자1의 Message Queue를 추가
- 사용자2가 `/topic/chat.1`로 메시지를 발행 (SEND)
- 서버가 `/topic/chat.1`을 라우팅하여 적절한 컨트롤러로 요청 전달
- 컨트롤러가 Message Broker에게 사용자2의 `/topic/chat.1`에 대한 메시지 발행을 전달
- Message Broker가 topic에 메시지를 삽입하고, 구독 중인 사용자 MQ에도 메시지를 삽입.
- 새로운 Message가 Queue에 들어온 사용자 세션을 가지고 있는 서버에게 Publish 이벤트 발생을 알리고, 메시지 전달 (Message)
- Server는 Message를 받아서 구독 중인 Client에게 Message를 전달
4, 8번은 내부 구현에 따라 다를 수 있다. 어떤 시스템은 서버가 단순히 WebSocket 연결만 관리하도록 만들고, 모든 STOMP 명령이 Message Broker로 전달되도록 만드는 경우도 있다. (하지만 이러면 Broker와 Client가 직접 연결되므로, 내부 인프라가 직접적으로 노출되는 꼴이다.)
6번 또한 사용자 별로 별도의 MQ를 만들지 않는 경우가 있다.
Server가 내장 혹은 외장 Brocker, 무엇을 사용하던 간에 중요한 것은 사용하려는 Broker가 Client와 통신할 때 사용하는 Sub Protocol을 이해할 수 있는지가 관건이다.
Sub Protocol이 "무엇을 해야 하는지"를 정의했으므로, Broker의 역할은 "어떻게 할 것인지"를 구현하는 것이 핵심이다.