코기판

protobuf란 무엇인가? (gRPC 시리즈 #2) 본문

Infra

protobuf란 무엇인가? (gRPC 시리즈 #2)

신코기 2019. 5. 12. 21:14

gRPC를 사용하려고, 가만히 들여다 보면 Protocol Buffers에 대한 이야기가 꼭 빠지지 않고 나옵니다.

줄여서 protobuf라고도 부르고, golang을 사용하다보면 pb라고 극단적으로 줄여서 말하기도 합니다.

 

스아실.. protobuf가 뭔지 몰라도, 왠지 생긴 것이 json과 왠지 비슷하기 때문에

그냥 대충 감으로 써도 gRPC 샘플을 만들어 보는데 무리가 없기는 하지만, 

모든 것들이 다 그러하듯 조금 더 practical하게 쓰려고 하면 막히는 부분이 있어서 protobuf에 대해서 공부하는 느낌으로 한 번 짚고 가려고 합니다. 

 

그래서, 이번 포스팅에서는

* protobuf가 무엇인지, 그리고 장점은 무엇인지

* protobuf 작성 가이드

* gRPC를 사용하기 위해서는 어떻게 해야하는지

 


What is protobuf? advantage?

protobuf에 대해서 알아보려고 공식 홈페이지에 들어가보면, 다음과 같이 소개되어 있습니다.

A language-neutral, platform-neutral, extensible way of serializing structured data for use in communications protocols, data storage, and more.

(serialization이란, data structure나 object state를 file이나 memory buffer와 같은, 저장될 수 있는 형태로 전환하는 것을 말합니다. 위키피디아 참조)

그러니까 protobuf는 어떤 언어나 플랫폼에서도 통신 프로토콜이나 데이텅 저장을 사용할 때, 구조화 된 데이터를 전환하게 해 주는 방법입니다.)

조금 어려운 정의일 수 있는데요, 직접 사용해본 결과, protobuf는 idl과 protoc의 조합이라는 느낌을 받았습니다.

 

예를 들어, Person이라는 데이터 구조가 있다고 합시다. 

이 구조는 name, id, email, phone number라는 네 개의 필드로 이루어져 있습니다. 

일단 이렇게 데이터에 대한 구조를 잡고 나면, protoc라는 code generation (혹은 build) 툴이

developer가 원하는 각각의 language로 class code를 만들어줍니다. 

다음 그림을 참고하시면 이해가 쉬울 것 같아요.

그럼 각각의 언어에서는 name() 혹은 set_name()과 같이, 각각의 field에 접근할 수 있는 accessor들이 생성됩니다. 

그 접근자나, class 코도들을 이용해 developer들이 code 안에서 populate, serialize, 혹은 retrieve하는데 쉽게 사용할 수 있는 것입니다. 

다음은 protobuffer에서 소개하는 코드 레벨에서의 사용 예입니다. 

Person person;
person.set_name("John Doe");
person.set_id(1234);
person.set_email("jdoe@example.com");
fstream output("myfile", ios::out | ios::binary);
person.SerializeToOstream(&output);

gRPC 사용의 관점에서는, serialize된 데이터를 communication에 사용하면, 훨씬 더 빠르게 req/res를 수행할 수 있겠죠?

 

공식 홈페이지에서는 protobuf가 xml에 비해 

- 더 간결하고, 

- 3배에서 10개 정도 작고,

- 20배에서 100배 빠르고, 

- 덜 모호하며,

- 프로그래밍 시 더 사용하기 쉬운 데이터 액세스 클래스를 제공한다.

라고 되어 있는데요, 

 

제는 사실 속도를 직접 측정해 본 것은 아니지만, 직접 써보고 느낀 것은 다른 것 보다도 

protoc 컴파일러가 정말 편리하게 code generation을 해준다는 것입니다.

 

그리고 어짜피 gRPC를 사용하시기로 마음 먹으셨다면 protobuf로 data structure 정의를 하시는 것이 속 편하실 것이라 생각합니다. 

 

 


protobuf guide

protobuf에는 version2가 있고, version3가 있습니다.

각각은 proto2 proto3라고 부르는데요, proto2가 당연하게도 더 오래된 버전이면서 지금의 default 버전입니다.

(참고로 proto1은 없습니다. 처음 오픈소스화 되었을때가 google 내부에서는 두 번째 버전이었기 때문에 proto2라고 이름을 붙였다고 합니다.)

 

proto2가 default이긴 하지만, 저는 proto3를 사용할겁니다. proto3부터 golang이 지원되거든요 .. 

(저는 원래 엄청난 js러버였는데, 요즘에는 golang이 더 편할 때가 있습니다 ㅠ 진짜 강력한 것 같아요)

모든 language 가이드를 여기서 다 소개할 수는 없을 것 같아서, 심플한 예제와 

그리고 제가 가장 궁금했던, 도대체 1 2 3 이런 숫자가 뭣을 의미하는가 !!! 에 대해서 소개해드리려고 합니다. 

 

proto문법은 별로 어렵지 않습니다. 

message SearchRequest {
  string query = 1;
  int32 page_number = 2;
  int32 result_per_page = 3;
  enum Corpus {
    UNIVERSAL = 0;
    WEB = 1;
    IMAGES = 2;
    LOCAL = 3;
    NEWS = 4;
    PRODUCTS = 5;
    VIDEO = 6;
  }
  Corpus corpus = 4;
}

딱 봐도, SearchRequest에 대한 data를 정의하고 있고, 

각각의 필드는 query, page_number, result_per_page, corpus로 이루어져 있네요.

각 필드 앞에는 자료형이 정의되어 있는 것을 보실 수 있습니다. 

자료형을 정의해 놓음으로써, gRPC에서는 자료형이 불분명하기 때문에 발생하는 문제에 대해서 예방할 수 있습니다. 

 

그리고 가장 특이한 점은, 각각의 필드 가장 우측에, =1 =2 =3 과 같이 숫자가 붙어있는데요,

이 숫자를 field number라고 부릅니다. 

field number가 필요한 이유는, protobuf가 메세지를 serializing하고 serializing할 때, 매칭에 활용됩니다.

이렇게 생각하면, 별로 필요없어 보이지만, 만약 나중에 message 타입에 새로운 필드를 추가 시 

호환성을 어느정도 보장한다고 할 수 있겠습니다. 

 

이제 예제를 보도록 하겠습니다. 

간단하게 식당에서 주문을 할 때, 오고갈만한 메세지에 대해서 준비해보았습니다.

syntax = "proto3";

/* This is corgipan example.
Adding comments can be done with this format */
// and also this format

message corgiOrder {
  string menu = 1; 
  int32 quantity = 2; 
  int32 tableNumber = 3;
  int32 preference = 4;
}

이제 이것을 protoc를 이용해서 사용 가능한 go 코드로 만들어보겠습니다.

일단 제 디렉토리 구조는 다음과 같습니다.

.
├── api
│   └── order.proto
└── source
// For the first time
$ go get -u github.com/golang/protobuf/protoc-gen-go

$ protoc -I=./api --go_out=./source ./api/order.proto

처음에는 protoc의 go 생성이 protoc-gen-go 모듈을 사용하기 때문에 설치해주었구요, 

이 과정은 처음에만 해주시면 됩니다. 

다음은 protoc를 변환하는 과정입니다. 

-I 옵션으로 import path를 설정해주고, 변환 후 저장될 목적지 디렉토리와, 실제 변환할 대상인 order.proto 파일을 인자로 넘겨주었습니다. 

 

그러면 구조에서 source에 변환된 파일이 하나 생성된 것을 볼수 있고, 

실제로 까보면 데이터에 접근할 수 있는 형태로 모두 변환된 것을 볼 수 있습니다.

.
├── api
│   └── order.proto
└── source
    └── order.pb.go
// Code generated by protoc-gen-go. DO NOT EDIT.
// source: order.proto

package order

import (
	fmt "fmt"
	proto "github.com/golang/protobuf/proto"
	math "math"
)

// Reference imports to suppress errors if they are not otherwise used.
var _ = proto.Marshal
var _ = fmt.Errorf
var _ = math.Inf

// This is a compile-time assertion to ensure that this generated file
// is compatible with the proto package it is being compiled against.
// A compilation error at this line likely means your copy of the
// proto package needs to be updated.
const _ = proto.ProtoPackageIsVersion3 // please upgrade the proto package

type CorgiOrder struct {
	Menu                 string   `protobuf:"bytes,1,opt,name=menu,proto3" json:"menu,omitempty"`
	Quantity             int32    `protobuf:"varint,2,opt,name=quantity,proto3" json:"quantity,omitempty"`
	TableNumber          int32    `protobuf:"varint,3,opt,name=tableNumber,proto3" json:"tableNumber,omitempty"`
	Preference           int32    `protobuf:"varint,4,opt,name=preference,proto3" json:"preference,omitempty"`
	XXX_NoUnkeyedLiteral struct{} `json:"-"`
	XXX_unrecognized     []byte   `json:"-"`
	XXX_sizecache        int32    `json:"-"`
}

func (m *CorgiOrder) Reset()         { *m = CorgiOrder{} }
func (m *CorgiOrder) String() string { return proto.CompactTextString(m) }
func (*CorgiOrder) ProtoMessage()    {}
func (*CorgiOrder) Descriptor() ([]byte, []int) {
	return fileDescriptor_cd01338c35d87077, []int{0}
}

func (m *CorgiOrder) XXX_Unmarshal(b []byte) error {
	return xxx_messageInfo_CorgiOrder.Unmarshal(m, b)
}
func (m *CorgiOrder) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) {
	return xxx_messageInfo_CorgiOrder.Marshal(b, m, deterministic)
}
func (m *CorgiOrder) XXX_Merge(src proto.Message) {
	xxx_messageInfo_CorgiOrder.Merge(m, src)
}
func (m *CorgiOrder) XXX_Size() int {
	return xxx_messageInfo_CorgiOrder.Size(m)
}
func (m *CorgiOrder) XXX_DiscardUnknown() {
	xxx_messageInfo_CorgiOrder.DiscardUnknown(m)
}

