Carrot
본문 바로가기
Unity/멋쟁이사자처럼 부트캠프

[멋쟁이사자처럼부트캠프] 유니티 게임 개발 5기(66일차) - Mirror Network 실습

by 독기품은토끼 2025. 8. 21.
✅ 오늘의 학습 목표
1. Mirror > Relay 학습
2. Mirror를 활용한 꼬리잡기 게임 만들기

 

1. Mirror

Mirror는 Unity에서 직접 서버를 운영하는 P2P/서버-클라 구조 멀티플레이 프레임워크이다.

Photon처럼 클라우드 서버를 쓰는 게 아니라 직접 서버를 운영하고 싶을 때 사용한다.

🥕 예행 작업
1. Asset 다운로드
 

Mirror | 네트워크 | Unity Asset Store

Get the Mirror package from Mirror Networking and speed up your game development process. Find this & other 네트워크 options on the Unity Asset Store.

assetstore.unity.com


2. Package Manager > Registry > Multiplayer Services 다운로드


3.  Package Manager > Registry > Multiplayer Play Mode 다운로드


4. Package Manager > Registry > Relay 다운로드


5. + > Install ... git URL > com.unity.jobs 다운로드


6. UTPTransport 다운로드

 

1. 기본 셋팅

  • 빈 오브젝트 생성 후
  • Relay Network Manager, Utp Transport 컴포넌트 추가
  • Relay Network Manager의 Network Info > Transprot에 Utp 스크립트 연결

UTP Transport는 Unity에서 제공하는 네트워크 전송 계층 중 하나이며 Network Manager에 연결해 주어 실제 통신이 가능하도록 해준다.

 

🚨 Relay란?

Relay Network Manager는 클라이언트와 서버가 직접 연결되지 못할 때 중간 서버(Unity Service)를 통해 네트워크 연결을 가능하게 해주는 관리자 컴포넌트이다.

 

위 작업으로 '현재 Network Manager는 실제 통신을 UTP 방식으로 하겠다' 라는 의미를 지닌다.

 

 

Photon View가 동기화 관리자 역할, Photon Transform View는 트랜스폼을 포톤에 등록시켜 주는 역할이 있었던 것처럼

Mirror에서도 동일한 역할을 하는 Network Identity와 Network Transform이 있다.

  • Reliable : 신뢰성 높음 > TCP > 느림
  • Unreliable : 신뢰성 낮음 > UDP > 빠름

 

2. 동기화 확인

2.1. UI 생성

using UnityEngine;
using UnityEngine.UI;
using Utp;

public class ConnectManager : MonoBehaviour
{
    public RelayNetworkManager networkManager;

    [SerializeField] private Button hostButton;
    [SerializeField] private Button clientButton;

    void Start()
    {
        hostButton.onClick.AddListener(() =>
        {
            networkManager.StartRelayHost(2, null);
        });

        clientButton.onClick.AddListener(() =>
        {
            networkManager.JoinRelayServer();
        });
    }
}

 

호스트 = 방 생성

클라이언트 = 발급된 Join Code를 통해 방에 접속하는 코드 구현

  • Host 버튼을 누르면 → StartRelayHost() 실행
  • Client 버튼을 누르면 → JoinRelayServer() 실행

 

 

2.2. 인증

using System;
using Unity.Services.Authentication;
using Unity.Services.Core;
using UnityEngine;

public class Authentication : MonoBehaviour
{
    async void Start() // async와 await은 세트
    {
        try
        {
            // 유니티 서비스 권한에 접근
            await UnityServices.InitializeAsync();
            await AuthenticationService.Instance.SignInAnonymouslyAsync();
            Debug.Log("Logged into Unity, player ID: " + AuthenticationService.Instance.PlayerId);
        }
        catch (Exception e)
        {
            Debug.LogError(e);
        }
    }
}

 

Relay를 사용하기 위해서는 Unity 서비스에 로그인을 해야 함

여기서는 별도의 계정 없이 익명 로그인(SignInAnonymous) 사용

UDP Transform의 README에 적힌 try-catch문 사용해 주었음

 

 

2.3. Relay Network Manager

public class RelayNetworkManager : NetworkManager
{
    public void StartRelayHost(int maxPlayers, string regionId = null)
    {
        utpTransport.useRelay = true;
        utpTransport.AllocateRelayServer(maxPlayers, regionId,
        (string joinCode) =>
        {
            relayJoinCode = joinCode;
            Debug.Log($"<color=#00FF00>{joinCode}</color>"); // 추가
            StartHost();
        },
        () =>
        {
            UtpLog.Error($"Failed to start a Relay host.");
        });
}

 

RelayNetworkManager는 Mirror의 NetworkManager를 상속받아 만든 커스텀 매니저이다.
해당 매니저의 핵심은 Relay 서버를 할당하고 Join Code를 생성한다.

 

