gRPC 통신
서비스가 여러 개로 나뉘면 서비스끼리 서로의 기능을 호출해야 합니다. 이때 다른 서버에 있는 함수를 내 코드의 함수처럼 부르게 해 주는 방식이 RPC(Remote Procedure Call, 원격 프로시저 호출)이고, gRPC는 구글이 만든 오픈 소스 RPC 프레임워크입니다. 호출할 함수와 주고받을 데이터의 구조를 .proto 파일에 한 번 정의하면, 여러 언어의 클라이언트와 서버 코드를 자동으로 만들어 줍니다.
gRPC는 전송에 HTTP/2를, 메시지 직렬화에 기본적으로 Protocol Buffers를 씁니다. 이진 형식이라 JSON보다 작고 빠르며, HTTP/2의 스트림 덕분에 요청과 응답이 꼭 1:1일 필요가 없어 서버 스트리밍, 클라이언트 스트리밍, 양방향 스트리밍도 지원합니다. 이 토픽에서는 네 가지 호출 방식, 직렬화 크기 차이, HTTP/2 다중화를 단계별로 봅니다.
핵심 개념
RPC
다른 서버의 함수를 로컬 함수처럼 호출하는 방식입니다. 네트워크 통신, 직렬화, 오류 처리를 프레임워크가 감춰 주어 개발자는 함수 호출처럼 코드를 씁니다.
핵심 개념
Protocol Buffers
.proto 파일에 스키마를 정의하고 이진 형식으로 직렬화하는 데이터 포맷입니다. 필드 이름 대신 필드 번호를 전송해 JSON보다 크기가 작고 처리도 빠릅니다.
핵심 개념
HTTP/2 위의 전송
gRPC 호출은 HTTP/2 스트림 하나에 대응합니다. 연결 하나에서 여러 호출이 동시에 진행되는 멀티플렉싱과 헤더 압축의 이점을 그대로 얻습니다.
핵심 개념
네 가지 호출 방식
단항(요청 1, 응답 1), 서버 스트리밍(응답 여러 개), 클라이언트 스트리밍(요청 여러 개), 양방향 스트리밍(양쪽이 여러 개)이 있고 .proto의 stream 키워드로 정합니다.
핵심 개념
스텁과 코드 생성
protoc 컴파일러가 .proto에서 클라이언트 스텁과 서버 뼈대 코드를 만듭니다. 언어가 달라도 같은 .proto를 쓰면 서로 호출할 수 있습니다.
핵심 개념
상태 코드와 데드라인
gRPC는 HTTP 상태 코드와 별개로 자체 상태 코드(OK, NOT_FOUND, DEADLINE_EXCEEDED 등)를 쓰고, 호출마다 제한 시간(데드라인)을 정해 오래 걸리는 호출을 끊을 수 있습니다.
gRPC의 배경
구글은 내부 서비스끼리 통신하는 데 Stubby라는 RPC 시스템을 오래 써 왔고, 2015년에 이를 바탕으로 한 gRPC를 오픈 소스로 공개했습니다. 이후 CNCF(Cloud Native Computing Foundation)의 프로젝트가 되었고, 마이크로서비스 사이의 통신에 쓰이기도 합니다. 웹 브라우저는 gRPC의 HTTP/2 기능을 그대로 쓸 수 없어, 브라우저용으로는 변환 계층을 둔 gRPC-Web을 사용합니다.
숫자로 확인하기
자주 하는 오해
면접에서 이렇게 물어요
면접 질문
gRPC와 REST의 차이는 무엇인가요?
REST는 보통 HTTP/1.1과 JSON으로 자원을 주고받고, gRPC는 HTTP/2와 Protocol Buffers로 함수 호출을 주고받습니다. gRPC는 스키마가 엄격하고 스트리밍을 지원하며, REST는 사람이 읽기 쉽고 브라우저에서 바로 쓸 수 있습니다.
면접 질문
Protocol Buffers가 JSON보다 작은 이유는?
필드 이름 대신 .proto에서 정한 필드 번호를 보내고, 정수를 가변 길이로 인코딩하며, 따옴표나 구분 기호 같은 텍스트 요소가 없기 때문입니다. 대신 양쪽이 같은 스키마를 가져야 합니다.
면접 질문
gRPC의 스트리밍은 왜 가능한가요?
HTTP/2가 한 연결 안에서 독립된 스트림을 여럿 열고 각 스트림에 메시지를 이어서 실을 수 있기 때문입니다. 호출 하나가 스트림 하나에 대응해 메시지 여러 개를 주고받을 수 있습니다.
면접 질문
gRPC를 쓰기 좋은 곳과 어려운 곳은?
서비스 사이의 내부 통신, 지연이 중요한 호출, 스트리밍이 필요한 곳에 좋습니다. 브라우저가 직접 호출하는 공개 API나 사람이 읽고 디버깅해야 하는 환경에서는 불편할 수 있습니다.
실제 시스템에서는
함께 보면 좋은 토픽
참고 자료
Protocol Buffers — .proto로 서비스와 메시지 정의
syntax = "proto3"; package demo; service Chat { // 단항: 요청 1개, 응답 1개 rpc GetUser (UserRequest) returns (User); // 서버 스트리밍: 요청 1개, 응답 여러 개 rpc WatchPrice (PriceRequest) returns (stream Price); // 클라이언트 스트리밍: 요청 여러 개, 응답 1개 rpc Upload (stream Chunk) returns (UploadResult); // 양방향 스트리밍 rpc Talk (stream Msg) returns (stream Msg); } message User { int32 id = 1; string name = 2; }
.proto 파일은 서비스(service)의 호출 목록과 주고받을 메시지(message)의 구조를 언어와 무관하게 정의합니다. rpc 줄에서 stream 키워드가 요청 쪽에 있는지, 응답 쪽에 있는지에 따라 네 가지 호출 방식이 정해집니다. 메시지의 각 필드에는 이름과 함께 번호(= 1, = 2)가 붙는데, 이 번호가 실제 전송 데이터에서 필드 이름을 대신합니다. 한 번 쓴 번호는 바꾸지 않는 것이 호환성을 지키는 규칙입니다. protoc 컴파일러가 이 파일에서 각 언어의 클라이언트 스텁과 서버 뼈대 코드를 생성하므로, 클라이언트는 원격 호출을 일반 함수 호출처럼 쓸 수 있습니다.
Python — grpcio 서버와 클라이언트 (단항 호출)
# server.py from concurrent import futures import grpc import helloworld_pb2, helloworld_pb2_grpc class Greeter(helloworld_pb2_grpc.GreeterServicer): def SayHello(self, request, context): return helloworld_pb2.HelloReply(message="Hello, " + request.name) server = grpc.server(futures.ThreadPoolExecutor(max_workers=10)) helloworld_pb2_grpc.add_GreeterServicer_to_server(Greeter(), server) server.add_insecure_port("localhost:50051") server.start() server.wait_for_termination() # client.py with grpc.insecure_channel("localhost:50051") as channel: stub = helloworld_pb2_grpc.GreeterStub(channel) reply = stub.SayHello(helloworld_pb2.HelloRequest(name="kim")) print(reply.message) # Hello, kim
서버는 .proto에서 생성된 GreeterServicer를 상속해 SayHello 메서드를 구현하고, grpc.server에 등록한 뒤 포트를 열어 요청을 기다립니다. 스레드 풀(ThreadPoolExecutor)이 동시에 들어온 호출을 나눠 처리합니다. 클라이언트는 채널(channel)로 서버에 연결하고, 생성된 스텁(GreeterStub)의 SayHello를 일반 함수처럼 호출합니다. 호출 한 번이 단항 RPC 하나이며, 직렬화와 HTTP/2 전송은 gRPC 라이브러리가 맡습니다. 위 코드는 암호화 없는 insecure 채널을 쓰는 연습용 예시이고, 실제 서비스에서는 TLS를 적용한 채널과 호출 제한 시간(deadline)을 함께 설정합니다.
Python — Protocol Buffers 바이트를 손으로 만들기
import json 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 encode_user(uid, name): data = name.encode() return (bytes([(1 << 3) | 0]) + varint(uid) # 필드 1: varint + bytes([(2 << 3) | 2]) + varint(len(data)) + data) # 필드 2: 길이 + 문자열 msg = encode_user(150, "kim") print(msg.hex(" "), len(msg)) # 08 96 01 12 03 6b 69 6d 8 print(len(json.dumps({"id": 150, "name": "kim"}, separators=(",", ":")))) # 23
Protocol Buffers의 각 필드는 태그, 값 순서로 직렬화됩니다. 태그는 (필드 번호 << 3) | 와이어 타입이고, 정수는 7비트씩 끊어 쓰는 가변 길이 정수(varint)로 150이 0x96 0x01 두 바이트가 됩니다. 문자열은 와이어 타입 2(길이 접두)로 길이 뒤에 바이트가 따라옵니다. 결과 08 96 01 12 03 6b 69 6d는 8바이트로, 같은 정보를 담은 JSON 23바이트보다 작습니다. 시각화의 직렬화 크기 탭이 보여 주는 값과 같습니다. 실제로는 protoc가 생성한 코드가 이 일을 대신하고, 음수나 큰 수, 중첩 메시지 같은 규칙이 더 있으므로 이 코드는 원리만 보이는 최소 예시입니다.
Q1
gRPC가 기본으로 사용하는 전송 프로토콜과 직렬화 방식은?
Q2
Protocol Buffers의 특징은?
Q3
gRPC가 지원하는 통신 방식은?