본문 바로가기

C#

C# WebSocket 서버 구현

일단, 본문에서는 ASP.NET을 포함해서 어떤 외부 라이브러리도 사용하지 않았다. 혹시 C#으로 웹소켓 서버를 만들려고 하는 사람이라면 이 글의 방법보다는 ASP.NET을 사용하거나 websocket-sharp 등 라이브러리를 쓰는게 낫다고 생각한다. 다만, 이걸 만들려고 여러 삽질을 하면서 웹소켓에 대한 이해를 높일 수 있었고 그 과정에서 얻은 지식을 공유하고자 본 글을 작성하였다.

원래는 평범한 소켓 서버를 구현하려고 했는데 기왕 하는 거 웹소켓 사양으로 하면 웹에도 대응할 수 있고 확장성도 좋아지겠지 하는 막연한 생각으로 시작했다. 사실 C# 웹소켓 서버 구현하는 것을 쉽게 생각한 이유는 구글에 C# WebSocket으로 검색하면 참고할만한 자료들이 바로 검색되었기 때문이다. 그중 가장 쉽게 접하고 많이 참고한 자료는 아래 두 개다.

일단 위 두 글에 있는 내용으로도 충분히 C# 웹소켓 서버를 만들 수 있다. 그러나 문제는 MDN Web docs 쪽의 경우 설명과 코드 자체는 간결하지만 데이터를 수신하는 단계까지만 설명하고 있고 동기식으로 작성되어 있다. 창업자닉군 님의 글은 설명도 친절하고 작성된 코드도 비동기식에 데이터 송수신까지 구현되어 있다. 다만, 이 코드의 경우 ① 연결을 종료하는 방법이 빠져있고 ② 버그가 있다는 문제점이 있다. (버그에 대한 자세한 내용은 후술한다.)

물론, 연결 종료 방법에 대해서는 일반적으로 소켓을 한쪽에서 그냥 일방적으로 닫아버릴 수도 있고 실제로 크롬에서 이렇게 하면 바로 연결 끊어짐을 감지하고 통신을 종료하긴 한다. 다만, 웹소켓에서는 소켓을 닫는 것과 별개로 통신 종료 방식에 대해 정의하고 있으며 안정적인 서비스를 위해서라도 해당 방식을 사용하여 통신을 종료하는 것이 좋다고 생각한다. 이에 본문에서는 연결-데이터 송수신-종료에 이르기까지 웹소켓 사양을 최소한으로 만족하여 구현하는 방법에 대해 설명하고자 한다.

 

포트 바인딩 및 연결 대기

소켓을 열고 클라이언트와 연결을 받아들이는 과정까지는 일반적인 소켓통신과 같다. C#에는 서버용 소켓을 쉽게 구현할 수 있도록 TcpListener 클래스를 제공하며 이 클래스의 객체를 생성하고 Start함수만 실행하면 바로 포트 바인딩까지 수행한다.

또한, BeginAcceptTcpClient함수를 콜백을 매개변수로 하여 실행하면 바로 비동기식으로 클라이언트 연결을 대기할 수 있다. 클라이언트와 연결이 이루어지면 매개변수로 넘겨준 콜백이 실행이 된다. 그리고 콜백 함수에서 TcpListener의 EndAcceptTcpClient를 호출하여 Accept를 종료하면 클라이언트와 통신이 가능한 TcpClient 객체를 받게 된다.

class ConnectionManager
{
    private readonly TcpListener tcpListener;

    public ConnectionManager(string address, int port)
    {
        tcpListener = new TcpListener(IPAddress.Parse(address), port);
        tcpListener.Start();
        //비동기 Listening 시작
        tcpListener.BeginAcceptTcpClient(OnAcceptClient, null);
    }

    private void OnAcceptClient(IAsyncResult ar)
    {
        TcpClient client = tcpListener.EndAcceptTcpClient(ar);
        WebSocketController webSocketController = new WebSocketController(client);
        //다음 클라이언트를 대기
        tcpListener.BeginAcceptTcpClient(OnAcceptClient, null);
    }
}

