Hits



네트워크 동기화

네트워크 동기화는 다음과 같이 크게 2가지 방법으로 나뉜다. RPC, NetworkVariables

  • NetworkVariables 는 게임 실행 이후 늦게 진입하는(난입) 클라이언트 간의 동기화를 진행하는데 가장 일반적으로 사용된다.

  • 반면, RPC 의 경우 게임 로직을 RPC 로 처리할 경우 데이터 소실로 인해 뒤늦게 진입한 클라이언트와의 동기화에 대한 신뢰성이 떨어질 가능성이 높다.



RPC (Remote procedure calls) 호출


  • RPC 는 Messaging, 이벤트 알림을 보내는 방법 뿐만 아니라 서버와 클라이언트 간 또는 클라이언트와 NetworkBehaviour 간의 직접 통신을 처리하는 방법이다.

  • 클라이언트는 NetworkObject 에서 Server RPC 를 호출할 수 있다. RPC 는 로컬 큐에 배치된 후 서버로 전송되며, 서버 버전의 동일한 NetworkObject 에서 실행된다.

  • 클라이언트에서 RPC 를 호출할 때 SDK 는 해당 RPC 의 객체, 컴포넌트, 메서드 및 매개변수를 기록하고 이 정보를 네트워크를 통해 전송한다.



  • Server RPC 의 작동 원리


Desktop View



  • Client RPC 의 작동원리


Desktop View



RPC 사용 방법

  • RPC 는 기본적으로 RPC 호출을 하고자하는 메소드 상단에 어트리뷰트로 선언하면 된다.

  • 주의 할 점은 메소드 이름 끝에 Rpc, ServerRpc, ClientRpc 등과 같은 이름을 붙여줘야만 한다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
// universial RPC
[Rpc(SendTo.Server)] // Server
public void PingRpc(int pingCount) { }
 
[Rpc(SendTo.NotServer)] // Client
void PongRpc(int pingCount, string message) { }
 
 
// Legacy RPC
[ServerRpc]
public void PingServerRpc(int pingCount) { }
 
[ClientRpc]
public void PongClientRpc(int pingCount, string message) { }



RPC Attribute Target Table

  • 뭔가 타겟들이 다양하게 많지만, 주로 사용하는 것은 Server, NotServer, Everyone 정도이다.


Desktop View
Desktop View


  • 위 어트리뷰트와 함께 자주 사용하는 파라미터 또한 존재한다.

Desktop View

1
2
3
4
5
[Rpc(SendTo.Everyone, RequireOwnership = false)]
 
[ServerRpc(RequireOwnership = false)]


  • 사실상 Legacy RPC 를 사용하는게 가장 쉽고 직관적이라고 생각하지만.. universal RPC attribute 로 대체되었다고 하니 가능하면 universial RPC 형태로 사용하는게..?
  • 근데 둘 다 사용이 가능한 것으로 보아 차이점에 대한 것은 추가적인 연구가 필요해보인다.


실제 사용 예시 1

Desktop View



실제 사용 예시 2

  • 각 클라이언트에서 TryAddIngredient 클라이언트 전용 메소드를 실행한다.
  • 이후 ServerRpc 인 AddIngredientServerRpc 를 호출한다.

  • ServerRpc 는 서버에서만 실행되지만, 메소드 내부에 ClientRpc 인 AddIngredientClientRpc 메소드를 실행시켜 연결된 모든 클라이언트에 반영 해준다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public bool TryAddIngredient(KitchenObjectSO kitchenObjectSO)
{
    AddIngredientServerRpc(GetKitchenObjectSOIndex(kitchenObjectSO))
}
 
 
 
[ServerRpc(RequireOwnership = false)]
private void AddIngredientServerRpc(int kitchenObjectSOIndex)
{
    AddIngredientClientRpc(kitchenObjectSOIndex);
}
 
 
 
[ClientRpc]
private void AddIngredientClientRpc(int kitchenObjectSOIndex)
{
    KitchenObjectSO kitchenObjectSO =
        KitchenGameMultiplayer.Instance.GetKitchenObjectSOFromIndex(kitchenObjectSOIndex);
     
    kitchenObjectSOList.Add(kitchenObjectSO);
     
    OnIngredientAdded?.Invoke(this, new OnIngredientAddedEventArgs
    {
        kitchenObjectSO = kitchenObjectSO
    });
}