.
.
.
.

 

 

조금 더 복잡한 형태로 한번 더 해볼까요?

이번엔 여행가서 주변 장소를 찾는다고 가정하고 메세지 타입을 만들었습니다.

$ tree
.
├── api
│   ├── find.proto
│   └── order.proto
└── source
    └── order.pb.go
syntax = "proto3";

/* This is corgipan example for request restaurant*/

message corgiFindRequest {
  float latitude = 1; 
  float longitude = 2;
  enum Place {
    ATTRACTIONS = 0;
    RESTAURANT = 1;
    TOILET = 2;
    ATM = 3;
    HOTEL = 4;
  }
  Place place = 3;

  message Result {
    string title = 1;
    string url = 2;
    string category = 3;
  }
  repeated Result results = 4;
}

이번 예제는 메세지 안에 메세지가 들어간 nested 형태와 enum 등 조금 더 화려해졌습니다.

같은 계층에 있는 layer에서 field number가 겹치지 않는 것 보이시죠?

특이한 점은 enum은 filed number를 0부터 시작할 수 있었는데, 

다른 일반적인 message field의 경우, 0부터 시작하게 되면 에러가 납니다.

에러 메세지에서 친절하게 설명해주어서, 빠르게 고치실 수 있을 것 같습니다. 

 

