저번 시간에 구현한 소켓 프로그래밍에서 한계점이 있었다.

그것은 바로 블로킹 방식이 사용되었다는 점이다.

즉 기존에 구현한 listenSocket.Accept()은 고객이 오기 전까지 하염없이 기달려야 한다는 것이 문제이다. Accept 뿐만이 아니라 Send, Receive가 다 블로킹 함수로 구현됬었다.

따라서 이번 시간에는 Listener 클래스를 만들어서 Accept을 논 블로킹 방식으로 구현해보 겠다.

블로킹과 논블로킹에 대한 설명은 아래 Rookiss 님의 글을 참조하자.


    class Listener
    {
        Socket _listenSocket;
        Action<Socket> _onAcceptHandler; 

        public void Init(IPEndPoint endPoint, Action<Socket> onAcceptHandler)
        {
            _listenSocket = new Socket(endPoint.AddressFamily, SocketType.Stream, ProtocolType.Tcp);
            // 첫 번째 매개변수: IP 버전4 인지 버전6인지 
            // 그 다음 매개변수: TCP를 쓸건지 UDP를 쓸건지 

            _onAcceptHandler += onAcceptHandler;

            // 문지기 교육 
            _listenSocket.Bind(endPoint); // 문지기의 핸드폰에 비밀식당의 주소를 알려주는 것. 

            // 영업시작 
            _listenSocket.Listen(10);

            SocketAsyncEventArgs args = new SocketAsyncEventArgs();
            args.Completed += new EventHandler<SocketAsyncEventArgs>(OnAcceptCompleted);
            RegisterAccept(args);

        }
      

먼저 Listener 소켓을 초기화하는 Init 메소드를 만들어 보자.

onAcceptHandler는 Init의 delegate 매개변수로, Action delegate인 Listener._onAccpetHandler에 연결하여 사용한다.

SocketAsyncEventArgs는 EventArgs를 상속받은 클래스로, 이벤트 데이터를 포함하는 클래스이다.

args.Completed는 이벤트 핸들러이다. 이벤트 핸들러는 이벤트가 발생했을 때 어떤 명령들을 실행할지 지정해주는 것이다. 이벤트 핸들러에 인자로 OnAcceptCompleted 라는 메소드를 넘겨줘서 args에 데이터가 들어왔다는 이벤트가 발생할 시 OnAcceptCompoleted가 실행되게 끔 한다.

그 다음 이벤트 데이터를 RegisterAccept(Accept를 예약하는 기능)에 넘겨준다.

일단 RegisterAccept 함수를 먼저 살펴보자.


 	public void RegisterAccept(SocketAsyncEventArgs args) 
        {
            args.AcceptSocket = null; // 초기화 (중요)

            bool pending = _listenSocket.AcceptAsync(args); // 비동기 

            if (pending == false) // 운 좋게 바로 연결
            {
                OnAcceptCompleted(null, args);
            }
        }

함수 이름에 Register가 들어가 있듯이 Accept를 예약한다는 의미이다.

Socket.AcceptAsync에 인자로 SocketAsyncEventArgs 를 넘겨준다.
이 함수의 반환값은 불리안 형이다. false가 나왔다는 뜻은 타이밍이 맞아 동기로 바로 처리가 되었다는 뜻이다. 그 경우는 OnAcceptCompleted 를 바로 호출해준다.

중요한 건 SocketAsyncEventArgs.AcceptSocket 을 초기화하는 작업이다. 이를 살펴보기 위해 OnAcceptCompleted 메소드를 살펴보자.

	public void OnAcceptCompleted(object sender, SocketAsyncEventArgs args)
        {
            if (args.SocketError == SocketError.Success)
            {
                // 실제로 유저가 왔으면 할 것
                _onAcceptHandler.Invoke(args.AcceptSocket); // 대리인에게 소켓을 주는 역활을 한다. 
                // args.AcceptSocket 한테 대리인의 소켓을 넣어준다. 
            }
            else
                Console.WriteLine(args.SocketError.ToString());

            RegisterAccept(args); // 낚시대를 바다에 다시 던진다. 
        }
     }

OnAcceptCompleted 메소드는 EventHandler<>의 인자로 들어가야 하므로 (object, EventArgs) 타입을 매개변수로 삼아야 한다.

Action<>.Invoke 으로 SocketAsyncEventArgs.AcceptSocket에 대리인의 소켓을 넣어준다.

작업이 완료되었으면 RegisterAccept를 호출하여 Accept를 예약한다.

그리고 SocketAsyncEventArgs.AcceptSocket에 넣어준 대리인의 소켓을 초기화 시키는 작업을 RegisterAccept에서 수행한다.


