gRPC 톺아보기

gRPC가 등장한 배경

Google의 고민

2001년 무렵 Google은 내부적으로 심각한 문제에 부딪혔다. 검색, 광고, 지도, 메일처럼 서로 다른 서비스들이 하루에도 수백억 번씩 서로를 호출해야 하는데, 당시 가장 널리 쓰이던 SOAPREST/JSON 방식으로는 도저히 감당이 안 됐다.

두 방식 모두 데이터를 텍스트로 주고받는다. 예를 들어 “사용자 이름이 Alice이고 나이는 30세”라는 정보를 JSON으로 전달하면 이렇게 된다.

{"name": "Alice", "age": 30}

사람 눈에는 읽기 편하지만, 이걸 초당 수백만 번 주고받으면 어떻게 될까? 텍스트를 파싱하고 직렬화하는 비용이 누적되면서 CPU, 네트워크 대역폭 모두 한계에 부딪힌다.

Google은 이 문제를 해결하기 위해 Stubby라는 내부 통신 시스템을 직접 만들었다. Stubby는 텍스트 대신 바이너리(0과 1)로 데이터를 주고받고, 훨씬 효율적인 전용 프로토콜을 사용했다. Google SRE Book에 따르면 Google의 모든 서비스가 Stubby를 통해 서로 통신했으며, 심지어 같은 프로그램 안에서의 함수 호출도 Stubby 방식을 따랐다.

Stubby에서 gRPC로

Stubby는 훌륭했지만 결정적인 결함이 있었다. Google 내부에서만 동작한다는 점이었다. Google만의 전용 인프라에 너무 깊게 의존하고 있어서, 외부에 공개하거나 다른 회사가 사용할 수 없었다.

그런데 마침 때가 맞았다. 2015년 무렵 인터넷 표준으로 HTTP/2가 등장했는데, Stubby가 내부적으로 해결한 문제들을 공개 표준 방식으로 해결하는 프로토콜이었다. gRPC 설계 원칙 문서는 이렇게 밝힌다.

“SPDY, HTTP/2, QUIC의 등장으로 Stubby의 많은 기능들이 공개 표준에 나타났다. Stubby를 재작업하여 이 표준화를 활용할 때가 되었음이 분명해졌다.”

Google은 2015년 2월 gRPC를 오픈소스로 공개했다. 2016년 8월에 프로덕션에서 사용 가능한 버전 1.0이 나왔고, 2017년에는 CNCF(클라우드 네이티브 컴퓨팅 재단)에 편입됐다. gRPC는 Stubby의 직접적인 코드 포팅이 아니라, 15년간 수백억 건의 요청을 처리하며 쌓은 교훈을 누구나 사용할 수 있는 공개 표준 위에 다시 구현한 것이다.


Protocol Buffers — 데이터를 주고받는 형식

gRPC를 이해하려면 먼저 Protocol Buffers(이하 Protobuf) 를 이해해야 한다. gRPC가 데이터를 주고받을 때 사용하는 형식이다. gRPC보다 먼저 2001년 Google 내부에서 개발됐고 2008년에 오픈소스로 공개됐다.

왜 만들어졌는가

Protobuf를 만든 Google 엔지니어 Kenton Varda는 세 가지 문제를 해결해야 했다.

XML은 이 규모에서 너무 느리고 용량이 크다. 수백억 번의 요청에서 XML처럼 장황한 텍스트 형식은 감당하기 어렵다.

구버전 서버와 신버전 서버가 동시에 동작해야 한다. 서버 100대 중 50대를 먼저 새 버전으로 업그레이드했을 때, 구버전 서버가 보낸 데이터를 신버전 서버가 읽을 수 있어야 하고 반대도 마찬가지여야 한다. 이것을 호환성(compatibility) 이라고 한다.

코드를 자동으로 생성해야 한다. Google의 코드베이스에는 수만 개의 서로 다른 데이터 구조가 있는데, 이것을 하나하나 수작업으로 직렬화 코드를 작성하는 것은 불가능하다.

핵심 아이디어 — 이름 대신 번호를 식별자로

Protobuf의 핵심 아이디어는 필드 이름 대신 필드 번호를 식별자로 사용하는 것이다. .proto 파일이라는 설계도에 데이터 구조를 이렇게 정의한다.

message User {
  string name = 1;
  int32  age  = 2;
}

