[Part 4 게임 서버] 네트워크 프로그래밍 5(Connector, TCP vs UDP)

<Connector>

* 통용되는 용어 x: Listener의 반대 역할을 하는 Connector

 

1. DummyClient에서 서버와 Connect하는 부분이 Blocking 함수로 작성 -> 지양 

2. ServerCore 프로젝트 우클릭 -> 추가 -> 새항목: Connector.cs 만들어야함.

: 왜 굳이 반드시 연결해야하는 코드가 필요한가? 서버는 연결을 받는 것 아닌가?

 1) Connect 후 Receive와 Send하는 부분을 고용으로 사용하기 위해

 2) 서버를 분산처리 하여 만들 수 있음 ex)  NPC, AI만 관리하는 서버 vs 몬스터만 관리하는 서버 등으로 분리할 수 있음 

   -> 이럴 때, 서버끼리 통신하기 위해서는 서버도 Connect 해야 한다. 

 

3. Connector.cs 코드 작성 

 1) DummyClient의 Program.cs에서 작성했던 코드를 보면 우선 Socket을 만들고 해당 Socket을 Connect 해줬다. 

   -> Connector.cs에서도 Socket이 Connect하는 부분이 필요하다 

   -> Listener와 Receive & Sender를 했던 부분에서 했던 것과 같이 Register와 Complete하는 부분이 필요 

    class Connector
    {
        public void Connect(IPEndPoint endPoint)
        {
            // 휴대폰 설정
            Socket socket = new Socket(SocketType.Stream, ProtocolType.Tcp);
        
        }

         void RegisterConnect(SocketAsyncEventArgs args)
        {

        }
        void OnConnectComplete(object sender, SocketAsyncEventArgs args)
        {

        }

    }

 2) Connect 함수

  a) Socket을 받은 후 SocketAsyncEventArgs를 설정하기 위해 args 객체 생성

  b) 이전에 했던 것과 같이 OnConnectCompleted 델리게이트 추가 

  c) 후에 서버의 EndPoint를 args.RemoteEndPoint에 추가해준다. -> 어디로 연결되어야하는지 추가!

  d) 이후 Socket을 RegisterConnect 함수에 쓰기위해서 Socket을 클래스의 멤버 변수로 사용하는 방법이 있지만, 여기서는 args.UserToken을 사용하여 원하는 정보를(Socket)에 대한 정보를 넘겨준다. 

   - 경우에 따라 Listener처럼 천명, 만명 Listen을 받을 수 있는 것처럼 Connect도 많이 연결될 수 있도록 구현하기 위해 멤버 변수 Socket으로 받기 보다는 Event를 통해 받고 있다. (? 이해를 못함)

  e) 후에 RegisterConnect로 등록해준다. 

        public void Connect(IPEndPoint endPoint)
        {
            // 휴대폰 설정
            Socket socket = new Socket(SocketType.Stream, ProtocolType.Tcp);

            SocketAsyncEventArgs args = new SocketAsyncEventArgs();
            args.Completed += OnConnectCompleted;
            args.RemoteEndPoint = endPoint; // EndPoint가 상대방의 Socket의 IP이다. 
            args.UserToken = socket; // UserToken을 통해 원하는 정보를 넘겨준다. 

            RegisterConnect(args);
        }

 3) RegisterConnect 함수 

  a) args.UserToken에서 Socket을 받는다. 

  b) 마찬가지로 비동기적으로 Connect을 한다: ConnectAsync를 통해 

  c) pending값을 확인하여 pending값이 false이면 OnConnectCompleted 함수를 호출한다. 

        void RegisterConnect(SocketAsyncEventArgs args)
        {
            Socket socket = args.UserToken as Socket;
            if (socket == null)
                return;

            bool pending = socket.ConnectAsync(args);
            if (pending == false)
                OnConnectCompleted(null, args);
        }

 4) OnConnectCompleted 함수 

  a) 이전의 Listener.cs에서의 함수와 마찬가지로 Session을 받아서 Session을 실행시켜줘야 함

    -> Func<Session> 으로 받아서 Invoke 시켜준다. 

  b) Func<Session> 멤버 변수를 만들고 Connect 함수에서 연결한다. 

  c) _sessionFactor.Invoke 시켜서 해당 함수를 실행시킨 후 Session을 반환하도록한다. 

  d) session.Start를 시작시킨 후, session.OnConnected 함수로 연결된 후 해야할 기능을 실행하도록 한다.  

    - session.Start에서 인자로 args.ConnectSocket으로 현재 연결된 Socket의 정보를 넘긴다. 

    class Connector
    {
        Func<Session> _sessionFactory;
        public void Connect(IPEndPoint endPoint, Func<Session> sessionFactory)
        {
            // 휴대폰 설정
            Socket socket = new Socket(SocketType.Stream, ProtocolType.Tcp);
            _sessionFactory = sessionFactory; // Session Factory 저장
        void OnConnectCompleted(object sender, SocketAsyncEventArgs args)
        {
            if(args.SocketError == SocketError.Success)
            {
                // Session을 받아서 만들어야함 (클라이언트가 연결된 후 어떤 행동을 할 것인가?)
                Session session = _sessionFactory.Invoke();
                session.Start(args.ConnectSocket); // 내가 연결한 Socket으로 Session Start해라 
                session.OnConnected(args.RemoteEndPoint);
            }
            else
            {
                Console.WriteLine($"OnConnectCompleted Fail: {args.SocketError}");
            }
        }

* Connector.cs 전체 코드 

using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading.Tasks;

namespace ServerCore
{
    public class Connector
    {
        Func<Session> _sessionFactory;
        public void Connect(IPEndPoint endPoint, Func<Session> sessionFactory)
        {
            // 휴대폰 설정
            Socket socket = new Socket(SocketType.Stream, ProtocolType.Tcp);
            _sessionFactory = sessionFactory; // Session Factory 저장 

            SocketAsyncEventArgs args = new SocketAsyncEventArgs();
            args.Completed += OnConnectCompleted;
            args.RemoteEndPoint = endPoint; // EndPoint가 상대방의 Socket의 IP이다. 
            args.UserToken = socket; // UserToken을 통해 원하는 정보를 넘겨준다. 

            RegisterConnect(args);
        }

        void RegisterConnect(SocketAsyncEventArgs args)
        {
            Socket socket = args.UserToken as Socket;
            if (socket == null)
                return;

            bool pending = socket.ConnectAsync(args);
            if (pending == false)
                OnConnectCompleted(null, args);
        }
        void OnConnectCompleted(object sender, SocketAsyncEventArgs args)
        {
            if(args.SocketError == SocketError.Success)
            {
                // Session을 받아서 만들어야함 (클라이언트가 연결된 후 어떤 행동을 할 것인가?)
                Session session = _sessionFactory.Invoke();
                session.Start(args.ConnectSocket); // 내가 연결한 Socket으로 Session Start해라 
                session.OnConnected(args.RemoteEndPoint);
            }
            else
            {
                Console.WriteLine($"OnConnectCompleted Fail: {args.SocketError}");
            }
        }

    }
}

4. DummyClient 프로젝트에서 어떻게 Connector.cs 사용? 

 1) ServerCore의 모든 파일을 DummyClient에 복사하는 것은 세련되지 않음. 

 2) ServerCore 프로젝트의 경우 실제로 사용되기 보다는 라이브러리로만 사용됨 

  - ServerCore 우클릭 -> 속성 -> 출력 형식을 콘솔 애플리케이션에서 클래스 라이브러리로 바꿈

 3) 이제 ServerCore를 시작프로젝트로 한 후 Ctrl + F5 버튼을 눌러 실행하면 라이브러리이기 때문에 실행할 수 없다는 오류 메세지가 뜬다. 

 4) Server와 DummyClient를 우클릭 -> 추가 -> 참조 -> 프로젝트: ServerCore를 클릭하여 추가한다. 

 5) SeverCore의 Program.cs의 내용을 Server의 Program.cs로 복사한다 (ServerCore의 메인 함수 삭제) 

   - using ServerCore를 추가하여 ServerCore의 라이브러리를 가져온다. 