NetworkVariables 동기화

  • NetworkVariables 는 RPC 와 달리 서버와 클라이언트 간의 프로퍼티등을 지속적으로 동기화하는 방법이다.

  • RPC 및 메시지와 달리 특정 시점의 일회성 통신이 아니고, 연결되지 않은 클라이언트와는 공유되지 않음.


  • NetworkVariable 은 지정된 값 타입 T 의 Wrapper 로, 실제로 동기화되는 값을 접근하려면 NetworkVariable.Value 속성을 사용해야한다.

  • 여기서 주의할 점은 T 타입에는 Value 타입(int, bool, float, string 도 FixedString) 만 지정할 수 있다는 것 (Reference 타입은 안됨.)


1
2
3
4
5
6
7
8
9
private NetworkVariable<int> testValue = new NetworkVariable<int>();
 private const int initValue = 1111;
 
 public override void OnNetworkSpawn()
{
       testValue.Value = initValue;
 
       ...
 }



NetworkVariable 은 다음과 같이 동기화 된다.

1. 새로운 클라이언트가 게임에 참여 (혹은 늦게)

  • NetworkBehaviour 에 NetworkVariable 속성이 있는 NetworkObject가 생성되면, NetworkVariable 의 현재 상태(Value)는 클라이언트 측에서 자동으로 동기화


2. 연결된 클라이언트

  • NetworkVariable 값이 변경되면, NetworkVariable.OnValueChanged 이벤트에 구독한 모든 연결된 클라이언트는 값이 변경되기 전에 이를 알림받는다.

  • OnValueChanged 콜백에는 두 개의 매개변수가 있다. previous, current


실제 사용 예시 1

  • state 라는 NetowkrVariable 을 생성하고 OnValueChanged 에 실행할 State_OnValueChanged 메소드를 등록시킨다.

  • 이후 state 값이 변경될 때 마다, OnStateChanged event 를 구독하고 있는 모든 구독자들을 Invoke 한다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// T 타입인 State 는 enum
[SerializeField] private NetworkVariable<State> state = new NetworkVariable<State>(State.WaitingToStart);
 
public event EventHandler OnStateChanged;
 
public override void OnNetworkSpawn()
{
    state.OnValueChanged += State_OnValueChanged;
    isGamePaused.OnValueChanged += IsGamePaused_OnValueChanged;
 
    if (IsServer)
    {
        NetworkManager.Singleton.OnClientDisconnectCallback += NetworkManager_OnClientDisconnectCallback;
        NetworkManager.Singleton.SceneManager.OnLoadEventCompleted += SceneManager_OnLoadEventCompleted;
    }
}
 
private void State_OnValueChanged(State previousValue, State newValue)
{
    OnStateChanged?.Invoke(this, EventArgs.Empty);
}


실제 사용 예시 2

  • 각 클라이언트는 Door를 사용할 때 마다 ServerRpc 를 호출하고 서버 측에서 Door의 상태인 State 를 토글한다.

  • 이후 래핑된 Door.State.Value 가 변경되면 모든 연결된 클라이언트는 새로운 current value(여기서는 bool) 로 동기화되고 OnStateChanged 메소드가 각 클라이언트에서 호출된다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
public class Door : NetworkBehaviour
{
    public NetworkVariable<bool> State = new NetworkVariable<bool>();
 
    public override void OnNetworkSpawn()
    {
        State.OnValueChanged += OnStateChanged;
    }
 
    public override void OnNetworkDespawn()
    {
        State.OnValueChanged -= OnStateChanged;
    }
 
    public void OnStateChanged(bool previous, bool current)
    {
        // note: `State.Value` will be equal to `current` here
        if (State.Value)
        {
            // door is open:
            //  - rotate door transform
            //  - play animations, sound etc.
        }
        else
        {
            // door is closed:
            //  - rotate door transform
            //  - play animations, sound etc.
        }
    }
 
