[Unity] Netcode for GameObjects(NGO) (3)

조경민·2026년 4월 20일

Unity Relay - 인터넷 환경 연결

  • 실제 인터넷 환경에서는 NAT(Network Address Translation, 네트워크 주소 변환) 와 방화벽이 직접 연결을 막기 때문에 중간에서 트래픽을 중계해 주는 서버가 필요.

  • Unity는 이를 해결하기 위한 Relay 서비스를 UGS의 일부로 제공

  • Host가 Relay 서버에 자리를 잡고 Join Code를 발급받으면, Client는 그 Join Code로 같은 자리에 참가할 수 있음.

  • 사전 개념

    키워드역할
    async"이 메서드 안에서 await를 사용하겠다" 는 선언
    await"이 작업이 끝날 때까지 기다렸다가 다음 줄로 넘어간다"
    Task비동기 작업 자체를 나타내는 타입. 반환값이 있으면 Task
    try-catch실행 중 발생하는 에러를 잡아서 처리하는 구문 (예외 발생 시 비용이 큼)
    • 서버 통신은 항상 try-catch
      • 네트워크 오류는 언제든 발생할 수 있어서 UGS 호출은 항상 try-catch 로 감싸는 습관을 들이는 것이 좋음

Relay 쓰는 구조에서는 Auth(인증) → Relay(방 생성 or 참가) → Network 시작 순서 필수로 지켜야 함.


UGS 초기화와 익명 로그인

  • Relay 호출 전에 반드시 UGS 가 초기화되고 인증이 완료되어 있어야 함.
using System;
using System.Threading.Tasks;
using Unity.Services.Authentication;
using Unity.Services.Core;
using UnityEngine;

public class AuthService : MonoBehaviour
{
    public static AuthService Instance { get; private set; }

    private async void Awake()
    {
        SetSingleton();
        await InitializeAsync();
    }

    private void SetSingleton()
    {
        if (Instance != null)
        {
            Destroy(gameObject);
            return;
        }
        Instance = this;
        DontDestroyOnLoad(gameObject);
    }

    //          IEnumerator
    public async Task InitializeAsync()
    {
        try
        {   // yield return
            await UnityServices.InitializeAsync();

            if (!AuthenticationService.Instance.IsSignedIn)
            {
                await AuthenticationService.Instance.SignInAnonymouslyAsync();
            }

            Debug.Log($"[Auth] 로그인 완료: {AuthenticationService.Instance.PlayerId}");
        }
        catch (Exception e)
        {
            Debug.LogError($"[Auth] 초기화 실패: {e.Message}");
            throw;
        }
    }
}
  • IsSignedIn 체크 없이 SignInAnonymouslyAsync 를 호출하면 중복 로그인 예외가 발생할 수 있음

Relay Host 와 Client 구현

  • Host는 Relay 서버에 Allocation(연결 공간)을 요청해 Join Code 를 발급받고, Client는 그 Join Code 로 같은 Allocation에 참가
using System;
using System.Threading.Tasks;
using Unity.Netcode;
using Unity.Netcode.Transports.UTP;
using Unity.Networking.Transport.Relay;
using Unity.Services.Relay;
using Unity.Services.Relay.Models;
using UnityEngine;

public class RelayNetworkService : MonoBehaviour
{
    public static RelayNetworkService Instance { get; private set; }

    private void Awake() => SetSingleton();

    // maxConnections에는 Host 자신을 포함하지 않음. 4인 게임이면 3을 전달
    public async Task<string> StartHostWithRelayAsync(int maxConnections = 3)
    {
        try
        {
            // Relay 서버에 공간 할당
            Allocation allocation = await RelayService.Instance.CreateAllocationAsync(maxConnections);

            // 다른 플레이어가 접속할 Join Code 생성
            string joinCode = await RelayService.Instance.GetJoinCodeAsync(allocation.AllocationId);

            // UnityTransport 에 Relay 서버 정보 주입
            RelayServerData serverData = AllocationUtils.ToRelayServerData(allocation, "dtls");
            NetworkManager.Singleton.GetComponent<UnityTransport>().SetRelayServerData(serverData);

            // Host 시작
            NetworkManager.Singleton.StartHost();
            return joinCode;
        }
        catch (Exception e)
        {
            Debug.LogError($"[Relay] Host 시작 실패: {e.Message}");
            throw;
        }
    }

    public async Task StartClientWithRelayAsync(string joinCode)
    {
        try
        {
            // Join Code 로 Allocation 참가
            JoinAllocation joinAllocation = await RelayService.Instance.JoinAllocationAsync(joinCode);

            // UnityTransport 에 Relay 서버 정보 주입
            RelayServerData serverData = AllocationUtils.ToRelayServerData(joinAllocation, "dtls");
            NetworkManager.Singleton.GetComponent<UnityTransport>().SetRelayServerData(serverData);

            // Client 시작
            NetworkManager.Singleton.StartClient();
        }
        catch (Exception e)
        {
            Debug.LogError($"[Relay] Client 접속 실패: {e.Message}");
            throw;
        }
    }