여기서 = 1, = 2필드 번호다. JSON이라면 "name"이라는 문자열 자체를 매번 전송해야 하지만, Protobuf는 숫자 1만 전송한다. "1번 필드 = name"이라는 사실은 받는 쪽도 같은 .proto 파일을 갖고 있으니 알고 있다.

이 설계에서 두 가지 이점이 생긴다.

인코딩 효율성: 필드 번호 1~15는 태그(필드 번호 + 데이터 종류 코드)를 단 1바이트로 인코딩한다. customer_name 같은 이름은 13바이트다. 수십억 번의 전송에서 이 차이가 누적되면 대역폭과 처리 시간에 막대한 차이가 난다.

호환성 보장: 새 필드를 추가해도 모르는 필드 번호를 받으면 “무시한다”는 규칙 덕분에 구버전 코드가 안전하게 동작한다. 덕분에 서버를 한 번에 다 바꾸지 않아도 되는 점진적 배포가 가능하다.

바이트 수준에서 무슨 일이 일어나는가

Protobuf의 모든 필드는 태그 + 값 쌍으로 인코딩된다. 태그는 “이게 몇 번 필드이고, 값이 어떤 종류인가”를 알려주는 1~2바이트짜리 숫자다. 계산식은 이렇다.

태그 = (필드 번호 × 8) + 와이어 타입

와이어 타입(wire type) 은 파서에게 “값이 얼마나 긴지”를 알려주는 코드다. 정수(int32)는 와이어 타입 0을 쓰고, 문자열(string)은 와이어 타입 2를 써서 “먼저 길이를 알려주고 그만큼 읽어라”는 방식으로 동작한다.

varint는 정수를 고정 4바이트나 8바이트로 저장하는 대신, 값에 따라 1~10바이트를 가변적으로 사용하는 인코딩 방식이다. 각 바이트의 맨 앞 1비트는 “다음 바이트도 있냐”는 신호이고, 나머지 7비트가 실제 데이터다. 숫자 1은 1바이트, 150은 2바이트로 저장된다. 대부분의 정수 필드가 작은 값이라는 현실을 반영한 최적화다.

User { name: "Alice", age: 30 }을 인코딩하면 바이트 스트림은 이렇게 된다.

0A 05 41 6C 69 63 65 10 1E
│  │  └────────────┘ │  │
│  │    "Alice"      │  30 (age의 값)
│  5 (문자열 길이)   16 = (2×8)+0, 필드2/varint
8+2 = 10 = 0x0A (필드1/LEN 타입)

JSON의 {"name":"Alice","age":30} (26바이트)보다 훨씬 작다.

필드 번호를 재사용하면 왜 데이터가 깨지는가

Protobuf는 필드 이름을 전송하지 않고 번호만 전송하기 때문에, 번호가 곧 의미를 담고 있다. 파서 입장에서는 “5번 필드의 값이 왔다”는 사실만 알 뿐, 그게 어떤 이름인지는 스키마를 봐야 알 수 있다.

예를 들어 v1에서 int32 user_id = 1로 정의했다가, v2에서 이 필드를 지우고 string user_name = 1로 재사용했다고 가정하자. 두 타입은 와이어 타입이 다르다. 정수(int32)는 와이어 타입 0, 문자열(string)은 와이어 타입 2다. 구버전 클라이언트가 보낸 정수 42(varint 형식)를 신버전 서버가 “1번 필드는 string이니까 먼저 길이를 읽겠다”며 잘못 해석하면, 잘못된 위치에서 바이트를 읽게 된다. 그 결과로 다음 필드들의 경계도 모두 틀어지고, 뒤따르는 데이터 전체가 조용히 손상된다. Protobuf 파서는 이 상황을 오류로 감지하지 못할 수도 있다.

같은 와이어 타입이라도 문제가 생길 수 있다. int32sint32로 바꿨다고 하자. 두 타입 모두 와이어 타입 0을 쓴다. 그러나 음수를 인코딩하는 방식이 다르다. int32로 -1을 인코딩하면 10바이트짜리 varint가 생성되고, sint32는 ZigZag 방식으로 -1을 2바이트로 인코딩한다. 파서가 와이어 타입만 보고 “둘 다 varint니까 괜찮겠지”라고 판단하면 전혀 다른 숫자가 나오는데, 아무 오류 없이 통과한다.