위 예제를 아까처럼 go코드로 변환해보면, source 디렉토리에 새로운 파일이 생성됩니다.

$ tree
.
├── api
│   ├── find.proto
│   └── order.proto
└── source
    ├── find.pb.go
    └── order.pb.go

 

find.pb.go에는 이제 그 데이터에 접근 가능한 함수들이 생성된 것을 확인 할 수 있어요.

코드가 너무 길어서 역시 중간에 잘렸습니다 ;; 혹시 풀 코드를 원하시면 댓글 주세요!

// Code generated by protoc-gen-go. DO NOT EDIT.
// source: find.proto

package find

import (
	fmt "fmt"
	proto "github.com/golang/protobuf/proto"
	math "math"
)

// Reference imports to suppress errors if they are not otherwise used.
var _ = proto.Marshal
var _ = fmt.Errorf
var _ = math.Inf

// This is a compile-time assertion to ensure that this generated file
// is compatible with the proto package it is being compiled against.
// A compilation error at this line likely means your copy of the
// proto package needs to be updated.
const _ = proto.ProtoPackageIsVersion3 // please upgrade the proto package

type CorgiFindRequest_Place int32

const (
	CorgiFindRequest_ATTRACTIONS CorgiFindRequest_Place = 0
	CorgiFindRequest_RESTAURANT  CorgiFindRequest_Place = 1
	CorgiFindRequest_TOILET      CorgiFindRequest_Place = 2
	CorgiFindRequest_ATM         CorgiFindRequest_Place = 3
	CorgiFindRequest_HOTEL       CorgiFindRequest_Place = 4
)

