UNITY/유니티게임스쿨

프로토버퍼

램플릿 2024. 9. 23. 10:30

프로토 버퍼


 

프로토버퍼란?

 데이터는 특정한 데이터 형식을 갖춘 패킷 단위로 교환되며, 패킷은 다양한 종류의 데이터를 기계가 쓰고 읽기 편리하게 직렬화(serialize)하여 나타낸다. 데이터를 저장하거나 기기 간에 주고받을 때 일정한 형식으로 직렬화함으로서 데이터의 손실을 방지하고 올바르게 해석할 수 있게 한다.

직렬화 Serialize - 프로토버퍼는 데이터를 바이트코드로 변환하기 때문에 효율적인 전송과 저장공간 절약에 도움이 된다. (작은 크기의 직렬화된 데이터 생성)
역직렬화 Deserialize - 바이너리 형식으로 저장되므로 빠르게 메모리 객체로 변환할 수 있다.

 

메시지 패킷에 대한 형식과 암호화에 대해 신경쓸 필요 없이 프로토 문법만을 이용해서 편리하게 데이터를 주고받을 수 있다.

 

프로토버퍼가 없으면 JSON으로 문자열단위의 무거운 통신을 수행해야하지만, 이 역할을 프로토버퍼가 해결해줌으로써 가볍고 빠른 패킷 사용, 인코딩과 디코딩을 간단히 할 수 있다.

 

🔗참고 문서 - 프로토버퍼 Basics
https://protobuf.dev/getting-started/csharptutorial/

🔗[C#서버] 구글 프로토 버퍼(Google Protobuf C#)
https://usingsystem.tistory.com/152

 

 

 

GoLang, Visual Studio Code 설치

https://lamplit.tistory.com/109

 

Go, Visual Studio Code 설치하기

GoLang 설치하기Go 설치🔗Go Programming Language 설치⇒ https://go.dev/dl/   Featured downloads에서 자신의 OS와 맞는 설치 파일을 다운받는다.   설치 프로그램을 실행하면 별도의 옵션 지정 없이 설치가

lamplit.tistory.com

🔗참고 문서 - Go 개발을 위한 Visual Studio Code 설치 및 구성
https://learn.microsoft.com/ko-kr/azure/developer/go/configure-visual-studio-code

 

 

 

프로트 버퍼 설치

🔗 protoc-28.2-win64.zip 다운로드 링크
https://github.com/protocolbuffers/protobuf/releases

 

  • Assets > Show all assets > protoc-28.2-win64.zip 다운로드 후 설치하고 싶은 경로에 압축풀기
  • 압축풀기한 폴더 안의 bin 경로 복사하기
  • '시스템 환경 변수 편집'을 검색하여  '시스템 속성 > 환경 변수 > 시스템 변수 > 새로 만들기'에 환경변수로 해당 경로를 입력.

        예시 ) 변수이름-Protoc , 변수값- C:\ProtoBuffer\protoc-28.2-win64\bin (이후 시스템변수의 'Path'를 편집하여 %Protoc% 를 추가해준다.)

 

  • git 프로젝트를 생성하고 clone한 뒤, VScode에서 해당 디렉토리를 열어준다.
  • 터미널을 열고(Terminal > New Terminal) 현재 위치에서 아래 문장 입력
> go mod init golangTCP
> go mod tidy

 

 

▽ 명령어 실행 예시 화면

 

 

 

확장자 .go 로 끝나는 이름의 예제 파일 만들어보기

(이름은 아무거나 상관 없음)

 

  • main.go 예제 코드
package main

import "fmt"

func main() {
	fmt.Println("Hello")
}

 

  • 터미널에서 실행한 결과 (go run '파일이름')

 

  • F5를 눌러 실행한 결과 (장점: 코드에 중단점을 걸어 과정을 확인할 수 있음)

 

 

 


 

NuGetForUnity


