웹소켓(Websocket)은 요즘 많은 주목을 받고 있습니다. "미래의 기술"이라고 불리기도 하고, Rails 5의 ActionCable 덕분에 그 어느 때보다 쉽게 사용할 수 있다는 이야기도 들려옵니다. 하지만 웹소켓은 정확히 무엇이며, 어떻게 동작하는 걸까요?
이 글에서는 Ruby를 사용해 아주 간단한 웹소켓 서버를 처음부터 직접 만들어 보면서 이 질문에 답해 보겠습니다. 작업을 마치면 브라우저와 서버 사이의 양방향 통신(bi-directional communication)을 성공적으로 구현하게 될 것입니다.
이 글의 코드는 학습용 예제입니다. 실제 프로덕션 환경에서 웹소켓을 구현하려면 훌륭한 websocket-ruby 젬(gem)을 사용하는 것이 좋습니다. 공식 WebSocket 명세(Spec) 문서도 함께 참고하시길 권합니다.
웹소켓, 도대체 왜 필요한가?
웹소켓은 일반적인 HTTP 연결이 가진 근본적인 한계를 해결하기 위해 탄생했습니다. 일반 HTTP 연결로 웹페이지를 요청하면 서버는 콘텐츠를 전송한 뒤 곧바로 연결을 닫아버립니다. 다른 페이지를 요청하려면 새로운 연결을 다시 만들어야 하죠. 대부분의 경우 이 방식은 잘 동작하지만, 다음과 같은 상황에서는 최선이 아닙니다.
- 실시간 업데이트가 필요한 경우: 채팅 애플리케이션처럼 새 메시지가 도착하는 즉시 화면을 갱신해야 하는 서비스라면, 일반 HTTP 요청만으로는 서버를 계속 폴링(polling)하여 새 데이터가 있는지 확인하는 수밖에 없습니다. 이는 비효율적입니다.
- 수많은 소규모 요청이 발생하는 경우: 프런트엔드가 서버에 아주 작은 요청을 수없이 보내야 한다면, 매번 새 연결을 생성하는 오버헤드가 성능 병목이 될 수 있습니다. (HTTP/2에서는 이 문제가 다소 완화되었습니다.)
반면 웹소켓은 서버와 단 한 번의 연결을 맺은 뒤, 그 연결을 계속 열어두고 양방향 통신에 사용합니다.
클라이언트 측 코드
웹소켓은 주로 브라우저와 웹 서버 간의 통신에 사용됩니다. 브라우저 측은 JavaScript로 구현합니다. 아래 예제는 로컬 서버로 웹소켓을 열고 메시지를 전송하는 아주 단순한 JavaScript 코드입니다.
<!doctype html>
<html lang="en">
<head>
<title>Websocket Client</title>
</head>
<body>
<script>
var exampleSocket = new WebSocket("ws://localhost:2345");
exampleSocket.onopen = function (event) {
exampleSocket.send("Can you hear me?");
};
exampleSocket.onmessage = function (event) {
console.log(event.data);
}
</script>
</body>
</html>
간단한 정적 서버를 띄우고 이 파일을 브라우저에서 열면 에러가 발생합니다. 당연한 결과죠. 아직 서버가 없으니까요. 이제 하나 만들어 봅시다. :-)
서버 만들기 시작
웹소켓 연결은 평범한 HTTP 요청으로 시작됩니다. 다만 생명주기(lifecycle)가 조금 특이합니다.
- 브라우저가 "웹소켓으로 업그레이드해 달라"는 의미의 특별한 헤더를 포함한 일반 HTTP 요청을 보냅니다.
- 서버가 특정 형식의 HTTP 응답을 반환하지만, 연결을 닫지 않습니다.
- 브라우저와 서버는 열린 연결 위에서 특수한 웹소켓 프로토콜을 사용해 데이터 프레임(frame)을 주고받습니다.
따라서 첫 번째 단계는 웹 서버를 만드는 것입니다. 아래 코드는 가능한 한 가장 단순한 형태의 웹 서버입니다. 실제로 무언가를 제공하지는 않고, 요청을 기다렸다가 STDERR로 출력만 합니다.
require 'socket'
server = TCPServer.new('localhost', 2345)
loop do
# 연결 대기
socket = server.accept
STDERR.puts "Incoming Request"
# HTTP 요청 읽기. \r\n만 있는 줄이 나오면 요청이 끝난 것
http_request = ""
while (line = socket.gets) && (line != "\r\n")
http_request += line
end
STDERR.puts http_request
socket.close
end
서버를 실행하고 웹소켓 테스트 페이지를 새로 고침하면 다음과 같은 출력이 나타납니다.
$ ruby server1.rb
Incoming Request
GET / HTTP/1.1
Host: localhost:2345
Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Version: 13
Sec-WebSocket-Key: cG8zEwcrcLnEftn2qohdKQ==
눈치챘겠지만, 이 HTTP 요청에는 웹소켓과 관련된 헤더들이 여럿 포함되어 있습니다. 이것이 바로 웹소켓 핸드셰이크(handshake)의 첫 단계입니다.
핸드셰이크(The Handshake)
모든 웹소켓 통신은 핸드셰이크로 시작됩니다. 클라이언트와 서버가 모두 웹소켓 통신을 시작한다는 사실을 인지하고, 프로토콜 버전에 대해 합의했음을 확인하는 절차입니다. 동작 방식은 다음과 같습니다.
1. 클라이언트가 HTTP 요청 전송
GET / HTTP/1.1
Host: localhost:2345
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: E4i4gDQc1XTIQcQxvf+ODA==
Sec-WebSocket-Version: 13
이 요청에서 가장 중요한 부분은 Sec-WebSocket-Key입니다. 클라이언트는 서버가 이 값을 변형한 결과를 돌려주기를 기대하며, 이는 XSS 공격 및 캐싱 프록시에 대한 방어 장치 역할을 합니다.
2. 서버가 응답
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: d9WHst60HtB4IvjOVevrexl0oLA=
서버 응답은 Sec-WebSocket-Accept 헤더를 제외하면 정해진 틀 그대로입니다. 이 헤더는 다음과 같이 생성됩니다.
# 클라이언트가 보낸 값에 마법 문자열(magic string)을 붙인 뒤,
# SHA1 해시를 생성하고 base64로 인코딩한다.
Digest::SHA1.base64digest([sec_websocket_accept, "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"].join)
눈을 의심할 필요 없습니다. 실제로 마법 상수(magic constant)가 등장합니다.
3. 핸드셰이크 구현하기
이제 서버가 핸드셰이크를 완료하도록 코드를 수정해 보겠습니다. 먼저 요청 헤더에서 보안 토큰을 추출합니다.
# 헤더에서 보안 키를 가져온다.
# 없으면 연결을 종료한다.
if matches = http_request.match(/^Sec-WebSocket-Key: (\S+)/)
websocket_key = matches[1]
STDERR.puts "Websocket handshake detected with key: #{ websocket_key }"
else
STDERR.puts "Aborting non-websocket connection"
socket.close
next
end
다음으로 이 보안 키로 유효한 응답을 생성합니다.
response_key = Digest::SHA1.base64digest([websocket_key, "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"].join)
STDERR.puts "Responding to handshake with key: #{ response_key }"
socket.write <<-eos
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: #{ response_key }
eos
STDERR.puts "Handshake completed."
웹소켓 테스트 페이지를 새로 고침하면 더 이상 연결 에러가 발생하지 않습니다. 연결이 성립된 것입니다!
서버 출력에서 보안 키와 응답 키를 확인할 수 있습니다.
$ ruby server2.rb
Incoming Request
Websocket handshake detected with key: Fh06+WnoTQQiVnX5saeYMg==
Responding to handshake with key: nJg1c2upAHixOmXz7kV2bJ2g/YQ=
Handshake completed.
웹소켓 프레임 프로토콜
웹소켓 연결이 한번 성립되면 HTTP는 더 이상 사용되지 않습니다. 이후 데이터는 웹소켓 프로토콜을 통해 교환됩니다.
프레임(frame)은 웹소켓 프로토콜의 기본 단위입니다. 웹소켓 프로토콜은 프레임 기반으로 동작하는데, 이게 무슨 뜻일까요?
브라우저에게 웹소켓으로 데이터를 전송하거나 서버가 응답하도록 요청할 때마다, 데이터는 여러 조각(chunk)으로 나뉘고 각 조각은 메타데이터로 감싸져 하나의 프레임이 됩니다.
프레임 구조는 다음과 같습니다. 맨 윗줄의 숫자는 비트(bit) 위치를 나타내며, 확장 페이로드 길이(extended payload length)처럼 일부 필드는 항상 존재하지 않을 수도 있습니다.
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-------+-+-------------+-------------------------------+
|F|R|R|R| opcode|M| Payload len | Extended payload length |
|I|S|S|S| (4) |A| (7) | (16/64) |
|N|V|V|V| |S| | (if payload len==126/127) |
| |1|2|3| |K| | |
+-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - +
| Extended payload length continued, if payload len == 127 |
+ - - - - - - - - - - - - - - - +-------------------------------+
| |Masking-key, if MASK set to 1 |
+-------------------------------+-------------------------------+
| Masking-key (continued) | Payload Data |
+-------------------------------- - - - - - - - - - - - - - - - +
: Payload Data continued ... :
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
| Payload Data continued ... |
+---------------------------------------------------------------+
가장 먼저 눈에 들어오는 점은 이것이 바이너리(binary) 프로토콜이라는 것입니다. 따라서 약간의 비트 조작이 필요하지만 걱정하지 마세요. 생각보다 어렵지 않습니다. 예를 들어 확장 페이로드 길이 필드는 페이로드가 127바이트 미만일 때는 존재하지 않습니다.
데이터 수신하기
핸드셰이크가 완료되었으니 이제 바이너리 프레임을 파싱할 차례입니다. 설명을 단순하게 유지하기 위해 들어오는 프레임을 한 번에 한 바이트씩 살펴보겠습니다. 그다음 전체 코드를 합쳐서 실제 동작을 확인해 보겠습니다.
첫 번째 바이트: FIN과 Opcode
위 표에서 볼 수 있듯이 첫 번째 바이트(처음 8비트)에는 몇 가지 정보가 담겨 있습니다.
- FIN (1비트): false라면 메시지가 여러 프레임으로 분할되어 있다는 의미입니다.
- opcode (4비트): 페이로드가 텍스트인지, 바이너리인지, 아니면 연결 유지를 위한 "핑(ping)"인지 알려줍니다.
- RSV (3비트): 현재 웹소켓 명세에서는 사용되지 않습니다.
첫 번째 바이트는 IO#getbyte 메서드로 얻고, 데이터 추출에는 간단한 비트마스킹(bitmasking)을 사용합니다. 비트 연산자가 낯설다면 관련 자료를 먼저 참고하는 것도 좋습니다.
first_byte = socket.getbyte
fin = first_byte & 0b10000000
opcode = first_byte & 0b00001111
# 우리 서버는 단일 프레임 텍스트 메시지만 지원한다.
# 클라이언트가 다른 것을 보내면 예외를 발생시킨다.
raise "We don't support continuations" unless fin
raise "We only support opcode 1" unless opcode == 1
두 번째 바이트: MASK와 페이로드 길이
프레임의 두 번째 바이트에는 페이로드에 대한 추가 정보가 담겨 있습니다.
- MASK (1비트): 페이로드가 마스킹(masked)되어 있는지 나타내는 불리언 플래그입니다. true라면 사용 전에 "언마스킹(unmasking)" 과정이 필요합니다. 명세에 따르면 클라이언트에서 서버로 오는 프레임은 항상 마스킹되어 있어야 합니다.
- payload length (7비트): 페이로드가 126바이트 미만이라면 길이가 여기 저장됩니다. 이 값이 126보다 크면, 실제 길이를 담은 추가 바이트들이 뒤따릅니다.
두 번째 바이트 처리 코드는 다음과 같습니다.
second_byte = socket.getbyte
is_masked = second_byte & 0b10000000
payload_size = second_byte & 0b01111111
raise "All frames sent to a server should be masked according to the websocket spec" unless is_masked
raise "We only support payloads < 126 bytes in length" unless payload_size < 126
STDERR.puts "Payload size: #{ payload_size } bytes"
세 번째~일곱 번째 바이트: 마스킹 키(Masking Key)
들어오는 모든 프레임의 페이로드는 마스킹되어 있다고 가정합니다. 내용을 언마스킹하려면 마스킹 키와 XOR 연산을 해야 합니다.
이 마스킹 키는 다음 네 바이트를 차지합니다. 별도로 처리할 필요는 없고, 그냥 배열로 읽어 들이면 됩니다.
mask = 4.times.map { socket.getbyte }
STDERR.puts "Got mask: #{ mask.inspect }"
4바이트를 배열로 읽어 들이는 더 깔끔한 방법을 알고 계시다면 알려주세요.
times.map이 조금 생소할 수 있지만, 제가 생각할 수 있는 가장 간결한 방법이었습니다.
여덟 번째 바이트부터: 페이로드(Payload)
좋습니다. 메타데이터는 끝났습니다. 이제 실제 페이로드를 가져올 수 있습니다.
data = payload_size.times.map { socket.getbyte }
STDERR.puts "Got masked data: #{ data.inspect }"
이 페이로드는 마스킹되어 있으므로 그대로 출력하면 깨진 문자처럼 보입니다. 언마스킹하려면 각 바이트를 마스크의 해당 바이트와 XOR하면 됩니다. 마스크는 4바이트뿐이므로, 페이로드 길이에 맞춰 반복해서 적용합니다.
unmasked_data = data.each_with_index.map { |byte, i| byte ^ mask[i % 4] }
STDERR.puts "Unmasked the data: #{ unmasked_data.inspect }"
이제 바이트 배열을 손에 넣었습니다. 이를 유니코드 문자열로 변환해야 합니다. 웹소켓의 모든 텍스트는 유니코드입니다.
STDERR.puts "Converted to a string: #{ unmasked_data.pack('C*').force_encoding('utf-8').inspect }"
전체 코드 조합하기
지금까지의 코드를 모두 합치면 다음과 같은 스크립트가 완성됩니다.
require 'socket' # TCPServer, TCPSocket 클래스 제공
require 'digest/sha1'
server = TCPServer.new('localhost', 2345)
loop do
# 연결 대기
socket = server.accept
STDERR.puts "Incoming Request"
# HTTP 요청 읽기. \r\n만 있는 줄이 나오면 끝
http_request = ""
while (line = socket.gets) && (line != "\r\n")
http_request += line
end
# 헤더에서 보안 키 추출. 없으면 연결 종료
if matches = http_request.match(/^Sec-WebSocket-Key: (\S+)/)
websocket_key = matches[1]
STDERR.puts "Websocket handshake detected with key: #{ websocket_key }"
else
STDERR.puts "Aborting non-websocket connection"
socket.close
next
end
response_key = Digest::SHA1.base64digest([websocket_key, "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"].join)
STDERR.puts "Responding to handshake with key: #{ response_key }"
socket.write <<-eos
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: #{ response_key }
eos
STDERR.puts "Handshake completed. Starting to parse the websocket frame."
first_byte = socket.getbyte
fin = first_byte & 0b10000000
opcode = first_byte & 0b00001111
raise "We don't support continuations" unless fin
raise "We only support opcode 1" unless opcode == 1
second_byte = socket.getbyte
is_masked = second_byte & 0b10000000
payload_size = second_byte & 0b01111111
raise "All incoming frames should be masked according to the websocket spec" unless is_masked
raise "We only support payloads < 126 bytes in length" unless payload_size < 126
STDERR.puts "Payload size: #{ payload_size } bytes"
mask = 4.times.map { socket.getbyte }
STDERR.puts "Got mask: #{ mask.inspect }"
data = payload_size.times.map { socket.getbyte }
STDERR.puts "Got masked data: #{ data.inspect }"
unmasked_data = data.each_with_index.map { |byte, i| byte ^ mask[i % 4] }
STDERR.puts "Unmasked the data: #{ unmasked_data.inspect }"
STDERR.puts "Converted to a string: #{ unmasked_data.pack('C*').force_encoding('utf-8').inspect }"
socket.close
end
웹소켓 테스트 페이지를 새로 고침해 서버에 요청을 보내면 다음과 같은 출력을 확인할 수 있습니다.
$ ruby websocket_server.rb
Incoming Request
Websocket handshake detected with key: E4i4gDQc1XTIQcQxvf+ODA==
Responding to handshake with key: d9WHst60HtB4IvjOVevrexl0oLA=
Handshake completed. Starting to parse the websocket frame.
Payload size: 16 bytes
Got mask: [80, 191, 161, 254]
Got masked data: [19, 222, 207, 222, 41, 208, 212, 222, 56, 218, 192, 140, 112, 210, 196, 193]
Unmasked the data: [67, 97, 110, 32, 121, 111, 117, 32, 104, 101, 97, 114, 32, 109, 101, 63]
Converted to a string: "Can you hear me?"
클라이언트에게 데이터 전송하기
클라이언트에서 장난감 웹소켓 서버로 테스트 메시지를 성공적으로 보냈습니다. 이제 서버에서 클라이언트로 메시지를 되돌려 보내 보겠습니다.
다행히 이쪽은 훨씬 간단합니다. 마스킹 처리가 필요 없기 때문입니다. 서버에서 클라이언트로 전송되는 프레임은 항상 마스킹되지 않은 상태입니다.
프레임을 한 바이트씩 읽었던 것처럼, 이번에도 한 바이트씩 조립해 나가겠습니다.
첫 번째 바이트: FIN과 opcode
페이로드는 한 프레임에 들어가고 텍스트 형식입니다. 즉 FIN은 1, opcode도 1입니다. 앞서 사용한 것과 같은 비트 형식으로 결합하면 다음 숫자가 됩니다.
output = [0b10000001]
두 번째 바이트: MASKED와 페이로드 길이
이 프레임은 서버에서 클라이언트로 향하므로 MASKED는 0입니다. 따라서 무시해도 됩니다. 페이로드 길이는 단순히 문자열의 길이입니다.
output = [0b10000001, response.size]
세 번째 바이트부터: 페이로드
페이로드는 마스킹되지 않은 그냥 문자열입니다.
response = "Loud and clear!"
STDERR.puts "Sending response: #{ response.inspect }"
output = [0b10000001, response.size, response]
전송!
이제 보낼 데이터가 담긴 배열이 준비되었습니다. 이를 회선(wire)으로 전송할 수 있는 바이트 문자열로 변환해야 합니다. 이때 매우 유용한 Array#pack 메서드를 사용합니다.
socket.write output.pack("CCA#{ response.size }")
다소 낯선 문자열 "CCA#{ response.size }"은 Array#pack에게 "배열에 8비트 부호 없는 정수 두 개가 있고, 뒤이어 지정된 크기의 문자열이 온다"고 알려주는 지시자입니다.
Chrome의 네트워크 검사기(network inspector)를 열어보면 메시지가 멀리까지 잘 전달된 것을 확인할 수 있습니다.
더 해볼 과제
여기까지입니다! 웹소켓에 대해 조금은 배웠기를 바랍니다. 물론 이 서버에는 빠진 기능이 많습니다. 연습을 계속하고 싶다면 다음 항목들을 구현해 보세요.
- 멀티 프레임(multi-frame) 페이로드 지원
- 바이너리 페이로드 지원
- Ping / Pong 지원
- 대용량(long) 페이로드 지원
- 종료 핸드셰이크(closing handshake)