4학년 수업 게임 제작 프로젝트 진행 중 멀티플레이 기능을 구현하던 중 겪은 어려움과 알아낸 것에 대한 내용입니다. Unity에서 공식으로 릴리즈한 Netcode for gameObjects는 아직 1.1.0 버전이기에 참고 자료도 많지 않았고, Unity를 사용해보는 것 도 처음이기에 Server/Client RPC에 대해 잘 알지 못했다. 멀티플레이 기능을 구현하고자 하는 Unity 개발자 분들에게 도움이 되었으면 하는 바람으로 글을 작성합니다.
Netcode for gameObjects란?
Photon PUN2, MLAPI, 등 Unity에서 멀티플레이 기능을 구현하기 위해 여러 솔루션들이 존재한다. Netcode for gameObjects는 2021년 8월에 Unity가 출시한 공식 멀티플레이 기능 솔루션이다. 전체적인 흐름은 다른 솔루션들과 크게 다르지 않은 걸로 보이는데, Host(Server), Client가 존재하고 RPC 혹은 NetworkVariable(후에 설명)로 변수들을 제어하는 방식이다.

가장 먼저, Package Manger에서 Netcode for GameObjects를 설치해야 한다. 설치 후, 빈 GameObject를 하나 생성 한다.

생성한 오브젝트에, Network Manager 스크립트를 추가한다.
이후, 이동이 가능한 플레이어 Prefab을 준비하거나, 미리 만들어 둔 게임에서 플레이어 Prefab을 가져온다.

플레이어 Prefab에 Network Object 스크립트를 추가해 준다. 그 후, 다시 Network Manager로 돌아가서 Inspector를 보자.

해당 스크립트에서, Player Prefab 부분은, 사용자가 접속 할 때 자동으로 그 플레이어에게 해당 Prefab을 소유권과 함께 생성해 준다. NetworkPrefabs 배열 부분은, 네트워크 상에서 동기화 될 모든 게임 오브젝트들을 설정하는 곳이다. 또한, 네트워크 상에서 동기화 될 모든 오브젝트들은, Network Object 스크립트를 가지고 있어야 한다. 따라서, Player Prefab을 두 곳에 모두 끌어서 배치해 준다.
추가) Network Transport는 Unity Transport를 선택해 준다.


그리고, Network Variable을 조금 내려 보면 3개의 버튼이 존재하는 데, UI 를 만들어 각각의 기능들을 게임 씬 내에서 버튼으로 사용할 수 있도록 해야 한다.
NetworkManagerUI.cs
using Unity.Netcode;
using UnityEngine;
using UnityEngine.UI;
public class NetworkManagerUI : NetworkBehaviour
{
[SerializeField] private Button hostButton;
[SerializeField] private Button clientButton;
private void Awake()
{
hostButton.onClick.AddListener(() =>
{
if (NetworkManager.Singleton.StartHost())
{
Debug.Log("Host started...");
}
else
{
Debug.Log("Host could not be started...");
}
});
clientButton.onClick.AddListener(() =>
{
if (NetworkManager.Singleton.StartClient())
{
Debug.Log("Client started...");
}
else
{
Debug.Log("Client could not be started...");
}
});
}
}

이제 빌드 후, 에디터와 빌드 된 게임에서 각각 Host와 Client 버튼을 눌러 주면, 각 화면에 자신의 플레이어 Prefab이 나타나는 것을 볼 수 있다.

플레이어 스크립트 관리
하지만, 플레이어를 이동시킬 시 한쪽에서만 이동하는 것이 아닌 두 플레이어 Prefab이 마치 한 몸인 것 처럼 움직이게 된다.

