WebSocket - 메시지 전달

강동현·2025년 4월 25일

Spring Websocket

목록 보기
6/9

websocket 통신이 실제로 어떻게 이루어지는지, 그 기본 단위인 메시지 프레임에 대해 깊이 파헤쳐 보려고 한다. 전통적인 웹의 HTTP 요청-응답 모델은 실시간 양방향 통신에는 한계가 명확했다. WebSocket(RFC 6455)은 이 문제를 해결하기 위해 등장했고, 단일 TCP 연결 위에서 지속적인 양방향 통신 채널을 제공한다.

TCP 프로토콜

TCP 프로토콜 그 자체로는 '메시지'의 경계를 모르는 바이트 스트림 프로토콜이다. 즉 보내는 쪽에서 메시지를 나눠 보내도 받는 쪽은 그냥 연속된 데이터로 받게 된다. 여기서 WebSocket은 어떻게 "여기서부터 여기까지가 하나의 메시지야!!"라고 알려줄 수 있을까?

바로 WebSocket 프레이밍 프로토콜 덕분인데 이번엔 RFC 6455 표준, 특히 WebSocket 메시지 프레임 구조와 그 안에 담긴 정보들을 확인해 보려고 한다.

WebSocket 프레임 구조

WebSocket 핸드셰이크가 성공하면, 이후 모든 통신은 표준화된 '프레임'단위로 이루어진다. 이 프레임은 헤더와 페이로드로 구성되며, 헤더에는 중요한 메타 정보가 담겨 있다. RFC 6455에 정의된 구조는 다음과 같다.

프레임의 헤더 필드를 보면 다양한게 있는데 하나씩 알아가보자.

  • FIN (1bit) : 이 프레임이 메시지의 마지막 조각 인지를 나타낸다. (1이면 마지막 0이면 중간)
    메시지가 길어서 여러 프레임으로 나눠서 보낼 때 사용된다.
  • RSV1, RSV2, RSV3 (1bit) : 확장을 위해서 예약된 비트들이다. 기본적으로 0이어야하며 핸드셰이크 시 특정 확장 사용에 합의했다면 그 확장의 정의에 따라 다른 의미를 가질 수 있다. 중요한 건 명시적 합의 없이 0이 아닌 값을 사용하면 안된다.
  • Opcode(4 bits) : 프레임의 종류를 나타낸다. 즉, 페이로드 데이터를 어떻게 해석해야 하는지를 알려준다.
  • MASK (1bits) : 페이로드 데이터가 마스킹 되었는지를 나타낸다. (1: 마스킹됨, 0: 마스킹 안됨) 클라이언트가 서버로 보내는 모든 프레임은 반드시 MASK = 1이어야하고, 서버가 클라이언트로 보내는 프레임은 반드시 MASK = 0이어야 한다.
  • Payload length : 페이로드 데이터의 길이를 나타낸다. 효율성을 위해 가변 길이 인코딩을 사용한다.
    • 초기 7비트 값이 0~125 : 이 값 자체가 길이다. (작은 메시지에 대한 헤더 오버헤드를 최소화 하기 위함.)
    • 초기 7비트 값이 126 : 다음 16비트(2바이트) 값이 실제 길이다. (최대 65535 바이트)
    • 초기 7비트 값이 127 : 다음 64비트(8바이트) 값이 실제 길이다. (매우 큰 메시지 지원)
  • Masking-key (0 또는 4 bytes) : MASK 비트가 1일때만 존재하고, 페이로드 마스킹에 사용된 32비트 키를 담고 있다.

여기서 초기 7비트라 하면 0~127이어야 하는데 왜 125일까? 이유는 7비트 필드 내에서 특정 값을 기준으로 가변 길이 인코딩을 효율적으로 구현하기 위함에 초기의 값은 125까지 사용가능하며 126이면 추가로 2바이트의 값을 더한 길이를 사용하겠다라는 의미고 127이면 추가로 8바이트를 더 사용하겠다라는 의미다 그래서 헤더의 길이가 가변적이고 최소 2바이트에서 최대 14바이트까지 될 수 있다.

Opcode 해독

Opcode는 프레임의 성격을 규정한다. 크게 데이터 프레임과 제어 프레임으로 나뉜다.

  • 데이터 프레임 : 실제 데이터 전송 담당
    • Text : 페이로드가 UTF-8 텍스트 데이터임을 나타낸다. 채팅 메시지, JSON, XML 등을 보낼 때 사용된다.
    • Binary : 페이로드가 바이너리 데이터임을 나타낸다. 이미지, 비디오/오디오, 직렬화된 객체 등 모든 종류의 바이너리 데이터를 보낼 때 사용된다.
    • Continuation : 이전에 시작된 Text 또는 Binary 메시지의 조각이 계속됨을 나타낸다. 단독으로 사용될 수 없고 반드시 다른 데이터 프레임 뒤에 와야 한다.
  • 제어 프레임 : 연결 관리 담당
    • 조각화 불가 : 제어 프레임은 절대로 조각화될 수 없다. (즉, FIN 비트는 항상 1이어야 한다.)
    • 작은 크기 : 페이로드 길이는 반드시 125바이트 이하여야 한다.
    • 인터리빙 가능 : 데이터 프레임 조각들 사이에 끼어들어 전송될 수 있다.