* 내가 실수로 Server를 Sever라고 작성하여 모든 nameSpace에 Sever라고 되어있어서 using ServerCore를 가져오지 못했음 

 -> Listener, Session, Connector의 모든 nameSpace를 ServerCore로 바꿔준다. 

   - Listener, Session, Connector의 모든 클래스가 다른 프로젝트에서도 사용될 수 있기 때문에 public을 붙여준다. 

 6) ServerCore의 Program.cs는 사용하지 않을 것이기 때문에 삭제 

 7) 솔루션 -> 속성 -> 여러개 프로젝트 시작에서 DummyClient와 Server를 실행하도록 한다. 

=> ServerCore는 서버 엔진, Server는 콘텐츠 단으로 분리가 되었다.  

 

5. DummyClient의 program.cs에서 Connector를 이용하여 Nonblocking으로 바꿔준다. 

 1) DummyClient의 Program.cs에서도 Server처럼 Session을 상속받는 클래스가 있어야한다. 

 2) Server의 GameSession을 복사해서 붙인다. 그 후에 DummyClient에서 처음 서버 Socket과 Connect를 한 후 메세지를 Send 했기 때문에 해당 코드를 OnConnected에 붙여넣기 한다. 

        public override void OnConnected(EndPoint endPoint)
        {
            Console.WriteLine($"OnConnected : {endPoint}");

            for (int i = 0; i < 5; i++)
            {
                // 보낸다.
                byte[] sendBuff = Encoding.UTF8.GetBytes($"Hello World! {i}");
                Send(sendBuff);
            }
        }

 3) 후에 Program.cs에서 Receive를 했던 부분은 필요 없음 -> Session에서 비동기로 받기 때문에 다음과 같이 어디서 보낸 메세지인지만 바꾼다. 

        public override void OnRecv(ArraySegment<byte> buffer)
        {
            // 성공 -> 버퍼의값을 받는다. 
            string recvData = Encoding.UTF8.GetString(buffer.Array, buffer.Offset, buffer.Count);
            Console.WriteLine($"[From Server] {recvData}");
        }

 4) Connector를 이용하여 서버와 비동기적으로 Connect해야한다.  

  - Connector 객체를 선언하고 Connect 함수를 호출하면 비동기적으로 서버와 Connect이 시작되고 연결이 완료되면 콜백으로 OnConnectComplete 함수가 실행되어 Session의 OnConnected 함수가 실행된다. 

   -> Connect 함수에서 람다로 Session을 생성하여 반환 

  - main 함수 내의 Socket을 생성하고 Send하고 Receive하고 Disconnect하는 부분은 필요 없기 때문에 날린다 .

 

