1. 등장 배경
1.1 Server-Client Model
PC(Personal Computer)의 개념이 없던 시절, 프로그램은 하나의 메인 프레임에서 동작하는 Monolothic 구조로 설계되었다. 이때까지만 해도 모든 기능들이 한 공간에서 구동되다보니 지금처럼 네트워크 통신이 크게 중요하진 않았을 것이다.
기술 발전에 따라 소형 컴퓨터 장비들(PC, 워크스테이션 서버 등)이 등장하게 되고, 기업 입장에선 매우 고가인 메인 프레임워크를 비교적 저가의 워크스테이션 서버로 대체하고 싶어했지만! 메인 프레임워크의 초고사양 서비스를 워크스테이션 서버에서 그대로 제공하기엔 한계가 있었다. 때문에 메인 프레임워크의 기능을 워크스테이션 서버로 분산시키고, 네트워크 연결로 서비스하는 방식을 채택하게되었다. 흔히 말하는 Server-Client Model이 바로 이것. 이처럼 서버 간 혹은 서버와 개인 PC 간 네트워크 연결/통신이 중요해지면서 OSI 7 layer, TCP/IP 등 네트워크 계층 구조가 정의되고 발전하기 시작하였다.
1.2 IPC
프로세스들은 기본적으로 상호독립적이다. 메모리를 공유하지 않기 때문에 각자 자신의 일만 하며 서로 간섭을 하지 않는다. 하지만 필요에 따라 프로세스간 정보를 교환해야하는 경우가 있기에(사실 대다수기에), 별도 수단을 이용하게 되는데 이런 프로세스 통신하는 방법론을 통칭하여 'IPC(Inter Process Communication)' 라고 한다.
1.2.1 Socket
IPC 기법에는 공유 메모리, PIPE, 메시지 큐 등 여러가지가 있지만 이 중 소켓(socket)을 살펴보자.
socket이란, 앞서 언급한 OSI 7 layer 구조의 Application Layer(L7)에서 Transport Port(L4)의 TCP 또는 UDP를 이용하기 위한 수단이다. 일종의 창구 역할을 하는 것. 목적지와의 통신이 컴퓨터 내부가 아니라 온라인 범위에서 이루어지기 때문에 '네트워크 간 통신'이라고 구분하기도 하지만, 실질적으로는 로컬 컴퓨터의 프로세스와 원격지 컴퓨터의 프로세스가 IPC 통신을 하는 것이라 볼 수 있다.
소켓은 대부분의 언어에서 API 형태로 제공하는 편리함 때문에 지금도 많이 사용되고 있지만, 일련의 통신 과정을 직접 구현해야 하므로 네트워크 관련 장애를 처리하는 것 또한 고스란히 개발자의 몫이다. 서비스가 고도화될 수록 수백 수천가지 데이터가 돌아다니게 될텐데, 이에 따라 data formatting 을 하는 것도 점점 어려워지게 되겠지.
1.2.2 RPC (Remote Procedure Call)
이런 소켓의 한계에서 RPC(Remote Procedure Call)라는 기술이 등장한다. 이름 그대로 네트워크로 연결된 서버 상의 프로시저(함수, 메서드 등)를 원격으로 호출할 수 있는 기능이다. 네트워크 통신을 위한 작업 하나하나 챙기기 귀찮으니 통신이나 call 방식에 신경쓰지 않고 원격지의 자원을 내 것처럼 사용한다는 의지가 서려있다. RPC는 IDL(Interface Definication Language) 기반으로 다양한 언어를 가진 환경에서도 쉽게 확장이 가능하기에 인터페이스 협업에도 용이하다는 장점이 있다.
- 지원 언어 : C++, Java, Python, Ruby, Node.js, C#, Go, PHP, Objective-C ...
RPC에선 Stub(스텁)이라는 아이와 친해져야한다. 서버와 클라이언트는 서로 다른 주소 공간을 사용하므로, 함수 호출에 사용된 매개 변수를 꼭 변환해줘야 한다. 안그러면 메모리 매개 변수에 대한 포인터가 다른 데이터를 가리키게 될 테니. 이 변환을 담당하는게 stub이다.
client stub은 함수 호출에 사용된 파라미터의 변환(Marshalling, 마샬링) 및 함수 실행 후 서버에서 전달 된 결과의 변환을, server stub은 클라이언트가 전달한 매개 변수의 역변환(Unmarshalling, 언마샬링) 및 함수 실행 결과 변환을 담당하게 된다. 이런 Stub을 이용한 기본적인 RPC 통신 과정을 살펴보자.
(그림출처 : https://middlewares.files.wordpress.com/2008/04/17.jpg)
1) IDL(Interface Definition Language)을 사용하여 호출 규약 정의
- 함수명, 인자, 반환값에 대한 데이터형이 정의된 IDL 파일을 rpcgen으로 컴파일하면 stub code가 자동으로 생성됨
2) Stub Code에 명시된 함수는 원시코드의 형태로, 상세 기능은 server에서 구현
- 만들어진 stub 코드는 클라이언트/서버에 함께 빌드
3) client에서 stub 에 정의된 함수를 사용할 때,
4) client stub은 RPC runtime을 통해 함수 호출하고
5) server는 수신된 procedure 호출에 대한 처리 후 결과 값을 반환
6) 최종적으로 Client는 Server의 결과 값을 반환받고, 함수를 Local에 있는 것 처럼 사용하게 됨
원격지의 것을 끌어다 로컬처럼 사용하는데다, IDL 기반이라 확장성도 좋고 개발자도 편해지고 요즘같은 분산 프로그래밍 환경에선 큰 이점을 제공한다. 그런데 왜 상대적으로 쉽게 찾아볼 수 없었을까?
RPC는 상당히 획기적인 방법론이었으며, 분산 환경의 등장에 따라 함께 발전해 온 오래된 기술이다. 따라서 구현체도 CORBA, RMI 등 여러가지가 있었다. 이들 모두 로컬에서 제공하는 빠른 속도, 가용성 등을 분산 프로그래밍에서도 제공하고 있다고 홍보를 했지만, 정작 구현의 어려움/지원 기능의 한계 등으로 제대로 활용되지 못했다. 그렇게 RPC 프로젝트는 점차 뒷방으로 빠지게되며(ㅠ) 데이터 통신을 우리에게 익숙한 Web을 활용해보려는 시도로 이어졌고, 이 자리를 REST가 차지하게된다.
1.2.3 REST (REpresentational State Transfer)
REST는 HTTP/1.1 기반으로 URI를 통해 모든 자원(Resource)을 명시하고 HTTP Method를 통해 처리하는 아키텍쳐이다. 자원 그 자체를 표현하기에 직관적이고, HTTP를 그대로 계승하였기에 별도 작업 없이도 쉽게 사용할 수 있다는 장점으로 현대에 매우 보편화 되어있다. 하지만 REST에도 한계는 존재한다. REST는 일종의 스타일이지 표준이 아니기 때문에 parameter와 응답 값이 명시적이지 않으며 또한 HTTP 메소드의 형태가 제한적이기 때문에 세부 기능 구현에는 제약이 있다.
덧붙여, 웹 데이터 전달 format으로 xml, json을 많이 사용하는데...
XML은 html과 같이 tag 기반이지만 미리 정의된 태그가 없어(no pre-defined tags) 높은 확장성을 인정 받아 이기종간 데이터 전송의 표준이었다. 하지만 다소 복잡하고 비효율적인 데이터 구조탓에 속도가 느리다는 단점이 있었다. 이런 효율 문제를 JSON이 간결한 Key-Value 구조 기반으로 해결하는 듯 하였으나, 제공되는 자료형의 한계로 파싱 후 추가 형변환이 필요한 경우가 많아졌다. 또한 두 타입 모두 string 기반이라 사람이 읽기 편하다는 장점은 있으나, 바꿔 말하면 데이터 전송 및 처리를 위해선 별도의 Serialization이 필요하다는 것을 의미한다.
2. gRPC (google Remote Procedure Call)
gRPC는 google 사에서 개발한 오픈소스 RPC(Remote Procedure Call) 프레임워크이다. 이전까지는 RPC 기능은 지원하지 않고, 메세지(JSON 등)을 Serialize할 수 있는 프레임워크인 PB(Protocol Buffer, 프로토콜 버퍼)만을 제공해왔는데, PB 기반 Serizlaizer에 HTTP/2를 결합하여 RPC 프레임워크를 탄생시킨 것.
REST와 비교했을 때 기반 기술이 다르기에 특징도 많이 다르지만, 가장 두드러진 차이점은 HTTP/2를 사용한다는 것과 프로토콜 버퍼로 데이터를 전달한다는 점이다. 그렇기에 Proto File만 배포하면 환경과 프로그램 언어에 구애받지 않고 서로 간의 데이터 통신이 가능하다.
2.1 HTTP/2
http/1.1은 기본적으로 클라이언트의 요청이 올때만 서버가 응답을 하는 구조로 매 요청마다 connection을 생성해야만 한다. cookie 등 많은 메타 정보들을 저장하는 무거운 header가 요청마다 중복 전달되어 비효율적이고 느린 속도를 보여주었다. 이에 http/2에서는 한 connection으로 동시에 여러 개 메시지를 주고 받으며, header를 압축하여 중복 제거 후 전달하기에 version1에 비해 훨씬 효율적이다. 또한, 필요 시 클라이언트 요청 없이도 서버가 리소스를 전달할 수도 있기 때문에 클라이언트 요청을 최소화 할 수 있다.
(그림출처 : https://jumpic.com/hashtag.php?q=http2)
2.2 ProtoBuf (Protocol Buffer, 프로토콜 버퍼)
Protocol Buffer는 google 사에서 개발한 구조화된 데이터를 직렬화(Serialization)하는 기법이다.
직렬화란, 데이터 표현을 바이트 단위로 변환하는 작업을 의미한다. 사람보단 컴퓨터 친화적인 데이터 표현이랄까.
아래 예제처럼 같은 정보를 저장해도 text 기반인 json인 경우 82 byte가 소요되는데 반해, 직렬화 된 protocol buffer는 필드 번호, 필드 유형 등을 1byte로 받아서 식별하고, 주어진 length 만큼만 읽도록 하여 단지 33 byte만 필요하게 된다.
(그림 출처 : https://martin.kleppmann.com/2012/12/05/schema-evolution-in-avro-protocol-buffers-thrift.html)
자세한 Encoding/Decoding 원리는 Protocol Buffer의 기본 정보를 명세하는 Proto File의 구성 요소를 살펴본 후 다뤄보자. 예시들은 대부분 google에서 제공하는 Protocol Buffer 공식 docs를 참고하였다.
2.3 Proto File
2.3.1 Message and Field
Proto File에서는 주고 받는 data들을 message 라는 것으로 정의한다. 이 메시지는 여러가지 타입의 필드로 구성되는데 아래 예시로 query, page_number, result_per_page 라는 필드를 가지는 SearchRequest 라는 메시지를 정의해보았다.
1) Naming
message 이름은 CamelCase 형태, field 이름은 under_bar 형태로 사용할 것을 권장하고 있다.(필수는 아님)
유의할 것은 field 이름은 숫자로 시작할 수 없다는 점인데, 숫자를 표기해야 할 경우 꼭 문자 뒤에 표기해주어야 한다.
ex) query_1 (o) / 1_query (x)
2) Field Tag (= Field number)
메시지에 정의된 필드들은 각각 고유한 번호를 가지게되고 이는 Enconding 이후 binary data에서 필드를 식별하는데 사용된다. Field Tag는 최소 1, 최대 536,870,911(=2^29-1) 로 지정 가능하며, 19000 ~ 19999는 프로토콜 버퍼 구현을 위해 reserved 된 값이므로 사용할 수 없다. 필드 번호가 1~15일 때는 1byte, 16~2047은 2byte를 Tag로 가져가게 되는데, 때문에 자주 호출되는 필드에 대해선 1~15로 지정해두는 것이 좋다.
여기서 한 가지 의문이 들었다. 1byte는 8bit라서 255까지 표현이 가능한데 필드 번호로 쓰일땐 왜 1~15 까지만 표현될까? 그 이유는 [2.4 Protocol Buffer Encoding] 부분에서 알아보았다.
3) proto2 VS proto3
위 예제에서는 첫 줄에 syntax = "proto3"을 지정해줌으로써 proto version 3의 규약을 따르겠다고 선언했다. 이를 명시하지 않으면 default로 version2 문법을 따르게 된다. 아래와 같이 지원 언어도 다르지만, message 작성 시 field rule 지정 등 문법에도 차이가 나기 때문에 꼭 version을 해주도록 하자.
- Proto2 지원 언어 : C++, Java, Python, Go
- Proto3 지원 언어 : C++, Java, Python, Go, Ruby, Objectice-C, C#, JavaScript, PHP, Dart
4) Proto File Field Rule
- required : 필수로 가져야 할 필드 (only use proto2)
- optional : 해당 필드를 가지지 않거나 하나만 가짐 (only use proto2)
- repeated : 임의 반복 가능한 필드 (번호 및 값의 순서는 보존)
- [packed=true] 옵션 : key-value 쌍 형태에서 value만 반복
위 예시처럼 proto2의 경우 required, optional를 필드 별로 꼭 명시해주어야 한다. proto3에선 required, optional은 사라지고, repeated 만 사용된다.
proto2도 계속 기술지원이 되고 있으나, 지원 언어 및 새로운 기능 지원을 위해 proto3을 사용할 것을 권장한다. 이 글에서도 proto3에 맞추어 기술하도록 하겠다.
(+) 추가
처음 회사 블로그에 원글을 기술했을 당시 proto3에선 optional 같은 explicit presence가 거의 없어졌던 상태였으나,
protobuf release 3.12 버전부터 실험적으로 proto3에 추가 되었으며, 3.15 버전부터는 정식으로 재도입 되었다. (docs)
Historically, proto2 has mostly followed explicit presence, while proto3 exposes only no presence semantics. Singular proto3 fields of basic types (numeric, string, bytes, and enums) which are defined with the optional label have explicit presence, like proto2.
- (this is an experimental feature added as of release 3.12, and must be enabled by passing a flag to protoc)
- (this feature is enabled by default as release 3.15)
아래와 같이 repeated rule을 주게되면 Field를 배열의 형태로도 사용할 수 있게 된다. 필드는 Key-Value 구조로 저장되어 repeated field를 사용할 때도 key가 계속 붙게되는데, reqeated 뒤에 packed 옵션을 주면 value만 반복하게끔 할 수 있다. 어차피 필드 번호는 바뀌지 않으니 되도록 이 옵션을 주면 보다 효율적인 Enconding이 되겠군.
2.3.2 Package
package는 message type 이름을 중첩없이 구분할 때 사용한다. 메시지 사용 시 package를 명시함으로써 필드와 명확히 구분해준다. 아래 예제에서는 Open이라는 message를 타입으로 하는 field 이름을 open으로 주어 모호한 정의를 package로 구분하였다. 사실 foo.bar라는 package를 굳이 쓰지 않는다고 사용이 불가한 것은 아니지만, 구성 메시지가 많다면 명확하게 구분될 수 있게 명시해 주는 것이 좋겠다.
단, package 선언 시 해당 package name은 경로와 상관없이 프로젝트 내에서 유일해야한다. 그렇지 않다면 런타임에 panic 메시지를 보게될 것... (참고)
2.3.3 Service
Service는 RPC를 통해 서버가 클라이언트에게 제공할 함수의 형태를 정의한다. 서비스명과 RPC 메소드명 모두 CamelCase 형태를 권장한다. 옵션을 주지 않으면 단일 요청/응답으로 동작하지만, stream 옵션을 주면 streaming RPC를 구현할 수 있다.
Unary RPC
Streaming RPC
2.4 Protocol Buffer Encoding
기본적인 proto file 작성법 및 message 구조를 알았으니 이제 실제 Serializing 되는 과정을 살펴보자.
2.4.1 Message (Key-Value) Encoding
앞서 정의한 message 들은 일련의 Key-Value 쌍으로 이루어진 binary data로 인코딩되는데, Key는 Field Number 뿐만 아니라, 해당 Field의 data type을 지시하는 Wire Type을 표현한다.
Key는 'field_number << 3) | wire_type' 의 형태이다. 일반적인 1byte인 key의 경우 'Filed Number(5bit) + Wire Type(3bit)' 로 이루어진다. Field Number는 proto file에 명시된대로 들어가지만, Wire Type은 선언한 data type 별로 지정된다.
앞선 proto3 message 예제를 다시 살펴보자.
query의 Field number는 1이며, string이므로 wire type은 2가 된다. 때문에 Key는 00001 | 010 = 0x0a 가 될 것이다.
result_per_page 필드의 경우 Field number = 3이며, int32이므로 wire type이 0 즉, 00011 | 000 = 0x18를 Key로 가질 것이다.
2.4.2 Varints
1) wire type 0
wire type에 따른 자료형은 여러가지가 있지만 정수를 Serializing 하는 Varints Encoding부터 알아보자. Varints에 포함되는 정수형 타입들은 첫 byte의 1bit를 무조건 뒤 뒷 byte에 대한 지시 역할을 하는 msb(most significant bit)로 가지게 된다. 이 값이 1이면 뒷 데이터가 더 있다는 것이고, 0이라면 이어지는 byte stream과는 분리된다는 의미다. "least significant group first" 룰을 따르기 때문에 하위 byte부터 저장된다.
앞선 2.3.1-2) 에서 1byte로는 Field number를 1~15까지만 표현 가능한 이유가 여기에 있다.
Field number는 정수형이므로 실제 Field Type에 관계없이 Varints Encoding 법칙을 따르게 된다. 따라서 1byte key에서 wire type 부분을 제외한 5bit 중 1bit가 msb로 사용된다. 즉 key가 1byte라도 실제 필드 번호 값을 담는 크기는 4bit이기 때문에 1~15까지만 표현 가능한 것이다. 그 이상의 값이라도 Varints Encoding 법칙을 벗어나지는 않는다. (아래 정수 300 encoding 예시 참고)
(+) 추가 설명이 필요한 분들을 위한 TMI
이 법칙 때문에 2bytes로 표현 가능한 field tag의 최대 값도 4095(2^12-1)가 아닌 2047(2^11-1)이 된 것이다. 1byte 적 최대 4bit에다가, byte 추가 후 msb 1bit를 뺀 7bit 로만 표현 할 수 있기 때문에. (4+7=11)
간혹... 왜 최댓값이 536,870,911(2^29-1) 일까? 첫 4bit 후 7bit 씩 채울수 있는거면 33554431(2^25-1) 이지 않을까? 4byte를 다 썼을 때 마지막 msb는 0이지 않을까? 뭐 이런 류의 궁금점들이 나올 수 있다.
헷갈리면 안된다. msb는 그저 varints encoding 될 때 ‘추가로’ 할당되는 1bit일 뿐이다. 즉, 우리가 신경쓸 거리가 아니라는 소리다. 그래서 바로 앞 추가 설명에도 '표현 가능한' 이라는 표현을 썼다.
정리하자면 4byte=32bit 라는 제한은 단지 serializing의 편의를 위해 구글성님들께서 우리가 넣을 수 있는 값에 제한을 걸어둔 것일 뿐, 이미 정의해둔 field가 호출되어 encoding 된 후에 몇 byte가 것과는 관계가 없다. (실제로 최대값으로 field tag를 지정해 호출되는 패킷을 보면 key에 해당하는 영역은 4byte가 넘는다.)
2) Encoding
300이라는 정수를 Encoding 해보자.
① 300은 이진수로 256 + 32 + 8 + 4 = 100101100 로 표기된다.
② Varints 직렬화를 위해선 byte 당 msb가 포함돼야 하므로 7bit 단위로 구분해준다.
→ □000 0010 □010 1100
③ least significant group first 룰에 맞게 byte를 역순으로 나열한다.
→ □010 1100 □000 0010
④ msb를 설정해준다.
→ 1010 1100 0000 0010
3) Deconding
만약 '1010 1100 0000 0010' 라는 Serializing 된 데이터를 받았고 이를 Decoding 한다면, 위 절차를 거꾸로 따라가면 된다.
① msb를 해제한다.
→ □010 1100 □000 0010
② byte는 역순으로 나열되어 있으므로 다시 역순으로 정렬한다.
→ □000 0010 □010 1100
③ msb 제거 후 data를 연접한다.
→ 100101100 = 300
4) Signed Integers
만약 protocol buffer에서 음의 정수를 표기한다면 signed int(sint) 형을 사용하는게 효율적이다. 일반 int형에서는 음수로 사용 시 절댓값에 관계없이 항상 고정된 byte 크기를 잡는데, signed int형은 ZigZag Encoding으로 이미 부호있는 정수를 부호없는 정수로 mapping시켜 두었기 때문이다.
2.4.3 Non-Varint number
1) wire type 1,5
정수형이 아닌 실수형 타입은 예외 없이 간단하다. wire type 1인 64bit double 등은 고정된 64bit 데이터를 지시하며, wire type 5인 32bit형 float 등은 고정된 32bit 데이터를 지시한다.
2) wire type 2
string 같이 wire type이 2인 경우, varints 형태에서 길이를 지시하는 byte가 추가된다. 즉 key를 읽어서 wire type이 2라면 그 다음 바이트는 길이를 지정하는 것. 실제 설정된 value는 UTF-8로 인코딩된다.
예를 들어 2.3.1에서 query 필드의 type은 string이었고 key는 0x0a 라고 하였는데, 이 query 필드에 "testing"이라는 데이터가 저장되었다면 Encoding시 "0a 07 74 65 73 74 69 6e 67" 로 표기될 것이다.
- 0x0a : 0001 0010 → field num = 1 & wire type = 2 (string)
- 0x07 : 7 byte
- UTF-8 encoding : "testing"
길이 byte는 1 byte로 지정된 것은 아니며, 만약 wire type2 데이터의 길이가 0xFF = 255 bytes 이상이라면 아래와 같이 2byte 이상으로도 표기된다.
참고로 default receive message limit는 4*1024*1024=4MB 이다. 이 한계치를 넘어가는 데이터를 받으면 에러가 발생하기 때문에 보다 큰 데이터를 받기 위해선 streaming rpc로 구현하거나, grpc Dial시 CallOption을 수정하여 message max 값을 수정해주어야 한다. 하지만 Server, Client 양 쪽 코드레벨에서 설정을 맞춰줘야 하기에 개인적으로 선호하지 않는다. Default Size를 넘어가는 메시지를 보내고자 한다면 Stream Service로 구축한다.
단, Stream으로 구현한다 하더라도 전송 한 번에 보낼 수 있는 메시지 크기 제한은 4MB로 동일하다. 데이터 크기 자체가 큰 것이라면 4MB에 맞춰 쪼개는 작업이 수반되어야 할 것이다.
2.4.4 Embedded Message
package 설명에서 잠깐 예를 들었지만 message 정의 시 field type을 다른 메시지로도 지정할 수 있다. 아래와 같은 Test1이라는 message에 a라는 field가 있고 여기에 150이 설정됐다고 가정하면 a 필드는 08 96 01으로 encoding 된다. 이 Test1이라는 message가 Test3 message의 field type으로 사용된다면, wire type = 2 이므로 길이 byte가 추가되고 결과적으로 c 필드는 1a 03 08 96 01 로 encoding 된다.
1) a=150
08 96 01
0x08 : 0000 1000 → field num = 1 & wire type = 0 (int32)
0x96 0x01 : 1001 0110 0000 0001
→ □001 0110 □000 0001
→ 0000001 0010110 → 10010110
2) c
0x1a : 0001 1010 → filed num = 3 & wire type = 2 (embedded Mesgage)
0x03 : 3byte
08 96 01: a=150
2.5 Value Type
2.5.1 Scalar Type
이 표는 일반적으로 사용되는 Scalar 자료형들이 각 언어로 generate 되었을 때 어떤 type으로 변환되는지 정리한 표이다. 유의 사항이 언어별로 좀 상이한데 특히 python의 경우 예외가 많다.
.proto Type | C++ | Java | Python[2] | Go | Ruby | C# | PHP | Dart |
double | double | double | float | float64 | Float | double | float | double |
float | float | float | float | float32 | Float | float | float | double |
int32 | int32 | int | int | int32 | Fixnum or Bignum (as required) | int | integer | int |
int64 | int64 | long | int/long[3] | int64 | Bignum | long | integer/string[5] | Int64 |
uint32 | uint32 | int[1] | int/long[3] | uint32 | Fixnum or Bignum (as required) | uint | integer | int |
uint64 | uint64 | long[1] | int/long[3] | uint64 | Bignum | ulong | integer/string[5] | Int64 |
sint32 | int32 | int | int | int32 | Fixnum or Bignum (as required) | int | integer | int |
sint64 | int64 | long | int/long[3] | int64 | Bignum | long | integer/string[5] | Int64 |
fixed32 | uint32 | int[1] | int/long[3] | uint32 | Fixnum or Bignum (as required) | uint | integer | <span style="colo |
Data Type 별 호환 자료형
* [1] Java : unsigned int32/64는 signed로 표시 → 최상위 비트가 부호 비트로 사용
* [2-4] Python
* [2] Python 변환 시 type check 필수
* [3] unsigned int32/64는 long으로 변환되므로 해당 변수 사용시 int 형변환 후 사용
* [4] string은 unicode로 변환 (단, ASCII code면 str)
* [5] PHP : 64bit 자료형은 64bit 환경에서는 정수, 32bit 시스템에서는 string으로 변환됨
2.5.2 Default Value
protocol buffer에서 지정하는 default 값들이다. 특별히 유의해야할 점은 enum이라는 열거형 데이터는 첫번째 값을 default value로 지정한다는 점이다. message field에 사용되는 Type에 경우 언어에 따라 조금씩 다르기 때문에 API reference 문서를 참조하자.
2.5.3 Enumeration
1) 기본 문법
이번엔 첫번째 값을 default value로 삼는 enum형을 살펴보겠다. enum은 상수를 저장하는 열거형 데이터이며, encoding 하면 const 상수 형태로 저장되고, 각 name / value 별 map이 별도로 지정된다. 예를 들어 메시지 유형을 정의할 때, enum에 원하는 상수 값을 넣어두면 이걸 값으로도 참조할 수 있는 것.
enum형을 쓸 때 유의할 것은 default value에 정의됐다시피 첫 번째 값이 default 값으로 지정되므로 첫 상수는 0으로 지정해줘야 한다는 점이다. 그 밖에 값들은 세미콜론(;)으로 구분한다.
2) 옵션
enum에서 사용할 수 있는 옵션이 2가지가 있다.
enum 전체에 적용되는 allow_alias 옵션을 주면 다른 name에 같은 값을 줄 수 있다. 향후 value를 참조할 때는 복수 개 값이 있을 테니 oneof 로 하나만 선정하여 사용한다.
enum 값을 삭제하거나 주석처리하여 update 시켰다면 다른 사용자가 이 값을 재 사용하게 될 수도 있는데, 이는 proto file의 버전이 안맞을 경우 데이터 손상 등의 문제를 일으킬 수 있다. 이를 방지하고 싶다면, value / name을 더이상 접근할 수 없게 reserve 한다.
단, 아래 예제와 같이 reserved 만 선언한다면 error가 리턴되므로 default 값을 고려하여 enum 첫 줄엔 하나 이상의 상수를 선언해야한다.
2.5.4 Maps
다음은 map. 흔히 아는 일련의 key-value 쌍이다. 이 맵의 필드들은 reqeated 될 수 없으며, 순서가 있는 데이터도 아니고 언어별로 구현 방식이 상당히 다르다. key type은 string과 scalar type의 자료형만 지정 가능하며 value type은 map 외의 모든 자료형 지정이 가능하다.
때문에 개인적인 필요에 의해 테스트 해본 점은 map을 중첩해서 구현하는 방법인데...
이런 경우엔 map field를 같는 message를 다시 사용하는 방식으로 구현한다. 하지만 이렇게 하면 쓸데없이 stub code가 복잡해지는 터라 권장하긴 어려워 보인다. 다만 특별한 대안은 없어 꼭 필요하다면 message name을 좀 줄인다던지 정도로 용량을 줄일 수 있겠다.
2.6 인증 (Authentication)
암호화 인증이 필요한 경우 ssl/tls 및 token 기반 두가지 메커니즘을 지원한다. 대체적으로 전자를 사용하여 인증 후 암호화 통신을 진행하는데 아래 그림과 같이 client 의 경우 dial 옵션으로, Server에선 신규 grpc 서버 생성 시 적용된다. token 기반 인증은 gRPC로 Google API 접근 시 OAuth2 token과 같은 google access token이 지원된다.
2.7 Failover Test
gRPC 운영 환경에서 일어날 수 있을 만한 case를 가정하여 Test한 결과를 공유한다. 테스트 환경은 아래와 같다.
- OS : CentOS 7.4 64Bit
- gRPC : v1.26.0-dev
- Protocol Buffer : v3.10.0
- 사용 언어 : Golang
2.7.1 server down일 때 Client가 요청하는 경우
Server가 준비되지 않은 상태에서 Client가 요청하는 경우, retansmission은 8회씩 새로운 session이 맺어질 때까지 무한정 시도된다.
(왜 8회씩 시도되나 싶어 관련 설정 등을 찾아보았으나 아직까지 모르겠다 ㅠ)
2.7.2 server down 후 다시 up되었을 때
일반적으로 server가 down된다면 timeout에 걸려 통신이 종료되는데 (default timeout= 1s).
timeout을 일정치 이상 준 경우엔 sever down 감지 후 recovery되어 올라왔을 때 retransmission에 응답하여 새로운 세션으로 연결된다.
2.7.3 client timeout이 충분히 커도, 8회 초과 시 close
단 timeout이 충분히 큰 경우라도 8회 Retransmission까지 응답이 없으면 해당 통신은 종료된다.
2.7.4 L4 환경에서 failover
VIP로 gRPC 요청할 때, session이 할당된 active server down 시엔 기존 session은 FIN 종료 후 즉시 새로운 session으로 연결된다. 참고로 L4 구동 모드는 Proxy이며 Round Robin 알고리즘으로 LB 설정하였다.
3. 마무리
3.1 gRPC summary
3.1.1 특징 및 장점
gRPC에서는 아직 브라우저 관련 API가 제공되지 않기 때문에 브라우저에서 직접 gRPC 서비스를 호출하는 것은 불가능하다. 또한 기존 데이터 통신과 다르게 텍스트 기반이 아니라 Encoding 된 Binary Stream이기 때문에 사람이 읽기는 어렵다. 하지만 아래와 같이 장점이 훨씬 큰 기술이므로 서비스 개발 시 높은 생산성, 다양한 언어, 빠른 속도 등의 좋은 퍼포먼스를 보여줄 것이다.
1) 높은 생산성과 효율적인 유지보수
- ProtoBuf의 IDL만 정의하면 높은 성능을 보장하는 서비스와 메세지에 대한 소스코드가 자동으로 생성
2) 다양한 언어와 플랫폼 지원
- IDL을 활용한 서비스 정의 한 개로 다양한 언어와 플랫폼에서 동작하는 서버와 클라이언트 코드가 생성
3) HTTP/2 기반의 양방향 스트리밍
- 서버와 클라이언트가 서로 동시에 데이터를 스트리밍으로 주고 받음
4) 높은 메시지 압축률과 성능
- HTTP/2에 의한 압축뿐만 아니라 protoBuf에 의한 메시지 정의에 의해서 메시지 크기를 획기적으로 줄임
5) 다양한 gRPC 생태계
- 필요에 따라 Authentication, Tracing, Load Balancing, Health Checking, API Gateway 등의 다양한 도구 지원
3.1.2 When is it used?
그렇다면 어떤 서비스에 gRPC를 활용하면 좋을까?
거의 모든 서버 시스템 개발에 효율적으로 적용될 수 있지만, 특히 Microservice Architecture 서비스에 적합하다. 마이크로서비스는 작은 서비스들을 유기적으로 결합해 하나의 응용프로그램을 개발하는 방법론인데 구성 서비스가 독립적이기에 개발 및 배포 운영이 용이하여, 확장을 유연하게 할 수 있다. 때문에 새로운 기술 도입 및 변경에도 용이한 면을 보인다. 하지만 분산 시스템 특성상 공통 기능의 중복이 발생하여 메모리를 비효율적으로 사용할 수도 있고, 프로그램 규모가 커질 수록 구성원들의 철학이나 기술 스택이 제각기 다르니 운영도 어려워지게된다. 이에 gRPC는 앞서 언급한 특징 덕에 이러한 단점을 보완하며 장점을 극대화 시킬 수 있을 것이다.
브라우저를 사용하지 않는 백엔드간 서버 통신이나, 자원 한정적인 환경에서도 유용하다. byte/호출/cpu 수 등으로 과금되는 클라우드 환경에서는 비용 절감의 효과도 생각할 수 있겠다. 최근엔 시스코, 주니퍼 등 주요 네트워크 장비에서도 grpc를 모두 지원하고 있어 모니터링이나 자동화 등 인프라 운영에도 활용 방안이 많을 것으로 기대된다.
3.1.3 참고
1) 공식 사이트
- gRPC : https://www.grpc.io/docs/
- Protocol Buffer : https://developers.google.com/protocol-buffers/docs/overview
2) 원글
추가되는 내용은 이 블로그에 업데이트
- https://blog.naver.com/n_cloudplatform/221751268831
- https://blog.naver.com/n_cloudplatform/221751405158
'Network' 카테고리의 다른 글
로드 밸런싱(SLB, Server Load Balancing) 기본 개념 (4) | 2020.05.12 |
---|---|
CPS, TPS (0) | 2019.04.07 |
댓글