그래서 Protobuf는 삭제한 필드 번호를 reserved로 잠가두도록 설계돼 있다.

message User {
  reserved 2;        // 이 번호는 영원히 재사용하지 않겠다는 선언
  reserved "age";    // 이 이름도 재사용 금지
  string name = 1;
}

이렇게 선언해두면 이 번호나 이름으로 새 필드를 정의하려 할 때 컴파일러가 에러를 낸다. 런타임이 아니라 빌드 시점에 잡을 수 있다는 점이 중요하다.

proto2와 proto3의 차이

Protobuf에는 두 버전이 있다. proto2는 2008년 공개 당시 버전으로, 필드마다 required, optional, repeated 중 하나를 반드시 표시해야 했다. required는 “이 필드는 반드시 있어야 한다”는 뜻인데, 이것이 나중에 큰 문제가 됐다. 한번 required로 선언한 필드를 나중에 제거하면, 그 필드가 없는 메시지를 받은 구버전 코드가 실패한다. 점진적 배포가 불가능해지는 것이다.

proto3는 2016년에 이 문제를 해결하기 위해 required를 아예 없앴다. 모든 필드가 사실상 선택적(optional)이 되어 스키마 진화가 훨씬 안전해졌다. gRPC 공식 문서는 proto3 사용을 권장한다. 두 버전은 같은 규칙을 따를 때 바이트 수준에서 호환된다.


HTTP/2 — gRPC가 이 프로토콜을 선택한 이유

HTTP/1.1의 구조적 병목

gRPC가 HTTP/2 위에서 동작하는 이유를 이해하려면, HTTP/1.1의 한계를 먼저 알아야 한다.

HTTP/1.1에서 클라이언트와 서버는 하나의 TCP 연결 위에서 통신한다. TCP 연결은 전화 통화처럼 한번 연결을 맺으면 데이터를 안정적으로 주고받을 수 있는 통로다. 문제는 이 연결이 한 번에 하나의 요청만 처리한다는 점이다. 첫 번째 요청의 응답이 올 때까지 두 번째 요청을 보낼 수 없다. 계산대가 하나인 편의점에서 앞 사람 계산이 끝나야 다음 사람이 시작할 수 있는 것과 같다. 이것을 Head-of-Line Blocking(선두 차단) 이라고 한다.

브라우저는 이 문제를 우회하기 위해 같은 서버에 동시에 6~8개 연결을 맺는다. 그러나 서버 입장에서는 수많은 클라이언트가 각각 여러 연결을 맺어오니 연결 수가 폭발적으로 늘어난다. 10개 서비스가 각각 9개 대상과 6개 연결을 유지하면 540개의 TCP 연결이 필요하지만, HTTP/2에서는 180개면 충분하다. HTTP 요청마다 Authorization, User-Agent, Cookie 같은 헤더를 텍스트로 반복 전송하는 낭비도 있었다.

HTTP/2가 이 문제를 해결한 방법

HTTP/2의 핵심 아이디어는 하나의 TCP 연결 안에서 여러 요청을 동시에 처리하는 것이다. 이를 멀티플렉싱(multiplexing) 이라고 한다.

HTTP/2는 데이터를 전송할 때 프레임(frame) 이라는 작은 조각으로 나눈다. 각 프레임에는 “이 프레임이 어느 요청의 조각인지”를 알려주는 스트림 ID가 붙어 있다. 세 개의 요청이 동시에 진행된다면, 프레임들이 이렇게 섞여서 전송된다.

[요청1 프레임][요청2 프레임][요청3 프레임][요청1 프레임][요청2 응답 프레임]...

받는 쪽은 스트림 ID를 보고 프레임들을 올바른 요청에 재조립한다. 요청2가 먼저 완료되면 요청1을 기다리지 않고 바로 응답을 전달할 수 있다. 이것이 Head-of-Line Blocking을 해결하는 방식이다.

또한 HTTP/2는 HPACK이라는 헤더 압축 기술을 사용한다. 이전에 보낸 헤더들을 양쪽이 함께 기억해두고, 반복되는 헤더는 짧은 참조로 대체한다. 반복 헤더를 85~90% 줄일 수 있다.

