gRPC와 Protocol Buffer: 섀넌 정보이론, Serialization, RPC 동작 방식
소프트웨어 시스템이 계속 발전함에 따라 서비스 간의 효율적인 통신에 대한 필요성이 그 어느 때보다 중요해졌다. 이를 달성하기 위한 두 가지 인기 있는 접근 방식은 protocol buffer가 있는 gRPC와 JSON을 사용하는 REST API이다. 해당 글에서는 직렬화 및 마샬링의 기본 사항을 자세히 살펴보고 protocol buffer의 내부 및 외부를 살펴보고 성능, 확장성 및 유지 관리 측면에서 gRPC와 REST API를 비교해보고자 한다.
0. 섀넌 정보이론
프로세스간 통신 기법의 근간에는 섀넌 정보이론이 있다. 기존 전문가들은 통신의 문제를 물리적으로 풀려고만 하였고 잡음 문제 극복 등 문제의 본질을 파악하지 못해 초보적인 수준에 머물러있었다. 섀넌은 통신의 문제를 혁신적으로 바라보게 하였다. 섀넌은 《A Mathematical Theory of Communication》의 논문을 1948년에 내면서 통신이란 무엇인지 정의했다. (정보이론 개념은 머신러닝에서도 많이 쓰인다. 결국 정보량을 효율적으로 받아 학습하는 것이기 때문이다.)
섀넌의 1948년 논문(정보이론)과 튜링의 1936년 논문(튜링기계)은 같은 플롯으로 구성되어 있다.
- 우선 애매했던 대상(섀넌은 '정보량', 튜링은 '기계적인 계산')을 과감하게 정의한다
- 그리고 그 정의가 받아들일 만하다고 설득한다.
- 그런 후 그 정의로부터 논리적으로 엄밀한 사실들(셰넌은 메시지 전달의 한계, 튜링은 기계적인 계산의 한계)을 유도한다. 세상을 바꾼 두 논문은 같은 패턴이다.
정보량
통신의 주인공이 '정보량'이라는 주장은 '메시지의 정보량이 뭐냐'는 정의에서부터 시작한다. 섀넌이 정보량을 정의하는 관점은 이렇다. 잦은 것은 정보량이 적고 드문 것은 정보량이 많다. 자주 쓰는 건 예측하기 쉽기 때문이다. 자주 보이고 드물게 보이는 차이. 이 차이가 없으면 예측이 어렵다.
- 정보 많음: 차이 없이 모든 글자가 골고루 사용되는 세계에서 온 메시지는 정보가 많다.
- 정보 적음: 반면에 그 차이가 있는 세계에서 온 메시지는 정보가 적다. 자주 보이는 글자는 흔히 나타날 것이므로 보지 않고도 맞추기 쉽기 때문이다.
섀넌이 찾은 메시지 정보량 H는 이렇다.
- p(i): 글자 i가 나타날 확률 (0~1) 사이의 값 . 그래서 로그는 음수다. 그래서 음의 부호를 붙여 양의 값으로 만든다.
- ㄱ,ㄴ,ㄷ,ㄹ 각 25%라면 H = 2가 된다. 네 글자로 가지는 최대의 정보량이다.
- 두 글자가 공평하게 50%라면 H= 1이된다. 이게 두글자가 가질 수 있는 최대 정보량이다. 어느 한 글자가 조금이라도 더 자주 나타나는 확률이라면 메시지 정보량아 1보다 작아진다.
정보의 양이란 무질서의 정도, 정보량 정의는 열역학에서 이야기하는 엔트로피의 정의와도 같다. 메시지의 정보량은 글자들의 예측불허의 정도와 일치한다.
- 메시지에 나타나는 글자들이, 흔하거나 드문 게 따로 있다면 정보가 적다. 흔한 것들이 대다수일 테고 이는 예측이 쉬우므로 무질서가 적은 대상이다.
- 드문 단어 = 블필요한 단어가 끼면 정보량은 준다. '불필요하다'는 말 속에 이미 분별이 있다는 뜻이고, 분별이 있다는 건 그만큼 무질서가 적은 것이다.
- 그러나 흔하거나 드문 차이 없이 골고루라면 예측이 어렵기 떄문에 정보가 많다. 무질서가 큰 것이다.
섀넌은 위와 같이 메시지의 정보량을 정의하고 그 정의가 적절하다고 설득한 후, 온전한 소통을 가능하게 하는 핵심 정리 두 개를 도출한다.
첫 번째 정리 - 잡음이 없는 채널
전달하고자 하는 정보량이 H고, 채널 용량이 C라고 하자. 메시지 전달은 최대 초당 C/H로 항상 가능하다.
이 정리는 통념을 뒤집었다.
- 당시 전문가들: 메시지의 전달 가능 여부는 채널 매체의 물리적 성질(주파수 등)에 의존한다고 봤다.
- 섀넌: 그게 아니라 메시지 자체의 정보량에만 좌지우지된다는 사실을 도출한 것이다. 메시지는 항상 전달 가능할 뿐 아니라 그 전달 속도는 메시지의 정보량과 채널 용량에만 의존한다는 것이다.
두 번째 정리 - 잡음이 있는 채널
정보량이 초당 H라고 하고 채널 용량은 초당 C라고 하자.
H <= C 이면 온전히(잡음에 의한 생채기가 충분히 적어지도록) 전달할 수 있다 .
H > C 이면 잡음에 의한 생채기를 H-C미만으로 줄일 수 없다.
두 번째 정리는 더 혁명적이다.
- 당시 전문가들: 채널 잡음이 어느 이상이 되면 메시지를 온전히 전달할 방법은 없다고 믿었다. 잡음을 거스르는 방법은 더 큰 에너지(큰 소리로) 전달하는 방법밖에는 없다고 믿었고, 에너지가 커지면 잡음도 함께 걷잡을 수 없이 커지는 현상 때문에 속수무책이었다.
- 섀넌: 아무리 잡음이 많은 채널이어도 정보량만 적으면 메시지를 온전히 전달할 수 있다.
아무리 잡음이 많은 채널이라도 초당 전달하는 정보량이 채널 용량만 넘지 않는다면 (H <= C) 온전하게 전달할 방법이 있다고 했다. 따라서, 초당 정보량이 채널 용량을 초과한다면(H>C) 메시지의 정보량을 줄여주면 온전히 전달할 수 있다.
정보량을 줄이는 방법, Encoding
정보량을 줄이는 방법은 메시지에 있는 심벌들을 반복하거나 잡음으로 상처 날 메시지를 원상복구시키는 방법을 추가하면 된다. 이런 부가적인(정보량 줄이기) 방법들을 메시지에 추가하다 보면 단위시간당 전달할 수 있는 정보량은 줄어들 게고 언젠가는 H<=C가 되어서 그런 메시지는 온전히 전달할 수 있게 된다.
- 정보량 줄이기 = 엔트로피 낮추기 = 흔하거나 드문게 따로 있는 (예측 쉬운) 형태로 만들기
섀넌에 따르면 어떠한 잡음에서도 온전히 통신할 수 있고 방법은 하드웨어가 아니라 소프트웨어(메시지 자체)에 있다고 말한 것이다. 그 방법은 무엇일지 모르지만 존재한다고 이론적으로 확인해 줬으니 메시지를 표현하는(인코딩) 방법을 찾으면 되는 것이었다. 결과적으로 지금 우리가 분산시스템이나 여러 통신에 사용하는 직렬화, rpc 통신 기법, protocol buffer도 다 이 뿌리로부터 이어져 온 기술이다.
1. Serialization 및 Marshaling의 이해
직렬화(Serialization) 및 마샬링(Marshaling)은 서비스 또는 프로세스 간의 통신을 용이하게 하기 때문에 분산 시스템에서 필수 개념이다.
직렬화 Serialization
직렬화는 개체 또는 데이터 구조를 네트워크를 통해 전송하거나 파일에 저장할 수 있는 형식으로 변환하는 프로세스이다. 이 프로세스를 통해 다양한 플랫폼, 언어 및 아키텍처 간에 데이터를 공유할 수 있다.
예를들어, 여러가지의 자료형을 가지고 있는 Java 코드가 있다. 물론 그 중에서 레퍼런스 타입도 포함되어 있다. 이 객체 정보를 다른곳에서 그대로 쓰고 싶은 경우에 어떻게 해야할까? 객체 '주소'를 수신자에게 전송하는 것은 아무 의미가 없다. '0x0033AA12'가 A 객체 주소를 의미하지만 다른 곳으로 넘어가면 아무 의미가 없는 주소일 뿐이다. 그래서 직렬화란, 객체 주소 값을 모두 조회하여 실제로 의미하는 값을 가져오고 primitive한 데이터로 전부 변조하는 작업(byte stream으로 바꾸는 작업)을 바로 직렬화라고 한다. 그렇기 때문에 어떠한 객체 정보를 파일로 따로 저장을 하거나, 네트워크 상에서 주고받을 때 용이하게 한다.
마샬링 Marshaling
밀접하게 관련된 개념인 마샬링은 데이터를 프로토콜 메시지와 같은 표준화된 형식으로 캡슐화하여 전송할 데이터를 준비하는 프로세스를 말한다.
마샬링은 직렬화랑 비슷한 개념이다. 직렬화와 다른 점이라면 직렬화는 ‘byte stream으로 변환‘을 하는 것이지만, 마샬링은 ‘변환하는 일련의 과정‘을 뜻한다. 그러므로 마샬링이 좀 더 큰 개념이다. 직렬화의 과정에 마샬링이 포함이 되며, 실제로 객체 전송은 아래와 같은 순서로 진행된다. 그래서 직렬화를 마샬링이라고 해도 무방하다.
- 직렬화된 객체를 바이트 단위로 분해한다 (marshalling)
- 직렬화 되어 분해된 데이터를 순서에 따라 전송한다
- 전송 받은 데이터를 원래대로 복구한다 (unmarshalling)
직렬화와의 가장 큰 차이점은 직렬화는 객체가 대상이지만 마샬링은 변환자체에 목적이 있기 때문에, 대상과 변환할 오브젝트가 한정되지 않는다. 그렇기 때문에 서로 다른 언어간의 데이터 전송은 직렬화라고 하지 않고 마샬링이라고 한다. 이러한 프로세스는 분산된 구성 요소 간의 원활한 통신을 보장하여 개발자가 확장성이 뛰어나고 강력한 시스템을 구축할 수 있도록 한다.
2. Protocol Buffer
protocol buffer는 구글에서 오픈소스로 공개한 언어, 구조화(structured)된 데이터를 직렬화(serialization) 하는 방식이다. 줄여서 'protobuf', 더 줄여서 'pb'라고 부른다. protobuf는 여러 프로그램 언어를 지원한다. 서비스 또는 스토리지 간의 통신을 위해 구조화된 데이터를 직렬화하는 매우 효율적이고 간결한 방법을 제공한다. protocol buffer를 사용하려면 간단하고 사람이 읽을 수 있는 스키마 언어를 사용하여 "message"라는 데이터 구조를 정의한다.
Person 이라는 객체를 JSON으로 표현하면 아래와 같다. 데이터의 크기는 공백을 제거하면 82 바이트가 사용된다.
// 82 byte
{
"userName": "Martin",
"favouriteNumber": 1337,
"interests": ["daydreaming", "hacking"]
}
Person 객체에 대한 프로토콜 버퍼 스키마는 다음과 같다. 이 스키마를 사용하여 위의 데이터를 인코딩하면 33바이트로 표현이 가능하다.
// 33 byte
message Person {
required string user_name = 1;
optional int64 favourite_number = 2;
repeated string interests = 3;
}
프로토콜 버퍼가 어떻게 데이터를 직렬화할까?
user_name 같은 속성 값을 field tag로 대체하여 데이터를 줄이는 게 핵심이다. 섀넌 정보이론에 따르면 정보를 단순한게 만들어 정보량을 줄이는 것이다. 속성 값과 TYPE을 조합하여 1바이트 메타정보로 표현할 수 있다. protobuf는 다른 IDL(XML, JSON..)에 비해 더 적은 용량으로 데이터를 저장할 수 있기 때문에 압축률이 좋고 처리 속도가 빠르다. 하지만, 바이너리 데이터로 표현하기 때문에 사람이 확인하기 어렵다는 단점이 있다.
같은 정보를 저장해도 text 기반인 json인 경우 82 byte가 소요되는데 반해, 직렬화된 protocol buffer는 필드 번호, 필드 유형 등을 1byte로 받아서 식별하고, 주어진 length 만큼만 읽도록 하여 단지 33 byte만 필요하게 된다.
protocol buffer 사용 방법은 다음과 같다.
- '.proto' 파일에서 메시지 형식을 작성한다.
- protoc 컴파일러를 통해 원하는 언어로 컴파일 한다. protobuf 컴파일러는 원하는 프로그래밍 언어로 코드를 생성하므로 이러한 메시지를 쉽게 인코딩 및 디코딩할 수 있다.
- 프로토콜 버퍼를 사용하는 전체 목적은 데이터를 다른 곳에서 구문 분석 할 수 있도록 직렬화하는 것이다. 직렬화를 위해 인코딩(=마샬링)한다.
- 직렬화된 데이터를 사용하기 위해 디코딩(=언마샬링)한다.
Protocol buffer의 장단점을 정리하면 다음과 같다.
- 장점: 프로토콜 버퍼는 처리 시간과 메모리 사용 측면에서 작고 빠르며 매우 효율적이다. 또한 강력한 유형 지정, 이전 버전과의 호환성 및 버전 관리 지원을 제공하여 스키마 진화를 단순화한다다.
- 단점: 그러나 프로토콜 버퍼는 JSON과 같은 형식보다 사람이 읽기가 어려울 수 있으며 구현 및 유지 관리를 위해 추가 도구 및 라이브러리가 필요할 수 있다.
3. RPC
socket이란, 앞서 언급한 OSI 7 layer 구조의 Application Layer(L7)에서 Transport Port(L4)의 TCP 또는 UDP를 이용하기 위한 수단이다. 통신이 복잡해질수록 data 처리가 까다로운 단점이 있다. 이러한 소켓의 단점과 어떻게 하면 분산 네트워크 컴퓨터 환경에서 프로그래밍을 쉽게 할수있을까 고민하다가 RPC 기술이 등장하였다.
RPC(remote procedure call, 원격 프로시저 호출)은 별도의 원격 제어를 위한 코딩 없이 다른 주소 공간에서 '함수'나 '프로시저'를 실행할 수 있게하는 프로세스 간 통신 기술이다. 다시 말해, 원격 프로시저 호출을 이용하면 프로그래머는 함수가 실행 프로그램에 로컬 위치에 있든 원격 위치에 있든 동일한 코드를 이용할 수 있다.
- RPC는 Client-Server 간의 커뮤니케이션에 필요한 상세정보는 최대한 감춘다.
- RPC는 Client와 Server는 각각 일반 메소드를 호출하는 것처럼 원격지의 프로시저를 호출할 수 있다.
RPC의 동작방식 - IDL 기반
IDL(Interface Definition Language)은 인터페이스 정의 언어로 두 프로세스간 통신 다리 역할을 한다. 특정 언어에 국한되지 않아 확장성이 좋다.
그리고 네트워크 전송과정에서 바이트 전송 순서를 보장하기 위해 XDR(External Data Representation) 형태로 변환하여 RPC 호출을 실행한다. (기본 데이터 타입(정수형, 부동소수점 등)에 대한 메모리 저장방식(Little-Endian / Big-Endian) 이 CPU 아키텍처별로 다르기 때문)
- Stub 코드 생성: IDL을 사용하여 서버의 프로토콜이 정의 (함수명, 인자, 반환값에 대한 데이터 타입)된 파일을 'rpcgen' 컴파일러를 이용하여 Stub 코드를 자동으로 생성한다.
- Stub을 클라이언트, 서버 프로그램에 각각 포함하여 빌드한다.
- Stub 코드는 데이터 타입을 XDR 형식으로 변환하여 RPC 호출을 실행한다.
- 서버는 수신된 함수/프로시저 호출에 대한 처리를 서버 Stub을 통해 처리 완료 후, 결과값을 XDR 변환하여 Return
- 최종적으로 클라이언트 프로그램은 서버가 Return한 결과값을 전송 받는다.
Stub
Stub은 RPC 기술 핵심이다. 서버와 클라이언트는 서로 다른 주소 공간을 사용 하므로 함수 호출에 사용된 매개 변수를 꼭 변환해줘야 한다. 안그러면 메모리 매개 변수에 대한 포인터가 다른 데이터를 가리키게 된다. 클라이언트 프로그램 입장에서는 자신의 프로세스 주소 공간의 함수를 호출하는 것처럼 보이는데, 클라이언트 Stub에 정의된 함수를 호출하는 것이다.
RPC의 장단점
- 장점: 하부 네트워크 프로토콜에 신경쓰지 않아도 되기 때문에 고유 프로세스 개발에 집중이 가능하다. 프로세스간 통신 기능을 비교적 쉽게 구현하고 정교한 제어가 가능하다.
- 단점: 호출 실행과 반환 시간이 보장되지 않는다. (네트워크 구간을 통하여 RPC 통신을 하는 경우, 네트워크가 끊겼을 때 치명적 문제 발생) 또한 보안이 보장되지 않는다.
RPC의 대표적인 구현체로는 구글의 ProtocolBuffer,페이스북의 Thrift, 트위터의 Finalge가 있다.
4. gRPC
gRPC는 Google이 개발한 모든 환경에서 실행할 수 있는 오픈 소스 고성능 RPC(원격 프로시저 호출) 프레임워크이다. gRPC는 클라이언트와 서버는 내부의 서버부터 사용자의 데스크톱까지 다양한 환경에서 실행하고 서로 통신할 수 있으며, gRPC에서 지원하는 모든 언어로 작성할 수 있다. 예를 들어 Go, Python 또는 Ruby로 된 클라이언트가 있고 Java로 gRPC 서버를 쉽게 만들 수 있다.
Protocol Buffer
기본적으로 gRPC는 구조화된 데이터를 직렬화하기 위에서 미리 언급한 프로토콜 버퍼를 사용한다.
1) 프로토 파일에서 직렬화하려는 데이터의 구조를 정의한다
이 파일은 확장자가 .proto인 일반 텍스트 파일이다. 프로토콜 버퍼 데이터는 메시지로 구조화되며, 각 메시지는 필드라고 하는 일련의 이름-값 쌍을 포함하는 정보의 작은 논리적 레코드이다.
// person.proto
message Person {
string name = 1;
int32 id = 2;
bool has_ponycopter = 3;
}
2) protoc 컴파일러를 사용해 프로토 정의에서 원하는 언어로 '데이터 액세스 클래스'를 생성한다
이 클래스는 name(), set_name()과 같은 각 필드에 대한 간단한 접근자와 전체 구조를 원시 바이트로 직렬화/구문 분석하는 메서드를 제공한다. 예를 들어, 선택한 언어가 C++인 경우 위 예제에서 컴파일러를 실행하면 Person이라는 클래스가 생성된다. 그런 다음 애플리케이션에서 이 클래스를 사용하여 Person 프로토콜 버퍼 메시지를 채우고, 직렬화하고, 검색할 수 있다.
// The greeter service definition.
service Greeter {
// Sends a greeting
rpc SayHello (HelloRequest) returns (HelloReply) {}
}
// The request message containing the user's name.
message HelloRequest {
string name = 1;
}
// The response message containing the greetings
message HelloReply {
string message = 1;
}
gRPC는 프로토 파일에서 코드를 생성하기 위해 특수 gRPC 플러그인과 함께 protoc을 사용한다. 프로토 파일에는 메시지 유형을 채우고, 직렬화하고, 검색하기 위한 일반 프로토콜 버퍼 코드뿐만 아니라 생성된 gRPC 클라이언트 및 서버 코드가 포함된다.
gRPC 서비스
많은 RPC 시스템과 마찬가지로 gRPC는 매개 변수 및 반환 유형을 사용하여 원격으로 호출할 수 있는 메서드를 지정하여 서비스를 정의한다는 아이디어를 기반으로 한다. 기본적으로 gRPC는 프로토콜 버퍼를 사용한다. 서비스 인터페이스와 페이로드 메시지의 구조를 모두 설명하기 위한 IDL로 사용된다.
service HelloService {
rpc SayHello (HelloRequest) returns (HelloResponse);
}
message HelloRequest {
string greeting = 1;
}
message HelloResponse {
string reply = 1;
}
5. gRPC와 REST API 비교
gRPC와 REST API는 모두 서비스 간 통신을 가능하게 하지만 프로토콜, 데이터 형식 및 설계 원칙 측면에서 다르다.
특징 | REST API | gRPC |
protocol | HTTP/1.1, HTTP/2, HTTP/3 | HTTP/3 |
data format | JSON, XML(default: JSON) | Protobuf |
api 설계 | resource 기반 HTTP 메서드 | contract-first, rpc call |
스트리밍 | limited(sse, web socket) | built-in 양방향 스트리밍 |
언어 지원 | JSON 형식에 의존하기 때문에 언어에 구애받지 않는 폭 넓은 지원 | 여러 언어를 지원하지만 .proto 파일에서 생성된 코드 필요 |
성능 및 효율성 | 일반적으로 더 낮음(JSON, HTTP/1.1로 인해) | 높음(Protobuf, HTTP/2로 인해) |
요약하면 REST는 HTTP 프로토콜 기반으로 자원을 명시하고 HTTP Method를 통해 처리하는 기법이다. HTTP 프로토콜을 그대로 사용하기 때문에 별도 어려운 작업 없이도 쉽게 사용할 수 있다는 장점이 있다. 하지만 자원 명시적인 Restful한 API 설계는 꽤나 까다롭다. 그리고 data format은 xml, json으로 protobuf에 비해 비교적 비효울적 포맷을 사용한다. gRPC는 더 나은 성능, 효율적인 직렬화 및 양방향 스트리밍과 같은 REST API에 비해 몇 가지 이점을 제공한다. REST API와 gRPC 중에서 선택하는 것은 성능, 단순성 또는 스트리밍 기능의 필요성과 같은 프로젝트의 특정 요구 사항에 따라 다를 수 있다.
Refs
https://grpc.io/docs/what-is-grpc/
https://protobuf.dev/overview/
https://people.math.harvard.edu/~ctm/home/text/others/shannon/entropy/entropy.pdf