    [Rpc(SendTo.Server)]
    public void ToggleServerRpc()
    {
        // this will cause a replication over the network
        // and ultimately invoke `OnValueChanged` on receivers
        State.Value = !State.Value;
    }
}






Custom NetworkVariables

  • NetworkVariable 을 이른바 “패킷” 형태로 커스텀하게 동기화가 가능해보인다.

  • 주로 커스텀 형태의 NetworkVariable 은 제네릭 타입을 사용하여 직렬화를 해줘야한다.


NetworkVariable 예시

  • 커스텀 NetowkrVariable 을 선언할 때, 값의 초기화 뿐만 아니라 NetworkVariableReadPermission, NetworkVariableWritePermission 옵션을 지정할 수도 있었다.

  • MyCustomData 라는 임의의 struct 즉 “구조체”로 직렬화를 진행했다.

  • 해당 구조체는 INetworkSerializable 이라는 인터페이스를 상속받아야한다.

  • 이후 내부적으로 동기화 하고 싶은 Value 타입의 변수들을 선언하고


1
NetworkSerialize<T>


  • 메소드 내부에서 BufferSerializer 를 통해 변수들의 직렬화를 진행한다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
private NetworkVariable<MyCustomData> randomNumber = new NetworkVariable<MyCustomData>(
    new MyCustomData
    {
        _int = 1,
        _bool = true,
        _message = "nan"
    }, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Owner);
 
 
 
 
public struct MyCustomData : INetworkSerializable
{
    public int _int;
    public bool _bool;
    public string _message;
     
    public void NetworkSerialize<T>(BufferSerializer<T> serializer) where T : IReaderWriter
    {
        serializer.SerializeValue(ref _int);
        serializer.SerializeValue(ref _bool);
        serializer.SerializeValue(ref _message);
    }
}


  • 이후 OnValueChanged 에 randomNumber 값을 람다식으로 구독하고 디버그 로그로 출력해주자.

  • “T” 키를 눌러 randomNumber 값을 지정하여 출력하는 로그인데, 여기서 주의할 점은 해당 NetworkVariable 의 소유 권한을 누구에게 부여할 것이냐 이다.

  • 위 예제 Door 에서는 state 값이 ServerRpc 를 통해 바뀌며 서버가 주체가 되는 NetworkVariable 인 반면, 해당 예제는 randomNumber 클라이언트가 주체가 되는 NetworkVariable 이다.

  • 요약하자면, NetworkVariable 의 소유권이 서버가 관리(각종 FSM 상태, 게임 로직 변수) vs 클라이언트가 관리 (플레이어의 스텟) 에 따라 역할이 나뉘게 된다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public override void OnNetworkSpawn()
  {
      randomNumber.OnValueChanged += (MyCustomData previousValue, MyCustomData newValue) =>
      {
          Debug.Log(OwnerClientId + ";  " + newValue._int + ";  " + newValue._bool + ";  " + newValue._message);
      };
  }
 
  private void Update()
  {
      if (!IsOwner) return;
 
      if (Input.GetKeyDown(KeyCode.T))
      {       
          randomNumber.Value = new MyCustomData
          {
              _int = Random.Range(0, 100),
              _bool = Random.Range(0, 1) == 0 ? true : false,
              _message = "Hello, World!",
          };
      }
  }



NetworkList 예시

  • NetworkVariable 의 List 형태인, NetworkList 또한 존재한다.

  • IEquatable 인터페이스가 추가된 것을 확인할 수 있는데, 이는 NetworkVariable 혹은 NetworkList 구조체 타입이 동일성 비교를 효율적으로 수행할수 있도록 하기 위함이다.

  • 또한 데이터 변경 여부를 감지하여 네트워크 트래픽을 줄일 수 있다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public struct PlayerData : IEquatable<PlayerData>, INetworkSerializable
{
    public ulong clientId;
    public int colorId;
    public FixedString64Bytes playerName;
    public FixedString64Bytes playerId;
     
    public bool Equals(PlayerData other)
    {
        returnIEquatable
            clientId == other.clientId &&
            colorId == other.colorId &&
            playerName == other.playerName &&
            playerId == other.playerId;
    }
 
    public void NetworkSerialize<T>(BufferSerializer<T> serializer) where T : IReaderWriter
    {
        serializer.SerializeValue(ref clientId);
        serializer.SerializeValue(ref colorId);
        serializer.SerializeValue(ref playerName);
        serializer.SerializeValue(ref playerId);
    }
}


  • List 타입이라 그런지 previous, current 두 개의 매개변수로 나뉘지 않고 NetworkListEvent 내부적으로

  • Value, PreviousValue 두 가지 변수가 존재한다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public event EventHandler OnPlayerDataNetworkListChanged;
 