한 가지 중요한 한계가 있다. HTTP/2는 애플리케이션 수준의 Head-of-Line Blocking은 해결하지만, TCP 수준의 것은 해결하지 못한다. TCP 패킷이 유실되면 그 패킷이 재전송될 때까지 해당 연결의 모든 스트림이 기다려야 한다. 이것이 이후 HTTP/3(QUIC over UDP)이 등장한 배경이다. 그러나 대부분의 내부 서비스 환경에서 패킷 유실은 드문 일이라, gRPC 입장에서는 HTTP/2가 충분히 좋은 선택이었다.

gRPC가 HTTP/2 위에서 메시지를 어떻게 담는가

gRPC 메시지가 HTTP/2를 타고 이동하는 방식은 명확하게 정해져 있다.

모든 gRPC 요청은 HTTP POST 방식으로, 경로는 /서비스이름/메서드이름 형태다. 예를 들어 UserServiceGetUser 메서드를 호출하면 /UserService/GetUser로 POST가 날아간다.

gRPC 메시지는 HTTP/2의 DATA 프레임 안에 5바이트 헤더 + 메시지 본문 형태로 담긴다. 첫 1바이트는 압축 여부(0 또는 1), 다음 4바이트는 메시지 본문의 길이다. 받는 쪽은 길이를 먼저 읽고 그만큼만 읽으면 메시지 경계를 정확히 알 수 있다.

응답 상태를 전달하는 방식도 독특하다. gRPC는 HTTP 응답 코드를 거의 항상 200 OK로 보내고, 실제 성공 여부는 응답 마지막에 붙는 트레일러(trailer) 에 담는다. grpc-status: 0이면 성공, 다른 숫자면 오류다. 이렇게 분리하는 이유는 서버가 스트리밍으로 데이터를 보내다가 끝에 가서 오류가 발생하는 상황을 표현할 수 있어야 하기 때문이다.


네 가지 통신 패턴

gRPC는 상황에 따라 네 가지 통신 패턴을 기본으로 제공한다.

Unary RPC는 가장 일반적인 방식이다. 클라이언트가 요청 하나를 보내면 서버가 응답 하나를 돌려준다. 일반 함수 호출과 동일하다. 로그인, 결제, 데이터 조회 등 대부분의 기능에 사용한다.

Server Streaming RPC는 클라이언트가 요청 하나를 보내면 서버가 응답을 여러 개 연속으로 보낸다. 실시간 배달 추적, 주식 시세 피드, 대용량 파일 다운로드처럼 서버가 계속 데이터를 밀어줘야 하는 상황에 적합하다. gRPC는 스트림 안에서 메시지 순서를 보장한다.

Client Streaming RPC는 그 반대다. 클라이언트가 데이터를 계속 보내고, 서버가 마지막에 하나의 결과를 반환한다. 대용량 파일 업로드, IoT 센서 데이터 수집처럼 클라이언트가 데이터를 생산하는 상황에 사용한다.

Bidirectional Streaming RPC는 양쪽이 동시에 스트림을 주고받는다. 두 스트림은 완전히 독립적으로 동작해서, 서버가 모든 클라이언트 메시지를 기다린 후 응답할 수도 있고, 번갈아 교환할 수도 있다. 실시간 채팅, 온라인 게임, 음성 인식 같은 상황에 사용한다.


운영 시 주의해야 할 것들

로드 밸런싱이 기본 설정으로 작동하지 않는 이유

로드 밸런싱(load balancing) 은 서버 하나로 감당하기 어려운 트래픽을 여러 서버에 골고루 나눠주는 것이다. 로드 밸런서에는 두 종류가 있다. L4 로드 밸런서는 TCP 연결 단위로 분배하고 패킷 안의 내용을 볼 수 없다. L7 로드 밸런서는 HTTP 요청 내용을 읽어서 요청 단위로 분배한다. Kubernetes의 기본 로드 밸런서(kube-proxy)는 L4다.

여기서 문제가 생긴다. gRPC는 HTTP/2 위에서 동작하고, HTTP/2는 하나의 TCP 연결을 오래 유지하면서 그 위에 모든 요청을 멀티플렉싱한다. L4 로드 밸런서는 “새 TCP 연결이 들어오면 서버를 하나 골라서 연결해준다”는 역할만 하는데, gRPC 클라이언트는 처음에 TCP 연결을 하나 맺고 이후에는 같은 연결을 계속 재사용한다. 결과적으로 최초 연결이 맺어진 서버 하나로 모든 요청이 영원히 몰린다. 실제 테스트에서 서버 5대가 있는 환경에서 50개 요청을 보냈더니 50개 전부가 서버 1대에만 갔다는 결과가 나온 바 있다.

