Protobuf
공식 웹사이트: Protocol Buffers | Google Developers
소개
공식 튜토리얼: Protocol Buffer Basics: Go | Protocol Buffers | Google Developers
Protocol Buffers는 구글이 2008 년에 오픈소스로 공개한 언어 및 프로토콜과 무관하며 확장 가능한 구조화된 데이터 직렬화 메커니즘으로, 언패키징 및 패키징 시 더 빠르며 주로 RPC 분야 통신 관련에 사용됩니다. 데이터의 구조화 방식을 정의한 후 특수하게 생성된 소스 코드를 사용하여 구조화된 데이터를 다양한 데이터 스트림에 쉽게 쓰고 다양한 데이터 스트림에서 읽을 수 있으며 다양한 언어에서 사용할 수 있습니다. 이하 Protocol Buffers는 protobuf로 통칭합니다.
protobuf는 비교적 인기가 많으며, 특히 Go 분야에서는 gRPC 가 이를 프로토콜 전송의 직렬화 메커니즘으로 사용합니다.
문법
먼저 예제를 통해 protobuf 파일이 대체로 어떤 모양인지 살펴보겠습니다. 전반적으로 문법은 매우 간단하며 10 여 분이면 익힐 수 있습니다. 다음은 search.proto라는 파일의 예제이며, protobuf의 파일 확장자는 .proto입니다.
syntax = "proto3";
message SearchRequest {
string query = 1;
string number = 2;
}
message SearchResult {
string data = 1;
}
service SearchService {
rpc Search(SearchRequest) returns(SearchResult);
}- 첫 줄
syntax = "proto3";는proto3문법을 사용함을 나타내며, 기본적으로proto3문법을 사용합니다. message는 구조체와 유사하게 선언되며,proto의 기본 구조입니다.SearchRequest에는 세 개의 필드가 정의되어 있으며, 각 필드에는 이름과 유형이 있습니다.service에는 하나의 서비스가 정의되어 있으며, 하나의 서비스에는 하나 이상의 rpc 인터페이스가 포함될 수 있습니다.- rpc 인터페이스는 반드시 하나만 있어야 하며 매개변수와 반환값이 있어야 하며, 그 유형은 반드시
message여야 하고 기본 유형일 수 없습니다.
또한 주의할 점은 proto 파일의 각 줄末尾에는 반드시 세미콜론이 있어야 합니다.
주석
주석 스타일은 Go 와 완전히 동일합니다.
syntax = "proto3";
/* 주석
* 주석 */
message SearchRequest {
string query = 1; //주석
string number = 2;
}유형
유형 수식어는 message에서만 나타날 수 있으며 단독으로 나타날 수 없습니다.
기본 유형
| proto Type | Go Type |
|---|---|
| double | float64 |
| float | float32 |
| int32 | int32 |
| int64 | int64 |
| uint32 | uint32 |
| uint64 | uint64 |
| sint32 | int32 |
| sint64 | int64 |
| fixed32 | uint32 |
| fixed64 | uint64 |
| sfixed32 | int32 |
| sfixed64 | int64 |
| bool | bool |
| string | string |
| bytes | []byte |
배열
기본 유형 앞에 repeated 수식어를 추가하면 배열 유형을 나타내며, Go 의 슬라이스에 해당합니다.
message Company {
repeated string employee = 1;
}map
protobuf 에서 map 유형을 정의하는 형식은 다음과 같습니다.
map<key_type, value_type> map_field = N;key_type은 숫자 또는 문자열이어야 하며, value_type은 유형 제한이 없습니다. 예제를 살펴보겠습니다.
message Person {
map<string, int64> cards = 1;
}필드
사실 proto 는 전통적인 키 - 값 유형이 아니며, 선언된 proto 파일에는 구체적인 데이터가 나타나지 않습니다. 각 필드의 = 뒤에 오는 것은 현재 message의 고유 번호여야 하며, 이러한 번호는 바이너리 메시지 본문에서 이러한 필드를 식별하고 정의하는 데 사용됩니다. 번호는 1 부터 시작하며, 1-15 번은 1 바이트를 차지하고 16-2047 번은 2 바이트를 차지하므로 자주 나타나는 필드는 1-15 번으로 할당하여 공간을 절약하고, 이후 자주 나타날 수 있는 필드를 위해 일부 공간을 남겨두어야 합니다.
message의 필드는 다음 규칙을 따라야 합니다.
singular: 기본적으로 해당 유형의 필드이며, 구조가 잘 정의된message에서는 해당 필드가 0 개 또는 1 개만 있어야 합니다. 즉, 동일한 필드가 반복적으로 존재할 수 없습니다. 다음과 같이 선언하면 오류가 발생합니다.protobufsyntax = "proto3"; message SearchRequest { string query = 1; string number = 2; string number = 3;//필드 중복 }optional:singular와 유사하지만 필드 값이 설정되었는지 명시적으로 확인할 수 있으며, 다음 두 가지 상황이 있을 수 있습니다.set: 직렬화됩니다.unset: 직렬화되지 않습니다.
repeated: 이러한 유형의 필드는 0 회 이상 나타날 수 있으며, 반복 값은 순서대로 유지됩니다 (말하자면 배열이며, 동일 유형의 값이 여러 번 반복적으로 나타날 수 있으며 나타나는 순서대로 유지되는 것이 바로 인덱스입니다).map: 키 - 값 쌍 유형의 필드로, 선언 방식은 다음과 같습니다.protobufmap<string,int32> config = 3;
보존 필드
reserve 키워드를 사용하여 보존 필드를 선언할 수 있으며, 보존 필드 번호가 선언된 후에는 다른 필드의 번호와 이름으로 더 이상 사용할 수 없으며 컴파일 시에도 오류가 발생합니다. 구글 공식에서 제공한 답변은 다음과 같습니다: 만약 proto 파일의 새 버전에서 일부 번호가 삭제되면, 미래에 다른 사용자가 이러한 삭제된 번호를 재사용할 수 있지만, 만약 이전 버전의 번호로 돌아가면 필드에 해당하는 번호가 일치하지 않아 오류가 발생합니다. 보존 필드는 컴파일 시기에 이러한 알림 역할을 하여 해당 보존 사용 필드를 사용할 수 없음을 알려주며, 그렇지 않으면 컴파일이 통과되지 않습니다.
syntax = "proto3";
message SearchRequest {
string query = 1;
string number = 2;
map<string, int32> config = 3;
repeated string a = 4;
reserved "a"; //구체적인 이름의 필드를 보존 필드로 선언
reserved 1 to 2; //번호 시퀀스를 보존 필드로 선언
reserved 3,4; //선언
}이렇게 되면 이 파일은 컴파일을 통과하지 못합니다.
사용 중지된 필드
필드가 사용 중지된 경우 다음과 같이 작성할 수 있습니다.
message Body {
string name = 1 [deprecated = true];
}열거형
열거 상수를 선언하고 필드의 유형으로 사용할 수 있으며, 주의할 점은 열거 항목의 첫 번째 요소는 반드시 0 이어야 한다는 것입니다. 왜냐하면 열거 항목의 기본값은 첫 번째 요소이기 때문입니다.
syntax = "proto3";
enum Type {
GET = 0;
POST = 1;
PUT = 2;
DELETE = 3;
}
message SearchRequest {
string query = 1;
string number = 2;
map<string, int32> config = 3;
repeated string a = 4;
Type type = 5;
}열거 항목 내부에 동일한 값의 열거 항목이 있는 경우 열거 별명을 사용할 수 있습니다.
syntax = "proto3";
enum Type {
option allow_alias = true; //별명 사용 허용 구성 항목 활성화 필요
GET = 0;
GET_ALIAS = 0; //GET 열거 항목의 별명
POST = 1;
PUT = 2;
DELETE = 3;
}
message SearchRequest {
string query = 1;
string number = 2;
map<string, int32> config = 3;
repeated string a = 4;
Type type = 5;
}중첩 메시지
message Outer { // 레벨 0
message MiddleAA { // 레벨 1
message Inner { // 레벨 2
int64 ival = 1;
bool booly = 2;
}
}
message MiddleBB { // 레벨 1
message Inner { // 레벨 2
int32 ival = 1;
bool booly = 2;
}
}
}message 안에 message를 중첩하여 선언할 수 있으며, 중첩 구조체와 유사합니다.
Package
protobuf 파일에 선택적 패키지 수식어를 추가하여 프로토콜 메시지 유형 간의 이름 충돌을 방지할 수 있습니다.
package foo.bar;
message Open { ... }그런 다음 메시지 유형을 정의할 때 필드에서 패키지 이름을 사용할 수 있습니다.
message Foo {
...
foo.bar.Open open = 1;
...
}Import
가져오기를 사용하면 여러 protobuf 파일이 정의를 공유할 수 있으며, 문법은 다음과 같으며 가져올 때 파일 확장자를 생략할 수 없습니다.
import "a/b/c.proto";가져올 때는 모두 상대 경로를 사용하며, 이 상대 경로는 가져오는 파일과 가져오는 파일의 상대 경로를 가리키는 것이 아니라 protoc 컴파일러가 코드를 생성할 때 지정된 스캔 경로에 따라 달라집니다. 다음과 같은 파일 구조가 있다고 가정합니다.
pb_learn
│ common.proto
│
├─monster
│ monster.proto
│
└─player
health.proto
player.protoplayer 디렉토리 부분의 코드만 생성하고 스캔 경로를 player 디렉토리만 지정한 경우, health.proto와 player.proto 간의 상호 가져오기는 단일 파일 이름으로 직접 작성할 수 있습니다. 예를 들어 player.proto가 health.proto를 가져오는 경우입니다.
import "health.proto";만약 이때 player.proto가 common.proto나 monster 디렉토리 아래의 파일을 가져오는 경우 컴파일이 실패하므로 아래 작성 방식은 완전히 잘못되었습니다. 컴파일러가 이러한 파일을 찾을 수 없기 때문입니다.
import "../common.proto"; // 잘못된 작성 방식TIP
덧붙여 말하면, .., . 이러한 기호는 가져오기 경로에 나타날 수 없습니다.
컴파일 시 pb_learn을 스캔 경로로 지정한 경우 상대 경로를 통해 다른 디렉토리의 파일을 가져올 수 있으며, 실제로 가져오는 경로는 해당 파일의 절대 주소가 pb_learn에 대한 상대 주소입니다. 아래 player.proto가 다른 파일을 가져오는 예제를 살펴보겠습니다.
import "common.proto";
imrpot "monster/monster.proto";
import "player/health.proto";동일한 디렉토리 아래에 있는 health.proto도 이때 상대 경로를 사용해야 합니다. 따라서 프로젝트에서는 일반적으로 모든 protobuf 파일을 보관하기 위해 별도의 폴더를 생성하고 컴파일 시 이를 스캔 경로로 지정하며, 해당 디렉토리 아래의 모든 가져오기 동작도 이를 기반으로 한 상대 경로입니다.
TIP
goland 편집기를 사용하는 경우, 직접 생성한 protobuf 디렉토리는 기본적으로 해석할 수 없어 빨간색으로 표시되는 상황이 발생합니다. goland 가 인식하도록 하려면 수동으로 스캔 경로를 설정해야 하며, 그 원리는 위에서 설명한 것과 완전히 동일합니다. 설정 방법은 다음과 같습니다. 아래 설정을 엽니다.
File | Settings | Languages & Frameworks | Protocol BuffersImport Paths에서 수동으로 스캔 경로를 추가하며, 이 스캔 경로는 컴파일 시 지정한 경로와 일치해야 합니다.

Any
Any 유형을 사용하면 proto 정의 없이 메시지를 임베디드 유형으로 사용할 수 있으며, 구글에서 정의한 유형을 직접 가져올 수 있습니다. 이는 기본 제공되며 수동으로 작성할 필요가 없습니다.
import "google/protobuf/any.proto";
message ErrorStatus {
string message = 1;
repeated google.protobuf.Any details = 2;
}구글은 또한 매우 많은 다른 유형을 미리 정의했으며, 자세한 내용은 protobuf/ptypes at master · golang/protobuf (github.com) 에서 확인할 수 있습니다. 주로 다음이 포함됩니다.
- 기본 유형의 캡슐화
- 시간 유형
- Duration 유형
이들의 protobuf 정의는 protoc 컴파일러의 include 디렉토리에 있어야 합니다.
OneOf
여기서 공식 문서에서 제공한 설명은 너무 번거롭습니다. 쉽게 말하면 필드가 전송 시 여러 가능한 유형이 있지만 최종적으로 하나의 유형만 사용될 수 있음을 나타내며, 내부에 repeated로 수식된 필드가 나타날 수 없습니다. 이는 C 언어의 union과 유사합니다.
message Stock {
// Stock 특정 데이터
}
message Currency {
// Currency 특정 데이터
}
message ChangeNotification {
int32 id = 1;
oneof instrument {
Stock stock = 2;
Currency currency = 3;
}
}Service
service 키워드로 RPC 서비스를 정의할 수 있으며, 하나의 RPC 서비스에는若干个 rpc 인터페이스가 포함되고 인터페이스는 unary 인터페이스와 스트림 인터페이스로 나뉩니다.
message Body {
string name = 1;
}
service ExampleService {
rpc DoSomething(Body) returns(Body);
}스트림 인터페이스는 다시 단방향 스트림과 양방향 스트림으로 나뉘며, 일반적으로 stream 키워드로 수식합니다. 아래 예제를 살펴보겠습니다.
message Body {
string name = 1;
}
service ExampleService {
// 클라이언트 스트림
rpc DoSomething(stream Body) returns(Body);
// 서버 스트림
rpc DoSomething1(Body) returns(stream Body);
// 양방향 스트림
rpc DoSomething2(stream Body) returns(stream Body);
}스트림이란 하나의 연결에서 장기간 상호 데이터를发送하는 것을 말하며, unary 인터페이스처럼 간단한一问一答 방식이 아닙니다.
Empty
empty 는 실제로 빈 message로, Go 의 빈 구조체에 해당하며 필드를 수식하는 데는 거의 사용되지 않으며 주로 어떤 rpc 인터페이스에 매개변수가 없거나 반환값이 없음을 나타내는 데 사용됩니다.
syntax = "proto3";
import "google/protobuf/empty.proto";
service EmptyService {
rpc Do(google.protobuf.Empty) returns(google.protobuf.Empty);
}Option
option 은 일반적으로 protobuf의 일부 동작을 제어하는 데 사용됩니다. 예를 들어 Go 언어 소스 코드 생성 패키지를 제어하려면 다음과 같이 선언할 수 있습니다.
option go_package = "github/jack/sample/pb_learn;pb_learn"세미콜론 앞은 코드 생성 후 다른 소스 파일의 가져오기 경로이며, 세미콜론 뒤는 해당 생성 파일의 패키지 이름입니다.
일부 최적화를 할 수 있으며, 다음 몇 가지 사용 가능한 값이 있으며 반복적으로 선언할 수 없습니다.
SPEED, 최적화 정도가 가장 높으며 생성된 코드 볼륨이 가장 크며, 기본적으로 이것입니다.CODE_SIZE, 코드 생성 볼륨을 줄이지만 리플렉션을 사용하여 직렬화합니다.LIFE_RUNTIME, 코드 볼륨이 가장 작지만 일부 기능이 부족합니다.
다음은 사용 사례입니다.
option optimize_for = SPEED;이 외에도 option 은 message와 enum에 일부 메타 정보를 추가할 수 있으며, 리플렉션을 사용하여 이러한 정보를 가져올 수 있습니다. 이는 매개변수 검증 시 특히 유용합니다.
컴파일
컴파일은 즉 코드 생성으로, 위에서는 protobuf 파일만 정의했으며 실제로 사용할 때는 특정 언어 소스 코드로 변환해야 사용할 수 있습니다. 우리는 protoc 컴파일러를 통해 이를 완료하며, 이는 여러 언어를 지원합니다.

설치
컴파일러를 다운로드하려면 protocolbuffers/protobuf: Protocol Buffers - Google's data interchange format (github.com) 에서 최신 버전의 Release 를 다운로드하면 됩니다. 일반적으로 압축 파일입니다.
protoc-25.1-win64
│ readme.txt
│
├─bin
│ protoc.exe
│
└─include
└─google
└─protobuf
│ any.proto
│ api.proto
│ descriptor.proto
│ duration.proto
│ empty.proto
│ field_mask.proto
│ source_context.proto
│ struct.proto
│ timestamp.proto
│ type.proto
│ wrappers.proto
│
└─compiler
plugin.proto다운로드 완료 후 bin 디렉토리를 환경 변수에 추가하여 protoc 명령을 사용할 수 있도록 합니다. 완료 후 버전을 확인하며, 정상적으로 출력되면 설치가 성공한 것입니다.
$ protoc --version
libprotoc 25.1다운로드한 컴파일러는 기본적으로 Go 언어를 지원하지 않습니다. Go 언어 코드 생성은 별도의 실행 파일이며, 다른 언어는 모두 함께 통합되어 있으므로 protocbuf 정의를 Go 언어 소스 코드로 번역하는 Go 언어 플러그인을 설치합니다.
$ go install google.golang.org/protobuf/cmd/protoc-gen-go@latestgRPC 서비스 코드를 생성해야 한다면 다음 플러그인도 설치합니다.
$ go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest설치 후 버전을 확인합니다.
$ protoc-gen-go-grpc --version
protoc-gen-go-grpc 1.3.0
$ protoc-gen-go --version
protoc-gen-go.exe v1.31.0이러한 플러그인도 별도의 바이너리 파일이지만 protoc를 통해서만 호출할 수 있으며 단독으로 실행할 수 없습니다.
(this program should be run by protoc, not directly)이 외에도 openapi 인터페이스 문서를 생성하는 플러그인 등 많은 다른 플러그인이 있으며, 관심이 있다면 직접 검색해 볼 수 있습니다.
생성
이전 예제를 계속 사용하여 설명하며, 구조는 다음과 같습니다.
pb_learn
│ common.proto
│
├─monster
│ monster.proto
│
└─player
health.proto
player.proto코드 생성의 경우 총 세 가지 매개변수를 지정해야 합니다.
- 스캔 경로: 컴파일러가 어디서
protobuf파일을 찾고 가져오기 경로를 해석하는지 지시합니다. - 생성 경로: 컴파일된 파일을 어디에 둘지 지정합니다.
- 대상 파일: 어떤 대상 파일이 컴파일될지 지정합니다.
시작하기 전에 protobuf 파일의 go_package 설정이 올바른지 확인해야 합니다. protoc -h를 통해 지원되는 매개변수를 확인하며, 가장 흔히 사용되는 것은 -I 또는 --proto_path로, 여러 번 사용하여 여러 스캔 경로를 지정할 수 있습니다. 예를 들어:
$ protoc --proto_path=./pb_learn --proto_path=./third_party스캔 경로만 지정하는 것만으로는 충분하지 않으며, 생성 경로와 대상 protobuf 파일도 지정해야 합니다. 여기서는 go 파일을 생성하므로 --go_out 매개변수를 사용하며, 이는 이전에 다운로드한 protoc-gen-go 플러그인에서 지원합니다.
$ cd pb_learn
$ protoc --proto_path=. --go_out=. common.proto
$ ls
common.pb.go common.proto monster/ player/--go_out 매개변수는 생성 경로를 지정하며, .은 현재 경로를 나타내고 common.proto는 컴파일할 파일을 지정합니다. grpc 코드를 생성하려면 (사전 grpc 플러그인 설치 필요) --go-grpc_out 매개변수를 추가할 수 있습니다 (protobuf 파일에 service가 정의되어 있지 않으면 해당 파일이 생성되지 않습니다).
$ protoc --proto_path=. --go_out=. --go-grpc_out=. common.proto
$ ls
common.pb.go common.proto common_grpc.pb.go monster/ player/common.pb.go는 생성된 protobuf 유형 정의이며, common_grpc.pb.go는 생성된 gRPC 코드로, 전자에 기반합니다. 해당 언어의 정의가 생성되지 않으면 gRPC 코드도 생성할 수 없습니다.
디렉토리 아래의 모든 protobuf 파일을 컴파일하려면 와일드카드 *를 사용할 수 있습니다. 예를 들어:
$ protoc --proto_path=. --go_out=.. common.proto --go-grpc_out=. ./*.proto모든 파일을 포함하려면 ** 와일드카드를 사용할 수 있습니다. 예를 들어 ./**/*.proto입니다.
$ protoc --proto_path=. --go_out=.. common.proto --go-grpc_out=. ./**/*.proto하지만 이 방법은 이러한 와일드카드를 지원하는 shell 에만 적용됩니다. 예를 들어 Windows 에서 cmd 나 powershell 은 이러한 작성 방식을 지원하지 않습니다.
D> protoc --proto_path=. --go_out=.. common.proto --go-grpc_out=. ./**/*.proto
Invalid file name pattern or missing input file "./**/*.proto"다행히 gitbash 는 Linux 의 많은 명령을 지원하며 Windows 도 이러한 문법을 지원하도록 할 수 있습니다. 매번 반복되는 명령을 작성해야 하는 것을 피하기 위해 makefile 안에 넣을 수 있습니다.
.PHONY: all
proto_gen:
protoc --proto_path=. \
--go_out=paths=source_relative:. \
--go-grpc_out=paths=source_relative:. \
./**/*.proto ./*.protopaths=source_relative:.가 추가된 것을 볼 수 있습니다. 이는 파일 생성의 경로 모드를 설정하는 것으로, 총 다음 몇 가지 옵션이 있습니다.
paths=import, 기본적으로 이것이며, 파일은import가 지정한 디렉토리에 생성되며, 모듈 경로일 수도 있습니다. 예를 들어protos/buzz.proto라는 파일이 있고paths=example.com/project/protos/fizz를 지정하면 최종적으로example.com/project/protos/fizz/buzz.pb.go가 생성됩니다.module=$PREFIX, 생성 시 경로 접두사를 삭제합니다. 위의 예에서 접두사example.com/project를 지정하면 최종적으로protos/fizz/buzz.pb.go가 생성됩니다. 이 모드는 주로 모듈 안에 직접 생성하는 데 사용됩니다 (차이점이 없는 것 같습니다).paths=source_relative, 생성된 파일은 지정된 디렉토리에서protobuf파일과 동일한 상대 구조를 유지합니다.
콜론 : 간격 후는 지정된 생성 경로입니다.
| common.proto
| common.pb.go
│
├─monster
│ monster.pb.go
│ monster.proto
│
└─player
health.pb.go
health.proto
health_grpc.pb.go
player.pb.go
player.proto리플렉션
options 를 통해 enum과 message를 확장할 수 있으며, 먼저 "google/protobuf/descriptor.proto"를 가져옵니다.
import "google/protobuf/descriptor.proto";
extend google.protobuf.EnumValueOptions {
optional string string_name = 123456789;
}
enum Integer {
INT64 = 0[
(string_name) = "int_64"
];
}이는 해당 열거값에 메타 정보를 추가한 것과 같습니다. message도 다음과 같이 동일합니다.
import "google/protobuf/descriptor.proto";
extend google.protobuf.MessageOptions {
optional string my_option = 51234;
}
message MyMessage {
option (my_option) = "Hello world!";
}이는 protobuf에 대한 리플렉션과 같으며, 코드 생성 후 Descriptor 를 통해 접근할 수 있습니다. 다음과 같습니다.
func main() {
message := pb_learn.MyMessage{}
message.ProtoReflect().Descriptor().Options().ProtoReflect().Range(func(descriptor protoreflect.FieldDescriptor, value protoreflect.Value) bool {
fmt.Println(descriptor.FullName(), ":", value)
return true
})
}출력
my_option:"Hello world!"이 방식은 Go 에서 구조체에 tag 를 추가하는 것과 유사하며, 이 방식을 통해 매개변수 검증 기능도 구현할 수 있습니다. options 에 규칙을 작성하고 Descriptor 를 통해 검사하면 됩니다.
