5 분 소요

이 글은 Rookiss님의 [C#과 유니티로 만드는 MMORPG 게임 개발 시리즈] Part4: 게임 서버를 보고 헷갈리는 부분 위주로 정리한 글입니다. 틀리거나 부족한 부분이 있을 수 있습니다.

리팩토링

간단한 socket프로그래밍

저번에 socket을 이용해서 간단한 통신을 해보았는데, 이번에는 여러 문제점을 고친다.

  1. 동기 방식을 비동기로 전환
  2. 기능별로 클래스를 나눠서 가독성을 높임
  3. 서버 엔진 코드와, 컨텐츠 부분의 코드를 분리

비동기 Socket 프로그래밍

전에 만든 socket프로그래밍은 Accept, recv, send, connect 등 통신이 일어날 때 무식하게 상대방을 기다리는 blocking방식이였다.
이러한 방식은 직관적이여서 코드 흐름을 이해하기는 쉽지만 Blocking 함수가 나올 때 마다 상대방의 응답이 있기 전까지 멈춰버리는 매우 큰 단점이 있다.
이 방식을 해결하기 위해 비동기적 socket프로그래밍 방식으로 코드의 수정이 필요하다.

  • 동기: 클라이언트가 서버에 또는 서버가 클라이언트에 요청을 보내면 다른 작업을 할 수 없음.
  • 비동기: 클라이언트가 서버에 또는 서버가 클라이언트에 요청을 보내고 다른 작업을 할 수 있다. 응답을 기다리지 않고 다른 작업을 수행할 수 있다.

Socket 비동기 함수 역시 지원되기 때문의 함수를 이용해 구현한다. 비동기 방식으로 구현할 경우 함수의 실행과, 응답 후 처리가 동시에 실행되지 않기 때문에, 이벤트를 이용해서 응답 후 처리 부분을 구현해주어야 한다.
그래서 모든 Async 함수들은 인자값으로 SocketAsyncEventArgs 클래스를 입력받는다.
이 클래스를 통해 응답이 왔을 때 실행되어야 할 코드를 우리가 지정해 줄 수 있다.

기능별로 클래스 나누기

Accept를 담당하는 Listener클래스를 만들고
연결이 된 상태에서 여러 기능(Recv, Send,Disconnect)을 담당할 Session클래스,
Connect를 담당하는 Connector클래스를 만든다.

코드 분리

완성된 ServerCore코드를 라이브러리화 시키고 실질적인 사용은 다른 곳 함.

ServerCore 전체 코드

Listener (Accept 담당)

public class Listener
{
    Socket _listenSocket;
    Func<Session> _sessionFactory;

    public void Init(IPEndPoint endPoint, Func<Session> sessionFactory)
    {
        _listenSocket = new Socket(endPoint.AddressFamily, SocketType.Stream, ProtocolType.Tcp);
        _sessionFactory += sessionFactory;

        _listenSocket.Bind(endPoint);

        _listenSocket.Listen(10);

        // RegisterAccept 함수와 OnAcceptCompleted함수가 순환되며 반복되는 구조
        SocketAsyncEventArgs args = new SocketAsyncEventArgs();
        args.Completed += new EventHandler<SocketAsyncEventArgs>(OnAcceptCompleted);
        RegisterAccept(args);
    }


    
    void RegisterAccept(SocketAsyncEventArgs args)
    {
        args.AcceptSocket = null;

        bool pending = _listenSocket.AcceptAsync(args);

        // 딜레이없이 동기적으로 실행됨
        if (pending == false)
            OnAcceptCompleted(null, args);
            
    }

    void OnAcceptCompleted(object sender, SocketAsyncEventArgs args)
    {
        if(args.SocketError == SocketError.Success)
        {
            // 세션은 연결된 상태일 때 이용할 기능들이 있는 클래스
            Session session = _sessionFactory.Invoke();
            session.Start(args.AcceptSocket);
            session.OnConnected(args.AcceptSocket.RemoteEndPoint);
        }
        else
            Console.WriteLine(args.SocketError.ToString());

        RegisterAccept(args);
    }
}

Sessiong (연결될 때 송 수신을 담당)

public abstract class Session
{
    Socket _socket;         
    int _disconnected = 0;  // 0이면 연결중 1이면 이미 끊어진 상태

    object _lock = new object();
    // Send와 관련된 필드들
    // 이미 SendAsync가 실행 돼었을 때 Send가 추가로 호출될 경우 보낼 메세지을 잠깐 담아둘 곳
    Queue<byte[]> _sendQueue = new Queue<byte[]>();                 
    // Send에 담겨서 보내지는 메세지들 
    List<ArraySegment<byte>> _pendingList = new List<ArraySegment<byte>>(); 
    SocketAsyncEventArgs _sendArgs = new SocketAsyncEventArgs();    // Send전용 SocketAsyncEventArgs

    SocketAsyncEventArgs _recvArgs = new SocketAsyncEventArgs();    // Recv전용 SocketAsyncEventArgs

    // 자식 클래스에서 구현할 각 타이밍에 호출되는 추상 함수들
    public abstract void  OnConnected(EndPoint endPoint);       // 열결될 때 호출
    public abstract void  OnRecv(ArraySegment<byte> buffer);    // 메세지를 받을 때 호출
    public abstract void  OnSend(int numOfBytes);               // 보낼 때 호출
    public abstract void  OnDisconnected(EndPoint endPoint);    // 연결이 끊길 때 호출

    // 세션의 초기화와 Recv 함수가 시작됨
    public void Start(Socket socket)
    {
        _socket = socket;

        _recvArgs.Completed += new EventHandler<SocketAsyncEventArgs>(OnRecvCompleted);
        _recvArgs.SetBuffer(new byte[1024],0,1024);

        _sendArgs.Completed += new EventHandler<SocketAsyncEventArgs>(OnSendCompleted);

        RegisterRecv();
    }

    // 메세지를 보냄, 동시 접근되는걸 막기 위해 lock사용
    public void Send(byte[] sendBuff)
    {
        lock (_lock)
        {
            _sendQueue.Enqueue(sendBuff);
            // 이미 데이터가 전송중이지 않을 경우에만 RegisterSend실행
            if (_pendingList.Count == 0)
                RegisterSend();
        }
    }

    public void Disconnect()
    {
        // 만든 disconnected flag를 1(연결이 끊김)로함
        // 이미 1일 경우 끊어진 상태이므로 리턴을 함
        if (Interlocked.Exchange(ref _disconnected, 1) == 1)
            return;

        OnDisconnected(_socket.RemoteEndPoint);

        _socket.Shutdown(SocketShutdown.Both);
        _socket.Close();
    }

    #region 네트워크 통신

    void RegisterSend()
    {
        while (_sendQueue.Count > 0) 
        {
            byte[] buff = _sendQueue.Dequeue();
            _pendingList.Add(new ArraySegment<byte>(buff, 0, buff.Length));
        }

        // BufferList의 경우 직접Add하는것이 아닌 list를 만들어서 대입해야 오류가 없음
        _sendArgs.BufferList = _pendingList;

        bool pending = _socket.SendAsync(_sendArgs);
        if (pending == false)
            OnSendCompleted(null, _sendArgs);
    }

    void OnSendCompleted(object sender, SocketAsyncEventArgs args)
    {
        lock (_lock)
        {
            if (args.BytesTransferred > 0 && args.SocketError == SocketError.Success)
            {
                try
                {
                    _sendArgs.BufferList = null;
                    _pendingList.Clear();

                    OnSend(_sendArgs.BytesTransferred);

                    if (_sendQueue.Count > 0)
                    {
                        RegisterSend();
                    }
                    
                }
                catch (Exception e)
                {
                    Console.WriteLine($"OnSendCompleted Failed{e}");
                }
            }
            else
            {
                Disconnect();
            }
        }
    }

    void RegisterRecv()
    {
        bool pending = _socket.ReceiveAsync(_recvArgs);
        if (pending == false)
            OnRecvCompleted(null, _recvArgs);
    }

    void OnRecvCompleted(object sender, SocketAsyncEventArgs args)
    {
        if(args.BytesTransferred > 0 && args.SocketError == SocketError.Success)
        {
            try
            {
                OnRecv(new ArraySegment<byte>(args.Buffer, args.Offset, args.BytesTransferred));

                RegisterRecv();
            }
            catch (Exception e)
            {   
                Console.WriteLine($"OnRecvCompleted Failed{e}");
            }
        }
        else
        {

        }
    }
    #endregion
}

Connector (Connert 담당)

public class Connector
{
    Func<Session> _sessionFactory; // 만들어질 세션의 타입을 반환하는 함수
    public void Connect(IPEndPoint endPoint, Func<Session> sessionFactory)
    {
        Socket socket = new Socket(endPoint.AddressFamily, SocketType.Stream, ProtocolType.Tcp);
        _sessionFactory = sessionFactory;

        SocketAsyncEventArgs args = new SocketAsyncEventArgs();
        args.Completed += OnConnectCompleted;
        args.RemoteEndPoint = endPoint;
        args.UserToken = socket;

        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 = _sessionFactory.Invoke();
            session.Start(args.ConnectSocket);
            session.OnConnected(args.RemoteEndPoint);
        }
        else
        {
            Console.WriteLine($"OnConnectCompleted Failed { args.SocketError}");
        }
    }
}