HTTP/1.1에서는 이 문제가 덜하다. HTTP/1.1은 연결을 짧게 끊고 다시 맺는 경우가 많아서 자연스럽게 서버가 바뀐다. gRPC는 연결을 최대한 오래 유지하도록 설계되어 있어서 이 자연스러운 순환이 없다.

해결 방법은 세 가지다. 서비스 메시(Istio, Linkerd) 는 각 서버 옆에 프록시를 붙여 HTTP/2 프레임을 읽고 요청 단위로 분배한다. 애플리케이션 코드를 바꾸지 않아도 된다. 클라이언트 직접 분배는 Headless Service를 사용해 클라이언트가 모든 서버 주소를 알고 라운드 로빈 방식으로 요청을 나누는 방식이다. L7 프록시(Envoy, NGINX) 를 앞에 배치해 요청 단위로 분배하는 방법도 있다.

데드라인을 반드시 설정해야 하는 이유

gRPC 공식 문서는 이렇게 경고한다. “기본적으로 gRPC는 데드라인을 설정하지 않으며, 이는 클라이언트가 응답을 사실상 영원히 기다릴 수 있음을 의미한다.”

데드라인(deadline) 은 “이 시각까지 응답이 없으면 포기하겠다”는 절대 시각 기준이다. 타임아웃이 상대적 시간(“5초 안에”)인 것과 달리, 데드라인은 절대적 시각이다. 데드라인이 없으면 어떤 일이 벌어지는지 연쇄 과정을 보자.

서비스 C가 느려지거나 멈추면, 서비스 B는 C에게 보낸 요청의 응답을 무한정 기다리며 메모리와 스레드를 계속 점유한다. 새 요청이 들어와도 B는 리소스가 부족해 처리하지 못하고 B도 느려진다. 서비스 A는 B가 느려지자 A 역시 응답을 기다리며 리소스를 점유한다. 결국 원인은 C 하나였는데 전체 시스템이 멈춘다. 이것을 연쇄 장애(cascading failure) 라고 한다.

데드라인은 이 연쇄 고리를 끊는다. “5초 안에 응답 없으면 포기”라고 설정되어 있으면, B는 5초 후 대기를 끝내고 에러를 반환하면서 리소스를 해방시킨다.

gRPC 데드라인의 또 다른 강점은 자동 전파다. 서비스 A가 “10초 안에 완료해라”는 데드라인으로 B를 호출했을 때, B가 3초를 쓴 후 C를 호출하면 C에게는 “7초 안에 완료해라”라는 줄어든 데드라인이 자동으로 전달된다. A의 원래 제한 시간 안에 모든 하위 작업이 완료되도록 자연스럽게 맞춰진다. 상대적 타임아웃을 전파하면 서버마다 시계가 조금씩 달라 계산이 어긋날 수 있는데, 절대 시각을 전파하면 이 문제가 없다.

Dropbox는 gRPC 기반의 Courier 프레임워크를 운용하며 이 문제를 직접 경험했다. 그 결과 데드라인 설정을 개발자 재량이 아니라 프레임워크 수준의 필수 사항으로 만들었다. .proto 파일에 기본 데드라인을 명시해야 하며, 스트리밍처럼 데드라인이 의미 없는 경우에만 예외를 명시적으로 선언하도록 했다.


마무리

gRPC는 Google이 15년간 수백억 건의 내부 통신을 처리하며 쌓은 교훈을, HTTP/2와 Protocol Buffers라는 공개 표준 위에서 다시 구현한 프레임워크다. Protocol Buffers는 필드 번호라는 단순한 아이디어로 효율성과 호환성을 동시에 달성했고, HTTP/2는 멀티플렉싱으로 HTTP/1.1의 선두 차단 문제를 해결했다.

그러나 HTTP/2의 지속 연결 특성 때문에 Kubernetes 기본 L4 로드 밸런서가 제대로 작동하지 않으며, 데드라인 없는 운영은 연쇄 장애로 이어진다. 기술의 내부 동작을 이해하지 않은 채 사용하면, 문제가 생겼을 때 어디서 막혔는지 추론하기 어렵다.


참고 자료