Published using Google Docs
Golang으로 직접 WebSocket 통신 구현해보기
Updated automatically every 5 minutes

출처: https://zenn.dev/fukurose/articles/79a0dac7d19091

WebSocket 통신 패키지를 사용하지 않고

WebSocket 의 사양이 기재되어 있는 RFC 6455를 참고로, 패키지를 사용하지 않고 브라우저와 서버간 WebSocket 통신을 구현하려고 생각한 것이 시작이다.

이번의 구현예는, Go 언어로 했지만 구현에 관한 필요한 사양은 수시로 설명하므로, 다른 언어에서도 괜찮다.

꼭 함께 구현해 보자.

완성된 구현은 GitHub에 올리고 있다.

https://github.com/fukurose/go-websocket-doyasa

코드 다운로드

처음에

먼저 간단한 HTML을 반환하는 서버를 만들어 보겠다.

main.go

package main

import (
 
"log"
 
"net/http"
)

func main() {
 
var httpServer http.Server
 http.Handle(
"/", http.FileServer(http.Dir("public")))
 log.Println(
"start http listening :3000")
 httpServer.Addr =
":3000"
 log.Println(httpServer.ListenAndServe())
}

public/index.html
<html>
 <head>
   <title>Web Sokect DOYASA</title>
 </head>
 <body>
   <h1>WebSocket Test Client</h1>
 </body>
</html>

서버를 시작하여 http://localhost:3000 에 액세스하고 HTML이 정상적으로 표시되면 OK 이다.

브라우저에서 핸드셰이크

이제 브라우저와 서버간에 WebSocket 통신을 설정한다.

먼저 브라우저에서 서버로 핸드셰이크한다.

RFC에 따르면 클라이언트의 핸드셰이크 형식은 아래와 같다.

The handshake from the client looks as follows:

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-WebSo Version: 13

발췌: https://datatracker.ietf.org/doc/html/rfc6455#section-1.2

형식은 HTTP와 같다. 익숙하지 않은 항목을 보충하면

Upgrade: websocket Connection: Upgrade

WebSocket 통신하기 위한 것이다.

Sec-WebSocket-Key

나중 인증을 위해 사용한다.

Sec-WebSocket-Protocol

WebSocket 통신상에서 송수신 되는 데이터의 취급 프로토콜을 결정하는 것이다. JSON-RPC 라고도 지정할 수 있는 것 같지만, 이번은 사용하지 않는다. 선택 항목이다.

Sec-WebSocket-Version

WebSocket 버전이다. 2022/02 시점에서는 13이 최신이다.

이제 브라우저에서 서버로 핸드셰이크를 해보자. 브라우저에서 WebSocket 객체를 사용하면 손쉽게 핸드 셰이크 요청을 할 수 있다.

public/index.html

// 브라우저에서 핸드쉐이크를 보낸다
new WebSocket('ws://localhost:3000/websoket');

위의 예를 보면, /websocket 에 액세스할 수 있으므로 서버 측에서 요청을 받고, 핸드셰이크 내용을 살펴보겠다.

main.go

func handlerWebSocket(w http.ResponseWriter, r *http.Request) {
 dump, err := httputil.DumpRequest(r,
true)
 
if err != nil {
   http.Error(w, fmt.Sprint(err), http.StatusInternalServerError)
   
return
 }
 fmt.Println(
string(dump))
}

자세한 변경 사항은 GitHub에서 확인하기 바란다.

https://github.com/fukurose/go-websocket-doyasa/commit/ba45436db2e8b8e545be81c2270da312637d61c7

이제 서버를 시작하고 액세스해 본다.

서버 측에는 아래와 같은 요청이 왔음을 알 수 있다.

GET /websocket HTTP/1.1

Host: localhost:3000

Connection: Upgrade

Origin: http://localhost:3000

Sec-Websocket-Extensions: permessage-deflate; client_max_window_bits

Sec-Websocket-Key: dGdc5t123DuO4PkkB/eSUA==

Sec-Websocket-Version: 13

Upgrade: websocket

(User-Agent와 같은 불필요한 헤더가 삭제 되었다.)

일반적으로 예상대로 요청이다.

