[C#/Unity] 게임 서버 #6 - 세션, 세션의 추상화

Donghee·2024년 9월 3일
0

본 게시물은 Rookiss님의 '[C#과 유니티로 만드는 MMORPG 게임 개발 시리즈] Part4: 게임 서버' 강의를 듣고 정리한 내용임을 미리 알립니다.

세션

소켓 프로그래밍의 서버 과정을 다시 살펴보자.

우리가 지금까지 만든 서버코어 부분은, 4번까지는 AcceptAsync를 이용한 비동기 작업으로 잘 구현했다. 하지만 그 이후 세션 소켓을 주는 과정부터는 아직 미흡하다. 또한, Send와 Receive 메소드가 블로킹 함수이기 때문에, 동시에 여러 통신을 하는 데에 아직 부족함이 있다. 이들을 비동기로 처리하면서, 클라이언트에게 제공될 세션 클래스를 만들어보자.

Start

먼저 클래스 멤버와 Start 메소드 부분이다. Start 메소드는 Listener에서 AcceptSync가 완료되었을 시에, AcceptSocket을 받아 세션을 시작하는 역할을 한다.

namespace ServerCore
{
    public class Session
    {
        Socket _socket;
        object _lock = new object();
        SocketAsyncEventArgs _sendArgs = new SocketAsyncEventArgs();
        SocketAsyncEventArgs _recvArgs = new SocketAsyncEventArgs();

        public void Start(Socket socket)
        {
            _socket = socket;
            // ReceiveAsync를 사용하기 위한 설정
            _recvArgs.Completed += new EventHandler<SocketAsyncEventArgs>(OnRecvCompleted);
            // 버퍼 설정
            _recvArgs.SetBuffer(new byte[1024], 0, 1024);

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

            RegisterRecv();
        }
        ...
    }
}

우선 세션 소켓으로 쓸 소켓 객체를 인자로 받아 클래스 멤버변수로 저장한다. 이후 비동기 Receive와 Send에 활용할 SocketAsyncEventArgs들의 완료시 행동을 바인딩해준다. _recvArgs에는 받았을 때 저장해놓을 버퍼를 미리 할당해준다. 또한, 데이터를 받는 Receive쪽은 바로 대기를 시작해야 하므로, 이따가 나올 RegisterRecv 메소드를 호출해주자.

Send

먼저 데이터를 클라이언트로 보내는 Send 메소드를 구현해보자.

namespace ServerCore
{
    public class Session
    {
    	...
    	Queue<byte[]> _sendQueue = new Queue<byte[]>();
        List<ArraySegment<byte>> _pendingList = new();
        
		// 클라이언트에게 데이터를 보내는 함수        
        public void Send(byte[] sendBuff)
        {
            lock (_lock)
            {
                _sendQueue.Enqueue(sendBuff);
                // 누군가가 RegisterSend를 호출하지 않고 있다면
                if (_pendingList.Count == 0)
                {
                    RegisterSend();
                }
            }
        }
    }
}

우선 클래스 멤버 변수를 보자. byte 배열을 담는 Queue인 _sendQueue, 그리고 byte ArraySegment를 담는 List인 _pendingList가 있다. _sendQueue는 데이터를 여러번 받아 한번에 처리할 때 사용하고, _pendingList는 _sendQueue에 있는 데이터들을 받아와 Send를 비동기로 처리하는 데에 사용한다.

Send 메소드는 byte 배열 인자로 데이터를 받는다. 우선 _sendQueue에 버퍼를 저장하고, 이후 _pendingList가 0이라면 RegisterSend를 호출한다. _pendingList는 Send가 비동기로 처리되고 있다면, 무조건 0 이상의 Count를 가진다. 즉 '_pendingList의 Count가 0인지의 여부''비동기 Send가 처리되고 있는지 아닌지의 여부'이다.
RegisterSend는 Send를 비동기적으로 처리하기 시작하는 메소드인데, 이것이 여러번 호출되면 비동기로 호출하는 의미가 없다. 따라서 이를 락으로 걸어 한번만 처리되도록 하자.

RegisterSend

RegisterSend는 Send가 비동기적으로 처리되길 원할 때 호출하는 메소드이다.

namespace ServerCore
{
    public class Session
    {
    	...
        void RegisterSend()
        {
            while(_sendQueue.Count > 0)
            {
                byte[] buff = _sendQueue.Dequeue();
                _pendingList.Add(new ArraySegment<byte>(buff, 0, buff.Length));
            }
            _sendArgs.BufferList = _pendingList;

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

RegisterSend에서는 _sendQueue에 있는 byte 배열들을 하나씩 꺼내서, _pendingList에 추가한다. 이후 _sendArgs의 BufferList(여러 버퍼를 한번에 처리할 때 사용)로 _pendingList를 지정해준다. 그 후 _sendArgs를 인자로 해 SendAsync를 진행하는데, 이것이 바로 Send가 비동기적으로 처리되는 부분이다. 만약 비동기적으로 처리를 요청해도 바로 끝날 수가 있는데, 이때를 대비하기 위해 pending 변수가 false인지 확인해준다.
아까 락을 걸어준 이유 중에 또 하나는, _sendQueue에 접근하는 과정에서 경합 조건이 발생하기 때문이다. 만약 Dequeue하는 과정이나 List에 Add하는 것이 동시에 벌어진다면, 클라이언트에서는 한번 보낸 데이터가 서버에서 여러번 받아지는 결과가 도출된다.

이제 비동기적으로 처리되었을 때 호출되는 OnSendCompleted를 보자.

OnSendCompleted

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

                        // RegisterSend 하는 중에 누군가가 Send를 호출했다면
                        if (_sendQueue.Count > 0)
                        {
                            RegisterSend();
                        }
                    }
                    catch (Exception e)
                    {
                        Console.WriteLine($"OnSendCompleted Failed {e}");
                    }
                }
                else
                {
                    DisConnect();
                }
            }
        }
    }
} 