  • utpTransport.AllocateRelayServer()
    → Relay 서버를 빌리고, Join Code를 발급받음
  • joinCode는 클라이언트 쪽에서 접속할 때 필요
  • Relay 서버 연결이 성공하면 StartHost() 호출해서 호스트 시작

 

2.4. Unity 서비스 활성화

 

 

 

Relay와 Authentication을 사용하기 위해

Project Settings > Serviecs > Authentication에서 서비스가 정상적으로 활성화되어있는지 체크

 

 

2.5. Host 클릭 > Relay 서버 생성 > Join Code 발급 > Join Code를 통해 Client 접속

 

 

Player A에서 Host를 생성하고

Host 생성 시 발급되는 ID를 통해서 Client 측에서 접속 가능을 확인할 수 있다.

 

 

2.6. 플레이어 움직임 동기화

using Mirror;
using UnityEngine;

public class PlayerController : NetworkBehaviour
{
    void Update()
    {
        if (isLocalPlayer)
        {
            float h = Input.GetAxis("Horizontal");
            float v = Input.GetAxis("Vertical");

            var dir = new Vector3(h, v, 0);
            dir.Normalize();

            transform.position += dir * 2f * Time.deltaTime;
        }
    }
}

 

  • NetworkBehaviour
    Mirror에서 네트워크 동기화를 지원하는 스크립트 베이스 클래스
    Photon에서 쓰던 MonoBehaviourPun과 비슷한 역할을 한다고 보면 됨
  • isLocalPlayer
    이 오브젝트가 "내 플레이어"인지 확인할 때 쓰는 플래그
    Photon의 photonView.IsMine이랑 똑같은 개념

 

2. Mirror Sample 실습 - 꼬리잡기 게임

🥕 예행 작업
1. Unity Relay Mirror Sample 다운로드
 

GitHub - Unity-Technologies/unity-relay-mirror-sample: Unity Mirror Sample project! This project demonstrates how to use Unity T

Unity Mirror Sample project! This project demonstrates how to use Unity Transport Package (UTP) and Relay service with the Mirror Networking API. - Unity-Technologies/unity-relay-mirror-sample

github.com

2. Package Manager > Registry > Multiplayer Services 다운로드
3. Package Manager > Registry > Multiplayer Play Mode 다운로드
더보기

해당 프로젝트에서는 Relay를 사용하지 않으므로 Remove 해주었다.

그러면 에러가 무수히 많이 뜰 텐데 아래와 같이 수정해 주었고, 이미지가 없는 에러는 Alt + Enter로 해결되므로 생략했다.

 

1. 스네이크 이동

using System.Collections.Generic;
using Mirror;
using UnityEngine;

public class SnakeController : NetworkBehaviour
{
    [SerializeField] private GameObject tailPrefab;
    [SerializeField] private Transform coin;

    [SerializeField] private float moveSpeed = 3f;
    [SerializeField] private float turnSpeed = 120f;
    [SerializeField] private float lerpSpeed = 5f;

    private List<Transform> tails = new List<Transform>();

    void Awake()
    {
        coin = GameObject.FindGameObjectWithTag("Coin").transform;
    }

    void Update()
    {
        if (isLocalPlayer)
            MoveHead();
    }

    void LateUpdate()
    {
        MoveTail();
    }

    private void OnTriggerEnter(Collider other)
    {
        if (other.CompareTag("Coin"))
        {
            // 코인 획득 
            AddTail();

            // 코인 이동
            MoveCoin();
        }
    }

    private void MoveHead()
    {
        transform.Translate(Vector3.up * moveSpeed * Time.deltaTime);

        float h = Input.GetAxis("Horizontal");
        transform.Rotate(Vector3.forward * h * -turnSpeed * Time.deltaTime);
    }

    private void MoveCoin()
    {
        float ranX = Random.Range(-20f, 20f);
        float ranY = Random.Range(-10f, 10f);

        coin.position = new Vector3(ranX, ranY, 0);
    }

    private void AddTail()
    {
        GameObject newTail = Instantiate(tailPrefab);
        newTail.transform.position = transform.position;

        tails.Add(newTail.transform);
    }

