본문 바로가기

remote-craft

RemoteCraft 07 - Websocket (feat. Lobby)

웹소켓은 HTTP의 문제를 해결해주는 약속입니다. HTTP에서 원리적으로 해결할 수 없었던 문제는 “클라이언트의 요청이 없음에도, 그 다음 서버로부터 응답을 받는 상황”이었는데요. 웹소켓은 HTTP가 해결할 수 없었던 이 문제를 해결하는 새로운 약속이었습니다. 즉, 브라우저가 서버에 데이터를 요청하고 서버가 브라우저에 데이터를 보내기 위해 별다른 제약이 없습니다.
웹소켓에서는 서버와 브라우저 사이에 양방향 소통이 가능합니다. 브라우저는 서버가 직접 보내는 데이터를 받아들일 수 있고, 사용자가 다른 웹사이트로 이동하지 않아도 최신 데이터가 적용된 웹을 볼 수 있게 해줍니다. 웹페이지를 ‘새로고침’하거나 다른 주소로 이동할 때 덧붙인 부가 정보를 통해서만 새로운 데이터를 제공하는 웹서비스 환경의 빗장을 본질적으로 풀어준 셈입니다.
웹소켓 약속 하에서는 실시간 소통이 편안해지게 됩니다. 웹에서도 채팅이나 게임, 실시간 주식 차트와 같은 실시간이 요구되는 응용프로그램의 개발을 한층 효과적으로 구현할 수 있게 되었습니다. 가상화폐의 분산화 기술의 핵심도 web socket으로 구현할 수 있다는 점 언급해두고 싶습니다.

출처: Chullin님의 HTTP에서부터 WEBSOCKET까지


문제의 발단: HTTP API로  끝?

RemoteCraft의 시그널링 서버 Lobby를 개발 하면서 워커 - 로비, 유저 - 로비 사이의 통신을 위해 HTTP 요청에 대한 핸들러를 만들기 시작했다. WebRTC연결을 위해 필요한 SDP Offer & SDP Anwer 교환 및 게임인스턴스 로직(게임 시작, 게임 종료, 게임 저장, 게임 불러오기)등 모든 기능이 아무 걸림돌 없이 개발 할 수 있었다.  별 생각없이 endpoint를 뚫고, 그쪽으로 워커와 브라우저가 소통할 수 있게 로직을 짜면 되겠지? 라고 생각하며 HTTP 요청-응답 방식의 핸들러를 만들어 내기 시작했다.

 

아무런 의심없이 '시간을 아낄 수 있다..!'  하고 좋아했지만... 개발 환경은 로비, 워커, 유저가 모두 같은 로컬네트워크에 있었고 로컬 네트워크 상에서는 ICE Candidate을 찾는 행위가 무의미 하기때문에 해야할 일에서 완전히 배제하고 혼자 신나게 개발을 해왔던 것이었다. 

 

유저와 서버는 STUN서버를 통해 각각의 ICE Candidate 정보를 수집하고 이 정보를 P2P 연결을 할 상대와 교환하고 어떤 루트로 통신을 할 것 인지 협약을 맺어야 한다.  복잡한 웹 세상의 네트워크에서 두 기기가 직접적인 연결점을 찾고 그 연결을 유지 하는 과정에서 ICE Candidate은 수시로 업데이트 될 수 있다. 즉, 워커의 ICE Candidate정보가 업데이트 된다면 그 업데이트 된 데이터를 연결된 유저에게 데이터를 보내 주어야 한다는 이야기이다. 유저가 요청을 보내지 않더라도! 지금까지 만들어놨던 /start_game, /init_webrtc 등등 API를 보내주고 웹소켓 프로토콜을 이용하여 새로운 메시징 구조를 만들어 보자!

 

제목은 서버와 클라이언트와 서버의 통신으로 하겠습니다! 근데 이제... 양뱡향을 곁들인..


Lobby서버 와 Websocket

웹 소켓 구현 설명에 앞서 간단하게 현재 로비서버의 현재 HTTP통신과 관련된 구조는..

로비서버의 HTTP통신 구조

현재 Lobby서버에는 유저 + 워커와의 HTTP통신을 책임지고 있는 http request handler가 있다. 이를 통해 워커와 유저가 /worker_ws, /user_ws로 웹소켓 업그레이드 요청을 보내게 되고 연결이 성사되면 로비는 각각의 워커 또는 유저와 소통하는 websocket클라이언트(UserClient, WorkerClient)를 생성고 이 객체를 통해 유저, 워커와 통신을 하게 됨.