* DummyClient의 Program.cs 전체 코드 

using ServerCore;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;

namespace DummyClient
{
    class GameSession : Session
    {
        public override void OnConnected(EndPoint endPoint)
        {
            Console.WriteLine($"OnConnected : {endPoint}");

            for (int i = 0; i < 5; i++)
            {
                // 보낸다.
                byte[] sendBuff = Encoding.UTF8.GetBytes($"Hello World! {i}");
                Send(sendBuff);
            }
        }

        public override void OnDisconnected(EndPoint endPoint)
        {
            Console.WriteLine($"OnDisconnected : {endPoint}");
        }

        public override void OnRecv(ArraySegment<byte> buffer)
        {
            // 성공 -> 버퍼의값을 받는다. 
            string recvData = Encoding.UTF8.GetString(buffer.Array, buffer.Offset, buffer.Count);
            Console.WriteLine($"[From Server] {recvData}");
        }

        public override void OnSend(int numOfBytes)
        {
            Console.WriteLine($"Transferred bytes: {numOfBytes}");
        }
    }
    class Program
    {
        static void Main(string[] args)
        {

            // DNS (Donmain Name System)
            string host = Dns.GetHostName();
            IPHostEntry ipHost = Dns.GetHostEntry(host);
            IPAddress ipAddr = ipHost.AddressList[0];
            // IPEndPoint: 도착 IP 주소 
            IPEndPoint endPoint = new IPEndPoint(ipAddr, 7777); // ip주소, port 번호 

            // Connector 연결
            Connector connector = new Connector();

            connector.Connect(endPoint, () => { return new GameSession(); });

            while (true)
            {

                try
                {

                    
                }
                catch (Exception e)
                {
                    Console.WriteLine(e.ToString());
                }

                Thread.Sleep(100);
            }
        }
    }
}