class Program
    {

        static Listener _listener = new Listener();

        static void OnAcceptHandler(Socket clientSocket)
        {
            try
            {
                // 연락을 받는다. 
                byte[] recvBuff = new byte[1024];
                int recvBytesLength = clientSocket.Receive(recvBuff);
                string recvData = Encoding.UTF8.GetString(recvBuff, 0, recvBytesLength); // 문자열을 받는다고 가정 
                Console.WriteLine($"[From Client] {recvData}");

                // 연락을 보낸다. 
                byte[] sendBuff = Encoding.UTF8.GetBytes("Welcome to MMORPG Server");
                clientSocket.Send(sendBuff);

                // 쫒아낸다. 
                clientSocket.Shutdown(SocketShutdown.Both); // 연락을 주고 받지 않겠다. 
                clientSocket.Close(); // 대리인 나가세요 
            }
            catch (Exception e)
            {
                Console.WriteLine(e);
            }
           
        }

        static void Main(string[] args)
        {
            //DNS(Domain Name System) 
            string host = Dns.GetHostName(); // DESKTOP-S7EG95G
            IPHostEntry ipHost = Dns.GetHostEntry(host); // System.Net.IPHostEntry
            IPAddress ipAddr = ipHost.AddressList[0];  // fe80::b599:93ca:869a:5736%18
            IPEndPoint endPoint = new IPEndPoint(ipAddr, 7777); // 주소와 포트번호
            

            _listener.Init(endPoint, OnAcceptHandler);
               
            while (true)  
            // _listener.Init 이 무한루프를 돌기 때문에 사실상 필요없음
            // 프로그램이 안 꺼지게 해주는 역할
            {
                Thread.Sleep(100);
                Console.WriteLine("Listening...");              
            }
            
        }
    }

이제 Program Class를 살펴보자.

_listener.Init(endPoint, OnAcceptHandler)로 Listen(영업 개시)을 한다.

우리가 Init 함수에 OnAcceptHandler를 인자로 넘겨줬기 때문에
OnAcceptHandler 함수는 Accept일 때 (대리인의 입장이 허용욀 때) 송수신 후 대리인을 쫓아내는 기능을 수행하는 이벤트 핸들러가 된 셈이다.

사실 이벤트 형식이라 굳이 while문이 필요하지는 않지만 Main 코드가 끝나지 않게 하기위해 넣었다. Thread.Sleep(100)으로 0.1초마다 Listeneing이 출력되게 만들었다.

그림과 같이 서버와 클라이언트가 잘 연결된 것을 볼 수 있다.


주의할 점이 하나있다.

이벤트가 완료될 시 쓰레드풀에서 쓰레드를 가져와서 이벤트 핸들러를 동작한다.

멀티쓰레드 환경이 되기 때문에 OnAcceptCompleted 이벤트 핸들러를 작성할 때는 이 점을 유의해야 한다.


Rookiss님의 동기와 비동기 설명

블로킹 방식은 낚시대 한개를 던져놓고 그 앞에 기다리면서 하염없이 뚫어져라 쳐다보고 있는 셈입니다. 낚시대에 물고기가 낚이지 않으면 일평생 그 앞에서 아무것도 안하면서 대기를 타야 할 수도 있습니다.

논블로킹 방식은 낚시대를 N개 던져놓고, 그냥 다른 일을 하러 간 셈입니다. 폰 게임을 하다가 낚시대에 입질이 와서 뭔가 신호가 오면, 그제서야 돌아가서 낚시대를 끌어올릴 수 있겠죠.

대기 방식의 차이만 미세하게 있는 정도라고 볼 수는 없고,오히려 대기 방식에 따라서 아무것도 못하느냐 vs 다른걸 할 수 있느냐는 엄청 큰 차이가 있습니다.

Accept 정도는 와닿지 않을 수도 있지만 Send/Recv는 더 치명적입니다. 만약 클라/서버 통신에서 Send를 동기 함수로 만들다면, 실제로 클라에서 데이터를 보내고 서버에서 그 데이터를 받아서 처리하기 전까진 클라는 렌더링은 커녕 그 자리에서 멈춰서 서버 신호만 기다리는 상태가 됩니다.

논블로킹 방식의 함수를 호출하면, 당장은 다른 일을 하러 갈 수는 있지만 어찌됐건 그 함수가 성공적으로 처리가 되는지는 확인할 수는 있어야 하는데 크게 분류하면 polling 방식 (주기적으로 우리가 직접 체크하는 것)과 이벤트 방식 (처음에 걸어놓은 콜백 함수가 알아서 호출되는 것)이 존재합니다.

profile
POSTECH EE 18 / Living every minute of LIFE

0개의 댓글