Lobby는 userclient와 workerclient 매핑하여 메모리에 저장하고 이를 이용하여 메시지를 양측으로 relay하는 역할도 할 수 있다.

브라우저의 요청

GET /user_ws HTTP/1.1
Host: http://localhost:5252
Upgrade: websocket
Connection: Upgrade
Sec-Websocket-Key: 4q3803qgjhilerakwejfklwej==
Sec-Websocket-Version: 13


워커의 요청

GET /worker_ws HTTP/1.1
Host: http://localhost:5252
Upgrade: websocket
Connection: Upgrade
Sec-Websocket-Key: dGhlIHNueiofejofiZSBuub2+5=
Sec-Websocket-Version: 13

로비의 응답
101 UPGRADE

이렇게 연결이 완료되면.. 이제 서로 소통하는 방식에 대한 행위 및 규칙을 정해야한다!


customWebsocket 구현

RemoteCraft에서 WebsocketClientWSPacket이라는 메시지를 다른 클라이언트와 주고받으며 통신하고 그 행위를 정의하기 위해 3가지의 인터페이스를 가지고 있습니다. 하나씩 살펴 보겠습니다.

WSPacket

type WSPacket struct {
    // 패킷의 고유 아이디 [필수]
    PacketID    string `json:"packet_id"`
    // 패킷의 제목: 이 패킷은 무슨 일을 하려고 하는가? [필수]
    Title       string `json:"title"`
    // 패킷이 전달할 데이터: 추가적으로 전달해야 하는 데이터 [선택]
    Data        string `json:"data"`
    // 패킷이 전달될 Room의 ID [선택]
    RoomID      string `json:"room_id"`
    // 유저를 통해서 들어오는 패킷의 경우 보낸 유저의 PlayerIndex (1p, 2p 등등) [선택]
    PlayerIndex int    `json:"player_index"`
    // 유저를 통해서 들어오는 패킷의 경우 보낸 유저의 세션id [선택]
    SessionID string `json:"session_id"`
}

WSPacket은 유저, 로비, 워커가 모두 공용으로 사용하는 메세지의 약속이다. 모든 메시지는 이 형식에 맞춰서 작성되며, [필수]가 아닌 필드는 요청에 종류에 따라 데이터가 있을 수도, 존재하지 않을수도 있다.

 

TODO: 선택 필드는 모두 Data에 JSON string으로 넣는 방식으로 바꿔야 겠다.. WSPacket 이라는 struct만을 이용하려다보니 여러 종류의 메시지(Title)의 특성을 직관적으로 확인할 수 없다. ㅠㅠ

 

Client

type  Client struct {
    // 웹소켓 커넥션 객체
    connection	*websocket.Conn
    
    // send함수에 콜백을 달아야할 때 락을 거는 mutex
    sendCallbackMutex	sync.Mutex
    
    // 메시지를 작성할때 락을거는 mutex
    sendMutex	sync.Mutex

    // sendCallback : map[WSPacket.PacketID]callback
    // 메시지를 전송하고 실행할 함수를 등록할 map
    // 패킷을 보낼때 생성하는 PacketID를 키값으로 등록
    sendCallback	map[string]func(inMessage WSPacket)
    
    // recvCallback : map[WSPacket.Title]PacketHandler
    // 메시지를 받고 실행할 함수를 등록하는 map
    // 연결된 상대 클라이언트가 작성한 패킷의 Title을 키값으로 등록 
    recvCallback	map[string]func(inMessage WSPacket)

    Finish	chan struct{}
}

Client는 위와 같은 필드를 가지고 있고 이들과 특정 인터페이스(행위)를 이용하여 요청 - 응답 또는 메시징의 역할을 수행한다.

 

Interface

인터페이스는 총 3개로 되어있고 설명은 코드 내부에서 간단하게 하겠다.

  1. Send() : WSPacket을 보내는 행위
  2. Receive() : WSPacket을 받고 그에 따른 콜백함수를 등록하는 행위
  3. Listen() : Websocket을 통해 들어오는 메시지를 지속적으로 읽고 등록된 receive 콜백, send 콜백을 실행시키는 행위 

1. Send()