익숙하지 않은 Sec-Websocket-Extensions 은 확장을 나타낸다. permessage-deflate는 Per-Message Compression Extensions 로, 메시지 압축의 확장인 것 같다. 브라우저에서 사용할 수 있다면 서버에 알린다. 이번에는 사용하지 않으므로 무시한다.

요청을 보냈지만 브라우저 콘솔에

WebSocket connection to 'ws://localhost:3000/websocket' failed:

가 표시된다. 브라우저에서 악수를 시도했지만 서버가 무시한 상태이다.

그럼, 서버로부터도 손을 내밀어 보자.

서버에서 핸드셰이크

서버로부터의 핸드 셰이크는 아래의 형식이 되고 있다.

The handshake from the server looks as follows:

HTTP/1.1 101 Switching Protocols

Upgrade: websocket

Connection: Upgrade

Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

Sec-WebSocket-Protocol: chat

발췌: https://datatracker.ietf.org/doc/html/rfc6455#section-1.2 

위에서 설명한 대로 Sec-WebSocket-Protocol는 사용하지 않으므로 필요한 것은 Sec-WebSocket-Accept 뿐이다.

이 Sec-WebSocket-Accept은 RFC 6455 에는 아래와 같이 작성되어 있다.

concatenate this with the Globally Unique Identifier (GUID, [RFC4122]) "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" in string form, which is unlikely to be used by network endpoints that do not understan hash (160 bits) [FIPS.180-3], base64-encoded (see Section 4 of [RFC4648]), of this concatenation is then returned in the server's handshake.

발췌: https://datatracker.ietf.org/doc/html/rfc6455#section-1.3

요약하면

  1. 브라우저에서 온 Sec-Websocket-Key 끝에 258EAFA5-E914-47DA-95CA-C5AB0DC85B11 를 부여한다
  2. 해당 문자열을 기반 SHA-1 으로 해시 값 만들기
  3. 해시 값을 base64으로 인코딩
  4. 이것을 Sec-WebSocket-Accept 으로 반환

라는 것이다. 말한대로 구현해 보자.

main.go

func buildAcceptKey(key string) string {
 h := sha1.New()
 h.Write([]
byte(key))
 h.Write([]
byte("258EAFA5-E914-47DA-95CA-C5AB0DC85B11"))
 
return base64.StdEncoding.EncodeToString(h.Sum(nil))
}

그럼, Sec-WebSocket-Accept를 포함하여 브라우저에 응답을 반환한다.

main.go

func handlerUpdrade(w http.ResponseWriter, r *http.Request) {
 
// 여기에서 주고 받음은 HTTP protocol 이 아니므로 Hijacker 을 사용한다
 hijacker := w.(http.Hijacker)
 conn, readWriter, err := hijacker.Hijack()
 
if err != nil {
   
panic(err)
 }
 
defer conn.Close()

 
// 브라우저에서 key를 토대로 응답 용 key를 만든다
 key := r.Header.Get(
"Sec-Websocket-Key")
 acceptKey := buildAcceptKey(key)

 readWriter.WriteString(
"HTTP/1.1 101 Switching Protocols\r\n")
 readWriter.WriteString(
"Upgrade: websocket\r\n")
 readWriter.WriteString(
"Connection: Upgrade\r\n")
 readWriter.WriteString(
"Sec-WebSocket-Accept: " + acceptKey + "\r\n")
 readWriter.WriteString(
"\r\n") // 공백행으로 스테이터스 라인의 끝을 표시한다
 readWriter.Flush()
}

성공하였다!

연결되어 있는지 확인하기 위해 JS를 약간 변경한다.

public/index.html

const socket = new WebSocket('ws://localhost:3000/websocket');

// WebSocket 통신을 확립했을 때 발화하는 이벤트
socket.addEventListener(
'open', event => {
 console.log(
"upgraded!!");
});

GitHub에서의 diff 이다.

https://github.com/fukurose/go-websocket-doyasa/commit/a7ce876cb6e4d5af2b48018a93afe5b6c71bace1

이제 서버를 시작하고 실제로 살펴보겠다.

콘솔에 upgraded!! 표시되면 성공이다!

브라우저에서 보내기

WebSocket 통신이 확립 되었으므로, 실제로 메시지 교환을 해 보기로 하겠다.