이는 왜냐 하면, 플레이어의 움직임을 담당하는 PlayerMove.cs 스크립트가 PlayerPrefab에 있다고 할 때, 각각 자신만의 스크립트를 가지고 있는 것이 아닌, 오른쪽 그림 처럼 한 플레이어가 모든 플레이어의 PlayerMove.cs 스크립트를 가지고 있기 때문이다.
만약 "WASD"로 플레이어를 움직인다고 했을 때, 파란색 플레이어가 W를 누르면, 각각 파란색, 빨간색 PlayerMove.cs 에W를 누른 것이 되기 때문에 파란색 플레이어의 화면에서는 자신과, 빨간색 플레이어가 같이 움직이게 된다.
using UnityEngine;
using Unity.Netcode;
public class PlayerMove : NetworkBehaviour
{
void Update()
{
if (!IsOwner) return;
...
move();
}
private void move()
{
// 이동 로직
}
...
}
해결 방법은, 스크립트를 MonoBehaviour에서 NetworkBehaviour로 바꾸고, Update 가장 상단에, IsOwner 조건을 추가하는 것이다. IsOwner는 Unity.Netcode의 NetworkBehaviour에서 제공하는 것으로, 각 플레이어 Prefab의 소유권을 체크하고, 소유권이 있다면 true, 없다면 false를 리턴한다.

이제 소유권에 따라서 소유권이 없는 스크립트에 대해서는 바로 return되어 명령을 수행하지 않을 것이다.
변수의 네트워크 동기화
이제 이동 시 자신의 Prefab만을 움직이지만, 서로 화면이 동기화되지 않는 문제가 존재한다. 이는 플레이어의 x, y, z 값이 네트워크를 통해 상대에게 전송되지 않았다는 것을 의미한다. 이 문제에 대해서는, Unity가 Transform과 Animation을 동기화 하는 스크립트를 제공한다.
https://docs-multiplayer.unity3d.com/netcode/current/components/networktransform/index.html
NetworkTransform | Unity Multiplayer Networking
The position, rotation, and scale of a NetworkObject is normally only synchronized once (when that object spawns). To synchronize position, rotation, and scale at real-time during the game, you must use a NetworkTransform component. NetworkTransform synchr
docs-multiplayer.unity3d.com
- Window > Package Manager 에서 Package Manger를 연다
- Add (+) > Add from git URL…. 을 선택한다
- 해당 Git 링크를 붙여넣기 한다. https://github.com/Unity-Technologies/com.unity.multiplayer.samples.coop.git?path=/Packages/com.unity.multiplayer.samples.coop#main
- Add 를 선택한다
OwnerNetworkAnimator.cs
using Unity.Netcode.Components;
public class OwnerNetworkAnimator : NetworkAnimator
{
protected override bool OnIsServerAuthoritative()
{
return false;
}
}
다운받은 ClientNetworkTransform.cs 스크립트와 직접 작성한 OwnerNetworkAnimator.cs를 플레이어 Prefab에 추가하면, 이제 정상적으로 이동과 애니메이션이 동기화 되는 것을 볼 수 있다.

추가로, ClientNetworkTransform 스크립트의 Syncing 부분은, 어떤 값들을 동기화 할 지 선택하는 것이다. 동기화되는 값이 적을수록 네트워크 부하가 덜하며, 각자 자신의 게임에 맞도록 최대한 동기화 되는 값을 줄이는 것이 좋다. 나의 경우, 플레이어의 크기가 변하는 일은 없기 때문에 Scale 값은 모두 해제 했다.
Server / Client RPC 및 NetworkVariable
https://docs-multiplayer.unity3d.com/netcode/current/advanced-topics/message-system/clientrpc
ClientRpc | Unity Multiplayer Networking
Servers can invoke a ClientRpc to execute on all clients.
docs-multiplayer.unity3d.com
https://docs-multiplayer.unity3d.com/netcode/current/advanced-topics/message-system/serverrpc
https://docs-multiplayer.unity3d.com/netcode/current/basics/networkvariable
NetworkVariable | Unity Multiplayer Networking
Introduction
docs-multiplayer.unity3d.com
ClientRPC, ServerRPC, NetworkVariable에 대한 Unity의 공식문서이다.
우선 RPC나 NetworkVariable을 사용하는 이유에 대해서 알아보자.

파란색 플레이어가, 빨간색 플레이어에게 5의 데미지를 입혔을 때, 자신이 가지고 있는 빨간색 플레이어의 PlayerHP 스크립트에 존재하는 hp 변수를 변경할 것이다. 그러면, 빨간색 플레이어도 자신의 빨간색 PlayerHP 스크립트에 자신의 HP가 5가 깎였다고 변수를 동기화 해야 할 것이다. 이러한 변수들을 동기화 하는 것이 RPC와 NetworkVariable의 역할이다.
우선 RPC(Remote Procedure Call)란 무엇일까? 간략하게 설명하면, 원격으로 다른 컴퓨터에게 명령을 실행하도록 메세지를 보내는 것이다. Unity 멀티플레이를 구현할 때 RPC를 사용해야 하는 이유는, 클라이언트의 경우 다른 유저들의 스크립트를 바꿀 수도, 게임 오브젝트를 생성할 수도 없기 때문이다. 예를 들어서,

