이문회우

[Unity Netcode] 커스텀 메시징 1. 개념 잡기 본문

개발/[Unity]만 골라보기

[Unity Netcode] 커스텀 메시징 1. 개념 잡기

Koesob 2024. 12. 25. 21:38

멀티 게임을 만들어보자.

 

※ 개발 환경은 Unity 6이다.


Netcode

우선 Unity Netcode 패키지를 설치한다.

Window > Package Manager > Packages: Unity Registry > Netcode for GameObject

Unity Netcode는 Unity에서 멀티 플레이 개발이 가능하도록 돕는 툴(라이브러리)이다.

꼭 Netcode가 아니더라도, Unity에서의 멀티 플레이 개발을 지원하는 툴이 몇 가지 더 존재한다.

 

Mirror

https://github.com/MirrorNetworking/Mirror

 

GitHub - MirrorNetworking/Mirror: #1 Open Source Unity Networking Library

#1 Open Source Unity Networking Library. Contribute to MirrorNetworking/Mirror development by creating an account on GitHub.

github.com

과거에 Unity의 네트워크 개발 툴로 UNet이 존재했었다. 이후 Unity는 UNet의 개발을 중단했지만, 이를 이어받아 오픈소스로 현재까지 개발을 진행하고 있는 것이 Mirror다

 

Photon

Multiplayer Game Development Made Easy | Photon Engine

 

Multiplayer Game Development Made Easy | Photon Engine

EssentialPhoton Details Discover a summary of our product range, notable features, the power of the Photon Cloud, and our cost-effective pricing plans. HAVE A LOOK

www.photonengine.com

과거 유니티로 멀티 플레이 개발에 도전해봤다면 한 번쯤은 써봤을 포톤(Photon)이다.

포톤은 단순 툴은 아니라 밑에 상품으로 PUN이나, Fusion이 존재한다.

그리고 네트워크와 관련한 추가적인 서비스도 제공한다. 물론 돈을 내야지

 

Fishnet

GitHub - FirstGearGames/FishNet: FishNet: Unity Networking Evolved.

 

GitHub - FirstGearGames/FishNet: FishNet: Unity Networking Evolved.

FishNet: Unity Networking Evolved. . Contribute to FirstGearGames/FishNet development by creating an account on GitHub.

github.com

Mirror, Netcode와 마찬가지로 무료이며, 많은 기능을 제공한다. 나도 찾는 중에 알게 돼서 자세히 알진 못함


Netcode for GameObject (NGO)

https://github.com/Unity-Technologies/com.unity.netcode.gameobjects

 

GitHub - Unity-Technologies/com.unity.netcode.gameobjects: Netcode for GameObjects is a high-level netcode SDK that provides net

Netcode for GameObjects is a high-level netcode SDK that provides networking capabilities to GameObject/MonoBehaviour workflows within Unity and sits on top of underlying transport layer. - Unity-T...

github.com

Unity가 현재 밀고 있는 네트워크 라이브러리이다.

 

Netcode를 선택한 이유는 별 다른거 없다.

유니티를 쓰고 있으니까, 유니티껄 쓰자

 

기업이 관리하면 그 나름의 깔끔함이 있지 않겠느냐-는 마음도 없지 않아 있긴 했다만 개발이란게 꼭 그런건 아니긴 함.

 

그리고 멀티 플레이 구현을 위한 기술적 개념은 어떤 툴을 써도 비슷비슷하다.

Photon에도 RPC가 있듯이 Netcode에도 RPC가 있다. 나아가 Unity를 기반으로 만들어진 '메이플스토리 월드'는 RPC만으로 동작한다. '메이플스토리 월드'의 동작 방식과 관련해선 이전에 글을 쓴 적이 있다.

유니티 멀티 개념 잡기 / 넷코드 + 포톤 + 메이플스토리 월드

 

유니티 멀티 개념 잡기 / 넷코드 + 포톤 + 메이플스토리 월드

