본문 바로가기
UNITY/유니티게임스쿨

프로토버퍼

by 램플릿 2024. 9. 23.

프로토 버퍼


 

프로토버퍼란?

 데이터는 특정한 데이터 형식을 갖춘 패킷 단위로 교환되며, 패킷은 다양한 종류의 데이터를 기계가 쓰고 읽기 편리하게 직렬화(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 . 를 명령어로 입력해본다.