    private void MoveTail() // 꼬리가 따라다니는 기능
    {
        Transform target = transform;

        foreach (var tail in tails)
        {
            tail.position = Vector3.Lerp(tail.position, target.position, lerpSpeed * Time.deltaTime);
            tail.rotation = Quaternion.Lerp(tail.rotation, target.rotation, lerpSpeed * Time.deltaTime);

            target = tail;
        }
    }
}

 

꼬리 잡기 게임을 구현하기 위해서

우선 뱀이 코인을 먹으면 꼬리가 길어지는 것을 구현해 주었다.

 

그러나 아직 동기화 작업을 해주지 않았기 때문에

Multiplay Mode로 실행시키면 클라이언트 측에서는 뱀의 이동, 코인이나 Tail의 변화가 정상적으로 동기화되지 않는다.

 

using System.Collections.Generic;
using Mirror;
using UnityEngine;

public class SnakeController : NetworkBehaviour
{
    [SerializeField] private GameObject tailPrefab;
    
    // SyncVar : 대상이 변경되면 동기화해주는 기능
    [SyncVar]
    private Transform coin;

    [SerializeField] private float moveSpeed = 3f;
    [SerializeField] private float turnSpeed = 120f;
    [SerializeField] private float lerpSpeed = 5f;

    // SyncList : 추가 / 삽입 / 삭제할 때 동기화해주는 기능
    private SyncList<Transform> tails = new SyncList<Transform>();

    [Server] // Photon에서 IsMasterClient와 동일한 기능
    public override void OnStartServer()
    {
        coin = GameObject.FindGameObjectWithTag("Coin").transform;
    }

    void Update()
    {
        if (isLocalPlayer)
            MoveHead();
    }

    void LateUpdate()
    {
        MoveTail();
    }

    [ServerCallback]
    private void OnTriggerEnter(Collider other)
    {
        if (other.CompareTag("Coin"))
        {
            // 코인 획득 
            AddTail();

            // 코인 이동
            MoveCoin();
        }
    }

    private void MoveHead()
    {
        transform.Translate(Vector3.up * moveSpeed * Time.deltaTime);

        float h = Input.GetAxis("Horizontal");
        transform.Rotate(Vector3.forward * h * -turnSpeed * Time.deltaTime);
    }

    [Server] // 서버에서만 호출되는 함수
    private void MoveCoin()
    {
        if (coin == null)
            return;

        float ranX = Random.Range(-13f, 13f);
        float ranY = Random.Range(-10f, 10f);

        coin.position = new Vector3(ranX, ranY, 0);
    }

    [Server]
    private void AddTail()
    {
        GameObject newTail = Instantiate(tailPrefab);
        newTail.transform.position = transform.position;

        NetworkServer.Spawn(newTail, connectionToClient); // 연결된 모든 클라이언트에게 newTail 생성 알림

        tails.Add(newTail.transform);
    }

    private void MoveTail() // 꼬리가 따라다니는 기능
    {
        Transform target = transform;

        foreach (var tail in tails)
        {
            if (tail == null)
                continue;

            tail.position = Vector3.Lerp(tail.position, target.position, lerpSpeed * Time.deltaTime);
            tail.rotation = Quaternion.Lerp(tail.rotation, target.rotation, lerpSpeed * Time.deltaTime);

            target = tail;
        }
    }
}

 

1. [Server]

  • 서버에서만 실행되는 함수
  • Photon의 PhotonNetwork.IsMasterClient과 비슷한 느낌이다.
  • 코인을 랜덤 한 위치로 이동시키는 로직은 서버만 담당해야 한다. (클라이언트에서도 수행하면 사용자별로 위치가 다름)

 

2. [ServerCallback]

  • Unity의 이벤트 함수(OnTriggerEnter, Update 등) 앞에 붙이면 서버에서만 실행되도록 하는 기능이다.
  • 서버에서만 코인을 먹으면 꼬리를 추가하고, 코인의 이동을 처리한다
  • 만약 클라이언트에서 이 로직이 실행된다면 각 플레이어가 먹은 코인들이 모든 클라이언트에게 공유된다.

 

3. [SyncVar]

  • 서버에서 값이 변경되면 자동으로 클라이언트와 동기화하는 작업을 수행한다.
  • 따라서 코인 위치 참조를 모든 클라리언트가 똑같이 공유할 수 있게 된다.

 

4. [SyncList]

  • SyncList<T>는 리스트에 원소가 추가/삭제될 때 동기화하는 작업을 수행한다.
  • 뱀의 꼬리 Transform을 클라이언트 전부가 공유한다.

 

 

인스펙터에 추가할 컴포넌트들과 옵션은 위 이미지와 같다.

 

1. Network Identity