다음을 비롯해 여러 정보를 참고하여 글을 썼습니다. ㅎㅎ About Netcode for GameObjects | Unity Multiplayer Networking (unity3d.com) COMPLETE Unity Multiplayer Tutorial (Netcode for Game Objects) - CodeMonkey [Unity] 게임 서버/네

koesob.tistory.com

(도박장이 왜 열렸는진 직접 읽어보시라)

 

결국 어떤 도구를 사용하더라도 다른 도구로 넘어가는데에 큰 문제가 발생하지 않으니 편한 것을 쓰면 되겠다.

물론, 이 글은 Netcode for GameObject를 사용한다.

 

더 자세한 내용은 다음 유튜브 영상을 참고

가장 좋은 유니티 멀티 에셋은 무엇일까?

'어레이의 개발문고'라는 채널로, 주인장 분께서 디스코드도 운영하고 계시니 참고하자!

 


Network Manager 세팅

우선 새 씬을 만들어서 작업을 시작해보자.

씬의 이름은 Main으로 짓고 기본 생성되는 Scenes 폴더 내에 저장하자.


나는 이상하게 가이드 문서에서 씬 이름이나, 파일 이름, 변수 이름을 정해주지 않으면 괜히 불편했다.

각설하고, Netcode for GameObject 패키지가 설치되면, NetworkManager 컴포넌트를 사용할 수 있게 된다.

 

 

그러면 바로

너, Transport가 없잖아.

 

라는 경고가 뜬다.

Select transport → Unity Transport로 설정해주자.

그럼 Unity Transport 컴포넌트가 추가되고, Network Manager의 Transport가 설정되면서 경고가 사라진다.이제 멀티 플레이 개발 작업을 할 수 있게 된 것이다. 세팅 끝!

 

뭐 했는데 벌써 끝? 할 수도 있다.
애초에 Netcode for GameObject는 복잡한 네트워크 세팅에 대해 신경 쓸 필요 없게 만드는게 목표기 때문에 그렇다.
물론, 필요하다면 파볼 수 있다. 특히 Transport 부분을 건들면 된다.
필요할 때, 그 때가 되면 해보자. 지금 먼저 해볼게 있다.

이제 멀티 플레이 구현을 위한 네트워킹은 모두 Network Manager를 통하면 된다.

지금 당장 쓰진 않지만, Assets 폴더 밑에 Scripts 폴더를 만들고 Managers 스크립트를 만들었다.

 

그리고 어디서든 NetworkManager로 접근 할 수 있도록, NetworkManager는 Singleton 패턴으로 구현되어 있다.

자, 이제 본격적인 멀티 구현 프로그래밍을 해보자!

 

멀티 구현을 위해 쓸 기능은 NetworkManager의 CustomMessagingManager이다.

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

namespace Unity.Netcode
public class NetworkManager : MonoBehaviour, INetworkUpdateSystem
{
	// ...
    public static NetworkManager Singleton { get; }
    // ...
    public CustomMessagingManager CustomMessagingManager { get; }
    // ...
}

NetworkManager 코드를 보면 알 수 있듯이 CustomMessagingManager는 NetworkManager를 통해 바로 접근을 할 수 있다.

using System.Collections.Generic;

namespace Unity.Netcode
{
    public class CustomMessagingManager
    {
        public void RegisterNamedMessageHandler(string name, HandleNamedMessageDelegate callback);
        public void SendNamedMessage(string messageName, ulong clientId, FastBufferWriter messageStream, NetworkDelivery networkDelivery = NetworkDelivery.ReliableSequenced);
        // ...
        public delegate void HandleNamedMessageDelegate(ulong senderClientId, FastBufferReader messagePayload);
    }
}

그리고 CustomMessagingManager에서 우리가 사용할 내용은 딱 위에 저 세 부분이다.

정확히는 Handle ~ Delegate는 이해를 위해 포함시킨 부분일 뿐, 실제 쓰는 것은 RegisterNamed-와 SendNamed-메소드 뿐이다.

 