Server 전체 코드

라이브러리화 된 ServerCore를 사용하는 코드

// 서버에 맞게 Sessiond을 상속받아 추상함수 구현
class GameSession : Session
{
    public override void OnConnected(EndPoint endPoint)
    {
        Console.WriteLine($"OnConnected: {endPoint}");

        byte[] sendBuff = Encoding.UTF8.GetBytes("Welcome to MMORPG SERVER !");
        Send(sendBuff);

        Thread.Sleep(1000);

        Disconnect();
    }

    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 Client] {recvData}");
    }

    public override void OnSend(int numOfBytes)
    {
        Console.WriteLine($"Transferred bytes: {numOfBytes}");
    }
}
internal class Program
{
    static Listener _listener = new Listener();
    static void Main(string[] args)
    {
        // DNS (Domain Name System)
        string host = Dns.GetHostName();
        IPHostEntry ipHost = Dns.GetHostEntry(host);
        IPAddress ipAddr = ipHost.AddressList[0];
        IPEndPoint endPoint = new IPEndPoint(ipAddr, 7777);

        // 서버를 열음
        _listener.Init(endPoint, () => { return new GameSession(); });

        Console.WriteLine("Listening...");
        while (true)
        {
        }
    }
}

DummyClient 전체 코드

// 클라이언트에 맞게 Sessiond을 상속받아 추상함수 구현
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}");
    }
}

internal class Program
{
    static void Main(string[] args)
    {
        // DNS (Domain Name System)
        string host = Dns.GetHostName();
        IPHostEntry ipHost = Dns.GetHostEntry(host);
        IPAddress ipAddr = ipHost.AddressList[0];
        IPEndPoint endPoint = new IPEndPoint(ipAddr, 7777);

        // 서버에 연결 
        Connector connector = new Connector();

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

        while (true)
        {
            Thread.Sleep(1000);
        }
    }
}

댓글남기기