1:1이 아닌 1:N로 클라이언트를 받기 위해서는 콜백 안에서 다시 BeginAcceptTcpClient를 호출하면 계속 클라이언트를 받을 수 있다. 

TcpClient 객체를 받은 후 어떻게 제어할지는 프로그래머 마음이지만 위 코드에서는 각 클라이언트를 별도로 관리하기 위해 WebSocketController 클래스를 만들었다.

 

클라이언트와 데이터 주고받기

다음 할 일은 클라이언트로부터 데이터를 받을 준비를 해야 한다. 소켓 연결이 완료된 순간 클라이언트는 바로 Handshake 요청을 보내오기 때문이다. 데이터를 주고받는 것은 TcpClient에서 받을 수 있는 NetworkStream을 통해서 가능하다. 이 Stream은 데이터를 주는 것과 받는 것 모두 가능하며 각각 Read와 Write함수로 수행할 수 있다. Read 또한 TcpListener의 Accept와 마찬가지로 동기식이며 비동기 실행을 위해 BeginRead 함수를 사용하면 된다.

class WebSocketController
{
    //웹 소켓의 상태 객체
    public WebSocketState State { get; private set; } = WebSocketState.None;

    private readonly TcpClient targetClient;
    private readonly NetworkStream messageStream;
    private readonly byte[] dataBuffer = new byte[1024];

    public WebSocketController(TcpClient tcpClient)
    {
        State = WebSocketState.Connecting;	//완전한 WebSocket 연결이 아니므로 연결 중 표시

        targetClient = tcpClient;
        messageStream = targetClient.GetStream();
        messageStream.BeginRead(dataBuffer, 0, dataBuffer.Length, OnReadData, null);
    }

    private void OnReadData(IAsyncResult ar)
    {
        int size = messageStream.EndRead(ar);   //데이터 수신 종료
        
        //데이터 수신 재시작
        messageStream.BeginRead(dataBuffer, 0, dataBuffer.Length, OnReadData, null);
    }
}

마찬가지로 데이터 또한 계속 수시로 클라이언트에서 보내오기 때문에 데이터 수신이 종료되면 처리 후 BeginRead를 재호출하여 다시 수신을 시작한다.

 

핸드셰이크 수행 (Handshaking)

이제 본격적인 웹소켓 연결이 시작된다고 할 수 있다. 클라이언트는 웹소켓 서버와 연결이 되자마자 GET 요청을 보내는데 이 요청은 웹소켓 통신 포트로 연결되었다 해도 현재는 일반적인 HTTP 통신이기 때문에 웹 소켓 연결로 업그레이드를 요청하는 것이다. 서버는 이 요청에 알맞은 응답을 보내 줌으로써 핸드셰이크를 수행하게 된다.

이 핸드셰이크 과정은 최신 웹소켓 사양이 정의되어 있는 RFC 6455의 section 4.2.2.에서 확인할 수 있다. 내용 구현을 위한 핵심만 정리하면 다음과 같다.

  1. 클라이언트 요청 Header에서 Sec-WebSocket-Key 획득
  2. Sec-WebSocket-Key와 "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" 문자열 연결 (해당 문자열은 이 응답이 웹 소켓 승인을 위한 것임을 확인하는 GUID)
  3. 연결한 문자열을 SHA-1 및 base64 해시 계산을 통해 새로운 문자열 생성
  4. 응답 헤더에 생성한 문자열을 Sec-WebSocket-Accept의 값으로 추가 및 응답 회신
class WebSocketController
{
    //...(생략)

    private void OnReadData(IAsyncResult ar)
    {
        int size = messageStream.EndRead(ar);   //데이터 수신 종료

        byte[] httpRequestRaw = new byte[7];    //HTTP request method는 7자리를 넘지 않는다.
        //GET만 확인하면 되므로 new byte[3]해도 상관없음
        Array.Copy(dataBuffer, httpRequestRaw, httpRequestRaw.Length);
        string httpRequest = Encoding.UTF8.GetString(httpRequestRaw);

        //GET 요청인지 여부 확인
        if (Regex.IsMatch(httpRequest, "^GET", RegexOptions.IgnoreCase))
        {
            HandshakeToClient(size);        // 연결 요청에 대한 응답
            State = WebSocketState.Open;	// 응답이 성공하여 연결 중으로 상태 전환
        }

        //데이터 수신 재시작
        messageStream.BeginRead(dataBuffer, 0, dataBuffer.Length, OnReadData, null);
    }