자, 실제 코드를 보고 설명을 이어나가자.

using Unity.Collections;
using Unity.Netcode;
using UnityEngine;

public class Managers : MonoBehaviour
{
	// 메시징 매니저에게 메시지 수신 등록
    private void Register()
    {
        string messageName = "Hello Netcode";

        NetworkManager.Singleton.CustomMessagingManager.RegisterNamedMessageHandler(messageName, OnReceived);
    }

	// 메시지 송신
    private void Send()
    {
        string messageName = "Hello Netcode";
        ulong targetClientID = 0;
        FastBufferWriter messageStream = new FastBufferWriter(size: 128, allocator: Allocator.Temp);
        NetworkDelivery networkDelivery = NetworkDelivery.ReliableSequenced;

        NetworkManager.Singleton.CustomMessagingManager.SendNamedMessage(messageName, targetClientID, messageStream, networkDelivery);
    }

	// 메시지 수신시 할 동작
    private void OnReceived(ulong clientID, FastBufferReader reader)
    {
        // 메시지를 받고나서 행동 처리
    }
}

 

이는 Delegate, 또는 Delegate의 부류인 Action이나 Func을 사용해봤다면 유사한 구조다.

 

  Netcode Delegate
사용법 NetworkManager.Singleton.CustomMessagingManager (이하 CMM)에 접근해서 사용 delegate A; 선언
메시지(=이벤트) A에 대해 함수 X 등록 CMM.Register~Handler("A", X); A += X;
메시지(=이벤트) A 호출 CMM.Send~Message("A",-); A();
또는 A.Invoke();
메시지(=이벤트) A에 값 B를 넣어서 호출 FastBufferWriter writer = new();
writer.WriteValue("B");
CMM.Send~Message("A", -, writer, -);
A("B");
또는 A.Invoke("B);

 

Delegate가 연산자로 짧게 쓴 내용들을 함수 형태로 바꾼 것이다.

FastBufferWriter는 뭐고 WriteValue는 뭐에요-는 곧 설명한다. 잠시.

 

그래서 Custom ~ Manager가 하는 일을 Action의 형태로 바꿔보면 다음과 같을 것이다.

public class NetworkManager
{
	public Action CustomMessagingManager;
}

public class Server
{
	private void Awake()
	{
		NetworkManager.Singleton.CustomMessagingManager += ServerReceived;
	}

	private void ServerReceived()
	{
		Debug.Log("Hello Netcode");
	}
}

public class Client
{
	private void Start()
    {
    	// Server Awake 이후 실행
        NetworkManager.Singleton.CustomMessagingManager.Invoke();
    }
}


// 결과, Server에서 "Hello Netcode" 출력

 

'네트워크 매니저'가 일종의 네트워크 속 우체국이 되어주는 것이다.

 

그래서 클라이언트 쪽에서 메시지를 보내면, 우체국은 서버가 사전에 등록한 주소로 보내준다.

 

반대로, 서버가 메시지를 보내도, 클라이언트가 사전에 해당 메시지에 대해 등록을 했다면 클라이언트 쪽으로도 보내주는 중계자 역할을 해준다.

 

물론 아직 모르는 부분이 있다.

FastBufferWriter가 무엇인지, FastBufferReader는 무엇인지, 그리고 NetworkDelivery는 대체 뭔지, 아직 밝히지 않았다.


FastBuffer Writer/Reader, NetworkDelivery

사실 그렇게 거창한 내용은 없다.

 

위에서 짧게 언급했지만, 메시지에 값을 넣기 위해선 FastBufferWriter를 사용해야 한다.

왜 why.

네트워킹을 하는 것은 데이터가 내 컴퓨터를 벗어나 저 경계 너머 다른 컴퓨터로 가는 일이다. 그 가는 과정에서 어떤 험난한 일이 있을지 모르며, 와중에 해당 컴퓨터에 도착하더라도 그 컴퓨터는 이 도착한 데이터를 어떻게 처리해야하는지 하나도 모를지도 모른다.

따라서 전송할 값들을 모두 FastBufferWriter에 싣고 보낸다.

위에서 언급은 안했지만, '이 데이터를 이렇게 저렇게 처리하세요.' 하는 방법도 이 Writer에 싣고 보내게 된다.

그리고 그 '싣는 것'이 FastBufferWriter의 WriteValue 함수이다.

우리 데이터쿤은 아주 연약하답니다.

그리고 이 연약함 때문에 뒷따라 붙는 것이 NetworkDelivery라는 값이다.

// enum NetworkDelivery가 가질 수 있는 값.
Unreliable = 0
UnreliableSequenced = 1
Reliable = 2
ReliableSequenced = 3
ReliableFragmentedSequenced = 4

직관적인 내용이다.

Unreliable
: 도착 보장하지 않는다. 데이터쿤이 도착하지 않거나, 중복되서 도착하거나, 순서가 뒤바뀌어 도착할 수 있다.
그렇지만 빠르고 데이터 크기도 작다.
→ 실시간 위치 데이터에서 사용. 어차피 틱마다 받는 정보이기 때문에 몇 개 손실은 무시해도 금방 덮어씌워진다.

UnreliableSequenced
: 데이터가 손실될 수도 있다. 그래도 순서는 보장하겠다. 아직까진 빠르고 작다.
그저 누락과 무관하게 최신 데이터만 받으면 되는 경우.
→ 애니메이션 프레임 데이터. (중간에 손실이 발생하면 그 프레임은 뛰어넘어도 문제 없다.)

Reliable = 2
: 데이터 도착은 보장하겠다. 근데 순서는 모르겠다. 손실시 재전송 하기 때문에 오버헤드 발생.
→  게임 내 독립적인 이벤트.

ReliableSequenced = 3
: 데이터 도착도 보장하고 순서도 보장하겠다. 손실 나면 재전송까지 해준다. 당연히 이를 확인하기 위한 오버헤드.
→ 연속적인 명령, 채팅 메시지

ReliableFragmentedSequenced = 4
: 데이터 도착 보장. 순서 보장! 사장님이 미쳤어요. 데이터가 크다면, 여러 조각으로 분리해서 전송
모든 조각이 도착한 뒤에만 데이터가 처리된다.
→ 말 그대로 큰 데이터에 사용된다. 오버헤드는 물론 지연도 발생할 수 있겠다.

 

우리는 멀티 게임 초보 개발자이기에 ReliableSequenced로 두고 작업을 해도 무관하다. 더욱이 RPC를 사용하는 것이 아닌 Messaging 위주 개발이라면 하스스톤 같은 카드 게임이나, 체스 같은 보드 게임. 실시간성보다 순서가 중요한 게임을 다룰 확률이 높기에 더더욱 말이다.

앗, 근데 그러면 FastBufferReader는 무엇인가요.

 

보낼 때는 FastBufferWriter로 보내게 된다.

그리고, Netcode의 네트워크 처리를 거쳐 목표 컴퓨터에 도달하면 해당 데이터는 FastBufferReader로 전환되어, 이제 '읽을 수' 있게 된다.

편지라고 생각해보자. 당연히 이 편지를 쓸 수 있는 것은 '보내는 사람'이다. 그리고 읽을 수 있는 것은 '받는 사람'이다.

받는 사람이 write를 한다면 그것이 무슨 소용이겠는가.

참고로 'Fast'가 붙은 것에서 알 수 있듯이, '빨리 쓰고' '빨리 읽을 수 있게' 최적화 되어 있다.

당연히 읽는 것도 되고 쓰는 것도 되는 FastBuffer도 존재한다.

정확히는, FastBuffer 내에 FastBufferWriter와 FastBufferReader가 존재하며 각 기능에 맞춰 모드를 전환해가며 쓰는 것이다. 이에 대해선 곧 코드로 볼 날이 올 것이다.

 

자, 말이 길었다. 프로그래밍을 하자.

Managers 클래스에 임시로 구현했던 것을 구체화 시켰다.

using Unity.Collections;
using Unity.Netcode;
using UnityEngine;

public class ServerManager
{
    private void Register()
    {
        NetworkManager.Singleton.CustomMessagingManager.RegisterNamedMessageHandler("FromClient", OnReceived);
    }

    private void OnReceived(ulong clientID, FastBufferReader reader)
    {
        // 메시지를 받고나서 행동 처리
        Debug.Log("Server 메시지 받았습니다~");
    }

    private void Send()
    {
        // 아직 받는 사람 정보가 없다.
        ulong targetClientID = 0;
        // 아직 writer에는 아무런 정보가 안 실려 있다.
        FastBufferWriter writer = new FastBufferWriter(size: 128, allocator: Allocator.Temp);
        NetworkDelivery networkDelivery = NetworkDelivery.ReliableSequenced;

        NetworkManager.Singleton.CustomMessagingManager.SendNamedMessage("FromServer", targetClientID, writer, networkDelivery);
    }
}
using Unity.Collections;
using Unity.Netcode;
using UnityEngine;

public class ClientManager
{
    private void Register()
    {
        NetworkManager.Singleton.CustomMessagingManager.RegisterNamedMessageHandler("FromServer", OnReceived);
    }

    private void OnReceived(ulong clientID, FastBufferReader reader)
    {
        // 메시지를 받고나서 행동 처리
        Debug.Log("Client입니다. 메시지 받았어요~");
    }

    private void Send()
    {
        // 아직 받는 사람 정보가 없다.
        ulong targetClientID = 0;
        // 아직 writer에는 아무런 정보가 안 실려 있다.
        FastBufferWriter writer = new FastBufferWriter(size: 128, allocator: Allocator.Temp);
        NetworkDelivery networkDelivery = NetworkDelivery.ReliableSequenced;

        NetworkManager.Singleton.CustomMessagingManager.SendNamedMessage("FromClient", targetClientID, writer, networkDelivery);
    }
}

 

거의 똑같은 코드를 가진 ServerManager.cs와 ClientManager.cs이다.

코드를 읽어보면 알 수 있듯, ServerManager는 Client에서 오는 메시지("FromClient")를 받을 준비를 하면서, "FromServer" 메시지를 보낼 수 있다.

반대로 ClientManager는 Server에서 오는 메시지("FromServer")를 받을 준비를 하면서도, "FromClient" 메시지를 보낼 수 있다.

 

결국 서로에게 메시지를 보낼 수도 받을 수도 있단 뜻이다.

하지만 아직 받는 사람 정보가 없다.

 

서로에게 보낼 수도 받을 수도 있는데, 받는 사람은 없다는게 무슨 뜻일까?

 

지금의 구조는 ID가 0인 사람이 ID가 0인 사람에게 보내고 받는단 소리다.

정확히는, ID가 0인 사람의 Server가 ID가 0인 사람, 즉 자신의 Client에게 메세지를 주고 받는다는 얘기.

 

자, 두 명의 플레이어가 접속했고 각각의 ID는 0과 1이다.

그리고 0번 플레이어가 Host를 맡았기에 0번 플레이어는 Server 클래스와 Client 클래스가 모두 살아있지만,

그저 Client인 1번 플레이어는 Client 클래스만 동작할 수 있다.

※ Host, Client, Server에 대한 얘기는 다루지 않겠음.

 

우리의 목표는 0번 플레이어의 Server에서 1번 플레이어의 Client로 메시지를 전송하는 것이다.

 

이제 구현해야하는 내용들을 굵게, 기울여서 표시했다.

1. 접속 기능

2. 각각의 ID 확인

3. 0번 플레이어의 Server로부터 1번 플레이어의 Client로 메시지 전송

 

다음 챕터에서 구현을 해보자. 아마도 개념은 이 정도면 끝이다. 이제부터는 프로그래밍 위주로 돌아갈 것이다.