unity client와 연동하는 nodejs 위치동기화 멀티플레이 서버
학습 목표: 처음부터 프로젝트를 세팅하여 직접 위치 동기화를 위한 멀티플레이 서버를 구현한다.
npm init -y
npm install dotenv lodash long mysql2 protobufjs uuid
npm install -D nodemon prettier
기본 서버 server.js
작성
/init/ 디렉토리 : 서버의 초기화 부분 로직의 작성
- 서버의 초기화: 에셋 로딩, 패킷 로딩, 커넥션 테스트 등을 포함
/config/, /constants/ 디렉토리 : 환경변수 및 설정값들에 대한 데이터 저장과 관리
event : 소켓 이벤트의 분리 (연결, 연결 해제, 에러, 데이터 처리)
Currying : 여러 인수를 받는 함수를 인수가 하나인 함수들의 연속으로 변환.
pakcet 관련 처리 :
(1) protobuf serializer
- .proto 파일 세팅
- .proto 파일들을 로드하는 코드
(2) pakcet parsing
- byte 배열을 지정한 구조로 파싱
handler : request에 대한 적절한 핸들러 매핑과 처리
- 로그인 및 초기화
DB 연동과 migration
게임에서 관리될 데이터와 모델 관하여 클래스 정의
게임 비즈니스 로직 추가
Latency에 대해 dead reckoning을 통한 location synchronization
zero-base에서 직접 유니티 클라이언트 개발을 하는 것이 아니라,
클라이언트 기능을 기반으로 구현해야 하기에 어느 정도 분석이 필요하다.
중점적으로 봐야하는 스크립트는
NetworkManager.cs 와 Packets.cs 로 볼 수 있다.
Packets.cs
using UnityEngine;
using ProtoBuf;
using System.IO;
using System.Buffers;
using System.Collections.Generic;
using System;
public class Packets : MonoBehaviour
{
public enum PacketType { Ping, Normal, Location = 3 }
public enum HandlerIds {
Init = 0,
LocationUpdate = 2
}
public static void Serialize<T>(IBufferWriter<byte> writer, T data)
{
Serializer.Serialize(writer, data);
}
public static T Deserialize<T>(byte[] data) {
try {
using (var stream = new MemoryStream(data)) {
return ProtoBuf.Serializer.Deserialize<T>(stream);
}
} catch (Exception ex) {
Debug.LogError($"Deserialize: Failed to deserialize data. Exception: {ex}");
throw;
}
}
}
Pakcets
같은 경우 다음과 같이 정리했다.
PacketType : enum으로 정의된 패킷 타입
HandlerIds : 패킷에 대해 처리할 로직 이벤트 핸들러
Serialize, Deserialize 메서드 : 말그대로 Protobuf 포맷을 통한 직렬화와 역직렬화 메서드이다.
[ProtoContract]
public class CommonPacket
{
[ProtoMember(1)]
public uint handlerId { get; set; }
[ProtoMember(2)]
public string userId { get; set; }
[ProtoMember(3)]
public string version { get; set; }
[ProtoMember(4)]
public byte[] payload { get; set; }
}
위의 코드는 Unity C# 안에서 Protobuf 포맷으로 정의된 패킷 형태이다.
[ProtoContract]
C#에서 Protobuf-Net 라이브러리를 사용할 때 직렬화 대상 클래스에 반드시 붙여야 하는 태그이다.
필자의 경우 이전에 .NET 서버와 관련하여 Unity 에서 MessagePack이나 MemoryPack을 이용했기에 큰 부담없이 이해할 수 있었다.
[ProtoMember(n)]
직렬화 대상 클래스의 속성이 Protobuf 메시지의 필드로 매핑시키는 속성이다.
괄호 안의 n은 식별자 역할의 필드 고유 번호를 지정하며 1부터 시작한다. 직렬화 순서를 보장하기 위함이다.
NetworkManager.cs
NetworkManager
는 클라이언트 단에서 서버와의 TCP 소켓과 통신부 역할을 담당한다.
많은 메서드들을 구현하여 사용 중이지만, 주요 특징점들을 요약하면 다음과 같다.
// byte stream에 대해 빅 엔디안으로 컨버팅
public static byte[] ToBigEndian(byte[] bytes) {
if (BitConverter.IsLittleEndian)
{
Array.Reverse(bytes);
}
return bytes;
}
protobuf를 이용해 데이터를 byte stream으로 serialization하기 때문에,
데이터의 일관성과 정확성을 보장하고, 시스템 간 호환성을 유지하기 위해
Big Endian으로 통일시키기 위해 다음과 같은 컨버팅 역할의 메서드를 정의한다.
// 패킷 헤더 생성
byte[] CreatePacketHeader(int dataLength, Packets.PacketType packetType) {
int packetLength = 4 + 1 + dataLength; // 전체 패킷 길이 (헤더 포함)
byte[] header = new byte[5]; // 4바이트 길이 + 1바이트 타입
// 첫 4바이트: 패킷 전체 길이
byte[] lengthBytes = BitConverter.GetBytes(packetLength);
lengthBytes = ToBigEndian(lengthBytes);
Array.Copy(lengthBytes, 0, header, 0, 4);
// 다음 1바이트: 패킷 타입
header[4] = (byte)packetType;
return header;
}
// 공통 패킷 생성 함수
async void SendPacket<T>(T payload, uint handlerId)
{
// ArrayBufferWriter<byte>를 사용하여 직렬화
var payloadWriter = new ArrayBufferWriter<byte>();
Packets.Serialize(payloadWriter, payload);
byte[] payloadData = payloadWriter.WrittenSpan.ToArray();
CommonPacket commonPacket = new CommonPacket
{
handlerId = handlerId,
userId = GameManager.instance.deviceId,
version = GameManager.instance.version,
payload = payloadData,
};
// ArrayBufferWriter<byte>를 사용하여 직렬화
var commonPacketWriter = new ArrayBufferWriter<byte>();
Packets.Serialize(commonPacketWriter, commonPacket);
byte[] data = commonPacketWriter.WrittenSpan.ToArray();
// 헤더 생성
byte[] header = CreatePacketHeader(data.Length, Packets.PacketType.Normal);
// 패킷 생성
byte[] packet = new byte[header.Length + data.Length];
Array.Copy(header, 0, packet, 0, header.Length);
Array.Copy(data, 0, packet, header.Length, data.Length);
await Task.Delay(GameManager.instance.latency);
// 패킷 전송
stream.Write(packet, 0, packet.Length);
}
서버와 통신하기 위해, 서버에게 전송할 때에 패킷 생성을 위한 메서드들이다.
우선, CreatePacketHeader
메서드를 정의하여 패킷의 길이와 타입 데이터를 저장하여 헤더에 넣는다.
그리고 SendPacket
메서드로 payload 데이터와 그 식별자 handlerId에 대해 직렬화 과정을 거치고,
그 이외 다른 데이터들을 포함하여 커먼패킷 객체를 만들어 헤더와 패킷을 생성한다.
그리고
await Task.Delay(GameManager.instance.latency);
를 통해, GameManager
싱글턴 매니저에서 잡고 있는 레이턴시만큼 대기하여 전송한다.
현재는 클라이언트 to 서버로의 패킷 전송은, 초기패킷과 위치 동기화 패킷을 전송하는 것으로 위의 저 메서드를 사용하고 있다.
// 패킷 수신 시작
void StartReceiving() {
_ = ReceivePacketsAsync();
}
// 비동기로 처리할 수신 패킷 처리
async System.Threading.Tasks.Task ReceivePacketsAsync() {
while (tcpClient.Connected) {
try {
int bytesRead = await stream.ReadAsync(receiveBuffer, 0, receiveBuffer.Length);
if (bytesRead > 0) {
ProcessReceivedData(receiveBuffer, bytesRead);
}
} catch (Exception e) {
Debug.LogError($"Receive error: {e.Message}");
break;
}
}
}
void ProcessReceivedData(byte[] data, int length) {
incompleteData.AddRange(data.AsSpan(0, length).ToArray());
while (incompleteData.Count >= 5)
{
// 패킷 길이와 타입 읽기
byte[] lengthBytes = incompleteData.GetRange(0, 4).ToArray();
int packetLength = BitConverter.ToInt32(ToBigEndian(lengthBytes), 0);
Packets.PacketType packetType = (Packets.PacketType)incompleteData[4];
if (incompleteData.Count < packetLength)
{
// 데이터가 충분하지 않으면 반환
return;
}
// 패킷 데이터 추출
byte[] packetData = incompleteData.GetRange(5, packetLength - 5).ToArray();
incompleteData.RemoveRange(0, packetLength);
// Debug.Log($"Received packet: Length = {packetLength}, Type = {packetType}");
switch (packetType)
{
case Packets.PacketType.Normal:
HandleNormalPacket(packetData);
break;
case Packets.PacketType.Location:
HandleLocationPacket(packetData);
break;
}
}
}
이제 클라이언트가 서버로부터 받는 패킷은, 정상적으로 로그인 연결이 된 이후부터 StartReceiving
메서드를 호출해 이는 ReceivePacketsAsync
를 통해 비동기로 수신을 처리한다.
ReceivePacketsAsync
는 tcpClient.Connected를 확인하여 연결이 유지되는 동안 비동기 루프를 지속하며 수신을 처리하는데,
stream.ReadAsync를 사용해 네트워크 스트림에서 데이터를 읽고, 읽은 데이터의 크기를 bytesRead로 반환한다. 읽은 데이터가 있으면 ProcessReceivedData 메서드로 처리한다.
ProcessReceivedData
에서 패킷의 헤더크기와 길이를 확인하며 패킷 데이터를 추출하며, 패킷의 타입에 따라 알맞은 핸들러 로직 분기로 처리한다.
이렇게 클라이언트 스켈레톤 코드의 주요 로직을 정리해볼 수 있다.
다만, Single Thread의 node 서버와 달리,
Unity의 C# 환경은 기본적으로 Multi Thread이므로 이에 대한 처리가 추가적으로 필요할 것 같다.
lock과 queue를 사용하여 race condition에 대한 제어 로직을 추가하는 방향을 생각해야 할 것 같다.