Skip to content

✍️ 필사 모드: gRPC 심화 완전 가이드 2025: Protocol Buffers, 4가지 통신 모델, 인터셉터, 로드 밸런싱

한국어
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

TL;DR

  • gRPC = HTTP/2 + Protobuf: 5-10배 빠르고 작은 RPC
  • 4가지 모델: Unary, Server Streaming, Client Streaming, Bidirectional
  • Protobuf 최적화: 필드 번호, 변환된 byte, varint 인코딩
  • 인터셉터: 횡단 관심사 (인증, 로깅, 메트릭) 처리
  • Deadline 전파: 분산 환경에서 타임아웃 누적 방지
  • gRPC-Web: 브라우저에서 gRPC 사용

1. gRPC가 등장한 배경

1.1 RPC의 역사

RPC (Remote Procedure Call) = 다른 머신의 함수를 로컬 함수처럼 호출.

진화:

  • 1980s: Sun RPC (NFS의 기반)
  • 1990s: CORBA (복잡, 실패)
  • 2000s: SOAP/WSDL (XML, 느림)
  • 2010s: REST (JSON over HTTP)
  • 2015~: gRPC (Google이 내부에서 사용하던 Stubby 공개)

1.2 REST의 한계

GET /api/users/123 HTTP/1.1
Host: api.example.com

HTTP/1.1 200 OK
Content-Type: application/json
{"id": 123, "name": "Alice", "email": "alice@example.com"}

문제:

  1. JSON 비효율: 텍스트, 키 반복
  2. HTTP/1.1: head-of-line blocking
  3. 느슨한 계약: 스키마 강제 X
  4. 단방향: 양방향 스트리밍 어려움
  5. 클라이언트 코드 작성 부담

1.3 gRPC의 약속

service UserService {
  rpc GetUser(GetUserRequest) returns (User);
  rpc StreamUsers(Empty) returns (stream User);
}

message User {
  int64 id = 1;
  string name = 2;
  string email = 3;
}

자동 코드 생성 (모든 언어), 스키마 강제, 5-10배 작은 페이로드, HTTP/2 멀티플렉싱, 양방향 스트리밍.


2. Protocol Buffers 깊이

2.1 기본 구조

syntax = "proto3";

package myapp;

message User {
  int64 id = 1;
  string name = 2;
  string email = 3;
  repeated string tags = 4;
  Address address = 5;
}

message Address {
  string street = 1;
  string city = 2;
}

2.2 필드 번호의 의미

message User {
  int64 id = 1;      // ← 1은 필드 번호
  string name = 2;
}

왜 필드 번호?:

  • 이진 형식의 식별자
  • 이름 변경에도 호환 (번호만 같으면)
  • 삭제된 필드는 번호 재사용 X
// v1
message User {
  string user_name = 1;
}

// v2 (호환됨)
message User {
  string display_name = 1;  // 이름 바꿈, 번호 같음
}

2.3 Wire Format

Protobuf의 이진 형식:

┌─────────────┬──────────────┐
│ field_number│  field_value │
+ type    │              │
└─────────────┴──────────────┘

각 필드: (field_number << 3) | wire_type.

Wire Types:

  • 0: VARINT (int32, int64, bool)
  • 1: FIXED64 (double, fixed64)
  • 2: LENGTH_DELIMITED (string, bytes, message)
  • 5: FIXED32 (float, fixed32)

2.4 Varint 인코딩

작은 수는 작은 byte로 인코딩:

01 byte
1271 byte
1282 bytes
163832 bytes
163843 bytes

효과: 작은 ID, 카운터 등이 매우 효율적.

2.5 비교 예시

// JSON: 78 bytes
{"id":123,"name":"Alice","email":"alice@example.com"}
// Protobuf: 35 bytes (45% 작음)
0a 03 31 32 33  # field 1 (id), len 3, "123"
12 05 41 6c 69 63 65  # field 2 (name), len 5, "Alice"
1a 11 ...

큰 메시지일수록 차이가 큼 (key 반복이 없으므로).

2.6 Schema Evolution

