본문 바로가기

C#/근웹 연대기

c#으로 근본 없는 웹서버 개발기 9 : 소켓연결버그 원인 및 해결

⚠WARNING
ASP.NET에 대한 포스팅이 아닙니다
나가실 문은 오른쪽 하단입니다

 

원인 분석

전편의 버그를 잡아내기 위해 진입점에 디버그 포인트를 걸고, 버그가 발생되는 상황을 재연하기 위해 크롬과 웨일 브라우저를 각각 켜서 localhost에 접속하고 멈추는 구간을 찾기로 하였다.

먼저 크롬에서 메인 루프를 한 바퀴 돌아주고 웨일 브라우저에서 새로고침을 하니,

다음과 같이 디버그 포인트에 들어서질 않았다. 

vscode에서는 디버그 진입시 노란색 영역이 표시됨

 

그렇다

원인은 처음에 접속할 때 생기는 게 확실하다.

그래서 다시 처음 과정으로 돌아와, 스텝 by 스텝으로 스레드가 멈추는 구간을 한 단계 한 단계 진행을 시켜보니

문제없이 while문이 한 바퀴를 완료하고 있었다.

그렇다

이러한 버그는 디버그 스텝을 밟으면 그냥 실행하는 것과는 다르게 시간차가 생겨서 버그의 발생 시점을 알아낼 수 없는 짜증스러운 케이스인 것이다.

그래서 블로킹될만한 곳에 각각 콘솔을 찍어보니..

        byte[] data = new byte[8192];
        Console.WriteLine("리시브 시작");
        int cnt = client.Receive(data);
        Console.WriteLine("리시브 끝");

위 사진과 같이 "시작"과 ""의 짝이 안 맞는 걸로 보아, Receive 함수에서 블로킹이 되고 있는 것을 확인할 수 있었다.

 

그 후 일정 시간 이상이 지나면 다음과 같은 메시지를 통해 브라우저에서 연결을 해제하는 것을 확인할 수 있었다.

간략히 설명을 하자면, Receive함수는 동기 함수로 연결은 되어 있어도 받을 데이터가 없으면 스레드를 블록 한다.

정리를 위해, 귀납적 추론을 빌리자면

브라우저가 딱히 요청할 데이터도 없음에도 불구하고, 연결을 요청한다는 것이다.  

서버에 입장에서 보자면 어떤 연결 요청이라 받아들일 수밖에 없다.

그런데 이러식으로 하는 일 없이 무조건적으로 응석을 받아주면 1:1로 통신하는 것이 아니기 때문에, 서버의 연결 한도가 초과되어 문제가 생긴다.

 

아래의 코드는 브라우저로 인한 버그의 원인을 확실하게 하기 위하여 연결 패턴을 시물레이션 하기 위해 작성되었다.

static void Main(string[] args)
{
    Socket client = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
    client.Connect(IPAddress.Parse("127.0.0.1"), 80);

    Console.WriteLine("connect :" + client.Connected);

    Console.ReadLine();
    client.Close();

}

ENTER키를 누르지 않고 있으면 그냥 연결만 하는 상태가 되고 누르면, 연결이 해제되어 스레드의 블록이 해제되는... 동일한 상황이 연출됨을 알 수 있었다. (해제됨과 동시에 0바이트를 수신함)

그렇다

이것으로 원인은 확실해졌다.

 

브라우저가 왜 그러한 원인을 제공하는지에 대한 정확한 원인은 모르겠지만, 아마도 html의 내용을 파싱 하다가 src구문을 만나면 해당 파일을 요청해야 하므로 다시 연결을 해야 하는데, 이때 재연결에 걸리는 딜레이를 최소화하고자 무조건 연결을 한상태로 만들어 놓은 후 뒤늦게 요청 헤더를 보내는 것으로 사료된다.

그렇다

브라우저 입장에서 볼 때, 매우 합리적인 선택이다.

 

해결 방안

리소스 추가 요청에 대한 재연결 딜레이를 고려하여 Receive함수의 대기 제한시간을 적당히 준다.

추가 리소스 요청 유무에 따라 제한시간을 다르게 주는것이 좋아 보이나 클라이언트의 성능이나 인터넷 환경이 각자 다를 수 있으므로 적정시간인 5초 정도로 타협한다. 그 짧은 시간이라도 다른 손님을 기다리게 할  수 없으니, 단일 스레드 체제는 포기하고 다중 스레드 체제로 전환하여, 

메인 스레드는 손님을 받은 후, 자식 스레드에게 손님을 인계한다.

자식 스레드는 손님이 주문도 안 하고 5초 이상 기다리게 하면 손님을 포기한다. (클레임은 수용하지 않는다)

 

소스코드.

static void Main(string[] args)
{
    IPEndPoint ipep = new IPEndPoint(IPAddress.Any, 80);
    Socket server = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
    server.Bind(ipep);
    server.Listen(100);

    while (true)
    {
        Socket client = server.Accept();
        Task.Run(()=>newWork(client)); //자식 스레드에 인계
    }
}
static void newWork(Socket client)
{
    try
    {
        client.ReceiveTimeout = 5000; //최대 5초 기다려줌을 선언
        var dic = request(client);
        if (client.Connected == false || dic == null)
        {
            Console.WriteLine("Disconnected: by client");
            return;
        }

        if (dic.ContainsKey("err"))
        {
            resForbadrequest(client, dic["err"]);
            throw new WebException(dic["err"]);
        }

        if (dic["url"] == "/")
        {
            dic["url"] = "/index.html";
        }

        response(client, dic);
    }
    catch(SocketException ex){
        if(ex.SocketErrorCode == SocketError.TimedOut){
            Console.WriteLine("Disconnected: timeout");
        }else{
            Console.WriteLine(ex.ToString());
        }
    }
    catch (Exception ex)
    {
        Console.WriteLine(ex);
    }
    finally
    {
        client.Close();
    }
}

 

하기와 같이 자식스레드가 손님을 받는 모습을 확인할 수 있다.

리시브 지연으로 연결이 끊어진다

つづく