NuGet 사용을 위한 Unity Editor 세팅하기

 

  • 유니티 프로젝트 생성 후  Edit > Project Settings > Player > Other Settings > Configuration > Api Compatibility Level > .NET Framework로 변경.
🔗 NuGet이란?
https://learn.microsoft.com/ko-kr/nuget/what-is-nuget

 

 

 

 

NuGetForUnity 설치하기

🔗참고 링크 - Nuget을 유니티에서도 사용할 수 없을까?
https://velog.io/@yarogono/Unity-%EC%9C%A0%EB%8B%88%ED%8B%B0%EC%97%90%EC%84%9C-Nuget-%ED%8C%A8%ED%82%A4%EC%A7%80-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0

 

 

  • 위와 같은 오류 발생 시 유니티 재부팅하거나 아래 링크에서 유니티 패키지 파일 다운로드

⇒  https://github.com/GlitchEnzo/NuGetForUnity/releases  

 

Releases · GlitchEnzo/NuGetForUnity

A NuGet Package Manager for Unity. Contribute to GlitchEnzo/NuGetForUnity development by creating an account on GitHub.

github.com

 

 

 

 

 

NuGet For Unity 설치 완료 후 에디터 상단에 NuGet이 나타나면 Manage NuGet Packages에서 아래 세 가지 설치하기.

  • Google.Protobuf
  • Grpg.Core
  • Grpc.Tools

 

 

 

C# 스크립트를 생성하여 네임 스페이스에 'using Google.Protobuf;' 를 추가해보고 오류가 발생하지 않으면 정상 설치 완료.



 

🪄 VScode Terminal에서 프로토버퍼, grpc 설치하는 명령어

> go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
> go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest

 

> dotnet tool install -g Grpc.Tools

 

 

 


 

gRPC 서버 만들기


더보기

proto 파일 작성하기

  • 비주얼스튜디오 코드 > Open Folder... > 작업할 워크스페이스 선택 (새 폴더 'golangtcp' 생성 후 폴더선택)
  • 마우스 오른쪽 버튼 > New File... > 'main.go' 생성
  • 마우스 오른쪽 버튼 > New Folder... > 'protoc'  생성
  • 마우스 오른쪽 버튼 > New File... > 'game_message.proto' 생성
프로토버퍼 파일의 확장자는 .proto이다.

 

 

📄 game_message.proto 작성

syntax = "proto3";

package game;

option go_package = "game/messages";
option csharp_namespace = "GameMessages";

message PlayerPosition {
  int32 player_id = 1;
  float x = 2;
  float y = 3;
  float z = 4;
}

message GameState {
  repeated PlayerPosition players = 1;
}

service GameService {
  rpc UpdatePosition (PlayerPosition) returns (GameState) {}
}

 

  • 유니티에디터 > NuGet > Manage NuGet Packages에서 Grpc.Tools도 설치한 후 시스템 변수의 환경변수에 grpc_csharp_plugin.exe 파일의 위치를 추가.
변수이름 : GRPC
변수값 : (예시) C:\Users\User\Documents\0_Workspace\GameSchool\PROJECT\Test\TestProject\Packages\Grpc.Tools.2.67.0-pre1\tools\windows_x64\grpc_csharp_plugin.exe

 

 

 

  • 시스템변수 > 'Path' 편집 > 새로 만들기 > %GRPC%

 

 

  • 터미널에서 다음 명령어를 입력
> go get google.golang.org/grpc
> go get google.golang.org/protobuf

 

> cd protoc
> protoc --go_out=. --go_opt=paths=source_relative --go-grpc_out=. --go-rpc_opt=paths=source_relative game_messages.proto
> protoc --csharp_out=. --grpc_out=. --plugin=protoc-gen-grpc=$Env:GRPC game_messages.proto

(절대경로로 작성한 명령어 예시. 기능은 위와 동일)
> protoc --csharp_out=. --grpc_out=. --plugin=protoc-gen-grpc="C:\Users\User\Documents\0_Workspace\GameSchool\PROJECT\Test\TestProject\Packages\Grpc.Tools.2.67.0-pre1\tools\windows_x64\grpc_csharp_plugin.exe" game_messages.proto

 