SendAsync로 비동기적으로 완료되었을 때에 호출되는 OnSendCompleted이다. 이 메소드의 경우에도 _pendingList나 _sendQueue의 조작이 이루어지는데, 여기서 락을 걸어주지 않으면 문제가 발생할 수 있다. 따라서 락을 걸어주고 시작하자. 이후 SendAsync가 제대로 이루어졌는지 확인하고, _sendArgs의 BufferList와 _pendingList를 비워주자.
이후 _sendQueue의 Count를 다시 확인해주는데, 이는 SendAsync가 비동기적으로 이루어지는 과정에서 Send 메소드가 또 호출되어 있었을 가능성이 있기 때문이다. 만약 그렇다면 RegisterSend를 다시 호출해 _sendQueue를 다시 비워주며 SendAsync하는 과정을 재시작해주자.
만약 SendAsync가 제대로 이루어지지 않았다면 현재 연결에 이상이 있다는 의미로 받아들이고, 이따 나올 Disconnect 메소드를 호출해 연결을 끊자.

Receive

클라이언트에서 비동기적으로 데이터를 받을 Receive 부분을 구현해보자. 여기는 따로 public 메소드가 필요 없는데, 능동적으로 데이터를 보내는 Send와 달리, 수동적으로 대기하며 데이터가 오기만을 기다리면 되기 때문이다. 따라서 Start 부분에 RegisterRecv 메소드를 호출해 대기해준 것만으로 충분하다.

RegisterRecv

namespace ServerCore
{
    public class Session
    {
    	...
        void RegisterRecv()
        {
            bool pending = _socket.ReceiveAsync(_recvArgs);
            // 바로 성공이 되었다면
            if (!pending)
            {
                OnRecvCompleted(null, _recvArgs);
            }
        }
    }
} 

먼저 RegisterRecv는 _recvArgs를 인자로 해 ReceiveAsync 메소드를 호출한다. 이는 Receive를 비동기적으로 처리하는 메소드이다. 이후 Send 떄와 같이 pending 여부를 판독해 OnRecvCompleted를 호출해준다.

OnRecvCompleted

ReceiveAsync가 비동기적으로 처리를 완료했을 때 호출되는 OnRecvCompleted 메소드를 살펴보자.

namespace ServerCore
{
    public class Session
    {
        void OnRecvCompleted(object sender, SocketAsyncEventArgs args)
        {
            // 클라이언트가 접속을 끊었다면 0바이트를 받게 됨
            if (args.BytesTransferred > 0 && args.SocketError == SocketError.Success)
            {
                try
                {
                    RegisterRecv();
                }
                catch (Exception e)
                {
                    Console.WriteLine($"OnRecvCompleted Failed {e}");
                }   
            }
            else
            {
                DisConnect();
            }
        }
    }
} 