메시지는 이진 형식의 데이터 프레임으로 교환된다. 물론 이 프레임 형식도 RFC 6455에 설명되어 있다.

발췌: https://datatracker.ietf.org/doc/html/rfc6455#section-5.2 

.... 순서대로 설명해 간다.

먼저 각 항목을 설명하기 전에 이러한 값을 보유하는 프레임을 준비한다.

frame.go

type Frame struct {
fin          
int
rsv1          
int
rsv2          
int
rsv3          
int
opcode        
int
mask          
int
payloadLength
int
maskingKey    []
byte
payloadData   []
byte
}

먼저 Payload Data 이지만, 여기에는 교환하는 실제의 데이터가 들어간다. 자세한 것은 후술 한다.

우선, 데이터의 취득은, 옥텟 스트림( byte 의 배열)이 되기 때문에, 그 byte 배열로부터 최초의 1byte 를 취득해 본다. 1byte = 8bit 이므로, 프레임 형식으로 말하는 곳의, 왼쪽으로부터 0 ~ 7의 데이터를 취득할 수 있다. 즉, FIN 으로 opcode 이다.

frame.go

// 최초의 byte 를 읽는다
index := 0
firstByte :=
int(buffer[index])

그러면, 각 항목 설명해 간다.

FIN

이것은 final fragment 플래그이며, 1이면 끝나고 0이면 후속이 있음을 나타낸다. FIN은 가장 왼쪽의 비트이기 때문에, 1000 0000 (0x80)와의 논리 곱을 취해, 오른쪽으로 7 시프트 해서 취득하고 있다.

