[C#/Unity] 게임 서버 #9 - 패킷 처리

Donghee·2024년 9월 18일
0

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

패킷 처리

저번까지 우리는 버퍼에 대한 클래스를 제작했다. 이 버퍼를 통해 데이터를 주고 받는데, TCP 프로토콜을 쓰는 우리의 서버는 패킷의 완전성을 검사해야한다. 즉, 데이터가 오더라도 패킷이 온전한지 검증을 하고 처리를 해야하는 것이다.

패킷 세션 (PacketSession)

우리는 패킷을 주고 받을 수 있는 모든 세션을 PacketSession을 상속받은 클래스로 만들 것이다. 따라서 우선 가장 상위의 Session을 상속받은 PacketSession을 Session.cs에 만들어보자.

	public abstract class PacketSession : Session
	{
		public static readonly int HeaderSize = 2;

		// [size(2)][packetId(2)][ ... ][size(2)][packetId(2)][ ... ]
		public sealed override int OnRecv(ArraySegment<byte> buffer)
		{
			int processLen = 0;

			while (true)
			{
				if (buffer.Count < HeaderSize)
					break;

				ushort dataSize = BitConverter.ToUInt16(buffer.Array, buffer.Offset);
				if (buffer.Count < dataSize)
					break;

				OnRecvPacket(new ArraySegment<byte>(buffer.Array, buffer.Offset, dataSize));
				
				processLen += dataSize;
				buffer = new ArraySegment<byte>(buffer.Array, buffer.Offset + dataSize, buffer.Count - dataSize);
			}

			return processLen;
		}

		public abstract void OnRecvPacket(ArraySegment<byte> buffer);
	}

PacketSession 추상 클래스이다. 우선 OnRecv 메소드를 sealed 키워드를 통해 감싸준다. 이제 PacketSession을 상속하는 클래스들은 OnRecv 메소드를 오버라이드할 수 없게 된다.
대신 그 아래의 OnRecvPacket을 오버라이드해야 한다. PacketSession을 상속받는 클래스들은 모두 통신에서 패킷을 이용한다는 뜻이므로, 그 패킷을 어떻게 검증할지를 이 메소드에서 적어줘야한다.

OnRecv 메소드는 안에서 패킷의 헤더(패킷 사이즈)를 가져와 사이즈만큼 패킷이 왔는지까지만 검증해준다. 이후 실질적인 데이터 뽑아내는 작업은 OnRecvPacket에서 작업하는 것이다.

위의 주석에서 보이는 것처럼
[size(2)][packetId(2)][ ... ]
이 한 뭉텅이가 패킷이다. 개발하는 과정에서 이를 약속하고, size가 패킷을 알아볼 수 있는 헤더 역할을 하는 것이다.

패킷

우리는 패킷에 대한 정보, 클래스들을 만들어야 한다. 추후에는 패킷만 따로 빼서 저장해두겠지만, 우선은 DummyClient와 Server 각각에 똑같은 코드로 만들어두자.

	class Packet
	{
		public ushort size;
		public ushort packetId;
	}

	class PlayerInfoReq : Packet
	{
		public long playerId;
	}

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

	public enum PacketID
	{
		PlayerInfoReq = 1,
		PlayerInfoOk = 2,
	}

PlayerInfoReq는 클라이언트가 서버에게 처음 플레이어에 대한 고유 아이디를 주는 패킷, PlayerInfoOk는 서버가 그 정보를 받아서 플레이어의 정보를 다시 클라이언트에게 주는 패킷이다. 이들을 이용해 패킷을 주고 받아 보자.

서버 세션

이제 우리가 DummyClient, Server 쪽에 각각 만들어놨던 GameSession을 변경할 차례이다. 지금은 단순히 Session을 상속해 값을 서로 주고 받는 형태로 되어 있다. 이것을 패킷 처리를 고려한 형태로 만들어야 하는데, 이제 Program.cs에 같이 껴있기엔 클래스의 사이즈가 커지다보니, 따로 분리하자.

우선 DummyClient의 세션인데, 이것은 서버와 연결되어 있는 세션이기 때문에 ServerSession이라는 클래스로 만들었다.

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

			PlayerInfoReq packet = new PlayerInfoReq() { size = 4, packetId = (ushort)PacketID.PlayerInfoReq, playerId = 1001 };

			// 보낸다
			for (int i = 0; i < 5; i++)
			{
				ArraySegment<byte> s = SendBufferHelper.Open(4096);
				ushort size = 0;
				bool success = true;
			
				size += 2;
				success &= BitConverter.TryWriteBytes(new Span<byte>(s.Array, s.Offset + size, s.Count - size), packet.packetId);
				size += 2;
				success &= BitConverter.TryWriteBytes(new Span<byte>(s.Array, s.Offset + size, s.Count - size), packet.playerId);
				size += 8;
				success &= BitConverter.TryWriteBytes(new Span<byte>(s.Array, s.Offset, s.Count), size);

				ArraySegment<byte> sendBuff = SendBufferHelper.Close(size);

				if (success)
					Send(sendBuff);
			}
		}
        ...
	}