func (c *Client) Send(outMessage WSPacket, callback func(inMessage WSPacket)) {
    // 1. PacketID 생성
    outMessage.PacketID = uuid.Must(uuid.NewV4()).String()
    // 2. 보낼 WSPacket 직렬화 
    data, err := json.Marshal(outMessage)
    if err != nil {
        log.Println("[Websocket][Send] json marshal error: ", err)
        return
    }
    // callback 함수를 받았다면 등록
    if callback != nil {
        c.registerSendCallback(&outMessage, callback)
    }
    
    // 연결된 클라이언트에게 메시지 보내기
    c.send(data)
}

// c.send(data) 함수 원형
func (c *Client) send(message []byte) {
	c.sendMutex.Lock()
	defer c.sendMutex.Unlock()
	// TODO error handling necessary?
	_ = c.connection.SetWriteDeadline(time.Now().Add(WSTimeOut))
	_ = c.connection.WriteMessage(websocket.TextMessage, message)
}

2. Receive()

// 패킷에 대한 로직을 실행하고 응답으로 돌려주어야 할 메시지가 있다면 작성하는 패킷 핸들러
type PacketHandler func(inMessage WSPacket) (outMessage WSPacket)

// receive and response back
func (c *Client) Receive(title string, fn PacketHandler) {
    // WSPacket의 Title을 키값으로 함수를 등록한다.
    c.recvCallback[title] = func(inMessage WSPacket) {
        // 의도치 않은 종료 처리
        defer func() {
            if err := recover(); err != nil {
                log.Println("[Websocket][Receive]Recovered from err: ", err)
            }
        }()

        // 등록된 callback 함수 실행
        outMessage := fn(inMessage)

        // PacketHandler거 아무 메시지도 받환하지 않는다면 이대로 callback함수 종료
        if outMessage == EmptyPacket {
            log.Println("empty!!")
            return
        }

        //받은 메시지에 대한 응답으로 전달할 WSPacket의 메타데이터에 메타데이터 붙여넣기 
        outMessage.PacketID = inMessage.PacketID
        outMessage.SessionID = inMessage.SessionID

        // 데이터 직렬화
        data, err := json.Marshal(outMessage)
        if err != nil {
            log.Println("[WEBSOCKET][Receive] json marshal error:", err)
        }
        // 데이터 전송! (응답)
        c.send(data)
    }
}

// 예제

// 워커의 lobby와 연결되어있는 웹소켓 클라이언트.
// SDP_ANSWER라는 제목의 메시지에 handleSDPAnswer 함수를 등록
wc.lClient.Receive(api.SDP_ANSWER, wc.handleSDPAnswer)

// handleSDPAnswer 콜백 함수
func (wc *Controller) handleSDPAnswer(inMessage ws.WSPacket) ws.WSPacket {
	log.Println("[WORKER][handleSDPAnswer] is called")
    // .... 로직 실행
    
    // 응답 하지 않아도 되는 메시지
    return ws.EmptyPacket
}

3. Listen()

// 무한루프를 돌면서 해당 웹소켓 클라이언트로 들어오는 메시지를 읽고 등록된 콜백을 고루틴으로 실행하는 함수이다.

func (c *Client) Listen() {
    // 무한루프!
    for {
        // 메시지를 읽는다.
        _ = c.connection.SetReadDeadline(time.Now().Add(WSTimeOut))
        _, rawMessage, err := c.connection.ReadMessage()
        if err != nil {
            // 연결이 끊긴 경우 클라이언트의 Finish채널을 닫고 루프를 빠져나온다
            log.Println("[WEBSOCKET][Listen] Error on read, probably closed connection: ", err)
            close(c.Finish)
            break
        }
        
        // rawMessage에 있을 serialize된 WSPacket으로 변환
        wsPacket := WSPacket{}
        err = json.Unmarshal(rawMessage, &wsPacket)

        if err != nil {
            log.Println("[WEBSOCKET][Listen]error unmarshaling: ", rawMessage)
            continue
        }
        
        // wsPacket이 send로 보냈던 패킷에 대한 응답으로 받은 WSPacket인지 확인 한다.
        if callback, ok := c.sendCallback[wsPacket.PacketID]; ok {
            // 등록된 send콜백 함수를 고루틴으로 띄운다.
            go callback(wsPacket)
            // 이후 등록된 함수 map에서 삭제
            delete(c.sendCallback, wsPacket.PacketID)
        }
        // 읽은 메시지의 Title로 receieve 콜백함수가 등록 되어있는 확인
        if callback, ok := c.recvCallback[wsPacket.Title]; ok {
            // 등록되어 있다면 등록된 receieve 콜백함수를 새로운 고루틴 띄운다.
            go callback(wsPacket)
        }
    }
    
}