[Unity] 멀티플레이(1)

muchan
muchan
∙∙조회 12
[Unity] 멀티플레이(1)

설치할 패키지

  • com.unity.netcode.gameobjects (NGO)
  • com.unity.services.multiplayer (UGS / Sessions)
  • com.unity.services.authentication(UGS 로그인)
  • com.unity.multiplayer.playmode (가상 플레이어)
  • com.unity.transport (자동)
  • com.unity.multiplayer.center

Netcode for GameObjects(NGO)로 멀티플레이 연습을 진행하면서, Player 프리팹에 붙어 있는 두 스크립트 — MyPlayerMovement와 PlayerStats — 를 통해 "여러 클라이언트가 접속했을 때 플레이어가 어떻게 움직이고, 어떻게 상태를 공유하는지"를 정리해봤다.

Player 프리팹에는 다음 컴포넌트들이 붙어 있다.

  • NetworkObject — 네트워크 상에서 이 오브젝트를 식별하는 핵심 컴포넌트
  • NetworkTransform — 위치/회전 동기화 담당
  • MyPlayerMovement — 입력을 받아 이동/회전/점프 처리
  • PlayerStats — 점수·체력·이름 등 상태 동기화
  • CoinCollector — 코인 습득 로직

1. 내 캐릭터만 움직이게 하기 — IsOwner 체크

멀티플레이에서 가장 먼저 부딪히는 문제는 "내 입력이 왜 화면에 있는 모든 플레이어를 움직이는가"다. NetworkBehaviour를 상속받는 것만으로는 입력 처리가 자동으로 분리되지 않기 때문에, Update() 초반에 직접 소유권을 체크해줘야 한다.

csharp
private void Update()
{
    if (!IsOwner)
    {
        return;
    }

    Keyboard keyboard = Keyboard.current;
    if (keyboard == null)
    {
        return;
    }

    float move = 0f;
    float turn = 0f;
    ...
}

IsOwner는 NetworkBehaviour가 제공하는 프로퍼티로, "이 클라이언트가 이 오브젝트의 소유자인가"를 알려준다. 이 한 줄이 없으면 클라이언트 A의 키보드 입력이 화면에 스폰되어 있는 모든 NetworkObject의 Update()에서 동시에 실행되어, 결과적으로 다른 사람의 캐릭터까지 조종하게 된다.

즉 로직은 "각 클라이언트는 자기 소유 오브젝트의 입력만 처리한다"는 원칙으로 짜여 있다.

2. 이동은 로컬 계산, 동기화는 NetworkTransform이 담당

MyPlayerMovement를 보면 이동 처리 자체는 그냥 평범한 transform.Translate / transform.Rotate이다.

csharp
transform.Rotate(0f, turn * m_RotateSpeed * Time.deltaTime, 0f);
transform.Translate(0f, 0f, move * currentSpeed * Time.deltaTime);

RPC도, NetworkVariable도 없다. 그 이유는 프리팹에 같이 붙어 있는 NetworkTransform 컴포넌트가 알아서 위치/회전 변화를 감지해서 다른 클라이언트에게 전송해주기 때문이다.

  • 소유자(Owner) 클라이언트가 로컬에서 transform을 직접 바꾸면
  • NetworkTransform이 매 틱마다 변화를 감지해 서버(또는 지정된 권한)로 전송
  • 다른 클라이언트들은 그 값을 받아 보간(Interpolate)하며 화면에 반영

프리팹 설정을 보면 Interpolate = 1, PositionThreshold = 0.001 같은 값들이 있는데, 이게 바로 "얼마나 자주/부드럽게 동기화할지"를 결정하는 파라미터다. 덕분에 개발자는 이동 로직 자체는 싱글플레이 게임처럼 짜고, 동기화는 컴포넌트에 위임할 수 있다.

3. 상태 값은 다른 이야기 — PlayerStats의 두 가지 동기화 방식

이동은 Transform 자동 동기화로 해결됐지만, 체력·이름·점수 같은 "게임 로직 데이터"는 다른 방식이 필요하다. PlayerStats에서는 두 가지 패턴으로 구현해봤다.

(1) NetworkVariable — 서버가 값을 들고, 변경을 브로드캐스트

csharp
private readonly NetworkVariable<int> m_Hp = new NetworkVariable<int>(
    0,
    NetworkVariableReadPermission.Everyone,
    NetworkVariableWritePermission.Server
);