중요한 것은 OnConnected 부분이다. 아까 만들어둔 PacketSession을 상속하지 않고 그대로 Session을 상속하냐라고 할 수 있지만, 지금 클라이언트에서는 서버와 연결될 때 플레이어 정보만 넘겨주면 된다. 따라서 패킷을 받는 부분은 없고, 오로지 보내기만 하는 것이다.

중요한 부분은 size가 계속 커지면서 SendBuffer인 s에 byte 정보를 집어넣는 부분이다.
우리는 PlayerInfoReq 패킷을 보낼 것이므로, 이에 대한 정보를 하나씩 집어넣으면서 byte 변환이 잘 되었는지 success 변수를 통해 알 수 있게 된다.
BitConverter.TryWriteBytes 메소드 대신 BitConverter.GetBytes 메소드를 사용할 수도 있지만, 이들이 유니티에서 적용이 잘 될지는 해봐야 아는 것이고, 전자가 안정성은 떨어지는 대신 속도가 더 빨라 사용하기에 용이하다.

이런 변환이 모두 되고 나면 Send 과정을 진행한다.

클라이언트 세션

Server 부분에 있었던 GameSession도 따로 빼내서 만들어보자. 이는 클라이언트와 연결될 세션이므로, ClientSession이라는 클래스 이름을 가지게 된다. 또한 이는 이제 패킷을 받아서 처리할 것이므로, 위에서 만들었던 PacketSession을 상속받게 된다.

	class ClientSession : PacketSession
	{
		...
		public override void OnRecvPacket(ArraySegment<byte> buffer)
		{
			int pos = 0;

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

			switch ((PacketID)id)
			{
				case PacketID.PlayerInfoReq:
					{
						long playerId = BitConverter.ToInt64(buffer.Array, buffer.Offset + pos);
						pos += 8;
					}
					break;
				case PacketID.PlayerInfoOk:
					{
						int hp = BitConverter.ToInt32(buffer.Array, buffer.Offset + pos);
						pos += 4;
						int attack = BitConverter.ToInt32(buffer.Array, buffer.Offset + pos);
						pos += 4;
					}
					// 이후 패킷 처리
					break;
				default:
					break;
			}

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

중요한 것은 아까도 설명했던 OnRecvPacket 부분이다. 이제 OnRecv 대신 이 메소드에서 패킷을 어떻게 처리할 것인지를 구현해야 한다. 우리는 인자인 buffer로 패킷 부분 전체를 넘겨주므로, 사이즈와 아이디를 다시 한번 파악해 어떤 패킷인지를 판단한다. 그 후 PacketID에 따라 알맞은 정보를 추출한다.

일단은 패킷에서 데이터를 받아오는 과정만 구현하였고, 이후의 과정은 추후 구현할 예정이다.

profile
마포고개발짱

0개의 댓글