규칙:

  • ✅ 새 필드 추가 (다른 번호로)
  • ✅ 필드 이름 변경
  • ✅ optional → required X (proto3는 모두 optional)
  • ❌ 필드 번호 변경
  • ❌ 필드 타입 변경 (호환 안 됨)
  • ❌ 필드 삭제 후 번호 재사용 (reserved 사용)
message User {
  reserved 4, 5;          // 4, 5는 사용 안 함
  reserved "old_field";    // 이름도 reserved
  
  int64 id = 1;
  string name = 2;
  string email = 3;
}

3. 4가지 통신 모델

3.1 Unary RPC (가장 일반적)

rpc GetUser(GetUserRequest) returns (User);

동작: 1 요청 → 1 응답. REST와 비슷.

# Client
response = stub.GetUser(GetUserRequest(user_id=123))
print(response.name)
# Server
def GetUser(self, request, context):
    user = db.get(request.user_id)
    return User(id=user.id, name=user.name, email=user.email)

사용: CRUD, 일반 API.

3.2 Server Streaming

rpc StreamUsers(Empty) returns (stream User);

동작: 1 요청 → N 응답.

# Client
for user in stub.StreamUsers(Empty()):
    print(user.name)
# Server
def StreamUsers(self, request, context):
    for user in db.iter_all():
        yield User(id=user.id, name=user.name)

사용: 큰 결과 셋 (수천 행), 실시간 업데이트, 로그 스트리밍.

3.3 Client Streaming

rpc UploadEvents(stream Event) returns (UploadResponse);

동작: N 요청 → 1 응답.

# Client
def event_generator():
    for event in events:
        yield event

response = stub.UploadEvents(event_generator())
print(response.processed)
# Server
def UploadEvents(self, request_iterator, context):
    count = 0
    for event in request_iterator:
        process(event)
        count += 1
    return UploadResponse(processed=count)

사용: 대용량 업로드, 배치 처리.

3.4 Bidirectional Streaming

rpc Chat(stream ChatMessage) returns (stream ChatMessage);

동작: N 요청 ↔ N 응답. 양쪽이 독립적으로 메시지 보냄.

# Client
def send_messages():
    yield ChatMessage(text="Hello")
    yield ChatMessage(text="How are you?")

for response in stub.Chat(send_messages()):
    print(f"Received: {response.text}")
# Server
def Chat(self, request_iterator, context):
    for msg in request_iterator:
        yield ChatMessage(text=f"Echo: {msg.text}")

사용: 채팅, 실시간 게임, IoT 양방향 통신.

3.5 비교

모델요청응답사용 사례
Unary11CRUD
Server Streaming1N큰 결과, 실시간 업데이트
Client StreamingN1업로드, 배치
BidirectionalNN채팅, 게임

4. HTTP/2의 역할

4.1 HTTP/1.1의 한계

[Client] ─────────→ [Server]
[Connection 1: Request 1]
[Connection 2: Request 2]
[Connection 3: Request 3]
   ...

문제:

  • 각 요청 = 새 연결 (또는 keep-alive but sequential)
  • Head-of-line blocking
  • 헤더 반복

4.2 HTTP/2 멀티플렉싱

[Client] ─────────→ [Server]
[Single Connection]
   ├─ Stream 1: Request 1Response 1
   ├─ Stream 2: Request 2Response 2
   └─ Stream 3: Request 3Response 3

장점:

  • 단일 TCP 연결로 여러 요청 동시
  • 헤더 압축 (HPACK)
  • 서버 푸시
  • 바이너리 프레이밍

4.3 gRPC의 HTTP/2 활용

  • 각 RPC = 1 stream
  • 같은 연결로 수천 RPC 동시
  • TCP 연결 오버헤드 최소화
  • 양방향 스트리밍 자연스럽게 가능

4.4 헤더 압축

HTTP/1.1:

GET /api/users/123 HTTP/1.1
Host: api.example.com
User-Agent: MyApp/1.0
Accept: application/json
Authorization: Bearer xyz...

매번 전송. HTTP/2 (HPACK)는:

  • 정적 테이블 (자주 쓰는 헤더)
  • 동적 테이블 (이전 요청의 헤더 캐시)
  • Huffman 인코딩

헤더 크기 80%+ 절감.


5. 인터셉터 (Interceptors)

5.1 무엇인가?

