[CS300 #210] gRPC — 계약 먼저, HTTP/2 위의 원격 함수 호출
컴퓨터공학 300 주제 시리즈의 210번째 글이다. 전체 지도는 여기.
한 줄 요약
gRPC 는 Protocol Buffers 로 서비스와 메시지를 먼저 정의하고, 거기서 생성한 코드로 HTTP/2 위에서 원격 함수를 호출하는 RPC 프레임워크다. 이진 직렬화, 스트리밍, 데드라인 전파가 기본으로 들어 있다.
왜 필요한가
마이크로서비스 수십 개가 서로 JSON over HTTP 로 통신한다고 하자. 다음 문제가 반복된다.
- 필드 이름 오타나 타입 불일치가 실행해 봐야 드러난다. 서버가
userId를user_id로 바꾸면 클라이언트가 조용히 null 을 받는다. - 언어마다 클라이언트 코드를 손으로 짜고, 그 코드가 서버와 어긋난다.
- 텍스트 JSON 은 파싱 비용과 크기가 크다. 내부 호출이 초당 수만 건이면 무시하기 어렵다.
- 한 요청이 서비스 A → B → C 로 퍼질 때, 사용자가 이미 포기한 요청을 C 가 계속 처리한다.
gRPC 는 “스키마를 먼저 쓰고 코드는 생성한다”는 방식으로 앞의 두 문제를, 이진 인코딩과 HTTP/2 로 세 번째를, 데드라인과 취소 전파로 네 번째를 다룬다.
핵심 개념
.proto: 계약이 원본이다
syntax = "proto3";
package shop.v1;
service OrderService {
rpc GetOrder (GetOrderRequest) returns (Order); // 단항
rpc WatchOrders (WatchRequest) returns (stream Order); // 서버 스트리밍
rpc UploadItems (stream Item) returns (UploadSummary); // 클라이언트 스트리밍
rpc Chat (stream ChatMessage) returns (stream ChatMessage); // 양방향 스트리밍
}
message GetOrderRequest {
int64 order_id = 1;
}
message Order {
int64 id = 1;
string status = 2;
repeated Item items = 3;
}
protoc 와 언어별 플러그인이 이 파일에서 서버 뼈대와 클라이언트 스텁을 만든다. 클라이언트는 stub.GetOrder(req) 처럼 지역 함수를 부르듯 호출한다.
각 필드 뒤의 숫자는 필드 번호이고, 이진 인코딩에서 필드를 식별하는 것은 이름이 아니라 이 번호다. 그래서 규칙이 생긴다.
- 이미 쓴 필드 번호의 의미를 바꾸거나 재사용하지 않는다. 지운 필드는
reserved로 막아 둔다. - 새 필드는 새 번호로 추가한다. 옛 클라이언트는 모르는 필드를 건너뛰고, 새 클라이언트는 빠진 필드를 기본값으로 읽는다. 이것이 하위·상위 호환의 근거다.
이진 인코딩
Protocol Buffers 는 각 필드를 (필드 번호 << 3) | 와이어 타입 태그와 값으로 적는다. 정수는 7비트씩 끊어 쓰는 varint 로 작게 표현한다. 필드 이름은 전송되지 않는다. 그래서 같은 데이터가 JSON 보다 작고 파싱이 빠르다. 대신 사람이 바로 읽을 수 없고, 스키마 없이는 해석할 수 없다.
HTTP/2 위의 RPC
gRPC 는 HTTP/2(RFC 9113)를 전송 계층으로 쓴다.
HTTP/2 연결 하나
├─ 스트림 1: POST /shop.v1.OrderService/GetOrder ← RPC 하나 = 스트림 하나
├─ 스트림 3: POST /shop.v1.OrderService/WatchOrders
└─ 스트림 5: ...
헤더(:path, content-type: application/grpc, grpc-timeout ...)
DATA: [압축 플래그 1B][길이 4B][protobuf 메시지] ...
트레일러: grpc-status, grpc-message
연결 하나에 여러 RPC 가 스트림으로 다중화되므로 연결 수립 비용이 줄고, 한 스트림 안에서 메시지를 계속 흘려보낼 수 있어 스트리밍 RPC 가 자연스럽다. 결과 상태는 HTTP 상태 코드가 아니라 마지막 트레일러의 grpc-status 로 전달된다.
상태 코드와 데드라인
gRPC 는 자체 상태 코드를 쓴다. 자주 보는 것은 다음과 같다.
| 코드 | 뜻 | 재시도 |
|---|---|---|
| OK | 성공 | — |
| INVALID_ARGUMENT | 요청 값이 잘못됨 | 하지 않음 |
| NOT_FOUND | 대상 없음 | 하지 않음 |
| DEADLINE_EXCEEDED | 마감 시간 초과 | 상황에 따라 |
| UNAVAILABLE | 일시적으로 사용 불가 | 백오프 후 재시도 |
| UNAUTHENTICATED | 인증 정보 없음·무효 | 자격 갱신 후 |
| PERMISSION_DENIED | 권한 없음 | 하지 않음 |
gRPC 공식 문서는 클라이언트가 항상 현실적인 데드라인을 명시하라고 권한다. 클라이언트가 “이 시각까지 답이 없으면 포기한다”를 정하면, 그 값이 grpc-timeout 헤더로 서버에 전달된다. 서버가 다시 다른 서비스를 부를 때 남은 시간을 이어 넘기면, 체인 전체가 같은 마감을 공유한다. 사용자가 이미 포기한 요청을 끝단 서비스가 붙들고 있는 낭비를 줄인다.
약점과 대응
| 약점 | 이유 | 대응 |
|---|---|---|
| 브라우저에서 직접 호출이 어려움 | 브라우저 API 가 HTTP/2 트레일러 등을 다루지 못함 | gRPC-Web + 프록시, 또는 REST 게이트웨이 |
| L4 로드 밸런싱이 치우침 | 오래 사는 연결 하나에 요청이 다중화됨 | 클라이언트 측 로드 밸런싱, L7 프록시 |
| 디버깅이 불편 | 이진 페이로드 | 서버 리플렉션 + grpcurl 같은 도구 |
| 공개 API 로는 진입 장벽 | 스키마·코드 생성 필요 | 외부엔 REST, 내부엔 gRPC |
두 번째 항목은 쿠버네티스에서 특히 자주 걸린다. Service(ClusterIP)는 연결 단위로 분산하므로, 클라이언트가 연결 하나를 계속 재사용하면 요청이 파드 하나에 몰린다.
직접 해 보기
gRPC 라이브러리 없이 Protocol Buffers 의 인코딩과 gRPC 메시지 프레이밍을 손으로 만들어 본다.
import json, struct
def varint(n):
out = bytearray()
while True:
b = n & 0x7F
n >>= 7
if n: out.append(b | 0x80) # 뒤에 바이트가 더 있다는 표시
else: out.append(b); return bytes(out)
def field(num, wire_type, payload):
return varint((num << 3) | wire_type) + payload
def encode_user(uid, name, active):
# message User { int32 id = 1; string name = 2; bool active = 3; }
b = name.encode()
return (field(1, 0, varint(uid)) +
field(2, 2, varint(len(b)) + b) +
field(3, 0, varint(int(active))))
pb = encode_user(150, "testing", True)
js = json.dumps({"id": 150, "name": "testing", "active": True}, separators=(",", ":")).encode()
print("protobuf:", pb.hex(" "), f"({len(pb)} bytes)")
print("JSON :", js.decode(), f"({len(js)} bytes)")
# gRPC 메시지 프레이밍: 압축 여부 1바이트 + 길이 4바이트(빅엔디언) + 메시지
frame = struct.pack(">BI", 0, len(pb)) + pb
print("gRPC frame:", frame.hex(" "))
실행 결과:
protobuf: 08 96 01 12 07 74 65 73 74 69 6e 67 18 01 (14 bytes)
JSON : {"id":150,"name":"testing","active":true} (41 bytes)
gRPC frame: 00 00 00 00 0e 08 96 01 12 07 74 65 73 74 69 6e 67 18 01
바이트를 읽어 보자.
08= 필드 1, 와이어 타입 0(varint).96 01= 150. 150 은 7비트에 안 들어가므로 하위 7비트0010110에 계속 비트를 붙인0x96, 나머지1이0x01이다.12= 필드 2, 와이어 타입 2(길이 구분).07= 길이 7, 이어서 “testing” 의 UTF-8 바이트.18 01= 필드 3, varint, 값 1(true).
필드 이름이 하나도 없어서 41바이트 JSON 이 14바이트가 되었다. gRPC 프레임 앞 5바이트는 압축 플래그 00 과 빅엔디언 길이 00 00 00 0e(14)다. 이 형식은 gRPC 의 HTTP/2 프로토콜 문서에 정의되어 있다. 같은 결과를 protoc --encode 로 만들어 비교해 보는 것도 좋은 연습이다.
현업에서는
- 내부 서비스 간 통신: 외부 고객에게는 REST/JSON 을, 내부 서비스 사이에는 gRPC 를 쓰는 조합이 흔하다. 스키마 저장소를 따로 두고 CI 에서 깨지는 변경(필드 번호 재사용 등)을 검사한다.
- 쿠버네티스의 쏠림 문제: 홈랩 k3s 에서도 gRPC 서버 레플리카를 3개 띄웠는데 CPU 가 한 파드에만 몰린다면 연결 단위 분산을 의심한다. 헤드리스 Service 와 클라이언트 측 라운드 로빈, 또는 서비스 메시·L7 프록시로 푼다.
- 헬스 체크: gRPC 표준 헬스 체크 프로토콜이 있고, 쿠버네티스도 gRPC 프로브를 지원한다. HTTP 엔드포인트를 따로 만들지 않아도 된다.
- 장애 전파 차단: 데드라인 없는 호출은 하위 서비스가 느려질 때 스레드와 연결을 붙잡아 상위로 장애를 번지게 한다. 코드 리뷰에서 “이 호출의 데드라인은?”을 습관적으로 묻는다.
확인 문제
.proto의 필드 번호를 재사용하면 안 되는 이유는?- gRPC 의 네 가지 RPC 유형을 나열하라.
- gRPC 호출의 성공·실패는 HTTP 메시지의 어디에 담기는가.
- 데드라인을 하위 호출로 전파하면 무엇이 좋아지는가.
- 쿠버네티스 ClusterIP Service 뒤의 gRPC 서버에서 부하가 한 파드에 몰리는 이유와 해법은?
풀이
- 이진 인코딩은 이름이 아니라 번호로 필드를 식별하므로, 옛 클라이언트·저장된 데이터가 다른 의미의 값으로 잘못 해석된다.
- 단항(unary), 서버 스트리밍, 클라이언트 스트리밍, 양방향 스트리밍.
- 응답 끝 트레일러의
grpc-status(와grpc-message)에 담긴다. - 호출 체인 전체가 같은 마감을 공유해, 이미 시간이 지난 요청을 하위 서비스가 계속 처리하는 낭비와 자원 점유가 줄어든다.
- HTTP/2 연결 하나에 여러 요청이 다중화되는데 ClusterIP 는 연결 단위로만 분산하기 때문이다. 클라이언트 측 로드 밸런싱(헤드리스 Service)이나 요청 단위로 분산하는 L7 프록시를 쓴다.
더 읽을거리 (References)
- gRPC, Core concepts, architecture and lifecycle: https://grpc.io/docs/what-is-grpc/core-concepts/
- gRPC, gRPC over HTTP2 (프로토콜 문서): https://github.com/grpc/grpc/blob/master/doc/PROTOCOL-HTTP2.md
- Protocol Buffers, Encoding: https://protobuf.dev/programming-guides/encoding/
- RFC 9113, HTTP/2: https://www.rfc-editor.org/rfc/rfc9113