(16진수, 2진수 변환표는 https://qiita.com/inabe49/items/805c2d2bcd9e70c37ef6 에서 정리해 준다)

frame.go

f.fin = (firstByte & 0x80) >> 7

나머지는 같은 요령이다.

RSV1-3

Sec-Websocket-Extensions에서 확장을 사용하는 경우 여기에 플래그가 포함된다. 이번은 사용하고 있지 않지만, 일단, 취득만은 해 둔다.

frame.go

f.rsv1 = (firstByte & 0x40) >> 6
f.rsv2 = (firstByte & 0x20) >> 5
f.rsv3 = (firstByte & 0x10) >> 4

opcode

나머지 4bit에서 실제로 상호 작용하는 데이터 유형을 나타낸다. 이번에는 텍스트(0001)가 들어간다. 그 밖에도 바이너리라든지 커넥션 클로즈 등이 있다. 신경이 쓰이는 분은 RFC 6455를 확인한다.

frame.go

// 나머지 4bit 가 opcode 를 나타내므로 시프트는 불필요
f.opcode = firstByte & 0x0F

그럼 다음 항목이다. 프레임의 그림을 보면, 다음 byte에 포함되는 것은, MASK와 Payload length 같다.

frame.go

//다음 byte 를 읽는다
index += 1
secondByte :=
int(buffer[index])

MASK

맨 왼쪽 비트는 마스크 플래그이다. 1이면 Payload Data 마스크된다. 0이면 안 된다. 그리고, 클라이언트로부터 서버에 보내는 프레임은 반드시 마스크 하는 것이 결정되고 있다.

frame.go

// 취득은 FIN 과 같다
f.mask = (secondByte & 0x80) >> 7

Payload length

나머지 7bit는 Payload Data의 길이를 나타낸다.

frame.go

// 나머지 7bit 가 길이를 나타내기 때문에 시프트는 불필요
f.payloadLength = secondByte & 0x7F

다만, 이 Payload length 는 조금 변형이다. 7bit에서 지정할 수 있는 최대값은 127 이다. 이렇게하면 127보다 긴 데이터를 보낼 수 없으므로 다음 조건이 추가된다.

방금 취득한 Payload length 가

126 의 경우는, 「다음의 2byte 가 UInt16 로서 진짜 Payload length 가 된다」

127 의 경우는, 「다음의 8byte 가 UInt64 로서 진짜 Payload length 가 된다」

이다.

즉 126 과 127 은 예약 번호로서, 그 수치의 경우는, 다음의 byte 이후로 Payload 의 길이를 나타내고 있다.

frame.go

if f.payloadLength == 126 {
 
// 길이가 126인 경우는 다음 2byte가 UInt16 로서 진짜 Payload length 가 된다
 length := binary.BigEndian.Uint16(buffer[index:(index + 2)])
 f.payloadLength =
int(length)
 index += 2
}
else if f.payloadLength == 127 {
 
// 길이가 127인 경우는 다음 8byte 가 UInt64 로 진짜 Payload length 가 된다
 length := binary.BigEndian.Uint64(buffer[index:(index + 8)])
 f.payloadLength =
int(length)
 index += 8
}

Masking key

다음 4byte는 마스크의 키이다. 이것은 마스킹된 경우에만 부여된다.

frame.go

if f.mask > 0 {
 f.maskingKey = buffer[index:(index + 4)]
 index += 4
}

Payload Data

마침내 왔습니다! 여기에 '도야사'가 들어 있습니다. 꺼내 봅시다.

덧붙여서, Payload Data는 Extention Data+ Application Data로 구성되어 있으며, Extention Data는 확장을 사용하고 있었을 경우에 데이터가 들어옵니다. 이번에는 사용하지 않으므로 Application Data뿐입니다. 자 꺼내 봅시다.

frame.go

// 데이터의 길이는 payloadLength 에 들어가 있으므로 그만큼을 얻는다
payload := buffer[index:(index + f.payloadLength)]

여기서 잊지 말아야 할 것은 브라우저의 데이터가 가려져 있다는 것이다. 마스크 되어 있는 경우는 마스크 키와의 배타적 논리합 (XOR)을 할 필요가 있다.

frame.go

if f.mask > 0 {
 
for i := 0; i < f.payloadLength; i++ {
   payload[i] ^= f.maskingKey[i%4]
 }
}

f.payloadData = payload

수고했다. 이제 브라우저의 데이터 프레임을 성공적으로 처리할 수 있었을 것이다. 서버측에서 payloadData 를 표시하도록 구현을 변경한다.

main.go

data := make([]byte, bufferSize)
for {
 
// 브라우저에서 데이터 프레임을 받는다
 n, err := readWriter.Read(data)
 
if err != nil {
   
panic(err)
 }
 
 frame := Frame{}
 frame.parse(data[:n])
 fmt.Println(
string(frame.payloadData))
}

또, 브라우저측에서는 WebSoket 통신이 개시했을 때에, 「abc」를 보내도록 한다.

socket.addEventListener('open', function (event) {
 socket.send(
'abc');
});

diff 이다.

https://github.com/fukurose/go-websocket-doyasa/commit/dc66813e5d82adb673ad8163b435b190e2dc1a04#diff-33903057844a2a36f8ef0ff84defe5a68b17aa8e6ad54b956fca91870da404d1

서버를 시작하여 'abc'를 받을 수 있는지 확인해 보자.

서버에서 'abc' 보내기

모처럼의 양방향dl다. 서버에서도 'abc'를 보내기로 한다.

하는 방법은 간단하고, 받았을 때와는 반대의 것을 하면 된다. 이번은 간략을 위해서, payloadLength는 126 미만 결정 치로 한다.

frame.go

func (f *Frame) toBytes() (data []byte) {
 bits := 0
 bits |= (f.fin << 7)
 bits |= (f.rsv1 << 6)
 bits |= (f.rsv2 << 5)
 bits |= (f.rsv3 << 4)
 bits |= f.opcode

 
// first byte 를 추가
 data =
append(data, byte(bits))

 bits = 0
 bits |= (f.mask << 7)
 bits |= f.payloadLength
// 길이는 126 미만이라고 가정

 
// second byte를 추가
 data =
append(data, byte(bits))

 
// 실제 데이터를 추가
 data =
append(data, f.payloadData...)

 
return data
}

이것을 브라우저로 보낸다.

sendFrame := buildFrame("abc")
readWriter.Write(sendFrame.toBytes())
readWriter.Flush()

브라우저에서 받으면 메시지 이벤트가 발화된다. 내용을 콘솔에 표시해 본다.

socket.addEventListener('message',  event => {
 console.log(event.data);
});

diff 이다.

https://github.com/fukurose/go-websocket-doyasa/commit/7941e1f2078e54b3d0377580a04c3ce55ec41452