Interceptor = RPC 호출 전후에 실행되는 미들웨어. 횡단 관심사 처리.

용도:

  • 인증 (JWT 검증)
  • 로깅
  • 메트릭 (Prometheus)
  • 분산 트레이싱
  • 에러 처리
  • 재시도

5.2 Server Interceptor (Python)

import grpc

class AuthInterceptor(grpc.ServerInterceptor):
    def intercept_service(self, continuation, handler_call_details):
        metadata = dict(handler_call_details.invocation_metadata)
        token = metadata.get('authorization')
        
        if not verify_token(token):
            return grpc.unary_unary_rpc_method_handler(
                lambda req, ctx: ctx.abort(grpc.StatusCode.UNAUTHENTICATED, 'Invalid token')
            )
        
        return continuation(handler_call_details)

server = grpc.server(
    futures.ThreadPoolExecutor(max_workers=10),
    interceptors=[AuthInterceptor()]
)

5.3 Client Interceptor

class RetryInterceptor(grpc.UnaryUnaryClientInterceptor):
    def intercept_unary_unary(self, continuation, client_call_details, request):
        for attempt in range(3):
            try:
                return continuation(client_call_details, request)
            except grpc.RpcError as e:
                if e.code() != grpc.StatusCode.UNAVAILABLE:
                    raise
                time.sleep(2 ** attempt)
        raise

channel = grpc.intercept_channel(
    grpc.insecure_channel('localhost:50051'),
    RetryInterceptor()
)

5.4 Go Interceptor

func loggingInterceptor(
    ctx context.Context,
    req interface{},
    info *grpc.UnaryServerInfo,
    handler grpc.UnaryHandler,
) (interface{}, error) {
    start := time.Now()
    resp, err := handler(ctx, req)
    log.Printf("%s took %v", info.FullMethod, time.Since(start))
    return resp, err
}

server := grpc.NewServer(
    grpc.UnaryInterceptor(loggingInterceptor),
)

5.5 Chained Interceptors

server = grpc.server(
    futures.ThreadPoolExecutor(max_workers=10),
    interceptors=[
        TracingInterceptor(),
        AuthInterceptor(),
        LoggingInterceptor(),
        MetricsInterceptor(),
    ]
)

순서대로 실행. 인증 → 로깅 같은 chain.


6. Deadline과 Cancellation

6.1 Deadline의 중요성

# 잘못 - 무한 대기
response = stub.GetUser(GetUserRequest(user_id=123))

# 올바름 - 5초 deadline
response = stub.GetUser(GetUserRequest(user_id=123), timeout=5.0)

왜?:

  • 네트워크 문제 시 영원히 대기
  • 스레드 고갈
  • 사용자 경험

6.2 Deadline Propagation

[Client] (timeout=5s)
   ↓ deadline=now+5s
[Service A] (4s 소요)
   ↓ remaining=1s
[Service B] (이미 1s 남았으므로 짧게)

효과: 호출 체인 전체에 deadline 전파. 누적 timeout 방지.

def call_chain(context):
    # context의 deadline 자동 사용
    response = downstream_stub.SomeRPC(request, timeout=context.time_remaining())

6.3 Cancellation

import grpc

# 클라이언트가 취소
future = stub.GetUser.future(request)
time.sleep(2)
future.cancel()  # 서버에 취소 신호 전파

서버 측:

def GetUser(self, request, context):
    while not context.is_active():  # 클라이언트가 취소했나?
        return None
    
    # 또는
    if context.cancelled():
        return None

6.4 Context의 역할

Go에서:

ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

response, err := client.GetUser(ctx, &GetUserRequest{UserId: 123})

Context는:

  • Deadline 전달
  • 취소 신호
  • 메타데이터 (인증 토큰 등)

7. 로드 밸런싱

7.1 클라이언트 사이드 LB

gRPC 클라이언트가 여러 서버 IP를 알고 직접 선택.

# DNS-based
channel = grpc.insecure_channel(
    'dns:///my-service:50051',
    options=[
        ('grpc.lb_policy_name', 'round_robin')
    ]
)

정책:

  • pick_first: 첫 번째 사용 (기본)
  • round_robin: 순환
  • 사용자 정의

7.2 Look-aside LB