private NetworkList<PlayerData> playerDataNetworkList;
 
private void Awake()
{
    playerDataNetworkList = new NetworkList<PlayerData>();
    playerDataNetworkList.OnListChanged += PlayerDataNetworkList_OnListChanged;
}
 
 
private void PlayerDataNetworkList_OnListChanged(NetworkListEvent<PlayerData> changeEvent)
{
    OnPlayerDataNetworkListChanged?.Invoke(this, EventArgs.Empty);
}






RPC vs NetworkVariable

  • RPC 사용 : 일시적인 이벤트나 정보를 전달할 때 사용하며, 해당 정보가 수신될 때만 유용하다.

  • NetworkVariables 사용 : 지속적인 상태 정보를 관리하는 데 유용하다. “지속적인 상태 정보” 를 게임 내에서 계속 유지하고 모든 플레이어에게 일관적으로 전달되어야 한다.

  • 둘 중에 뭘 써야 하는지에 대한 가장 빠른 결정 방법은 “중간에 게임에 합류한 플레이어가 그 정보를 받아야 하는가?” 질문을 던져 보는 것이다.


Desktop View
NetworkVariable 는 현재 상태를 전송하여 늦게 합류한 클라이언트가 쉽게 최신 상태를 따라잡을 수 있게 해준다.


Desktop View
만약 RPC를 모든 클라이언트에게 보낸다면, 그 RPC가 전송된 후에 중간에 게임에 합류한 모든 플레이어는 그 정보를 놓치고 잘못된 비주얼을 클라이언트에서 보게 될 것.


  • 이렇게 되면 RPC 는 하등 쓸모 없고, NetworkVariables 로만 사용해도 되는거 아닌가요? 라고 생각할 수 있지만..



모든 것에 NetworkVariables 를 사용하지 않는 이유

  • RPCs가 더 간단하다.

  • 게임 내의 모든 일시적인 이벤트 (폭발, 오브젝트 생성 등과 같은) 를 위해 NetworkVariable 로 선언해서 사용할 필요는 없다는 것이다.


  • NetworkVariable 을 사용하여 두 변수가 동시에 수신 되도록 하고 싶다면, RPCs 가 적합

  • NetworkVariables “a” 와 “b” 를 변경하면, 클라이언트 측에서 두 변수가 동시에 수신된다는 보장이 없다.


  • 아래 사진을 보면 지연 시간으로 인해 클라이언트가 제 각기 다른 시간에 업데이트를 수신하는 것을 확인할 수 있다.


Desktop View
동일한 틱 내에서 업데이트된 서로 다른 네트워크 변수가 동시에 클라이언트에 전달된다는 보장은 없다.



  • 반면 동일한 RPC 의 두 매개변수로 보낸다면, 클라이언트 측에서 두 변수가 동시에 수신 가능.


Desktop View
여러 개의 다른 네트워크 변수들이 모두 동시에 동기화되도록 하기 위해 우리는 클라이언트 RPC를 사용하여 이러한 값 변화들을 함께 결합할 수 있다.



정리

  1. NetworkVariable 은 지속적인 상태 정보를 전달할 때 사용한다.
  2. 지속적인 상태 정보란, 게임 내에서 계속 유지되고 모든 플레이어에게 일관되게 전달되어야 하는 정보를 의미한다.
  3. 이를 통해 새로운 플레이어가 접속해도 최신 상태를 즉시 동기화할 수 있으며, 모든 플레이어가 동일한 상태정보를 공유할 수 있다.