var CorgiFindRequest_Place_name = map[int32]string{
	0: "ATTRACTIONS",
	1: "RESTAURANT",
	2: "TOILET",
	3: "ATM",
	4: "HOTEL",
}

var CorgiFindRequest_Place_value = map[string]int32{
	"ATTRACTIONS": 0,
	"RESTAURANT":  1,
	"TOILET":      2,
	"ATM":         3,
	"HOTEL":       4,
}

func (x CorgiFindRequest_Place) String() string {
	return proto.EnumName(CorgiFindRequest_Place_name, int32(x))
}

func (CorgiFindRequest_Place) EnumDescriptor() ([]byte, []int) {
	return fileDescriptor_701393f70b865f1c, []int{0, 0}
}

type CorgiFindRequest struct {
	Latitude             float32                    `protobuf:"fixed32,1,opt,name=latitude,proto3" json:"latitude,omitempty"`
	Longitude            float32                    `protobuf:"fixed32,2,opt,name=longitude,proto3" json:"longitude,omitempty"`
	Place                CorgiFindRequest_Place     `protobuf:"varint,3,opt,name=place,proto3,enum=CorgiFindRequest_Place" json:"place,omitempty"`
	Results              []*CorgiFindRequest_Result `protobuf:"bytes,4,rep,name=results,proto3" json:"results,omitempty"`
	XXX_NoUnkeyedLiteral struct{}                   `json:"-"`
	XXX_unrecognized     []byte                     `json:"-"`
	XXX_sizecache        int32                      `json:"-"`
}

func (m *CorgiFindRequest) Reset()         { *m = CorgiFindRequest{} }
func (m *CorgiFindRequest) String() string { return proto.CompactTextString(m) }
func (*CorgiFindRequest) ProtoMessage()    {}
func (*CorgiFindRequest) Descriptor() ([]byte, []int) {
	return fileDescriptor_701393f70b865f1c, []int{0}
}

.
.
.

 

이제 이렇게 생성된 go파일을 코딩을 위한 go 파일에서 import해서 쓰시면 됩니다. 

 

 

저는 머릿속에서 끄집어내서 예제를 만들려니까 좀 힘들었는데요 ;;

실생활에서 사용하시고자 한다면 다른 더 좋은 예제가 많이 있을 것 같습니다 

저도 더 좋은 예제를 발견하면 업데이트 하겠습니다.

 

오늘도 읽어주셔서 감사합니다 

다음에는 grpc로 실제 go frontend와 python backend가 통신하게 하는 것을 가지고 올게요 !

 

 

 

 

 

 

 

reference :

https://developers.google.com/protocol-buffers/docs/overview

 

Developer Guide  |  Protocol Buffers  |  Google Developers

Welcome to the developer documentation for protocol buffers – a language-neutral, platform-neutral, extensible way of serializing structured data for use in communications protocols, data storage, and more. This documentation is aimed at Java, C++, or Pyth

developers.google.com

Comments