(동작 안할 경우 아래를 다시 입력)
> go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
> go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest

 

 

더보기

배치 파일 작성하기

protoc폴더 하위에 protoGenerator.bat 파일 새로 만들기

 

 

📄 protoGenerator.bat 작성

@echo off
REM This batch file generates Go code from the Protobuf definition

REM Display current directory
echo Current directory: %CD%

REM Run protoc command
echo Generating Go code from Protobuf...
protoc --csharp_out=. --grpc_out=. --plugin=protoc-gen-grpc="%GRPC%" *.proto
protoc --go_out=. --go_opt=paths=source_relative --go-grpc_out=. --go-grpc_opt=paths=source_relative *.proto

REM Check if the command was successful
if %ERRORLEVEL% neq 0 (
    echo Error: Failed to generate Go code.
    exit /b 1
)

echo Go code generation completed successfully.

REM Pause to keep the command window open (optional)

 


배치 파일 실행하기

> .\protoGenerator.bat (배치파일 실행)

(오류가 있을 경우 - 필요한 파일 다운로드하는 과정. 빨간줄 오류 있을 시 아래를 실행해보세요)
> cd ..
> go mod tidy

 

 

 

잘 실행되면 아래와 같은 목록이 자동으로 생성된다.

 

 

 

GoLang으로 게임 서버 작성하기

아래와 같이 구조를 바꾸어준다. (cs파일들은 유니티 프로젝트로 옮겨줌)

 

📄 main.go 작성하기

package main

import (
	"context"
	"fmt"
	"log"
	"net"
	"sync"

	pb "golangtcp/messages"

	"google.golang.org/grpc"
)

type gameServer struct {
	pb.UnimplementedGameServiceServer
	mu      sync.Mutex
	players map[int32]*pb.PlayerPosition
}

func (s *gameServer) UpdatePosition(ctx context.Context, pos *pb.PlayerPosition) (*pb.GameState, error) {
	//스레드 세이프하게 락을 건다.
	s.mu.Lock()
    
    //함수가 끝날 때 락을 푼다.
	defer s.mu.Unlock()

	// server의 players의 pos를 업데이트한다.
	s.players[pos.PlayerId] = pos

	//저장할 스테이트를 생성한다.
	gameState := &pb.GameState{
		Players: make([]*pb.PlayerPosition, 0, len(s.players)),
	}
    
    //플레이어들의 목록을 append한다.
	for _, player := range s.players {
		gameState.Players = append(gameState.Players, player)
	}

	log.Printf("Updated position for player %d: (%f, %f, %f)", pos.PlayerId, pos.X, pos.Y, pos.Z)
	
    //gameState를 반환하고 error는 nil이다.
    return gameState, nil
}

func main() {
	//net 패키지 안에 listen을 호출하면 자동으로 tcp서버가 생성된다.
	lis, err := net.Listen("tcp", ":50051")
	if err != nil {
		log.Fatalf("Failed to listen: %v", err)
	}

	// grpc에 있는 API인(자동생성) NewServer를 만든다.
	s := grpc.NewServer()
	pb.RegisterGameServiceServer(s, &gameServer{
		players: make(map[int32]*pb.PlayerPosition),
	})

	fmt.Println("Game server is running on :50051")
	if err := s.Serve(lis); err != nil {
		log.Fatalf("Failed to serve: %v", err)
	}
}

 

(pb "경로" 부분이 오류가 난다면 go.mod 파일 최상단의 module - 부분을 확인해보세요)

 

 

📄 유니티에서 GameClient.cs 작성

using UnityEngine;
using Grpc.Core;
using GameMessages;
using System.Threading.Tasks;
using System.Collections.Generic;

public class GameClient : MonoBehaviour
{
    private GameService.GameServiceClient client;
    private Channel channel;
    public int playerId = 1;  // Set this to a unique value for each player
    private Dictionary<int, GameObject> playerObjects = new Dictionary<int, GameObject>();