    private void HandshakeToClient(int dataSize)
    {
        string raw = Encoding.UTF8.GetString(dataBuffer);

        string swk = Regex.Match(raw, "Sec-WebSocket-Key: (.*)").Groups[1].Value.Trim();
        string swka = swk + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
        byte[] swkaSha1 = System.Security.Cryptography.SHA1.Create().ComputeHash(Encoding.UTF8.GetBytes(swka));
        string swkaSha1Base64 = Convert.ToBase64String(swkaSha1);

        // HTTP/1.1은 연속된 CR, LF를 라인의 끝을 의미하는 마커로 정의
        byte[] response = Encoding.UTF8.GetBytes(
            "HTTP/1.1 101 Switching Protocols\r\n" +
            "Connection: Upgrade\r\n" +
            "Upgrade: websocket\r\n" +
            "Sec-WebSocket-Accept: " + swkaSha1Base64 + "\r\n\r\n");

        //요청 승인 응답 전송
        messageStream.Write(response, 0, response.Length);
    }
}

이 과정이 모두 수행되면 서버는 본격적으로 클라이언트와 웹소켓 통신이 가능해진다.

 

데이터 수신

웹소켓에서 데이터가 한 번 보내질 때 그 데이터들의 묶음을 프레임(Frame)이라고 한다. 이 프레임은 아래 그림과 같은 형식으로 구성한다. 

WebSocket Base Framing Protocol

 

모든 정보가 바이트 단위로 나눠지므로 1바이트씩 살펴보는 것이 이해하기 편하다. 각 요소에 대해 자세히 설명하자면 아래와 같다.

FIN RSV1 RSV2 RSV3 opcode
1 0 0 0 0001
  • FIN
    데이터가 완전한지 여부, 데이터가 분할돼서 올 수도 있지만 이것에 대한 처리는 여기서 다루지 않음
  • RSV1, RSV2, RSV3
    확장 요소 값으로 미리 정해진 것이 아니라면 값은 항상 0, 마찬가지로 여기서 다루지 않음
  • opcode
    데이터의 형식을 의미 0x1은 텍스트 0x2는 바이너리를 의미하며 그 외에는 RFC 6455의 section 5.2. 참조