먼저 Receive가 잘 되었는지 확인해주고, 잘 되었다면 RegisterRecv를 다시 호출시킨다. 이는 계속해서 데이터를 받을 수 있도록 대기를 반복하는 행위이다. 여기도 Send 때와 마찬가지로 데이터가 잘 받아지지 않았다면 Disconnect 해주자.

Disconnect

연결을 끊을 때 사용할 public 메소드인 Disconnect를 살펴보자.

namespace ServerCore
{
    public class Session
    {
        int _disconnected = 0;
        
        public void DisConnect()
        {
            // 멀티스레드 환경에서 한번만 실행되도록 Interlocked를 사용
            // Async 함수는 멀티스레드 환경에서 호출되기 때문
            if (Interlocked.Exchange(ref _disconnected, 1) == 1)
                return;

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

이미 곳곳에 Disconnect 메소드가 호출되는 것을 보았을 것이다. 때문에 먼저 CAS 방법으로 Disconnect가 호출되었는지를 파악하자. 아직 호출되지 않았다면, 아래로 내려가 소켓의 연결을 종료시켜준다.

세션 추상화

이제 세션을 통해 데이터를 주고 받는 부분은 비동기적으로 잘 구현이 되었다. 하지만 문제가 있다. 주고 받을 때의 행동을 딱히 지정해주지 않았다. 이는 세션을 사용할 구역마다 다르게 구현이 되어야 할 것이다. 우리는 이 문제를 그 구역에서 Session 클래스를 상속해 행동을 구현하는 방식으로 해결할 것이다.
이를 위해 Session 클래스를 추상 클래스로 지정하고, 추상 메소드들을 추가해보자.

namespace ServerCore
{
    public abstract class Session
    {
		...
        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);
        ...
    }
}

각각 연결, 데이터 수신, 데이터 송신, 연결종료 시의 행동을 추상화한 메소드이다.
OnSend는 OnSendCompleted에, OnRecv는 OnRecvCompleted에, OnDisconnected는 DisConnect에, 각자 행동이 시작될 곳에 알맞게 배치했다.
하지만 OnConnected는 어디 있어야 할까? 사실 Session 클래스의 _socket은 Listener가 Accept을 성공해, AcceptSocket으로 넘겨준 소켓이다. 따라서 Listener가 Accept를 성공했을 때가, OnConnected가 실행될 지점이라는 것이다. 이를 위해 Listener가 직접 Session을 만들어주도록 코드를 수정해보자.

Listener 수정

namespace ServerCore
{
    public class Listener
    {
    	...
        // 원하는 세션을 만들어줄 세션 공장
        Func<Session> _sessionFactory;

        public void Init(IPEndPoint endPoint, Func<Session> sessionFactory)
        {
            ...
            _sessionFactory += sessionFactory;
			...
        }
        
        void OnAcceptCompleted(object sender, SocketAsyncEventArgs args)
        {
            // 새로운 클라이언트가 접속했을 때
            if (args.SocketError == SocketError.Success)
            {
                Session session = _sessionFactory.Invoke();
                session.Start(args.AcceptSocket);
                session.OnConnected(args.AcceptSocket.RemoteEndPoint);
            }
            ...
        }

먼저 우리는 _sessionFactory라는 Session 타입의 Func를 클래스 멤버로 추가해줬다. 이는 Listener의 생성자로 받아서, 원하는 세션 파생 클래스를 만들어줄 일종의 공장 역할을 수행한다고 보면 된다.
이후 Accept가 완료되는 OnAcceptCompleted에서, _sessionFactory를 통해 세션을 만들어주고, 이를 시작한다. 그 이후 OnConnected를 실행시켜주면 연결되자마자의 행동을 알맞게 수행한다.

GameSession

이제 Session을 상속해 서버 쪽에서 GameSession이라는 이름으로 세션을 만들어보자.

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

            byte[] sendBuff = Encoding.UTF8.GetBytes("Welcome to 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}");
        }
    }

    class Program
    {
    	...
    }
}

GameSession은 추상 메소드들인 OnConnected, OnDisconnected, OnRecv, OnSend를 구현해줘야 한다. 각각 서버에서 세션이 할 행동들을 연결해주도록 하자.
이렇게 세션을 추상화시킴으로써, 서버와 클라이언트 각각에서 알맞는 세션의 행동을 지정해주어 재사용할 수 있다.

profile
마포고개발짱

0개의 댓글