    void Start()
    {
        channel = new Channel("localhost:50051", ChannelCredentials.Insecure);
        client = new GameService.GameServiceClient(channel);

        // Start position updates
        InvokeRepeating("UpdatePosition", 0f, 0.1f);
    }

    async Task UpdatePosition()
    {
        Vector3 position = transform.position;
        PlayerPosition playerPos = new PlayerPosition
        {
            PlayerId = playerId,
            X = position.x,
            Y = position.y,
            Z = position.z
        };

        try
        {
            GameState gameState = await client.UpdatePositionAsync(playerPos);
            UpdatePlayerPositions(gameState);
        }
        catch (RpcException e)
        {
            Debug.LogError($"RPC failed: {e.Status}");
        }
    }

    void UpdatePlayerPositions(GameState gameState)
    {
        foreach (var player in gameState.Players)
        {
            if (player.PlayerId != playerId)
            {
                if (!playerObjects.TryGetValue(player.PlayerId, out GameObject playerObject))
                {
                    playerObject = CreatePlayerObject(player.PlayerId);
                    playerObjects[player.PlayerId] = playerObject;
                }

                playerObject.transform.position = new Vector3(player.X, player.Y, player.Z);
            }
        }
    }

    GameObject CreatePlayerObject(int playerId)
    {
        GameObject playerObject = GameObject.CreatePrimitive(PrimitiveType.Sphere);
        playerObject.name = $"Player_{playerId}";
        return playerObject;
    }

    void OnDestroy()
    {
        CancelInvoke("UpdatePosition");
        channel.ShutdownAsync().Wait();
    }
}

 

 

 

서버 실행하기

> cd ..
> go run main.go
  •  종료는 ctrl+C

 

 


 

TCP 서버 만들기


현재 위의 코드가 작동하지 않아 gRPC 대신 임시로 TCP 서버 가동.  ~~~새로 시작~~~

 

proto 파일 작성하기

  • 비주얼스튜디오 코드 > Open Folder... > 작업할 워크스페이스 선택 (새 폴더 'golangtcp' 생성 후 폴더선택)
  • 마우스 오른쪽 버튼 > New File... > 'main.go' 생성
  • 마우스 오른쪽 버튼 > New Folder... > 'protoc'  생성
  • 마우스 오른쪽 버튼 > New File... > 'message.proto' 생성


📄 messages.proto 파일 생성

syntax = "proto3";

package game;

option go_package = "golangtcp/messages";

message PlayerPosition {
  float x = 1;
  float y = 2;
  float z = 3;
  string player_id = 4;
}

message ChatMessage {
  string sender = 1;
  string content = 2;
}

message GameMessage {
  oneof message {
    PlayerPosition player_position = 1;
    ChatMessage chat = 2;
  }
}

 

 

 

배치 파일 작성하기

protoc폴더 하위에 protoGenerator.bat 파일 새로 만들기

 

 

📄 protoGenerator.bat 작성

@echo off
REM This batch file generates Go code from the Protobuf definition

REM Display current directory
echo Current directory: %CD%

REM Run protoc command
echo Generating Go code from Protobuf...
protoc --csharp_out=. --grpc_out=. --plugin=protoc-gen-grpc="%GRPC%" *.proto
protoc --go_out=. --go_opt=paths=source_relative --go-grpc_out=. --go-grpc_opt=paths=source_relative *.proto

REM Check if the command was successful
if %ERRORLEVEL% neq 0 (
    echo Error: Failed to generate Go code.
    exit /b 1
)

echo Go code generation completed successfully.

REM Pause to keep the command window open (optional)

 

 

 

 

배치 파일 실행하기

> .\protoGenerator.bat (배치파일 실행)

(오류가 있을 경우 - 필요한 파일 다운로드하는 과정. 빨간줄 오류 있을 시 아래를 실행해보세요)
> cd ..
> go mod tidy

 

 

 