이러한 규칙들은 제어 프레임이 복잡한 처리 없이 빠르고 안정적으로 처리되도록 보장하여, 연결 종료나 상태 확인 같은 중요한 작업을 신속하게 수행하게 한다.

  • Close : 연결 종료를 시작하거나 응답하는데 사용된다. 선택적으로 상태코드와 종료 이유를 페어로드에 포함할 수 있다. 한쪽에서 Close프레임을 보내면 더 이상 데이터 프레임을 보내면 안되고 상대방의 Close 프레임 응답을 기다린 후 TCP 연결을 닫는 정상 종료 핸드셰이크를 수행한다.
  • Ping : 상대방이 살아있는지 확인하거나 네트워크 지연 시간을 측정하는데 사용 최대 125바이트의 임의 페이로드를 포함할 수 있다.
  • Pong : Ping 프레임에 대한 응답이며 반드시 Ping 프레임과 동일한 페이로드를 담아 가능한 빨리 보내야 한다.
  • 예약된 Opcode : 이 값은 현재 사용되지 않으며 미래를 위해 예약되어있는 값인데 이 프레임을 받으면 연결을 즉시 종료해야 된다.

메시지 조각내기 : 큰 메시지 전송과 인터리빙

메시지가 너무 클 경우 WebSocket은 이를 여러 프레임으로 조각내어 보낼 수 있다. 왜 조각화가 필요할까?

  • 메모리 효율 : 보내는 쪽에서 큰 메시지 전체를 메모리에 올리지 않고 보낼수 있다.
  • 중간 장치 처리 : 프록시 등이 큰 메시지를 단계적으로 처리할 수 있게 도와준다.
  • 인터리빙 : 큰 메시지를 보내는 도중에 급한 제어 프레임이나 다른 작은 메시지를 끼워 보낼 수 있어서 응답성이 좋아진다.

조각화는 FIN 비트와 Opcode

  1. 첫 조각 : 실제 Opcode를 사용하고 FIN=0으로 설정.
  2. 중간 조각 : Opcode=0x0을 사용하고 FIN=0으로 설정.
  3. 마지막 조각 : Opcode=0x0을 사용하고 FIN=1로 설정
    받는 쪽은 FIN=0인 Text/Binary 프레임 시작으로, FIN=0인 Continuation 프레임들의 페이로드를 계속 모으다가, FIN=1인 Continuation 프레임을 받으면 모아둔 데이터를 합쳐 원래 메시지를 복원한다. (대부분 라이브러리가 이 과정을 자동으로 처리한다.)

페이로드 마스킹 왜 클라이언트만 데이터를 숨겨 보낼까?

WebSocket의 흥미로운 규칙중 하나는 클라이언트가 서버로 보내는 모든 프레임은 반드시 페이로드를 마스킹해야된다는 점이다. 반면 서버는 마스킹되지 않은 프레임을 보낸다.

마스킹 알고리즘
1. 클라이언트는 각 프레임마다 무작위 4바이트 마스킹 키를 생성하여 헤더에 포함한다.
2. 페이로드의 각 바이트를 마스킹 키의 해당 바이트와 XOR 연산하여 변환된 데이터를 전송한다. (키는 순환적으로 사용)

언마스킹(서버)
서버는 프레임 헤더에서 마스킹 키를 읽어, 수신된 페이로드 데이터에 동일한 XOR 연산을 적용하여 원본 데이터를 복원한다.

이유가 뭘까?
왜 이렇게 복잡한 과정으로 처리를 하게 될까? 주된 이유는 중간 프록시 서버의 캐시 오염 공격을 방지하기 위해서이다. 과거 일부 프록시는 WebSocket을 잘 몰라서, 특정 바이트 패턴을 보고 HTTP 트래픽으로 오인하여 잘못 캐싱할 수 있었다. 악의적인 클라이언트가 조작된 WebSocket프레임을 보내면 프록시가 이를 잘못된 내용으로 캐싱하여 다른 사용자에게 영향을 줄 수 있었다.

하지만 마스킹은 매번 다른 랜덤 키로 페이로드 내용을 변형시켜, 동일한 데이터라도 네트워크 상에서는 매번 다른 바이트 패턴으로 보이게 한다. 이렇게 하면 프록시가 내용을 예측하거나 특정 패턴 기반으로 오작동하기 어렵게 만들어 캐시 오염 위험을 크게 줄인다.

이러한 마스킹은 보안 연결(WSS, TLS/SSL) 시에도 필수적으로 사용된다고 한다. TLS는 채널 전체를 암호화하지만 마스킹은 암호화된 터널 내부에서 WebSocket 프로토콜 자체를 대상으로 하는 특정 프록시 관련 공격을 막는 추가적인 보호 계층이다.

프레임 파싱과 처리 : 라이브러리의 역할

WebSocket 프레임을 수신하고 처리하는 것은 개념적으로 다음과 같은 과정을 거치게 된다.
1. 헤더읽기
2. FIN, RSV, Opcode, MASK, Payload length 해석
3. 마스킹 키 읽기
4. Payload length 만큼 페이로드 데이터 읽기
5. 언마스킹 수행
6. Opcode에 따라 처리

이러한 조각화된 메시지의 상태나 다양한 프로토콜 오류 처리 등을 저수준에서 라이브러리가 제공해주기 때문에 이 복잡한 과정을 구현안해줘도 된다. 그러나 이러한 프레이밍 메커니즘을 이해하는 것은 네트워크 문제 해결, 성능 최적화, 추가적인 고급 기능을 구현할 때 매우 중요하다고 볼 수 있다.

| 참고자료 |
** websocket에 대한 내용이 더 궁금하다면 다음 사이트를 참고하자
https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API/Writing_WebSocket_servers

profile
스스로에게 질문하고 답을 할 줄 아는 개발자

0개의 댓글