[Client][LB Service]"사용 가능한 서버: A, B, C"
[Client][Server A]

: gRPC + xDS (Envoy)

7.3 Proxy LB

[Client][Envoy Proxy][Servers]

: Envoy, Linkerd, Istio (Service Mesh)

장점: 클라이언트는 단일 endpoint만, 모든 LB 로직은 proxy.

7.4 헬스 체크

syntax = "proto3";

package grpc.health.v1;

service Health {
  rpc Check(HealthCheckRequest) returns (HealthCheckResponse);
  rpc Watch(HealthCheckRequest) returns (stream HealthCheckResponse);
}

message HealthCheckResponse {
  enum ServingStatus {
    UNKNOWN = 0;
    SERVING = 1;
    NOT_SERVING = 2;
  }
  ServingStatus status = 1;
}

표준 gRPC Health Check Protocol. 모든 gRPC 서버가 구현 가능.


8. 성능 최적화

8.1 메시지 크기 제한

기본 4MB. 큰 메시지는 거부됨.

options = [
    ('grpc.max_send_message_length', 100 * 1024 * 1024),  # 100MB
    ('grpc.max_receive_message_length', 100 * 1024 * 1024),
]

channel = grpc.insecure_channel('localhost:50051', options=options)

큰 데이터는 스트리밍 권장, 단일 메시지 X.

8.2 Connection Pooling

# 잘못 - 매 요청마다 새 연결
def get_user(user_id):
    channel = grpc.insecure_channel('localhost:50051')
    stub = UserServiceStub(channel)
    return stub.GetUser(GetUserRequest(user_id=user_id))

# 올바름 - 채널 재사용
channel = grpc.insecure_channel('localhost:50051')
stub = UserServiceStub(channel)

def get_user(user_id):
    return stub.GetUser(GetUserRequest(user_id=user_id))

왜?: TCP 연결 오버헤드. HTTP/2의 멀티플렉싱 활용.

8.3 KeepAlive

options = [
    ('grpc.keepalive_time_ms', 10000),       # 10초마다 ping
    ('grpc.keepalive_timeout_ms', 5000),     # ping 응답 5초 대기
    ('grpc.keepalive_permit_without_calls', True),
    ('grpc.http2.max_pings_without_data', 0),
]

효과: 죽은 연결 감지, NAT timeout 방지.

8.4 압축

# Server
server = grpc.server(..., compression=grpc.Compression.Gzip)

# Client
channel = grpc.insecure_channel(
    'localhost:50051',
    compression=grpc.Compression.Gzip
)

압축 알고리즘: gzip, deflate, snappy.

큰 메시지에 효과적, 작은 메시지는 오버헤드.


9. gRPC-Web — 브라우저 지원

9.1 문제

브라우저는 HTTP/2 trailers, raw HTTP/2 등을 지원 안 함. gRPC를 직접 사용 X.

9.2 gRPC-Web

브라우저 친화적 변종:

  • HTTP/1.1 또는 HTTP/2
  • Trailers를 body에 인코딩
  • CORS 지원
[Browser] ─ gRPC-Web ─→ [Envoy Proxy] ─ gRPC ─→ [Server]

9.3 사용

import { UserServiceClient } from './generated/user_pb_service'

const client = new UserServiceClient('https://api.example.com')

client.getUser(new GetUserRequest().setUserId(123), (err, response) => {
    if (err) console.error(err)
    else console.log(response.getName())
})

9.4 한계

  • 클라이언트 스트리밍 지원 X (대부분 구현)
  • 양방향 스트리밍 지원 X
  • 페이로드 약간 큼 (base64 인코딩)

9.5 Connect

Connect (Buf 회사): gRPC-Web의 후속작.

  • HTTP/1.1, HTTP/2, gRPC, gRPC-Web 모두 지원
  • 더 단순한 API
  • TypeScript first
import { createPromiseClient } from "@bufbuild/connect"
import { UserService } from "./gen/user_connect"

const client = createPromiseClient(UserService, transport)
const response = await client.getUser({ userId: 123 })

10. 디버깅과 도구

10.1 grpcurl

REST의 curl처럼 gRPC 호출:

# 서비스 목록
grpcurl -plaintext localhost:50051 list

