나는 웹소켓을 많이 써 봤고, 그 동작에 대해 정확히 이해한다고 생각했었다.
착각이였다. 쿠버네티스 클러스터 구성 중 웹소켓에 대한 부분이 나오자 나는 클러스터와 포트 노출, 그리고 로드밸런서를 어떻게 구성해야할지 혼란스러워했다.
결국 웹소켓 소스를 뜯어보고 나서야 어떻게 웹소켓이 연결되는지 이해하고 올바르게 클러스터에 웹소켓 서버를 적재 할 수 있었다.
이 과정에서 웹소켓에 대해 자세히 알아 볼 수 있는 기회가 있어 이렇게 글을 쓰게 되었다.
다들 아래와 같은 사진을 봤을 것이다.
또 웹소켓을 좀 공부했다 하는 분들은 아래와 같은 HTTP 헤더도 여러번 봤을 것이다.
GET /chat HTTP/1.1
Host: example.com:8000
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
솔직히 이 핸드쉐이킹 과정은 너무나도 널리 알려져 있고 다른 분들도 많이 다룬 내용이라 다시 내가 이야기할 필요성은 느끼지 않는다.
그런데 여기서 의문, 웹소켓은 분명 엄연히 HTTP
프로토콜을 이용한 TCP
통신이다.
엄밀하게는 웹소켓은 TCP를 전송 계층(transport layer)으로 이용하는 메시지 지향 프로토콜, 즉 HTTP와 유사한 통신 방법을 정의한 것이다.
단지 HTTP처럼 단독으로 생성되는 게 아닌 반드시 HTTP 프로토콜을 기반으로 해서 생성될 뿐
그러면 여기서 질문이 생겼는데 질문은 아래와 같다.
공부할 때는 책이 최고라지만, 프로그래밍에서는 그렇지 않다고 생각한다.
프로그래밍을 배울 때 막히는 부분이 있다면 무조건 소스코드를 보는 것이 올바른 태도라고 생각한다.
따라서 나는 Go
언어의 golang.org/x/net/websocket
패키지를 이용해서 분석을 진행해 볼 것이다.
지금부터 위의 세 질문에 대해 소스코드를 따라가며 분석하고 질문에 스스로 찾아낸 답을 표현할 것이다.
package main
import (
"io"
"net/http"
"golang.org/x/net/websocket"
)
func EchoServer(ws *websocket.Conn) {
io.Copy(ws, ws)
}
func main() {
http.Handle("/echo", websocket.Handler(EchoServer))
http.ListenAndServe(":80", nil)
}
위 코드는 호스트 컴퓨터의 80번 포트의 /echo
로 웹소켓 연결을 수행하면, 보낸 데이터를 그대로 받을 수 있는 웹소켓 서버 소스 코드이다.
보다시피 websocket.Handler
를 통해 EchoServer
라는 함수를 래핑해 HTTP
요청을 Websocket
으로 업그레이드하는 모습을 볼 수 있다.
그러면 이제 websocket.Handler
소스를 분석해 보자.
type Handler func(*Conn)
func (h Handler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
s := Server{Handler: h, Handshake: checkOrigin}
s.serveWebSocket(w, req)
}
이 코드는 여기서 볼 수 있다.
위의 코드에서 볼 수 있는데 websocket.Handler
로 감싼 함수는 http.HandlerFunc인터페이스를 구현한다.
이는 websocket.Server
로 변화되는데 이름은 거창하지만 사실 websocket.Server.serveWebSocket
만 보면 대부분이 이해 가능한 간단한 구조체이다.
그러면 websocket.Server.serveWebSocket
를 한번 봐 보자.
func (s Server) serveWebSocket(w http.ResponseWriter, req *http.Request) {
rwc, buf, err := w.(http.Hijacker).Hijack()
if err != nil {
panic("Hijack failed: " + err.Error())
}
// The server should abort the WebSocket connection if it finds
// the client did not send a handshake that matches with protocol
// specification.
defer rwc.Close()
conn, err := newServerConn(rwc, buf, req, &s.Config, s.Handshake)
if err != nil {
return
}
if conn == nil {
panic("unexpected nil conn")
}
s.Handler(conn)
}
이 코드는 여기서 볼 수 있다.
우선 위의 코드에서처럼 http.ResponseWriter는 http.Hijacker인터페이스로 형 변환한 이후 Hijack
메서드를 통해 저수준 소켓을 획득하게 된다.
이후 newServerConn
을 통해 websocket 통신에 필요한 추가 작업을 수행하고 s.Handler
에 설정된 함수로 커넥션을 전달하게 된다.
그러면 분명 Websocket 핸드쉐이킹과 관련된 코드들은 newServerConn
에 있을테니 다시 해당 코드를 분석해 보자.
func newServerConn(rwc io.ReadWriteCloser, buf *bufio.ReadWriter, req *http.Request, config *Config, handshake func(*Config, *http.Request) error) (conn *Conn, err error) {
var hs serverHandshaker = &hybiServerHandshaker{Config: config}
code, err := hs.ReadHandshake(buf.Reader, req)
if err == ErrBadWebSocketVersion {
fmt.Fprintf(buf, "HTTP/1.1 %03d %s\r\n", code, http.StatusText(code))
fmt.Fprintf(buf, "Sec-WebSocket-Version: %s\r\n", SupportedProtocolVersion)
buf.WriteString("\r\n")
buf.WriteString(err.Error())
buf.Flush()
return
}
if err != nil {
fmt.Fprintf(buf, "HTTP/1.1 %03d %s\r\n", code, http.StatusText(code))
buf.WriteString("\r\n")
buf.WriteString(err.Error())
buf.Flush()
return
}
if handshake != nil {
err = handshake(config, req)
if err != nil {
code = http.StatusForbidden
fmt.Fprintf(buf, "HTTP/1.1 %03d %s\r\n", code, http.StatusText(code))
buf.WriteString("\r\n")
buf.Flush()
return
}
}
err = hs.AcceptHandshake(buf.Writer)
if err != nil {
code = http.StatusBadRequest
fmt.Fprintf(buf, "HTTP/1.1 %03d %s\r\n", code, http.StatusText(code))
buf.WriteString("\r\n")
buf.Flush()
return
}
conn = hs.NewServerConn(buf, rwc, req)
return
}
https://cs.opensource.google/go/x/net/+/master:websocket/server.go;l=14-52
위 코드는 길지만 간결한데 우선 fmt.Fprintf
를 이용해 HTTP 통신을 직접 수행하는 모습이 눈에 보인다.
위에 보이는 HTTP 프로토콜은 Hijact
으로 더이상 자동으로 관리되 수 없는 소켓에 직접 HTTP 응답을 삽입하는 모습이다.
여기서 각각 HTTP 응답을 하는 부분들은 다음과 같다.
이다.
여기서 handshake
는 일반적으로 아무것도 지정하지 않았다면 checkOrigin
이라는 함수가 사용되게 되는데 이 함수의 정의는 다음과 같다.
func checkOrigin(config *Config, req *http.Request) (err error) {
config.Origin, err = Origin(config, req)
if err == nil && config.Origin == nil {
return fmt.Errorf("null origin")
}
return err
}
https://cs.opensource.google/go/x/net/+/master:websocket/server.go;l=101-107
위 코드는 HTTP의 Origin
헤더의 값이 없는 경우 오류를 반환하는 코드로 주석에 따르면 일부 Origin
헤더를 설정하지 않는 브라우저를 사용해야 한다면 기본값으로 이 핸드쉐이크 검증이 호출되지 않도록 설정해야 한다고 정의하고 있다.
아무튼 그런 코드들을 제외하고 위 함수를 간략화하면 3부분으로 나뉘는데 이는 각각 다음과 같다.
hs.ReadHandshake
hs.AcceptHandshake
hs.NewServerConn
이는 각각 살펴보면 다음과 같다.
func (c *hybiServerHandshaker) ReadHandshake(buf *bufio.Reader, req *http.Request) (code int, err error) {
c.Version = ProtocolVersionHybi13
if req.Method != "GET" {
return http.StatusMethodNotAllowed, ErrBadRequestMethod
}
// HTTP version can be safely ignored.
if strings.ToLower(req.Header.Get("Upgrade")) != "websocket" ||
!strings.Contains(strings.ToLower(req.Header.Get("Connection")), "upgrade") {
return http.StatusBadRequest, ErrNotWebSocket
}
key := req.Header.Get("Sec-Websocket-Key")
if key == "" {
return http.StatusBadRequest, ErrChallengeResponse
}
version := req.Header.Get("Sec-Websocket-Version")
switch version {
case "13":
c.Version = ProtocolVersionHybi13
default:
return http.StatusBadRequest, ErrBadWebSocketVersion
}
var scheme string
if req.TLS != nil {
scheme = "wss"
} else {
scheme = "ws"
}
c.Location, err = url.ParseRequestURI(scheme + "://" + req.Host + req.URL.RequestURI())
if err != nil {
return http.StatusBadRequest, err
}
protocol := strings.TrimSpace(req.Header.Get("Sec-Websocket-Protocol"))
if protocol != "" {
protocols := strings.Split(protocol, ",")
for i := 0; i < len(protocols); i++ {
c.Protocol = append(c.Protocol, strings.TrimSpace(protocols[i]))
}
}
c.accept, err = getNonceAccept([]byte(key))
if err != nil {
return http.StatusInternalServerError, err
}
return http.StatusSwitchingProtocols, nil
}
https://cs.opensource.google/go/x/net/+/master:websocket/hybi.go;l=490-535
조금 길지만 코드는 간단히 이야기하면, 다음과 같다.
Upgrade
헤더에 "websocket"
있는지 확인한다.Sec-Websocket-Key
헤더가 있는지 확인한다.Sec-Websocket-Version
헤더가 "13"
인지 확인한다.Sec-Websocket-Protocol
에 등록된 값 중 서버에서 지원하는 프로토콜 중 1개를 선택한다.func (c *hybiServerHandshaker) AcceptHandshake(buf *bufio.Writer) (err error) {
if len(c.Protocol) > 0 {
if len(c.Protocol) != 1 {
// You need choose a Protocol in Handshake func in Server.
return ErrBadWebSocketProtocol
}
}
buf.WriteString("HTTP/1.1 101 Switching Protocols\r\n")
buf.WriteString("Upgrade: websocket\r\n")
buf.WriteString("Connection: Upgrade\r\n")
buf.WriteString("Sec-WebSocket-Accept: " + string(c.accept) + "\r\n")
if len(c.Protocol) > 0 {
buf.WriteString("Sec-WebSocket-Protocol: " + c.Protocol[0] + "\r\n")
}
// TODO(ukai): send Sec-WebSocket-Extensions.
if c.Header != nil {
err := c.Header.WriteSubset(buf, handshakeHeader)
if err != nil {
return err
}
}
buf.WriteString("\r\n")
return buf.Flush()
}
https://cs.opensource.google/go/x/net/+/master:websocket/websocket.go;l=115
핸드쉐이크 허용 함수는 솔직히 이야기할 내용조차도 없다.
이 메서드는 그저 버퍼에 HTTP 101 요청을 보낼 뿐
func (c *hybiServerHandshaker) NewServerConn(buf *bufio.ReadWriter, rwc io.ReadWriteCloser, request *http.Request) *Conn {
return newHybiServerConn(c.Config, buf, rwc, request)
}
func newHybiServerConn(config *Config, buf *bufio.ReadWriter, rwc io.ReadWriteCloser, request *http.Request) *Conn {
return newHybiConn(config, buf, rwc, request)
}
func newHybiConn(config *Config, buf *bufio.ReadWriter, rwc io.ReadWriteCloser, request *http.Request) *Conn {
if buf == nil {
br := bufio.NewReader(rwc)
bw := bufio.NewWriter(rwc)
buf = bufio.NewReadWriter(br, bw)
}
ws := &Conn{config: config, request: request, buf: buf, rwc: rwc,
frameReaderFactory: hybiFrameReaderFactory{buf.Reader},
frameWriterFactory: hybiFrameWriterFactory{
buf.Writer, request == nil},
PayloadType: TextFrame,
defaultCloseStatus: closeStatusNormal}
ws.frameHandler = &hybiFrameHandler{conn: ws}
return ws
}
https://cs.opensource.google/go/x/net/+/master:websocket/hybi.go;l=576-583
https://cs.opensource.google/go/x/net/+/master:websocket/hybi.go;l=335-349
보다시피 기존 TCP 커넥션으로 생성된 소켓을 그대로 사용하는 모습을 볼 수 있다.
HTTP 연결은 101이후 종료된다.
하지만 한가지 중요한 측면이 있다면 HTTP 연결이 종료된다는 의미는 더이상 HTTP 프로토콜을 사용하지 않는다는 의미일 뿐, 실제로 물리적인 자원을 해제하거나 하는 행위가 뒤따르지는 않는다.
첫번째에서 연결된 결과로 HTTP 통신에 사용하던 소켓을 그대로 HTTP가 아닌 Websocket 통신용으로 이용한다.
이건 좀 너무 길어지는 감이 있어서 다음 포스트에 이어서 적으려고 한다.
솔직히 최근에는 좀 자만하고 있었다.
스스로 실력이 이정도면 괜찮지 않나? 여기고 있었는데 당연히 잘 알고 있다고 생각했던 웹소켓에 대해 정작 그 원리는 전혀 이해하고 있지 못한다는 사실이 충격적이였다.
사람은 역시 겸손해야 한다는 걸 새삼 다시 느꼈다.