    private void SetSingleton()
    {
        if (Instance != null)
        {
            Destroy(gameObject);
            return;
        }
        Instance = this;
        DontDestroyOnLoad(gameObject);
    }
}
  • SetRelayServerDataStartHost / StartClient 순서 중요
  • Host 와 Client 는 같은 connectionType("dtls") 을 사용해야 함

Join Code 교환 UI

using System;
using UnityEngine;
using UnityEngine.UI;
using TMPro;
using Unity.Netcode;

public class NetworkBootstrap : MonoBehaviour
{
    [SerializeField] private Button _startHostButton;
    [SerializeField] private Button _startClientButton;
    [SerializeField] private Button _disconnectButton;
    // Join Code 를 표시 · 입력하는 공용 InputField
    [SerializeField] private TMP_InputField _joinCodeInputField;
    // Join Code 를 클립보드에 복사하는 버튼
    [SerializeField] private Button _copyButton;

    private bool _isCallbacksBound;

    private void OnEnable()
    {
        BindNetworkCallbacks();
        BindButtonEvents();
    }

    private void OnDisable()
    {
        UnbindNetworkCallbacks();
        UnbindButtonEvents();
    }

    // Copy 버튼 이벤트 바인딩 추가, Host/Client 핸들러가 async 버전으로 변경됨
    private void BindButtonEvents()
    {
        _startHostButton.onClick.AddListener(OnStartHostClicked);
        _startClientButton.onClick.AddListener(OnStartClientClicked);
        _disconnectButton.onClick.AddListener(OnDisconnectClicked);
        _copyButton.onClick.AddListener(OnCopyClicked);
    }

    // Copy 버튼 이벤트 해제 추가
    private void UnbindButtonEvents()
    {
        _startHostButton.onClick.RemoveListener(OnStartHostClicked);
        _startClientButton.onClick.RemoveListener(OnStartClientClicked);
        _disconnectButton.onClick.RemoveListener(OnDisconnectClicked);
        _copyButton.onClick.RemoveListener(OnCopyClicked);
    }

    private void BindNetworkCallbacks()
    {
        if (_isCallbacksBound) return;
        if (NetworkManager.Singleton == null) return;

        NetworkManager.Singleton.OnClientConnectedCallback += OnClientConnected;
        NetworkManager.Singleton.OnClientDisconnectCallback += OnClientDisconnect;
        NetworkManager.Singleton.OnServerStarted += OnServerStarted;
        _isCallbacksBound = true;
    }

    private void UnbindNetworkCallbacks()
    {
        if (!_isCallbacksBound) return;
        if (NetworkManager.Singleton == null) return;

        NetworkManager.Singleton.OnClientConnectedCallback -= OnClientConnected;
        NetworkManager.Singleton.OnClientDisconnectCallback -= OnClientDisconnect;
        NetworkManager.Singleton.OnServerStarted -= OnServerStarted;
        _isCallbacksBound = false;
    }

    // Relay 연동으로 변경: StartHost 직접 호출 대신 Relay Allocation 생성 후 Join Code 를 InputField 에 표시
    private async void OnStartHostClicked()
    {
        try
        {
            string joinCode = await RelayNetworkService.Instance.StartHostWithRelayAsync();
            _joinCodeInputField.text = joinCode;
        }
        catch (Exception e)
        {
            Debug.LogError($"[Bootstrap] Host 시작 오류: {e.Message}");
        }
    }

    // Relay 연동으로 변경: InputField 의 Join Code 를 읽어 Relay 에 접속
    private async void OnStartClientClicked()
    {
        string joinCode = _joinCodeInputField.text.Trim();
        if (string.IsNullOrEmpty(joinCode)) return;

        try
        {
            await RelayNetworkService.Instance.StartClientWithRelayAsync(joinCode);
        }
        catch (Exception e)
        {
            Debug.LogError($"[Bootstrap] Client 접속 오류: {e.Message}");
        }
    }

    private void OnDisconnectClicked() => NetworkManager.Singleton.Shutdown();

    // 추가: Join Code 를 클립보드에 복사
    private void OnCopyClicked()
    {
        GUIUtility.systemCopyBuffer = _joinCodeInputField.text;
    }

    private void OnClientConnected(ulong clientId) => Debug.Log($"<color=green>[Network] 접속: {clientId}</color>");
    private void OnClientDisconnect(ulong clientId) => Debug.Log($"<color=red>[Network] 해제: {clientId}</color>");
    private void OnServerStarted() => Debug.Log("<color=green>[Network] 서버 시작</color>");
}
profile
안녕하세요

0개의 댓글