MASK Payload Length
1 0000011
  • MASK
    데이터가 마스킹이 되어 있는지 여부. 1이면 마스킹이 되어 있다는 것을 의미하며 Payload Length 다음 4바이트의 마스킹 키를 포함해야 한다.
  • Payload Length
    순수한 데이터의 길이를 의미. 126 미만의 값이면 Payload Length가 데이터 길이 값 그대로이며, 126일 경우는 다음 2바이트(int16, C#에서 ushort)가 길이 값이 되며, 127일 경우는 다음 8바이트(int64, C#에서 long)가 길이 값이 된다.

이후 조건에 따라 나오는 Payload 길이 값과 마스킹 키 값 다음에 나오는 데이터가 웹 소켓을 보내는 데이터의 본문이 된다.

서버에서 클라이언트로 보내는 데이터는 마스킹을 반드시 안 해야 하며 클라이언트에서 서버로 보내는 데이터는 마스킹을 반드시 해서 보내야 된다. 따라서 서버에서 받는 값은 반드시 마스킹을 해제하는 알고리즘이 포함되어야 하며 그 공식은 다음과 같다.

Di = Ei XOR M(i mod 4)

이렇게 구현한 코드는 아래와 같다. GET 요청이 아닐 경우 웹소켓 통신이라고 판단하여 데이터를 처리하는 함수가 추가되었다.

public enum PayloadDataType
{	//RFC 6455 기반
    Unknown = -1,
    Continuation = 0,
    Text = 1,
    Binary = 2,
    ConnectionClose = 8,
    Ping = 9,
    Pong = 10
}

class WebSocketController
{
    //...(생략)

    private void OnReadData(IAsyncResult ar)
    {
        //...(생략)

        if (Regex.IsMatch(httpRequest, "^GET", RegexOptions.IgnoreCase))
        {
            HandshakeToClient(size);
            State = WebSocketState.Open;
        }
        else
        {
            // 메시지 수신에 대한 처리, 반환 값은 연결 종료 여부
            if (ProcessClientRequest(size) == false) { return; }
        }

        //데이터 수신 재시작
        messageStream.BeginRead(dataBuffer, 0, dataBuffer.Length, OnReadData, null);
    }

    //...(생략)

    private bool ProcessClientRequest(int dataSize)
    {
        bool fin = (dataBuffer[0] & 0b10000000) != 0;   // 혹시 false일 경우 다음 데이터와 이어주는 처리를 해야 함
        bool mask = (dataBuffer[1] & 0b10000000) != 0;  // 클라이언트에서 받는 경우 무조건 true
        PayloadDataType opcode = (PayloadDataType)(dataBuffer[0] & 0b00001111);	// enum으로 변환

        int msglen = dataBuffer[1] - 128; // Mask bit가 무조건 1라는 가정하에 수행
        int offset = 2;		//데이터 시작점
        if (msglen == 126)	//길이 126 이상의 경우
        {
            // 빅 엔디안 형식으로 전송되었으며 C# 에서는 리틀 엔디안을 사용하므로 
            // 버퍼의 바이트 순서를 뒤집어서 변환
            msglen = BitConverter.ToInt16(new byte[] { dataBuffer[3], dataBuffer[2] });
            offset = 4;
        }
        else if (msglen == 127)
        {
            // 이 부분은 구현 안 함. 나중에 필요한 경우 구현
            // 제보에 따르면 위 경우와 마찬가지로 msglen 값을 얻을 때,
            // 바이트 데이터를 빅 엔디안 -> 리틀 엔디안으로 변환해야 하는 것을 고려해야 함
            Console.WriteLine("Error: over int16 size");
            return true;
        }

        if (mask)
        {
            byte[] decoded = new byte[msglen];
            //마스킹 키 획득
            byte[] masks = new byte[4] { dataBuffer[offset], dataBuffer[offset + 1], dataBuffer[offset + 2], dataBuffer[offset + 3] };
            offset += 4;

            for (int i = 0; i < msglen; i++)	//마스크 제거
            {
                decoded[i] = (byte)(dataBuffer[offset + i] ^ masks[i % 4]);
            }
            
            Console.WriteLine(Encoding.UTF8.GetString(decoded));	//데이터 출력
        }
        else
        {
            // 마스킹 체크 실패
            Console.WriteLine("Error: Mask bit not valid");
        }

        return true;
    }
}

 

 

데이터 송신

데이터 송신은 데이터 수신의 역으로 앞의 정보 비트를 포함해서 클라이언트로 보내면 된다. 위에서 서술했지만 마스킹은 안 해야 하기에 수신보다는 더 코드가 간단하다.

데이터 송신 함수가 추가되고 opcode가 Text일 경우 "Success!" 문자열을 반환하는 코드를 추가하였다.

//...(생략)

class WebSocketController
{
    //...(생략)

    private bool ProcessClientRequest(int dataSize)
    {
        //..(생략)
        PayloadDataType opcode = (PayloadDataType)(dataBuffer[0] & 0b00001111);	// enum으로 변환
        int msglen = dataBuffer[1] - 128; // Mask bit가 무조건 1라는 가정하에 수행
        //..(생략)

        if (mask)
        {
            byte[] decoded = new byte[msglen];  //데이터
            //...(생략)

            Console.WriteLine(Encoding.UTF8.GetString(decoded));	//데이터 출력
            switch (opcode)
            {
                case PayloadDataType.Text:
                    SendData(Encoding.UTF8.GetBytes("Success!"), PayloadDataType.Text);
                    break;
                case PayloadDataType.Binary:
                    //Binary는 아무 동작 없음
                    break;
                default:
                    Console.WriteLine("Unknown Data Type");
                    break;
            }
        }
        //...(생략)

        return true;
    }

    public void SendData(byte[] data, PayloadDataType opcode)
    {
        byte[] sendData;
        BitArray firstByte = new BitArray(new bool[] {
                    // opcode
                    opcode == PayloadDataType.Text || opcode == PayloadDataType.Ping,
                    opcode == PayloadDataType.Binary || opcode == PayloadDataType.Pong,
                    false,
                    opcode == PayloadDataType.ConnectionClose || opcode == PayloadDataType.Ping || opcode == PayloadDataType.Pong,
                    false,  //RSV3
                    false,  //RSV2
                    false,  //RSV1
                    true,   //Fin
                });
        //위 코드는 아래 설명 참조

        if (data.Length < 126)
        {
            sendData = new byte[data.Length + 2];
            firstByte.CopyTo(sendData, 0);
            sendData[1] = (byte)data.Length;    //서버에서는 Mask 비트가 0이어야 함
            data.CopyTo(sendData, 2);
        }
        else
        {
            // 수신과 마찬가지로 32,767이상의 길이(int16 범위 이상)의 데이터에 대응하지 못함
            sendData = new byte[data.Length + 4];
            firstByte.CopyTo(sendData, 0);
            sendData[1] = 126;
            byte[] lengthData = BitConverter.GetBytes((ushort)data.Length);
            Array.Copy(lengthData, 0, sendData, 2, 2);
            data.CopyTo(sendData, 4);
        }

        messageStream.Write(sendData, 0, sendData.Length);  //클라이언트에 전송
    }
}

첫번째 비트를 만들기 위해 BitArray라는 비트를 다루는 클래스를 사용했는데 BitArray를 다룰 때에는 주의할 점이 있다.  처음 BitArray 생성 시 비트에 해당하는 부울 배열을 매개변수로 넣는데 부울 배열 순서는 바이트로 변환 시 오른쪽에서 왼쪽 순서에 해당한다. 위의 창업자닉군 블로그의 코드가 버그가 있다고 했는데 바로 이 부분에서 부울 배열 순서를 거꾸로 넣은 부분이다. 해당 코드에서 데이터 타입이 1일 경우 opcode == 0001이 되어 전체 바이트가 10000001이 되어 우연찮게 제대로 동작하는 것처럼 보이는 것 뿐인 것이다.

어쨌든, BitArray 생성 시 부울 배열 순서를 거꾸로 해야 한다는 점을 고려하여 코드를 작성해야 할 것이다.

 

연결 종료 (Closing)

앞서 얘기했듯 연결 종료를 우아하게 하는 방법은 상호간 연결 종료에 대한 신호를 교환하고 합의하에 양 측의 소켓을 닫는 것이다. 웹 소켓은 이러한 방법에 대해서도 명시하고 있으며 그에 대한 내용은 다음과 같다.

연결 종료는 어느 한 쪽이 opcode를 연결 종료에 해당하는 8을 설정한 프레임을 보내는 것으로 시작한다. 이러한 프레임을 제어 프레임(Control Frame)이라고 하는 데 이 프레임을 받은 쪽에서는 똑같이 연결 종료 제어 프레임을 회신함으로서 상호 간 연결 종료 핸드셰이크가 성립하게 된다.

연결 종료 제어 프레임은 앞의 정보 비트는 opcode가 8이라는 점을 제외하고 일반 데이터 프레임(Data Frame)과 동일하고 대신 데이터에 어떻게 종료되는지 축약한 unsigned int16(C#의 ushort) 형식의 code와 자세한 설명을 포함한 reason에 해당하는 텍스트를 포함한다.

참고로 code와 reason을 포함하는 것은 강제가 아니다. 또한, 이 데이터들은 사람이 읽을 수 있는 텍스트일 필요는 없다. 또한, 연결 종료를 요청 받은 쪽은 처음 요청한 쪽의 code와 reason을 다르게 하지 않고 echo로 되돌려 주는 것이 일반적이다.

상호 간 연결 종료에 대한 제어 프레임을 교환했으면 가능한 한 빨리 소켓을 닫아야 하며 연결 종료 제어 프레임 교환 이후에는 어떤 프레임도 더이상 보내지 말아야 한다.

//...(생략)

class WebSocketController
{
    //...(생략)

    private bool ProcessClientRequest(int dataSize)
    {
        //..(생략)

        if (mask)
        {
            //...(생략)
            
            switch (opcode)
            {
                case PayloadDataType.Text:
                    SendData(Encoding.UTF8.GetBytes("Success!"), PayloadDataType.Text);
                    break;
                case PayloadDataType.Binary:
                    //Binary는 아무 동작 없음
                    break;
                case PayloadDataType.ConnectionClose:
                    //받은 요청이 서버에서 보낸 요청에 대한 응답이 아닌 경우에만 실행
                    if (State != WebSocketState.CloseSent)
                    {
                        SendCloseRequest(1000, "Graceful Close");
                    }
                    State = WebSocketState.Closed;
                    
                    Dispose();		// 소켓 닫음
                    return false;	// 더 이상 메시지를 수신하지 않음
                default:
                    Console.WriteLine("Unknown Data Type");
                    break;
            }
        }
        //...(생략)

        return true;
    }

    public void SendData(byte[] data, PayloadDataType opcode)
    {
        //...(생략)

        messageStream.Write(sendData, 0, sendData.Length);  //클라이언트에 전송
    }

    public void SendCloseRequest(ushort code, string reason)
    {
        byte[] closeReq = new byte[2 + reason.Length];
        BitConverter.GetBytes(code).CopyTo(closeReq, 0);
        byte temp = closeReq[0];
        closeReq[0] = closeReq[1];
        closeReq[1] = temp;
        // 변환된 바이트 배열은 리틀 엔디언
        // 웹에서는 빅 엔디언을 사용하므로 배열을 거꾸로 바꾸어 주어야 한다
        Encoding.UTF8.GetBytes(reason).CopyTo(closeReq, 2);
        SendData(closeReq, PayloadDataType.ConnectionClose);
    }
    
    public void Dispose()
    {
        targetClient.Close();
        targetClient.Dispose();	//모든 소켓에 관련된 자원 해제
    }
}

 

전체 코드 및 테스트

위 코드들을 종합하면 기본적인 연결-데이터 송수신-종료에 대한 웹소켓 사양들을 구현했다고 볼 수 있다. 물론 코드 주석에도 설명했고 그 외에 세세한 사양들은 무시된 것이 많지만 기본적인 데이터 송수신은 가능하다. 위 코드를 종합한 코드는 아래 첨부파일에서 확인 할 수 있다.

WebSocketServer.cs
0.01MB

이 코드를 실행시켜서 실제 테스트를 진행해 보자. 다음 파일에서 주소 부분을 수정해서 테스트 할 수 있으며 F12를 눌러 콘솔창에 찍히는 데이터를 확인할 수 있다.

ws_test.html
0.00MB

 

웹페이지 켜면 바로 연결과 데이터를 한 번 보내며 버튼을 누르면 데이터를 추가로 서버로 보낼 수 있다. Terminate 버튼을 누르면 연결을 종료하며 아래 그림과 같이 깔끔하게 연결이 종료되었음을 확인할 수 있다.

웹 소켓 테스트 페이지로 테스트 한 모습
웹소켓 서버 연결 종료 후 close 이벤트

 

결론

지금까지 라이브러리 없이 기본적인 C# 만으로 웹소켓 서버를 구현하는 방법에 대해 정리해 보았다. 다시 말하지만 본문에서는 Extended Payload Length를 가지는 데이터 전송이나 바이너리 데이터 전송 등 다른 중요한 사양들은 구현이 생략되어 있으며 심지어 리틀/빅 엔디언 관련해서 버그도 있다. (아래 댓글 달아주신 개발자님 덕분에 발견할 수 있었네요. 감사합니다.) 그렇기에 실제 구현은 라이브러리를 쓰는 것이 정신 건강이나 효율 측면에서 이로울 것이다.

다만, 아무것도 모르는 채 웹소켓을 사용하는 것보다 본문의 코드를 구현할 수 있을 정도의 웹소켓 동작 원리를 이해하고 웹소켓을 사용하면 분명 웹소켓을 다루는 데 훨씬 큰 도움이 될 것이라고 생각한다.

 

질문 사항 관련해서는 오른쪽 공지 확인해 주세요