프로토 버퍼
프로토버퍼란?
데이터는 특정한 데이터 형식을 갖춘 패킷 단위로 교환되며, 패킷은 다양한 종류의 데이터를 기계가 쓰고 읽기 편리하게 직렬화(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 설치 및 구성
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 설치하기
- 유니티 프로젝트에서 Window > Package Manager > + > Install package from git URL... > https://github.com/GlitchEnzo/NuGetForUnity.git?path=/src/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
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' 생성
📄 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 . 를 명령어로 입력해본다.
'UNITY > 유니티게임스쿨' 카테고리의 다른 글
CSV Parser (0) | 2024.07.19 |
---|---|
유니티 UI 이해 :: UI 스프라이트, 한글폰트 설정, 버튼 작성하기 (1) | 2024.06.10 |
Unity 비동기 프로그래밍 :: Node.js 설치, 간단한 비동기 서버 구축 (0) | 2024.06.07 |
[유니티게임스쿨 TIL] 유니티 기본 :: 캐릭터 애니메이션 설정하기, 트리거 활용하기 (0) | 2024.06.05 |
[유니티게임스쿨 TIL] C# 기초 문법 이해 :: 파일 입출력, Collection, LINQ (0) | 2024.06.04 |