  • Mirror가 "이 오브젝트는 네트워크로 관리할 거야"라고 표시하는 컴포넌트이다.
  • Photon에서 PhotonView를 붙여 네트워크 객체로 등록하는 것과 비슷하다.
  • Server Only : 오브젝트를 서버에서만 존재시키고 클라이언트에 스폰하지 않는 기능 → 충돌 판정이나 게임 규칙 같은 것을 서버에서만 처리하도록 해준다.

2. Network Transform

  • 위치나 회전, 스케일을 동기화해 주는 역할을 한다.
  • Photon의 PhotonTransformView과 동일하다.
  • 권한 (Authority)
    • Server : 서버가 위치를 전송
    • Client : 오브젝트의 소유 클라이언트가 위치를 전송

3. Network Manager → Registered Spawnable Prefabs에 Tail 등록

  • 서버가 Spawn할 때 클라이언트도 같은 프리팹을 알고 있어야 동일한 프리팹을 생성한다.

 


 

그러나 지금 위 인스펙터 설정에는 문제가 있다.

 

1. Coin → Network Identity: Server Only 체크

코인의 충돌/획득/재배치 같은 권위 로직을 서버에서만 처리하려고 Server Only를 체크하였는데

이럴 경우 클라이언트 화면에서는 코인이 보이지 않는 문제점이 있다.

 

2. Snake → Network Transform: Client Authority 미체크

코드에서 isLocalPlayer을 통해 클라이언트가 스스로 움직이도록 구현하였는데

서버가 그 움직임을 전송하지 않아서 서버 화면에서 클라이언트의 움직임이 보이지 않는 문제점이 있다.

 

강사님이 대혼란을 주셨다.. 진짜 미치는 줄 알았다ㅋ

 

 

 

2. 역할 분리 + 죽음 구현

2.1. GameManager

코인의 위치만 관리

using Mirror;
using UnityEngine;

public class GameManager : NetworkBehaviour
{
    public static GameManager Instance;

    [SerializeField] private Transform coin;

    [SyncVar(hook = nameof(OnCoinPositionChanged))]
    public Vector3 coinPosition;

    void Awake()
    {
        Instance = this;
    }

    public override void OnStartServer()
    {
        MoveCoin();
    }

    public void MoveCoin()
    {
        float ranX = Random.Range(-20f, 20f);
        float ranY = Random.Range(-10f, 10f);

        coinPosition = new Vector3(ranX, ranY, 0);
    }

    private void OnCoinPositionChanged(Vector3 prevPos, Vector3 newPos)
    {
        coin.position = newPos;
    }
}

 

coinPosition을

SyncVar(hook = nameof(OnCoinPositionChanged))] Vector3로 선언
→ 서버가 값을 바꾸면 클라에 자동 복제되고 훅(hook)에서 코인 Transform을 실제로 옮김

 