# 메서드 호출
grpcurl -plaintext -d '{"user_id": 123}' \
  localhost:50051 \
  UserService/GetUser

Reflection 필요:

from grpc_reflection.v1alpha import reflection
SERVICE_NAMES = (UserService_pb2.DESCRIPTOR.services_by_name['UserService'].full_name, reflection.SERVICE_NAME)
reflection.enable_server_reflection(SERVICE_NAMES, server)

10.2 BloomRPC / Postman

GUI 클라이언트.

10.3 로깅

import grpc
import logging

logging.basicConfig(level=logging.DEBUG)
os.environ['GRPC_VERBOSITY'] = 'DEBUG'
os.environ['GRPC_TRACE'] = 'all'

10.4 분산 트레이싱

OpenTelemetry 통합:

from opentelemetry.instrumentation.grpc import GrpcInstrumentorServer

GrpcInstrumentorServer().instrument()

자동으로 모든 gRPC 호출이 trace에 포함.


퀴즈

1. Protobuf의 필드 번호가 왜 중요한가요?

: 필드 번호는 이진 형식의 식별자입니다. JSON처럼 키 이름을 매번 전송하지 않고, 작은 정수만 사용 → 페이로드 크기 감소. 또한 schema evolution의 핵심: 필드 이름은 변경 가능하지만 번호는 변경 불가. 같은 번호면 이전 버전과 호환. 삭제된 필드의 번호는 절대 재사용하면 안 됨 (reserved로 표시). 1-15는 1 byte, 16-2047은 2 byte로 인코딩 → 자주 쓰는 필드는 1-15에 할당.

2. gRPC가 REST보다 빠른 이유는?

: 4가지 요소: (1) Protobuf — JSON 대비 5-10배 작음, 파싱 빠름, (2) HTTP/2 — 멀티플렉싱, 단일 연결로 많은 요청, (3) HPACK 헤더 압축 — 반복 헤더 80%+ 절감, (4) 이진 프로토콜 — 텍스트 파싱 오버헤드 X. 결과: 처리량 5-10배, latency 절반. 단점: 디버깅 어려움 (이진), 브라우저 직접 지원 X (gRPC-Web 필요).

3. Bidirectional streaming은 언제 사용하나요?

: 양쪽이 독립적으로 메시지를 보내야 할 때. 사용 사례: (1) 채팅 — 사용자가 메시지 보내고, 서버가 다른 사용자 메시지 푸시, (2) 실시간 게임 — 클라이언트의 입력과 서버의 게임 상태 양방향, (3) IoT — 디바이스의 센서 데이터와 서버의 명령, (4) 음성 인식 — 오디오 스트림과 transcript 양방향. WebSocket과 비슷하지만 gRPC의 강한 타입 + Protobuf 효율.

4. Deadline propagation의 중요성은?

: 분산 시스템에서 호출 체인의 누적 timeout을 방지합니다. 예: Client (5s) → A (4s 소요) → B를 5s timeout으로 호출하면 → 총 9s 가능. 올바른 동작: A가 B를 호출할 때 남은 시간(1s)으로 호출. gRPC는 Context를 통해 deadline을 자동 전파. 모든 다운스트림 호출이 부모의 deadline을 상속. Go의 context.Context, Python의 context.time_remaining(). 분산 시스템 안정성의 핵심.

5. gRPC-Web과 일반 gRPC의 차이는?

: 브라우저는 HTTP/2 trailers, raw streams 등 gRPC 기능을 직접 지원 못 합니다. gRPC-Web은 브라우저 친화적 변종으로 (1) HTTP/1.1 또는 HTTP/2 사용, (2) trailers를 body에 인코딩, (3) CORS 지원. 한계: 클라이언트 스트리밍과 양방향 스트리밍 미지원 (서버 스트리밍은 OK). 서버는 보통 Envoy proxy를 통해 gRPC-Web ↔ gRPC 변환. Connect (Buf)가 더 단순한 후속 표준.


참고 자료

현재 단락 (1/406)

- **gRPC = HTTP/2 + Protobuf**: 5-10배 빠르고 작은 RPC

작성 글자: 0원문 글자: 12,668작성 단락: 0/406