웹소켓은 어떻게 동작하는가?

김성현·2022년 9월 23일
0
post-thumbnail

웹소켓을 정말로 나는 이해하고 있을까?

나는 웹소켓을 많이 써 봤고, 그 동작에 대해 정확히 이해한다고 생각했었다.

착각이였다. 쿠버네티스 클러스터 구성 중 웹소켓에 대한 부분이 나오자 나는 클러스터와 포트 노출, 그리고 로드밸런서를 어떻게 구성해야할지 혼란스러워했다.

결국 웹소켓 소스를 뜯어보고 나서야 어떻게 웹소켓이 연결되는지 이해하고 올바르게 클러스터에 웹소켓 서버를 적재 할 수 있었다.

이 과정에서 웹소켓에 대해 자세히 알아 볼 수 있는 기회가 있어 이렇게 글을 쓰게 되었다.

웹소켓에 대한 질문.

다들 아래와 같은 사진을 봤을 것이다.

또 웹소켓을 좀 공부했다 하는 분들은 아래와 같은 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 프로토콜을 기반으로 해서 생성될 뿐

그러면 여기서 질문이 생겼는데 질문은 아래와 같다.

  • HTTP 101 코드 이후에 HTTP통신은 끊기는가?
  • 웹소켓은 TCP로 통신한다 하였는데 그러면 이 TCP 소켓은 대체 어디서 나온 소켓인가?
  • 우리가 흔히 사용하는 Websocket인터페이스를 살펴보면 웹소켓은 일반적인 TCP와는 다르게 버퍼 단위가 아닌 메시지 단위로 데이터를 주고받는다. 웹소켓은 소켓이라는 이름과는 다르게 소켓처럼 동작하지 않는다. 그 원리는 무엇인가?

Websocket을 이해하자.

공부할 때는 책이 최고라지만, 프로그래밍에서는 그렇지 않다고 생각한다.
프로그래밍을 배울 때 막히는 부분이 있다면 무조건 소스코드를 보는 것이 올바른 태도라고 생각한다.

따라서 나는 Go 언어의 golang.org/x/net/websocket 패키지를 이용해서 분석을 진행해 볼 것이다.

지금부터 위의 세 질문에 대해 소스코드를 따라가며 분석하고 질문에 스스로 찾아낸 답을 표현할 것이다.

HTTP서버에서 웹 소켓까지

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.ResponseWriterhttp.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 응답을 하는 부분들은 다음과 같다.

  • Websocket 버전이 맞지 않거나
  • 알 수 없는 에러가 일어났거나
  • Websocket Handshake에 실패했거나

이다.

여기서 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
    검사가 끝난 이후 올바른 HTTP 101 Switch Protocol 응답을 보낸다.
  • hs.NewServerConn
    기존 HTTP용도로 사용되던 소켓을 Websocket으로 전환한다.

이는 각각 살펴보면 다음과 같다.

ReadHandshake

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

조금 길지만 코드는 간단히 이야기하면, 다음과 같다.

  1. Upgrade헤더에 "websocket" 있는지 확인한다.
  2. Sec-Websocket-Key헤더가 있는지 확인한다.
  3. Sec-Websocket-Version헤더가 "13"인지 확인한다.
  4. 원래 요청한 서버 위치를 찾는다.
  5. Sec-Websocket-Protocol에 등록된 값 중 서버에서 지원하는 프로토콜 중 1개를 선택한다.

AcceptHandshake

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 요청을 보낼 뿐

NewServerConn

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 연결은 101이후 종료된다.

하지만 한가지 중요한 측면이 있다면 HTTP 연결이 종료된다는 의미는 더이상 HTTP 프로토콜을 사용하지 않는다는 의미일 뿐, 실제로 물리적인 자원을 해제하거나 하는 행위가 뒤따르지는 않는다.

두번째 질문, 웹소켓은 TCP로 통신한다 하였는데 그러면 이 TCP 소켓은 대체 어디서 나온 소켓인가?

첫번째에서 연결된 결과로 HTTP 통신에 사용하던 소켓을 그대로 HTTP가 아닌 Websocket 통신용으로 이용한다.

세번째 질문, 어떻게 버퍼 단위가 아닌 메시지 단위로 데이터를 주고받는가?

이건 좀 너무 길어지는 감이 있어서 다음 포스트에 이어서 적으려고 한다.


솔직히 최근에는 좀 자만하고 있었다.

스스로 실력이 이정도면 괜찮지 않나? 여기고 있었는데 당연히 잘 알고 있다고 생각했던 웹소켓에 대해 정작 그 원리는 전혀 이해하고 있지 못한다는 사실이 충격적이였다.

사람은 역시 겸손해야 한다는 걸 새삼 다시 느꼈다.

profile
수준 높은 기술 포스트를 위해서 노력중...

0개의 댓글