private readonly NetworkVariable<FixedString32Bytes> m_DisplayName =
    new NetworkVariable<FixedString32Bytes>(
        default,
        NetworkVariableReadPermission.Everyone,
        NetworkVariableWritePermission.Owner
);
  • 읽기 권한(Everyone): 모든 클라이언트가 체력 값을 볼 수 있음
  • 쓰기 권한(Server): 오직 서버만 값을 바꿀 수 있음

값이 바뀌면 OnValueChanged 이벤트가 호출되어 각 클라이언트에서 UI 갱신 등을 처리할 수 있다.

csharp
m_Hp.OnValueChanged += HandleHealthChanged;
...
if (IsServer)
{
    m_Hp.Value = m_StartHealth;
}

이름(m_DisplayName)은 반대로 쓰기 권한이 Owner다. 즉 "내 이름은 내가 정하고, 다른 사람은 읽기만 가능"한 구조로, 권한 조합만 바꿔서 완전히 다른 동기화 정책을 만들 수 있다는 걸 보여준다.

(2) RPC — 서버 권위 모델로 점수 처리

점수는 NetworkVariable 대신 RPC로 처리하도록 구현해봤다.

csharp
[Rpc(SendTo.Server)]
private void RequestScoreRpc(RpcParams rpcParams = default)
{
    ulong senderClientId = rpcParams.Receive.SenderClientId;

    if (senderClientId != OwnerClientId)
    {
        return;
    }

    m_Score += m_ScorePerpress;
    BroadcastScoreRpc(m_Score);
}

여기서 눈여겨볼 부분은 senderClientId != OwnerClientId 체크다. RPC는 SendTo.Server로 지정해도 "이 오브젝트의 소유자만 요청을 보낼 수 있다"는 걸 자동으로 보장해주지 않는다. 악의적인 클라이언트가 남의 PlayerStats에 RPC를 직접 호출하는 걸 막으려면, 서버 쪽에서 요청자와 소유자가 일치하는지 반드시 검증해야 한다. 이게 바로 서버 권위(Server Authoritative) 모델의 핵심이다 — 클라이언트는 "요청"만 하고, 실제 값 변경과 검증은 항상 서버가 한다.

값이 바뀐 뒤에는 BroadcastScoreRpc(SendTo.ClientsAndHost)로 모든 클라이언트에 결과를 다시 뿌려준다. 흐름을 정리하면:

scss
클라이언트 → (RequestScoreRpc) → 서버가 검증 후 점수 갱신
서버 → (BroadcastScoreRpc) → 모든 클라이언트에 새 점수 전파

또한 늦게 접속한 클라이언트를 위해 RequestCurrentScoreRpc로 "현재 점수 좀 알려줘"라고 서버에 물어보고, 서버는 RpcTarget.Single로 그 클라이언트에게만 답장하는 패턴도 들어 있다.

4. 테스트

Window - Multiplayer - Multiplayer Play Mode 들어가면

이런 창이 뜨고 Player2를 체크하면

다른 게임 뷰가 만들어진다

코드를 실행하면 멀티플레이를 실행할 수 있고

각 게임 씬을 활성화 하고 키보드를 움직이면

각각 따로 움직이는 것을 볼 수 있다

5. 정리 — 이동과 상태, 다른 동기화 전략을 쓰는 이유

두 스크립트를 나란히 보면 멀티플레이 동기화의 기본 감각이 잡힌다.

구분대상동기화 방식이유
이동TransformNetworkTransform 자동 동기화매 프레임 바뀌는 연속적인 값이라 별도 RPC 관리가 비효율적
체력/이름상태 값NetworkVariable + 읽기/쓰기 권한값 변경이 드물고, 권한 규칙(누가 쓰나)이 명확
점수상태 값RPC(Request → Server 검증 → Broadcast)서버 검증 로직이 필요하거나, 이벤트성 처리에 적합

공통적으로 관통하는 원칙은 "입력과 판정은 로컬(Owner) 또는 서버에서, 결과 전파는 네트워크 계층에 맡긴다" 는 것. IsOwner / IsServer 체크와 소유자 검증(OwnerClientId)만 잘 지켜도 대부분의 치팅 문제와 "남의 캐릭터가 조종되는" 버그를 막을 수 있다는 걸 실습을 통해 체감했다.