📄 main.go

package main

import (
	"encoding/binary"
	"fmt"
	"log"
	"net"

	pb "golangtcp/messages"

	"google.golang.org/protobuf/proto"
)

func main() {
	listener, err := net.Listen("tcp", ":8888")
	if err != nil {
		log.Fatalf("Failed to listen: %v", err)
	}
	defer listener.Close()
	fmt.Println("Server is listening on :8888")

	for {
		conn, err := listener.Accept()
		if err != nil {
			log.Printf("Failed to accept connection: %v", err)
			continue
		}
		go handleConnection(conn)
	}
}

func handleConnection(conn net.Conn) {
	defer conn.Close()
	for {
		// 메시지 길이를 먼저 읽습니다 (4바이트)
		lengthBuf := make([]byte, 4)
		_, err := conn.Read(lengthBuf)
		if err != nil {
			log.Printf("Failed to read message length: %v", err)
			return
		}
		length := binary.BigEndian.Uint32(lengthBuf)

		// 메시지 본문을 읽습니다
		messageBuf := make([]byte, length)
		_, err = conn.Read(messageBuf)
		if err != nil {
			log.Printf("Failed to read message body: %v", err)
			return
		}

		// Protocol Buffers 메시지를 파싱합니다
		message := &pb.PlayerPosition{}
		err = proto.Unmarshal(messageBuf, message)
		if err != nil {
			log.Printf("Failed to unmarshal message: %v", err)
			continue
		}

		// 메시지 처리
		processMessage(message)

		// 응답 메시지 생성 및 전송 (예: 에코)
		response, err := proto.Marshal(message)
		if err != nil {
			log.Printf("Failed to marshal response: %v", err)
			continue
		}

		// 메시지 길이를 먼저 보냅니다
		binary.BigEndian.PutUint32(lengthBuf, uint32(len(response)))
		conn.Write(lengthBuf)

		// 메시지 본문을 보냅니다
		conn.Write(response)
	}
}

func processMessage(message *pb.PlayerPosition) {
}

 

 

📄 TcpProtobufClient.cs

using UnityEngine;
using System;
using System.Net.Sockets;
using System.Threading;
using Google.Protobuf;
using Game;

public class TcpProtobufClient : MonoBehaviour
{
    private TcpClient tcpClient;
    private Thread receiveThread;
    private NetworkStream stream;
    private bool isRunning = false;

    private const string SERVER_IP = "127.0.0.1";
    private const int SERVER_PORT = 8888;

    void Start()
    {
        ConnectToServer();
    }

    void ConnectToServer()
    {
        try
        {
            tcpClient = new TcpClient(SERVER_IP, SERVER_PORT);
            stream = tcpClient.GetStream();
            isRunning = true;

            receiveThread = new Thread(new ThreadStart(ReceiveData));
            receiveThread.Start();

            Debug.Log("Connected to server.");
        }
        catch (Exception e)
        {
            Debug.LogError($"Error connecting to server: {e.Message}");
        }
    }

    void ReceiveData()
    {
        byte[] lengthBuffer = new byte[4];
        try
        {
            while (isRunning)
            {
                // 메시지 길이를 먼저 읽습니다
                if (stream.Read(lengthBuffer, 0, 4) != 4)
                {
                    throw new Exception("Failed to read message length");
                }
                int messageLength = BitConverter.ToInt32(lengthBuffer, 0);

                // 메시지 본문을 읽습니다
                byte[] messageBuffer = new byte[messageLength];
                if (stream.Read(messageBuffer, 0, messageLength) != messageLength)
                {
                    throw new Exception("Failed to read message body");
                }

                GameMessage message = GameMessage.Parser.ParseFrom(messageBuffer);
                ProcessMessage(message);
            }
        }
        catch (Exception e)
        {
            Debug.LogError($"Error receiving data: {e.Message}");
            isRunning = false;
        }
    }

