[Unity Dedicated Server] #2 - Ping-Pong

qweasfjbv·2026년 2월 9일

UnityServer

목록 보기
7/8

개요


유니티를 사용하여 서버와 클라이언트 간의 UDP통신을 하고, Ping-Pong 및 간단한 정보를 교환해 보도록 하겠습니다.

구현


먼저, 네트워크 통신을 위해서는 데이터를 직렬화/비직렬화 하여 구조체 <-> 바이트 배열 간에 전환을 구현해야 합니다.


public enum PacketType : byte
{
	S2C_Pong = 1,

	C2S_Ping = 100,
}

public static unsafe class Serializer
{
	public static byte[] Serialize<T>(PacketType type, T data) where T : unmanaged
	{
		int size = sizeof(T);
		byte[] buffer = new byte[size + 1];

		buffer[0] = (byte)type;
		fixed (byte* ptr = buffer)
		{
			UnsafeUtility.MemCpy(ptr + 1, &data, size);
		}

		return buffer;
	}

	public static T Deserialize<T>(out PacketType type, byte[] buffer) where T : unmanaged
	{
		type = (PacketType)buffer[0];

		T data;
		fixed (byte* ptr = buffer)
		{
			UnsafeUtility.MemCpy(&data, ptr + 1, sizeof(T));
		}
		return data;
	}
}

가장 간단하게, 첫 바이트는 PacketType으로 채우고, 그 뒤로는 T 타입을 바이트 배열로 변환하여 복사하겠습니다.
또한, T 타입은 unmanaged로 강제하여 레퍼런스 타입을 포함하지 않도록 했습니다.


이제 매니저를 구현해보겠습니다.

우선 UDP 통신의 추상 클래스를 구현해보겠습니다.

public abstract class UDPNetworkTransport
{
	protected UdpClient udp;
	protected IPEndPoint localEP;
		
	protected bool isRunning;
	protected Thread recvThread;
	protected ConcurrentQueue<UdpPacket> recvQueue = new();

	public virtual void Init()
	{
		localEP = new IPEndPoint(IPAddress.Any, Constants.PORT_DEDI);
		udp = new UdpClient(localEP);

		recvThread = new Thread(ReceiveLoop);
		recvThread.IsBackground = true;
		isRunning = true;
		recvThread.Start();
	}
	public virtual void OnUpdate()
	{
    	// 패킷 처리는 메인 스레드에서 해야합니다.
		while (recvQueue.TryDequeue(out UdpPacket packet))
		{
			Debug.Log("Handle Packet!");
			HandlePacket(packet);
		}
	}
	public virtual void Shutdown()
	{
		isRunning = false;
		recvThread?.Abort();
		udp?.Close();
	}
	public virtual void Send(IPEndPoint destEP, byte[] payload)
	{
		udp.Send(payload, payload.Length, destEP);
	}

	protected abstract void HandlePacket(in UdpPacket packet);

	// 해당 함수는 다른 스레드에서 실행합니다.
    // UdpPacket이 들어올때까지 대기하고, 들어오면 recvQueue에 넣어서 메인 스레드가 처리하도록 합니다.
	protected void ReceiveLoop()
	{
		try
		{
			while (true)
			{
				IPEndPoint sender = null;
				UdpPacket packet;
				packet.data = udp.Receive(ref sender);
				packet.sender = sender;

				recvQueue.Enqueue(packet);
			}
		}
		catch (Exception e)
		{
			Debug.Log($"UDP Receive stopped : {e}");
		}
	}
}

이 매니저를 토대로 서버가 사용할 매니저와 클라이언트가 사용할 매니저를 구현해보겠습니다.

DediServerManager

public class DediServerManager : UDPNetworkTransport
{
	private ConcurrentDictionary<IPEndPoint, ClientConnection> clients = new();

	public override void Init()
	{
		base.Init();
		Debug.Log("DediServer Init");
	}

	public override void OnUpdate()
	{
		base.OnUpdate();
	}

