컴퓨터 구조 운영체제 네트워크 자료구조 알고리즘 데이터베이스 내 학습
network · 19

gRPC 통신

마이크로서비스RPCgRPC

서비스가 여러 개로 나뉘면 서비스끼리 서로의 기능을 호출해야 합니다. 이때 다른 서버에 있는 함수를 내 코드의 함수처럼 부르게 해 주는 방식이 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 등)를 쓰고, 호출마다 제한 시간(데드라인)을 정해 오래 걸리는 호출을 끊을 수 있습니다.

스키마 우선입니다 — 호출과 데이터 구조를 .proto로 먼저 정의하고 코드를 생성하므로, 서버와 클라이언트의 계약이 파일 하나로 고정되고 언어가 달라도 어긋나지 않습니다.
작고 빠른 이진 메시지입니다 — 필드 이름을 보내지 않고 번호와 가변 길이 정수로 인코딩해, 같은 정보를 JSON보다 훨씬 작게 표현할 수 있습니다.
스트리밍이 기본 기능입니다 — 요청과 응답이 1:1일 필요가 없어, 시세 구독, 대용량 업로드, 실시간 양방향 통신을 같은 방식으로 다룹니다.
대신 사람이 읽기는 어렵습니다 — 이진 메시지는 브라우저 개발자 도구나 curl로 바로 읽을 수 없고, 같은 .proto 스키마가 있어야 해석됩니다. 디버깅에는 전용 도구가 필요합니다.
이 시각화에서 확인할 것 — "호출 방식" 탭에서 네 가지 호출의 메시지 수를, "직렬화 크기" 탭에서 JSON과의 바이트 차이를, "HTTP/2 다중화" 탭에서 연결 하나로 호출 여러 개가 진행되는 모습을 STEP으로 확인하세요.

gRPC의 배경

구글은 내부 서비스끼리 통신하는 데 Stubby라는 RPC 시스템을 오래 써 왔고, 2015년에 이를 바탕으로 한 gRPC를 오픈 소스로 공개했습니다. 이후 CNCF(Cloud Native Computing Foundation)의 프로젝트가 되었고, 마이크로서비스 사이의 통신에 쓰이기도 합니다. 웹 브라우저는 gRPC의 HTTP/2 기능을 그대로 쓸 수 없어, 브라우저용으로는 변환 계층을 둔 gRPC-Web을 사용합니다.

숫자로 확인하기

호출 방식별 메시지 수 — 시각화에서 단항은 요청 1개 · 응답 1개, 서버 스트리밍은 1개 · 3개, 클라이언트 스트리밍은 3개 · 1개, 양방향 스트리밍은 3개 · 3개를 주고받습니다.
직렬화 크기 — id 150, name "kim"인 같은 정보가 JSON(공백 없이)으로는 23바이트, Protocol Buffers로는 08 96 01 12 03 6b 69 6d 8바이트입니다. 약 65% 작습니다.
태그 계산 — 필드 태그는 (필드 번호 << 3) | 와이어 타입입니다. 필드 1의 정수는 (1 << 3) | 0 = 8(0x08), 필드 2의 문자열은 (2 << 3) | 2 = 18(0x12)입니다.
다중화 — 시각화에서 연결 1개 안에 스트림 3개(번호 1, 3, 5)가 열리고 프레임 9개가 교차해 오갑니다. HTTP/1.1이라면 동시 호출 3개에 연결이 보통 3개 필요합니다.

자주 하는 오해

gRPC는 항상 REST보다 빠르다 — 작은 이진 메시지와 연결 재사용 덕분에 유리한 경우가 많지만, 성능은 메시지 크기, 네트워크, 구현에 따라 달라집니다. 공개 API나 브라우저 직접 호출에서는 REST가 더 편하기도 합니다.
gRPC는 Protocol Buffers만 쓴다 — 기본 직렬화가 Protocol Buffers일 뿐, 다른 직렬화 형식을 쓰도록 바꿀 수도 있습니다.
브라우저에서 바로 호출된다 — 브라우저의 API로는 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나 사람이 읽고 디버깅해야 하는 환경에서는 불편할 수 있습니다.

실제 시스템에서는

마이크로서비스 — 서비스 사이의 내부 호출에 gRPC를 쓰고, 외부에는 REST API를 제공하는 구성이 흔합니다. 스프링 부트에서도 gRPC 서버와 클라이언트를 만들 수 있습니다.
인프라 도구 — etcd, 쿠버네티스의 컨테이너 런타임 인터페이스(CRI) 같은 인프라 구성요소가 gRPC로 통신합니다.
로드 밸런싱 — gRPC는 긴 연결 하나에 호출을 여럿 싣기 때문에 연결 단위(L4) 분산만으로는 한 서버로 쏠릴 수 있어, 호출 단위로 분산하는 HTTP/2 인식 프록시나 클라이언트 측 분산을 씁니다.
호환성 관리 — .proto의 필드 번호를 바꾸지 않고, 쓰지 않는 필드는 번호를 예약(reserved)해 재사용을 막는 식으로 서버와 클라이언트의 버전을 따로 올립니다.

함께 보면 좋은 토픽

HTTP — gRPC가 올라타는 HTTP의 구조를 봅니다.
WebSocket — 양방향 통신을 구현하는 또 다른 방식을 봅니다.
로드 밸런서 — 긴 연결 위의 호출을 나누는 방법을 봅니다.
HTTPS / TLS — gRPC 연결을 암호화하는 TLS를 봅니다.

참고 자료

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가 기본으로 사용하는 전송 프로토콜과 직렬화 방식은?

Google이 만든 이진 직렬화 포맷과 멀티플렉싱을 지원하는 HTTP 버전입니다.
gRPC는 HTTP/2 위에서 동작하며 기본적으로 Protocol Buffers로 메시지를 직렬화합니다. HTTP/2의 멀티플렉싱과 스트리밍 덕분에 효율적인 통신이 가능합니다.

Q2

Protocol Buffers의 특징은?

스키마를 먼저 정의하고 코드를 생성합니다.
Protocol Buffers는 .proto 파일로 스키마를 정의하고 그로부터 각 언어용 코드를 생성합니다. 이진 형식이라 JSON보다 크기가 작고 직렬화/역직렬화가 빠릅니다.

Q3

gRPC가 지원하는 통신 방식은?

HTTP/2 스트림 덕분에 요청/응답이 꼭 1:1일 필요가 없습니다.
gRPC는 요청 하나에 응답 하나인 단항 호출뿐 아니라, 서버 스트리밍, 클라이언트 스트리밍, 양방향 스트리밍까지 네 가지 통신 방식을 지원합니다.