공부/인프런 - Rookiss

Part 4-4-1. 패킷 직렬화 : Serialization #1

셩잇님 2023. 11. 30. 17:53
반응형

 

 

패킷 직렬화

 

 지난 시간까지 Session, Connector, RecvBuffer, SendBuffer, PacketSession 등 다양한 클래스를 만들어 패킷 통신에 대해서 진행해보았다. 또한 통신하는 과정에서의 개선, 최적화 등 다양한 방식의 작업 또한 추가적으로 진행해보았다. 이번 세션에서는 패킷 직렬화를 통해 String, List 등 다양한 타입을 가지는 자료형들에 대해서 어떻게 전송이 필요하고, 어떤 방식으로 압축해야 하는지 등 보다 자세하게 알아보자.

 


 

📌 직렬화 

 

 마이크로소프트 공식 홈페이지에 따르면 C#(.NET)에서의 직렬화란 '지속시키거나 전송할 수 있는 형태로 개체 상태를 변환하는 프로세스'를 뜻한다. 그렇다면 왜? 직렬화를 사용해야 하고 이용해야할까?

 

 이는 간단하다. String, List 등과 같이 다양한 데이터 타입을 추가하여 패킷을 구성한다고 생각하자. String과 List의 Data는 동적으로 변하기 때문에 몇 개가, 얼마나 필요한지 알 수 없다. 따라서 기존의 방식을 사용한다고 하면 패킷을 보낼 수 없다. 즉. ushort와 int 형과 같이 단순하게 숫자로 넘겨주는 방식이 아니기 떄문에 이를 압축하여 보내야 하는 것이다.

 

 데이터를 압축하는 방법이 있으므로, 이에 반대되는 개념(=압축을 푸는)도 반드시 존재한다. 이를 역직렬화라고 한다.

 


 

🧹 스크립트 추가

 

DummyClient 프로젝트에 Server Session 클래스를 새롭게 추가하여 주고,
반대로 Server 프로젝트에는 Client Session 클래스를 새롭게 추가한다.

 

❓ 이름을 거꾸로 쓴거 아니야?

 세션(Session)은 일전에 설명한 것과 같이 대리자와 유사하게 생각할 수 있다. 클라이언트에서 서버와 연결 되었을 때 보내주는 대리인이 바로 Server Session이라고 생각하면 이해하기 쉽다. 반대로 서버에서 클라이언트와 연결 되었을 때 보내는 것은 Client Session이다. 즉, 서버/클라이언트간 통신을 하는 친구이기에 이렇게 네이밍을 지정해준다.

 

❓ 세션을 굳이 나누는 이유는 뭐야?

 나중에 가면 세션이 여러개 필요하게 될 수 있기 때문이다. 일전에 언급한 바와 같이 서버에서 연결하는 대상이 꼭 클라이언트만 있으란 법은 없다. 분산 서버를 이용한다면 서버끼리도 통신을 해야 할 수 있기 때문에 이를 나누어 준다.

 


 

💻 Server Session

 

 실제 데이터를 넘기는 것처럼 가정하기 위해 새로운 클래스의 이름은 Player 타입으로 지어주었다. PacketID enum class 에서는 패킷의 타입을 구분하기 위해 enum 으로 작성한다. 이는 추후 유저의 이동 관련 패킷인지, 스킬 관련 패킷인지 등 구분하는 방식으로 사용할 수 있다.

    class PlayerInforReq : Packet
    {
        public long playerId;
    }

    class PlayerInforOk : Packet
    {
        public int hp;
        public int attack;
    }

    public enum PacketID
    {
        PlayerInforReq = 1,
        PlayerInforOk = 2,
    }

 

 이제 OnConnect 메서드에서 기존에 연결된 뒤 버퍼를 보내주던 부분을 변경한다. packet을 받아오던 클래스 타입을 PlayerInfoReq 클래스로 변경하고 size는 비워 둔 채, packetId와 playerId만 입력한다.

 

 public override void OnConnected(EndPoint endPoint)
 {
     PlayerInfoReq packet = new PlayerInfoReq() { packetId = (ushort)PacketID.PlayerInfoReq, playerId = 1001 };
     
     ...
 }

 


 

기존 코드를 살펴보자! 😤

 

            for (int i = 0; i < 5; i++)
            {
                ArraySegment<byte> openSegment = SendBufferHelper.Open(4096);
                byte[] buffer = BitConverter.GetBytes(packet.size);
                byte[] buffer2 = BitConverter.GetBytes(packet.packetId);
                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(packet.size);

                Send(sendBuff);
            }

 

 기존 코드에서는 반복문 내부에서 Array.Copy()시에 Offset에 값을 계속 더해주는 방식으로 사용하였다. 그러나 playerId와 같이 새로운 값들이 1개, 10개, 100개 추가된다면 이를 일일히 다 하드코딩 해야하는 문제가 생긴다. 따라서 이를 아래 코드와 같이 ushort 형 타입의 변수를 선언하여 처리한다.

 