	protected override void HandlePacket(in UdpPacket packet)
	{
    	// HACK - 테스트를 위한 임시 구현부입니다.
        // 게임 시작 시, 클라이언트 정보를 받아 추가해야합니다.
		ClientConnection client;
		if (!clients.TryGetValue(packet.sender, out client))
		{
			client = new ClientConnection
			{
				endPoint = packet.sender,
				isConnected = true
			};
			clients.TryAdd(packet.sender, client);
		}
		
		PacketType type = (PacketType)packet.data[0];
		Debug.Log(packet.sender.ToString() + " : " + type.ToString());

		switch (type)
		{
        	// 서버가 Ping을 받으면 Pong으로 응답합니다.
			case PacketType.C2S_Ping:
				{
					long clientTime = Serializer.Deserialize<long>(out _, packet.data);
					Send(packet.sender, Serializer.Serialize<long>(PacketType.S2C_Pong, clientTime));
				}
				break;
		}
	}
}

DediClientManager

public class DediClientManager : UDPNetworkTransport
{
	private IPEndPoint serverEP;

	public override void Init()
	{
		base.Init();

		// TODO - PORT will be changed by GameServerManager
		serverEP = new IPEndPoint(IPAddress.Parse(Constants.IP_ADDR), Constants.PORT_DEDI);
			
		// 멀티쓰레드로 1초마다 Ping 보내기
		new Thread(() =>
		{
			while (true)
			{
				Send(serverEP, Serializer.Serialize<long>(PacketType.C2S_Ping, NetworkTimer.NowMs()));
				Thread.Sleep(1000);
			}
		}).Start();

		Debug.Log("DediClient Init");
	}

	protected override void HandlePacket(in UdpPacket packet)
	{
		PacketType type = (PacketType)packet.data[0];
		switch (type)
		{
			case PacketType.S2C_Pong:
				{
					Debug.Log("Ping Latency : " + (NetworkTimer.NowMs() - Serializer.Deserialize<long>(out _, packet.data)));
				}
				break;
		}
	}

	public override void Send(IPEndPoint destEP, byte[] payload)
	{
		if (destEP == null) destEP = serverEP;
		base.Send(destEP, payload);
	}
}

해당 매니저들을 관리하는 매니저를 만들고, 서버를 빌드해서 VM에 올려보겠습니다.


	[DefaultExecutionOrder(-100)]
	public class ServerManagers : MonoBehaviour
	{
		private static ServerManagers instance;
		public static ServerManagers Instance { get => instance; }

		void Awake()
		{
			Init();

#if !UNITY_EDITOR && UNITY_SERVER
			Debug.unityLogger.logEnabled = true;
			Application.runInBackground = true;
			Application.logMessageReceived += OnLog;
#endif
		}

		private void Init()
		{
			if (null == instance)
			{
				instance = this;
				DontDestroyOnLoad(this.gameObject);
			}
			else
			{
				Destroy(this.gameObject);
			}

			dedi.Init();
		}

		private void Update()
		{
			dedi.OnUpdate();
		}

		private void OnDestroy()
		{
			dedi.Shutdown();

#if !UNITY_EDITOR && UNITY_SERVER
			Application.logMessageReceived -= OnLog;
#endif
		}

#if !UNITY_EDITOR && UNITY_SERVER
		private static UDPNetworkTransport dedi = new DediServerManager();
#else
		private static UDPNetworkTransport dedi = new DediClientManager();
#endif

		public static UDPNetworkTransport Dedi => dedi;

		#region Utils
		private void OnLog(string msg, string stackTrace, LogType type)
		{
			Console.Out.Flush();
		}
		#endregion
	}

우측 서버 로그에서 Ping을 받으면 좌측 유니티 클라이언트에 Pong을 보내서, 시간의 차를 통해 지연시간을 알아낼 수 있었습니다.

마무리


간단한 Ping-Pong 구현을 통해 유니티 Dedicated 서버와 클라이언트 간에 통신을 하는 방법을 알아보았습니다.
이제 이어서 CSP를 구현해보도록 하겠습니다.

0개의 댓글