[C#/Unity] 게임 서버 #8 - 버퍼 쪼개기(RecvBuffer, SendBuffer)

Donghee·2024년 9월 17일
0

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

버퍼 클래스

앞서 봤던 TCP 프로토콜은 패킷의 정보가 한번에 전달이 되지 않을수도 있고 다소 간격을 두고 전달이 될 가능성이 존재한다. 따라서 우리는 데이터를 받을 때, 혹은 전송할 때 한번에 많은 양을 가지고 있다가 차례대로 처리하는 버퍼가 필요하다.

RecvBuffer

먼저 데이터를 받는 용도로 사용할 RecvBuffer 클래스를 만들어보자.


namespace ServerCore
{
    public class RecvBuffer
    {
        ArraySegment<byte> _buffer;
        int _readPos;
        int _writePos;

        public RecvBuffer(int bufferSize)
        {
            _buffer = new ArraySegment<byte>(new byte[bufferSize], 0, bufferSize);
        }

        public int DataSize { get { return _writePos - _readPos; } }
        public int FreeSize { get { return _buffer.Count - _writePos; } }

        public ArraySegment<byte> DataSegment
        {
            get { return new ArraySegment<byte>(_buffer.Array, _buffer.Offset + _readPos, DataSize); }
        }
        public ArraySegment<byte> WriteSegment
        {
            get { return new ArraySegment<byte>(_buffer.Array, _buffer.Offset + _writePos, FreeSize); }
        }
		...
}

가장 먼저 나오는 멤버로는 byte를 담는 ArraySegment 형의 _buffer, 현재 받은 데이터를 읽어서 처리하는 위치의 커서에 해당하는 _readPos, 받은 데이터의 가장 마지막 위치의 커서에 해당하는 _writePos가 존재한다. 두 커서 변수를 통해 현재 유효한 데이터가 어디인지, 더 받을 수 있는 데이터는 어디인지 알 수 있다.
DataSize는 현재 유효한 데이터의 크기를 나타낸다. FreeSize는 남은 버퍼 공간의 크기를 나타낸다.
DataSegment는 현재 유효한 데이터의 버퍼를 반환한다. WriteSegment는 쓸 수 있는 남은 버퍼를 반환한다.

결국 _buffer가

[ ][ ][ ][ ][ ][ ][ ][ ][ ][ ]

이런 형태의 공간을 가지고 있다고 해보자. 처음에는 _readPos와 _writePos가 모두 0이기 때문에

[r,w][ ][ ][ ][ ][ ][ ][ ][ ][ ]

위와 같은 커서의 형태다. 여기서 3바이트의 데이터를 전송받았다고 가정해보자.

[r][ ][ ][w][ ][ ][ ][ ][ ][ ]

위와 같은 형태가 된다.
우리가 이후 처리를 하려고보니, 패킷은 2바이트 크기인 것이 확인되었다. 그렇다면 우리는 들어온 3바이트를 모두 처리하지 않고 2바이트만 처리한다. 그럼 _readPos가 움직여 이런 형태가 된다.

[ ][ ][r][w][ ][ ][ ][ ][ ][ ]

우리는 이제 이 커서를 움직이는 작업과, 버퍼가 넘치려 할때 다시 초기화해주는 인터페이스가 필요하다.

Clean

        public void Clean()
        {
            int dataSize = DataSize;
            if (dataSize == 0)
            {
                // 남은 데이터가 없다면 복사하지 않는다.
                // 데이터를 앞으로 당기는 작업을 하지 않는다.
                _readPos = _writePos = 0;
            }
            else
            {
                Array.Copy(_buffer.Array, _buffer.Offset + _readPos, _buffer.Array, _buffer.Offset, dataSize);
                _readPos = 0;
                _writePos = dataSize;
            }
        }

버퍼의 커서를 다시 앞으로 당겨주는 Clean 메소드이다. 현재 유효한 데이터의 크기인 DataSize를 불러와 체크한다. 유효 데이터가 없다면 다시 초기 상태로 만들어주고, 아니라면 유효 데이터만 앞으로 당겨주고 커서를 그에 맞게 당긴다.

OnWrite

        public bool OnWrite(int numOfBytes)
        {
            if (numOfBytes > FreeSize)
                return false;

            _writePos += numOfBytes;
            return true;
        }

데이터를 받았을 때에 호출될 OnWrite 메소드이다. 남은 공간보다 더 큰 데이터가 들어올 상황을 대비하고, 그것이 아니라면 _writePos를 움직인다.

OnRead

       public bool OnRead(int numOfBytes)
       {
           if (numOfBytes > DataSize)
               return false;

           _readPos += numOfBytes;
           return true;
       }

데이터를 처리한 후에 호출될 OnRead 메소드이다. 처리한 데이터가 현재 유효 데이터보다 크기가 큰 이상 상황을 대비하고, 그것이 아니라면 _readPos를 움직인다.

SendBuffer

다음은 데이터를 송신할때 사용할 SendBuffer 클래스이다. Send는 Receive와 다르게 Session 외부에서 보내는 버퍼를 만들어준 다음 전송을 시키고 있었다. 클래스를 만들기 전에도 RecvBuffer는 Session 내부에서 세션과 1대1 관계를 유지하고 있었지만, SendBuffer는 그렇지 않아 다른 구현 방식이 필요하다.

우리는 MMORPG에 대한 서버를 구축하는 것인데, 한 공간에 100명의 유저가 있다고 가정해보자. 이 유저끼리는 서로의 이동 정보를 보내야한다. 기존에 행하던 방식으로 이 작업을 한다면, 100명의 유저가 서로가 서로에게 모두 보내는 작업을 해야한다. 즉, 100개의 정보가 100*100번의 Send 작업으로 이어지는 것이다. 여기서 중요한 것은 보내는 횟수가 아니라, 보내기 위해 데이터를 byte로 컨버트하고 버퍼에 복사하는 작업의 횟수가 늘어난다는 점이다. 보내는 횟수는 그대로지만, 복사하는 작업은 한번만 하도록 SendBuffer는 Session 바깥으로, 즉 세션의 파생 클래스에 있도록 설계할 것이다.

    public class SendBuffer
    {
        byte[] _buffer;
        int _usedSize = 0;

        public int FreeSize { get { return _buffer.Length - _usedSize; } }

        public SendBuffer(int chunkSize)
        {
            _buffer = new byte[chunkSize];
        }
    ...
    }

먼저 클래스 멤버를 살펴보자. 실질적인 버퍼인 _buffer, 버퍼를 이용하고 있는 크기를 나타내는 _usedSize 변수, 그리고 남은 공간을 반환하는 FreeSize로 구성되어 있다. SendBuffer는 우리가 한번에 얼마나 사용할지 확실하게 예측이 불가능하다. 따라서 생성자로 큰 크기를 받아 버퍼를 생성하고, 이를 조금씩 잘라서 사용하는 형식으로 이용할 것이다.

Open

        public ArraySegment<byte> Open(int reserveSize)
        {
            if (reserveSize > FreeSize)
                return null;

            return new ArraySegment<byte>(_buffer, _usedSize, reserveSize);
        }

먼저 SendBuffer를 사용할때 호출해야하는 Open 메소드이다. 보낼 것이라고 예상한 크기를 인자로 받고, 이를 통해 버퍼를 잘라서 반환해준다.

Close

        public ArraySegment<byte> Close(int usedSize)
        {
            ArraySegment<byte> segment = new ArraySegment<byte>(_buffer, _usedSize, usedSize);
            _usedSize += usedSize;
            return segment;
        }

보낼 데이터를 알맞게 변환해 복사한 다음 호출해야하는 Close 메소드이다. 실질적으로 사용한 크기를 인자로 받아 _usedSize를 갱신하고 그만큼을 다시 잘라서 반환해준다. 이제 세션 파생 클래스에서는 이 버퍼를 이용해 실질적인 Send를 진행할 것이다.

SendBufferHelper

여기서 더 생각해보면, RecvBuffer처럼 Clean하는 부분이 나와야 될 것이라고 생각한다. 하지만 Send가 비동기적으로 진행되는 상황에서, SendBuffer의 데이터를 계속해서 이용할 수도 있다. 이런 상황에서 멋대로 Clean을 진행한다는 건, 전송되어야 할 데이터가 제대로 되지 않을 수도 있다는 걸 의미한다. 그래서 우리는 SendBuffer를 일회용으로 사용(크기가 다 찰 때까지 사용하고, 이후엔 새로운 SendBuffer를 만들어 사용하기)할 것이다. 이를 돕는 SendBufferHelper 클래스를 만들어보자.

    public class SendBufferHelper
    {
        public static ThreadLocal<SendBuffer> CurrentBuffer = new ThreadLocal<SendBuffer>(() => { return null; });

        public static int ChunkSize { get; set; } = 4096 * 100;

        public static ArraySegment<byte> Open(int reserveSize)
        {
            if (CurrentBuffer.Value == null)
                CurrentBuffer.Value = new SendBuffer(ChunkSize);

            if (CurrentBuffer.Value.FreeSize < reserveSize)
                CurrentBuffer.Value = new SendBuffer(ChunkSize);

            return CurrentBuffer.Value.Open(reserveSize);
        }

        public static ArraySegment<byte> Close(int usedSize)
        {
            return CurrentBuffer.Value.Close(usedSize);
        }
    }

우선 우리는 SendBuffer를 일회용으로만 사용하고, SendBufferHelper는 단지 이를 도울 용도로만 사용할 것이기 때문에 모두 전역 멤버로 선언해주었다. 하지만 이렇게 되면 멀티스레드 환경에서 경합할 여지가 생긴다. 따라서 실질적으로 버퍼 역할을 하는 CurrentBuffer를 ThreadLocal로 선언해 TLS 공간에 넣도록 하자. 이렇게 되면 스레드별로 버퍼가 따로 할당되어 서로 겹치는 일이 발생하지 않을 것이다. Open과 Close는 SendBuffer의 것을 덮어주는 형태로 구현되었다.

Session과의 연동

RecvBuffer

RecvBuffer는 사실 원래 Session 클래스에 있던 것을 별도의 클래스로 뺀 것에 가깝기 때문에, 그걸 그대로 바꿔주기만 하면 된다. 멤버 변수로 사용할 하나의 RecvBuffer를 선언해주고, 이를 통해 데이터를 전송받으면 된다. 여기서 RegisterRecv를 수행할 때 RecvBuffer의 Clean을 진행한다는 걸 유의깊게 보자.


    public abstract class Session
    {
    	...
        RecvBuffer _recvBuffer = new RecvBuffer(1024);

        void RegisterRecv()
        {
            // Clean을 통해 데이터를 앞으로 당긴다.
            _recvBuffer.Clean();
            // WriteSegment를 통해 남은 버퍼 공간을 가져온다.
            ArraySegment<byte> segment = _recvBuffer.WriteSegment;
            // SetBuffer를 통해 받은 데이터를 쓸 위치를 설정한다.
            _recvArgs.SetBuffer(segment.Array, segment.Offset, segment.Count);

            bool pending = _socket.ReceiveAsync(_recvArgs);
            // 바로 성공이 되었다면
            if (!pending)
            {
                OnRecvCompleted(null, _recvArgs);
            }
        }        
        ...
    }
}

이후 전달을 받은 OnRecvCompleted 메소드에서는, _readPos와 _writePos를 움직여주며 (OnRead, OnWrite를 통해) 버퍼를 처리하게 된다.

        void OnRecvCompleted(object sender, SocketAsyncEventArgs args)
        {
            // 클라이언트가 접속을 끊었다면 0바이트를 받게 됨
            if (args.BytesTransferred > 0 && args.SocketError == SocketError.Success)
            {
                try
                {
                    // Write 커서 이동
                    if(_recvBuffer.OnWrite(args.BytesTransferred) == false)
                    {
                        DisConnect();
                        return;
                    }

                    // 얼마나 처리했는지
                    int processLen = OnRecv(_recvBuffer.DataSegment);
                    if(processLen < 0 || _recvBuffer.DataSize < processLen)
                    {
                        DisConnect();
                        return;
                    }

                    // Read 커서 이동
                    if (_recvBuffer.OnRead(processLen) == false)
                    {
                        DisConnect();
                        return;
                    }
                    RegisterRecv();
                }
                catch (Exception e)
                {
                    Console.WriteLine($"OnRecvCompleted Failed {e}");
                }   
            }
            else
            {
                DisConnect();
            }
        }

SendBuffer

SendBuffer는 Session이 아닌 Session 파생 클래스에 직접 넣어줘야 한다. 이유는 아까 위에 말했듯이 복사 작업의 반복을 막기 위함이다.

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

            Knight knight = new Knight() { hp = 100, attack = 10 };

            ArraySegment<byte> openSegment = SendBufferHelper.Open(4096);
            byte[] buffer = BitConverter.GetBytes(knight.hp);
            byte[] buffer2 = BitConverter.GetBytes(knight.attack);
            Array.Copy(buffer, 0, openSegment.Array, openSegment.Offset, buffer.Length);
            Array.Copy(buffer2, 0, openSegment.Array, openSegment.Offset + buffer.Length, buffer2.Length);
            ArraySegment<byte> sendBuff = SendBufferHelper.Close(buffer.Length + buffer2.Length);

            Send(sendBuff);
            Thread.Sleep(1000);
            DisConnect();
        }
		...
    }

복사하는 작업은 파생 클래스별로 따로 구현될 수 밖에 없으므로, 우리는 여기에 SendBufferHelper를 이용해 sendBuff를 만들어줘야 한다.

profile
마포고개발짱

0개의 댓글