ushort count = 0;
Array.Copy(size, 0, openSeg.Array, openSeg.Offset + count, size.Length);
count += 2; // size == 2
Array.Copy(packetId, 0, openSeg.Array, openSeg.Offset + count, packetId.Length);
count += 2; // packetId == 2
Array.Copy(playerId, 0, openSeg.Array, openSeg.Offset + count, playerId.Length);
count += 8; // playerId == 8

 

그러나 이 방법도 모든 것이 해결되는 것은 아니다. 왜냐하면 기존에 사용하던 GetByte()는 byte[] 형태로 받기 때문에 ushort를 사용해 2바이트를 아껴주었지만, 다시 byte[]로 형 변환이 이루어지며 동적으로 할당하기 때문이다. 따라서 이 또한 새로운 방법으로 바꿔줘야 한다.

 


 

🦛 BitConverter.TryWriteBytes()

 

 TryWirteByte는 byte를 write 하고, 사이를 비교하여 성공, 실패 여부를 bool 값으로 뱉어주는 함수이다. 이 때 인자 값을 Span 타입을 받는데, 이는 ArraySegment와 유사하다. 참조

 

 따라서 코드를 다음과 같은 형식으로 다시 작성한다.

ArraySegment<byte> s = SendBufferHelper.Open(4096);

ushort count = 0;
// 사이즈 측정 시 오류를 체크하기 위한 변수
bool success = true;

// success &= BitConverter.TryWriteBytes(new Span<byte>(s.Array, s.Offset, s.Count), packet.size);
count += 2;
success &= BitConverter.TryWriteBytes(new Span<byte>(s.Array, s.Offset + count, s.Count - count), packet.packetId);
count += 2;
success &= BitConverter.TryWriteBytes(new Span<byte>(s.Array, s.Offset + count, s.Count - count), packet.playerId);
count += 8;
success &= BitConverter.TryWriteBytes(new Span<byte>(s.Array, s.Offset, s.Count), (ushort)count);
                
ArraySegment<byte> sendBuff = SendBufferHelper.Close(count);

if (success)
{
    Send(sendBuff);
}

 

 

위와 같이 Span 형식을 이용해 packetId와 playerId를 담아 사용한다. 즉 byte 배열에 담는 것이 아닌 처음 사이즈를 정할 때 사용하는 s에 담아주어 사용하게 되는 것이다. s의 유효범위(=4096)보다 큰 경우는 담을 수 없기에 false를 내뱉는다.

 

또한, size를 넘겨 주는 작업이 모두 마친 후에 사용자가 얼마나 썼는지 알 수 있기에 가장 하단에 미리 계산한 count 함수를 통해 size를 넘겨준다. 마지막으로 보낼 때 success의 값에 따라 Send 함수 여부를 실행할 지 말지 결정한다. 시스템 상의 오류, 혹은 유저의 악의적인 이유에 따른 이상한 패킷을 미리 차단해야 하기 때문에 이러한 예외처리가 필요하다.

 


 

💻 Client Session

 

 이제 서버에서 패킷을 받는 작업을 진행하면 된다. Server Session과 동일하게 패킷 정보를 모두 복사하여 가져온 후, 마찬가지로 count를 통해 이를 계산하는 방법으로 스크립트를 작성한다.

 

        public override void OnRecvPacket(ArraySegment<byte> buffer)
        {
            ushort count = 0;

            ushort size = BitConverter.ToUInt16(buffer.Array, buffer.Offset);
            count += 2;
            ushort id = BitConverter.ToUInt16(buffer.Array, buffer.Offset + count);
            count += 2;

            switch((PacketID)id)
            {
                case PacketID.PlayerInfoReq:
                    {
                        long playerId = BitConverter.ToInt64(buffer.Array, buffer.Offset + count);
                        count += 8;
                        Console.WriteLine($"PlayerInfoReq : {playerId}");
                    }
                    break;
            }

            Console.WriteLine($"RecvPacketId : {id}, Size : {size}");
        }

 

🎚️ Switch 문에 따른 PacketId 구분

 

 앞서 작성한 PacketId enum class에 따라 해당 패킷을 처리하는 방식이 달라지므로 이를 Switch 문을 통해 구분하여 진행하도록 한다.

 


 

😎 결과

 

 

 결과를 살펴보면 playerId 값, 보내는 쪽의 패킷 사이즈를 모두 넘겨주고, Recv에서도 정상적으로 12의 사이즈를 받은 것을 확인할 수 있다. 😎

 

 

반응형