    void ProcessMessage(GameMessage message)
    {
        // Unity의 메인 스레드에서 실행
        UnityMainThreadDispatcher.Instance.Enqueue(() =>
        {
            switch (message.MessageCase)
            {
                case GameMessage.MessageOneofCase.PlayerPosition:
                    var pos = message.PlayerPosition;
                    Debug.Log($"Received player position: Player {pos.PlayerId} at ({pos.X}, {pos.Y}, {pos.Z})");
                    // 여기서 플레이어 위치 업데이트 로직을 구현할 수 있습니다
                    break;
                case GameMessage.MessageOneofCase.Chat:
                    var chat = message.Chat;
                    Debug.Log($"Received chat: {chat.Sender} says: {chat.Content}");
                    // 여기서 채팅 메시지 표시 로직을 구현할 수 있습니다
                    break;
                default:
                    Debug.Log("Received unknown message type");
                    break;
            }
        });
    }

    public void SendPlayerPosition(string playerId, float x, float y, float z)
    {
        var position = new PlayerPosition
        {
            PlayerId = playerId,
            X = x,
            Y = y,
            Z = z
        };
        var message = new GameMessage
        {
            PlayerPosition = position
        };
        SendMessage(message);
    }

    public void SendChatMessage(string sender, string content)
    {
        var chat = new ChatMessage
        {
            Sender = sender,
            Content = content
        };
        var message = new GameMessage
        {
            Chat = chat
        };
        SendMessage(message);
    }

    private void SendMessage(GameMessage message)
    {
        if (tcpClient != null && tcpClient.Connected)
        {
            byte[] messageBytes = message.ToByteArray();
            byte[] lengthBytes = BitConverter.GetBytes(messageBytes.Length);

            // 메시지 길이를 먼저 보냅니다
            stream.Write(lengthBytes, 0, 4);
            // 메시지 본문을 보냅니다
            stream.Write(messageBytes, 0, messageBytes.Length);
        }
    }

    void OnDisable()
    {
        isRunning = false;
        if (receiveThread != null) receiveThread.Join();
        if (stream != null) stream.Close();
        if (tcpClient != null) tcpClient.Close();
    }
}

 

 

📄 UnityMainThreadDispatcher.cs

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

public class UnityMainThreadDispatcher : MonoBehaviour
{
    private static readonly Queue<Action> _executionQueue = new Queue<Action>();

    public static UnityMainThreadDispatcher Instance { get; private set; }

    private void Awake()
    {
        if (Instance == null)
        {
            Instance = this;
            DontDestroyOnLoad(this.gameObject);
        }
        else
        {
            Destroy(gameObject);
        }
    }

    public void Update()
    {
        lock(_executionQueue)
        {
            while (_executionQueue.Count > 0)
            {
                _executionQueue.Dequeue().Invoke();
            }
        }
    }

    public void Enqueue(Action action)
    {
        lock(_executionQueue)
        {
            _executionQueue.Enqueue(action);
        }
    }
}

 

 

 

📄 protoGenerator.bat

@echo off
REM This batch file generates Go code from the Protobuf definition

REM Display current directory
echo Current directory: %CD%

REM Run protoc command
echo Generating Go code from Protobuf...
protoc --csharp_out="C:\Users\User\Documents\0_Workspace\GameSchool\PROJECT\Test\TestProject\Assets\Scripts" *.proto
protoc --go_out=../../ *.proto

REM Check if the command was successful
if %ERRORLEVEL% neq 0 (
    echo Error: Failed to generate Go code.
    exit /b 1
)

echo Go code generation completed successfully.

REM Pause to keep the command window open (optional)

 

 

.proto 파일을 수정한 다음에는 꼭 저장한 다음에 .\protoGenerator.bat를 실행해주어야한다.

 


서버 실행하기

> cd ..
> go run main.go
  •  ctrl+C 누르면 서버 종료.

go run main.go 로 서버가 실행되지 않으면

go run . 를 명령어로 입력해본다.