SyncVar (hook)

  • 서버가 필드 값을 바꾸면 그 값이 관찰 중인 모든 클라에 동기화된다.
  • hook: 값이 네트워크로 경신될 때 호출되는 콜백 (여기서 UI/이펙트/Transform 이동 등 표현을 처리)
  • nameof(...)를 쓰는 이유: 문자열 대신 컴파일 타임 안전하게 메서드를 지정(리팩토링에 강함, 오타 방지

 

[호출 흐름]

 

이렇게 Mirror에서 값을 자동으로 넘겨주기 때문에

hook 시그니처는 정확히 void Method(T oldValue, T newValue) 형태여야 한다.

 

 

2.2. Snake

플레이어의 조작과 충돌 처리

using Mirror;
using UnityEngine;

public class Snake : NetworkBehaviour
{
    [SerializeField] private GameObject tailPrefab;
    [SerializeField] private MeshRenderer headRenderer;

    [SerializeField] private float moveSpeed = 3f;
    [SerializeField] private float turnSpeed = 120f;
    [SerializeField] private float lerpSpeed = 5f;
    [SerializeField] private float tailOffset = 0.5f;

    private SyncList<GameObject> tails = new SyncList<GameObject>();

    [SyncVar(hook = nameof(OnDeathStateChanged))]
    private bool isDead = false;

    public override void OnStartClient()
    {
        base.OnStartClient();
        tails.Callback += OnTailUpdated;
    }

    public override void OnStartLocalPlayer()
    {
        headRenderer.material.color = new Color(0.8f, 1f, 0.8f);
    }

    void Update()
    {
        if (isLocalPlayer && !isDead)
            MoveHead();
    }

    void LateUpdate()
    {
        MoveTail();
    }

    private void OnTriggerEnter(Collider other)
    {
        if (!isLocalPlayer && !isDead)
            return;

        if (other.CompareTag("Coin"))
        {
            GetCoin();
        }
        
        if (other.CompareTag("Tail"))
        {
            Tail tail = other.GetComponent<Tail>();
            if(tail.ownerIdentity != netIdentity) // 자기 꼬리에는 죽지 않게
                Died();
        }
    }

    private void MoveHead()
    {
        transform.Translate(Vector3.up * moveSpeed * Time.deltaTime);

        float h = Input.GetAxis("Horizontal");
        transform.Rotate(Vector3.forward * h * -turnSpeed * Time.deltaTime);
    }

    private void MoveTail()
    {
        Transform target = transform;

        foreach (var tail in tails)
        {
            if (tail != null)
            {
                tail.transform.position = Vector3.Lerp(tail.transform.position, target.position, lerpSpeed * Time.deltaTime);
                tail.transform.rotation = Quaternion.Lerp(tail.transform.rotation, target.rotation, lerpSpeed * Time.deltaTime);
                target = tail.transform;
            }
        }
    }

    void OnTailUpdated(SyncList<GameObject>.Operation op, int index, GameObject oldTail, GameObject newTail)
    {
        if (op == SyncList<GameObject>.Operation.OP_ADD && isLocalPlayer) // 추가돠었을 때
        {
            Transform target = transform;

            if (index > 0)
            {
                target = tails[index - 1].transform; // 꼬리쪽에 생성해라
            }

            newTail.transform.position = target.position - (target.up * tailOffset);
            newTail.transform.rotation = target.rotation;
        }
    }

    void OnDeathStateChanged(bool oldState, bool newState)
    {
        if (newState)
        {
            headRenderer.material.color = Color.gray;
        }
    }

    [Command]
    private void GetCoin()
    {
        GameManager.Instance.MoveCoin();
        AddTail();
    }

    [Server]
    private void AddTail()
    {
        Transform spawnTarget = transform;
        if (tails.Count > 0)
        {
            spawnTarget = tails[tails.Count - 1].transform;
        }

        Vector3 spawnPos = spawnTarget.position - (spawnTarget.up * tailOffset);
        Quaternion spawnRot = spawnTarget.rotation;

        GameObject newTail = Instantiate(tailPrefab, spawnPos, spawnRot);
        newTail.GetComponent<Tail>().ownerIdentity = netIdentity;

        NetworkServer.Spawn(newTail, connectionToClient);
        tails.Add(newTail);
    }

    [Command]
    private void Died()
    {
        isDead = true;
    }
}

 

[전체 흐름]

 

  • isLocalPlayer일 때 로컬 이동
  • 코인을 먹으면 [Command]로 서버에 “코인 먹었어” 요청
  • 서버가 확정: GameManager.MoveCoin()으로 위치 갱신 + AddTail() 스폰
  • 사망(isDead)은 [SyncVar(hook)]로 외형만 회색 처리

 

[Command]

Command는 클라이언트 → 서버로 보내는 원격 호출 (RPC) 기능을 수행

 

OnStartClient()

모든 클라이언트에 공통 값을 넣는 곳

 

OnStartLocalPlayer()

내가 조정한 클라이언트만 한정 초기화

 

 

 

2.3. Tail

소유자(Owner) 식별로 자기 꼬리 면역

using Mirror;
using UnityEngine;

public class Tail : NetworkBehaviour
{
    [SyncVar]
    public NetworkIdentity ownerIdentity;
}

 

 

ownerIdentity를 [SyncVar]로 들고

스폰 시 NetworkServer.Spawn(newTail, connectionToClient)로 소유자 부여

충돌 시 ownerIdentity != netIdentity이면만 사망 처리 → 내 꼬리에 부딪혀도 안 죽음

 

 

 


 

부트캠프 초반에 가장 우려했던 일이 요즘들어 자주 터지는 것 같다.

내용이 많이 어려워지다 보니 강사님의 설명도 엄청 단순해지셨고.. 그러다 보니 이해하고 넘어가는 게 아니라 스크립트 따라 치는 데에만 급급한 상황이 되어버렸다.

회고시간에 이렇게 정리를 해서 그나마 좀 이해가 되려고 하는데

솔직히 이것도 제대로 이해한 게 맞는지 모르겠다ㅠ.. 점점 방향성을 잃고 있는 기분.. 우리 진도가 그렇게 느린가?...