유니티를 사용하여 서버와 클라이언트 간의 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}");
}
}
}
이 매니저를 토대로 서버가 사용할 매니저와 클라이언트가 사용할 매니저를 구현해보겠습니다.
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;
}
}
}
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를 구현해보도록 하겠습니다.