5. 실행 결과 

 실행 결과 Client에서 서버로 for 문 5번으로 Hello World를 보내주고 Client는 서버에서 보낸 Welcom to MMORPG Server 를 출력한 후 Disconnect 

<TCP vs UDP>

1. 문자열로 메세지를 보내지 않고 패킷으로 보낸다. 

 ex) 이동 패킷: (3,2) 좌표로 이동하고 싶다. -> (15 3 2) : 15번의 정보이고 3, 2 만큼 이동해야 한다 라는 의미가 담긴 패킷을 서버에 보내면, 서버에서는 패킷을 Decapsulation하여 정보를 확인한다. 

 1) 패킷을 보내는 프로토콜에 따라 100 바이트를 보냈다고 해서 받는 쪽에서 100 바이트를 받는다는 보장이 없다. TCP는 경우에 따라 혼잡이 이러나면 조금만 보내고 여유될때 나머지를 보내는 작업을 하기도 한다. 

 => TCP와 UDP 프로토콜에 대한 이해도가 필요하다. 

 

2. TCP 프로토콜 vs UDP 프로토콜 

 1) TCP: 안전한 트럭, 전화 연결 방식 => 연결형 서비스 

   a) 연결을 위해 할당되는 논리적인 경로가 있다. 

: 일반적으로 전화를 할때, 수화기에 번호를 입력하여 상대방이 연락을 받을 때까지 기다림 -> 통화가 연결되야 대화가 가능

-> 연결형 서비스라고 볼 수 있음 

   b) 전송 순서가 보장된다. 

 : "안녕하세요. ~입니다."라고 대화를 하면 상대방 또한, "안녕하세요. ~ 입니다."의 순서로 듣게 된다. 

   c) 신뢰성 높고 속도 나쁘다  

     - 신뢰성이 높음: 분실이 일어나면 책임지고 다시 전송 

     - 흐름/ 혼잡제어: 물건을 주고 받을 상황이 아니면 일부만 보낸다.

     - 고려할 것이 많으니 속도가 나쁘다. 

 

 2) UDP: 위험한 총알 배송, 우편 전송 방식 => 비연결형 서비스 

   a) 연결이라는 개념이 없다. 

: 우편에는 나의 정보 및 주소를 기입한 후 그냥 보내기만 한다. -> 연결이라는 개념이 없음 

   b) 전송 순서 보장되지 않는다. 

 : 우편물이 뒤섞여서 보내지기 때문에 순서가 뒤바뀌어서 받을 수 있다. 

   c) 신뢰성은 낮고 속도는 빠르다. 

     - 분실에 대한 책임이 없음

     - 일단 보내고 생각한다. 

     - 단순하기 때문에 속도가 빠르다. 

 

3. 게임 만들때 핵심 요소 

 1) TCP는 게임 서버 입장에서 전송 순서가 보장되어 보다 편리

   - UDP는 순서를 보장하지 않기 때문에 패킷을 Decapsulation을 한 후 다시 순서를 맞추는 작업이 까다롭다. 

 2) TCP로 보내면 분실시 책임을 지고 다시 재전송을 하기 때문에 무조건 전송이 된다는 가정하에 코딩을 할 수 있다. 

   - UDP는 분실의 확률로 문제가 발생할 수 있다. 

 3) 혼잡제어로 TCP가 전체 데이터를 보내지 않을 경우, 장단점이 있다. 

   - 상대방 입장을 고려하는 건 좋지만, 내가 보내고자하는 것을 뚝뚝 잘라서 보내기 때문에 까다로움(도착시에 패킷을 조립하는데 어려움이 있음) 

   - UDP는 그냥 보내기때문에 패킷이 완전체로 도착을 함(물론 중간에 분실 위험이 있지만, 도착만 하면)

   => 부분적으로만 전송되는가 전체가 완전히 전송되는가?

 4) TCP가 느리다고 하였지만 UDP에 비해 느린 것이지 사용 못할 수준은 아니다.  

   - 속도때문에 UDP를 사용한다고 하여도 TCP와 같이 분실이나 혼잡 제어의 기능을 추가해줘야한다. 

   - UDP를 사용하도 되는 경우가 있음: 이동하는 과정에서 중간에 몇개를 분실한다고 크게 문제가 없음(도착지만 잘 알면 되기때문에) 

=> 일반적으로 TCP를 사용