Client인 빨간색 플레이어가 파란색 플레이어 에게 5의 데미지를 입혔을 때, 자신이 가지고 있는 파란색 PlayerHP 스크립트를 변경할 수 없다는 것이다. 그러면, 변경을 할 수 없으니 당연히 서버에게 변수의 변화를 알리는 것 또한 불가능하다. 스크립트의 변수를 변경할 수 있는 것은 오직 Server(Host)만이 가능하다.
그래서, Server RPC를 사용하게 되는데, Server RPC란 Client가 서버에게, "나 파란색 플레이어에게 5의 데미지를 입혔어. 업데이트 해 줄래?" 라고 메세지를 전하는 것이다. 그러면, Host가 파란색 PlayerHP 스크립트에 5의 데미지를 입히기 위해 변수를 조정하는 것이다.

그 후, 서버는 Client RPC를 이용해서 변화한 상황을 모든 Client들에게 알려 준다.

Network Variable은 작동 방식이 조금 더 단순하다.
Network Variable은 선언할 때
private NetworkVariable<int> m_SomeValue = new NetworkVariable<int>(default,
NetworkVariableReadPermission.Owner,
NetworkVariableWritePermission.Owner);
와 같은 형태로 쓰이는 데, 중요한 것은 Write 권한을 Onwer 혹은 Server 중 하나로 선택 할 수 있다.
이 Network Variable은 따로 RPC처럼 서버에게 알리지 않아도 변경이 된다면, 다른 플레이어들의 스크립트에서도 변경된다. 빨간 플레이어가 자신이 5의 데미지를 입어서 hp를 100 에서 95로 변경 했다면, 파란 플레이어의 빨간 PlayerHP 에서도 hp가 95로 변경되는 것이다.
유니티 공식 문서에서는 순간적으로 접근하는 변수는 RPC를 이용하고, 지속적인 변수는 NetworkVariable을 이용하라고 쓰여있다.
총알 발사 예시
총알이나 파이어볼을 발사하는 등 무언가를 발사하는 구현 방법에는 크게 두가지 방법이 있다.
1. 각자의 화면에서 Instantiate (네트워크를 사용하지 않음)
2. Network Object를 생성하여 게임 씬에 직접 생성 (네트워크를 사용 함)
1, 2번 방법 각각 장단점이 존재한다.
1번 방법의 경우 각자의 화면에서 총알을 생성하는 것이기 때문에 총알을 발사하는 경우 네트워크를 이용하지 않아 네트워크 트래픽이 적다. 하지만 총알의 위치 등이 플레이어마다 다를 수 있으므로 총알의 위치가 정확하게 동기화 되지 않는다. 한쪽은 총알을 맞고 있는데, 한쪽은 총알을 안맞고 있는 상황이 생길 수 있다.
2번 방법은 총알을 발사할 때 마다 Network Object를 생성해야 하기 때문에 네트워크 트래픽이 많이 소모하게 된다. 하지만 총알의 위치가 정확하게 동기화되기 때문에 1번 방법같은 상황은 생기지 않는다.
1번 방법
void Update()
{
if (Input.GetButton("Fire1"))
{
AttackServerRpc();
localAttack();
}
}
void localAttack()
{
animator.SetTrigger("doShot");
Shot();
}
[ServerRpc] //ServerRpc를 쓸 경우 함수명의 끝부분은 ServerRpc로 끝나야 한다
void AttackServerRpc()
{
AttackClientRpc();
}
[ClientRpc] //ClientRpc도 마찬가지
void AttackClientRpc()
{
if (!IsOwner) localAttack();
}
void Shot()
{
bulletPos.LookAt(playerAim.aimPos); //playerAim.aimPos는 총알이 나가는 부분을 결정하는 GameObject
GameObject instantBullet = Instantiate(bullet, bulletPos.position, bulletPos.rotation);
Rigidbody bulletRigid = instantBullet.GetComponent<Rigidbody>();
bulletRigid.AddForce(bulletPos.forward * bulletVelocity, ForceMode.Impulse);
}
흐름 예시
1) Host가 총알을 발사했을 때
- AttackServerRpc()와 localAttack() 이 호출 된다.
- localAttack()로 인해 Host의 화면에서는 자신이 총알을 발사한다.
- AttackServerRpc()로 인해 AttackClientRpc()가 호출된다.
- if (!IsOwner)문으로 인해 Host의 화면에서는 localAttack()이 실행되지 않고, 다른 client들의 스크립트에서 Host가 localAttack()을 실행한다. 따라서, Client의 화면에서 Host가 총알을 발사한다.
2) Client가 총알을 발사했을 때
- AttackServerRpc()와 localAttack() 이 호출 된다.
- localAttack()로 인해 Client의 화면에서는 자신이 총알을 발사한다.
- AttackServerRpc()로 인해 AttackClientRpc()가 호출된다.
- if (!IsOwner)문으로 인해 Client의 화면에서는 localAttack()이 실행되지 않고, 다른 client들/Host 의 스크립트에서 Client가 localAttack()을 실행한다. 따라서, Client를 제외한 모두의 화면에서 Client가 총알을 발사한다.
위 방법의 경우, localAttack()을 호출하는 이유는 동기화 시간을 최소화 하기 위해서이다.
https://www.youtube.com/watch?v=stJ4SESQwJQ&t=1304s
위 동영상 17:06 부분에서 자세히 설명 한다.
2번 방법
[SerializeField] private List<GameObject> spawnedBullets = new List<GameObject>();
void Update()
{
if (Input.GetButton("Fire1"))
{
Shot();
}
}
private void Shot()
{
ShotServerRpc();
}
[ServerRpc(RequireOwnership = false)]
private void ShotServerRpc()
{
animator.SetTrigger("doShot");
bulletPos.LookAt(playerAim.aimPos); //playerAim.aimPos는 총알이 나가는 부분을 결정하는 GameObject
GameObject instantBullet = Instantiate(playerItem.equipWeapon.bullet, bulletPos.position, bulletPos.rotation);
spawnedBullets.Add(instantBullet);
instantBullet.GetComponent<Bullet>().parent = this;
instantBullet.GetComponent<NetworkObject>().Spawn();
}
// 총알 삭제 역시 ServerRPC를 통해 이루어져야 함
[ServerRpc(RequireOwnership = false)]
public void DestroyBulletServerRpc()
{
GameObject toDestroy = spawnedBullets[0];
toDestroy.GetComponent<NetworkObject>().Despawn();
spawnedBullets.Remove(toDestroy);
Destroy(toDestroy);
}
마치며
위 제시한 코드들이 완전한 코드들이 아니기 때문에 바로 사용하기에 어려움이 있을 것이라 생각합니다. 또한, 저도 처음 공부하는 것이기에 이해가 잘못된 부분들이 있을 수 있습니다. 틀린 부분이 있다면 지적 바라며, 그 외에 궁금하거나 구현 도중 도움이 필요한 분들이 계시다면 최대한 도와드리도록 하겠습니다.
이 포스팅은 Netcode for GameObject만 사용했기에 다른 컴퓨터와 통신이 불가능 하지만, Relay 및 Lobby 서비스를 이용해, 유니티 자체 서버를 이용해서 인터넷만 있다면 멀티플레이가 가능한 서버를 제작할 수 있습니다.
https://www.youtube.com/watch?v=T8d8ovMsRr8&list=PLxmtWA2eKdQSf2EXE-tv0lmqmmdDzs0fV
이 플레이리스트는 Realy와 Lobby 기능을 구현할 때 많은 도움을 받은 유튜브 영상입니다.
현재 제작중인 게임 프로젝트의 깃허브 주소이며,
https://github.com/uhm-triplet/beatsbang
GitHub - uhm-triplet/beatsbang
Contribute to uhm-triplet/beatsbang development by creating an account on GitHub.
github.com
게임 소개 및 다운로드 웹사이트 입니다
https://uhm-triplet.github.io/
Triplet
Hello, we are Triplet
uhm-triplet.github.io