C++ gRPC 운영 기능: 인터셉터, 로드밸런싱, 데드라인 전파, 재시도, 헬스 체크

들어가며: “프로덕션 gRPC 배포가 막막해요”

프로덕션 gRPC에서 부딪히는 문제들

43-1과 52-1에서 gRPC 기초와 스트리밍을 다뤘다면, 이 글에서는 프로덕션급 고급 기능을 다룬다. Kubernetes 배포, 인증·로깅, 장애 복구, 여러 클라이언트가 한 스트림 서버에 붙는 브로드캐스트 구조까지 실무에서 맞닥뜨리는 문제와 해결 방법을 제시합니다.

시나리오 1: 50개 RPC마다 수동으로 인증 토큰 추가

상황: 마이크로서비스 A가 B를 호출할 때 매번 ClientContext에 authorization 메타데이터 추가
문제: 비즈니스 로직과 인증이 섞여 유지보수 어렵으며, 누락 시 UNAUTHENTICATED 에러
결과: 클라이언트 인터셉터로 모든 RPC에 자동 토큰 주입

시나리오 2: 3대 서버 중 1대에만 트래픽 몰림

상황: gRPC 서버 3대를 띄웠는데 pick_first(기본)로 첫 서버에만 연결
문제: 첫 서버 과부하, 나머지 서버 유휴
결과: round_robin 로드밸런싱 + DNS/다중 주소로 균등 분산

시나리오 3: 롤링 업데이트 중 UNAVAILABLE 폭주

상황: Kubernetes에서 Pod 재시작 시 5~10초간 연결 끊김
문제: 재시도 없이 한 번 실패하면 사용자 에러
결과: 지수 백오프 재시도 + 데드라인 관리로 일시 장애 극복

시나리오 4: Kubernetes liveness 프로브가 gRPC를 지원 안 함

상황: HTTP 헬스 체크는 되는데 gRPC 서버는 TCP만 체크 가능
문제: 서버가 살아있어도 RPC 처리 불가 상태 감지 못 함
결과: gRPC Health Checking Protocol + grpc_health_probe 연동

시나리오 5: 스트리밍 RPC에 짧은 데드라인 적용

상황: 60초 스트리밍 RPC에 5초 데드라인 설정
문제: 스트림 시작 직후 DEADLINE_EXCEEDED
결과: RPC 유형별 데드라인 분리 (단순 RPC 5초, 스트리밍 60초+)

시나리오 6: 재시도로 주문 2건 생성

상황: CreateOrder RPC가 UNAVAILABLE 후 재시도
문제: 서버는 1건 처리했는데 클라이언트가 재시도해 2건 생성
결과: 멱등하지 않은 RPC는 재시도 제외, 멱등 키 활용
flowchart TB
    subgraph 문제[실무 문제]
        P1[인증 중복] --> S1[인터셉터]
        P2[단일 서버 과부하] --> S2[로드밸런싱]
        P3[일시 장애] --> S3[재시도·데드라인]
        P4[헬스 체크] --> S4[Health Protocol]
        P5[멱등성 위반] --> S5[재시도 정책]
    end

목표:

  • 인터셉터: 인증·로깅·메트릭·트레이싱 완전 구현
  • 로드밸런싱: round_robin, Kubernetes DNS 연동
  • 데드라인: 전파, RPC 유형별 설정
  • 재시도: 지수 백오프, jitter, 멱등성 고려
  • 헬스 체크: gRPC 표준 Health Service 구현
  • 양방향 스트리밍: 여러 클라이언트에게 메시지를 브로드캐스트하는 채팅 서버와 그 함정 요구 환경: C++17 이상, gRPC 1.50+, grpc-health-probe (선택)

인증·로깅·요청 ID 인터셉터

인터셉터가 끼어드는 지점

인터셉터는 RPC 호출 전후에 실행되는 미들웨어입니다. 인증, 로깅, 메트릭, 메타데이터 처리 등 크로스커팅 관심사를 비즈니스 로직과 분리합니다.

sequenceDiagram
    participant App as 애플리케이션
    participant Auth as 인증 인터셉터
    participant Log as 로깅 인터셉터
    participant Net as 네트워크
    App->>Auth: RPC 호출
    Auth->>Auth: 토큰 주입
    Auth->>Log: 전달
    Log->>Log: 시작 시간 기록
    Log->>Net: 전달
    Net->>Log: 응답
    Log->>Log: 소요 시간 로깅
    Log->>Auth: 전달
    Auth->>App: 응답

클라이언트 인터셉터: 인증 토큰 자동 주입

gRPC C++ 인터셉터는 grpc::experimental 네임스페이스에 있습니다. Interceptor를 상속하고 Intercept를 오버라이드합니다.

#include <grpcpp/grpcpp.h>
#include <grpcpp/support/client_interceptor.h>
#include <grpcpp/support/interceptor.h>
using grpc::experimental::Interceptor;
using grpc::experimental::InterceptorBatchMethods;
using grpc::experimental::InterceptionHookPoints;
class AuthClientInterceptor : public Interceptor {
public:
    explicit AuthClientInterceptor(const std::string& token) : token_(token) {}
    void Intercept(InterceptorBatchMethods* methods) override {
        if (methods->QueryInterceptionHookPoint(
                InterceptionHookPoints::PRE_SEND_INITIAL_METADATA)) {
            // 메타데이터에 authorization 추가
            methods->AddSendInitialMetadata("authorization", "Bearer " + token_);
            methods->AddSendInitialMetadata("x-client-version", "1.0");
        }
        methods->Proceed();
    }
private:
    std::string token_;
};
class AuthClientInterceptorFactory
    : public grpc::experimental::ClientInterceptorFactoryInterface {
public:
    explicit AuthClientInterceptorFactory(const std::string& token)
        : token_(token) {}
    Interceptor* CreateClientInterceptor(
        grpc::experimental::ClientRpcInfo* info) override {
        return new AuthClientInterceptor(token_);
    }
private:
    std::string token_;
};
// 사용: 채널 생성 시 인터셉터 등록
std::shared_ptr<grpc::Channel> CreateChannelWithAuth(
    const std::string& target,
    const std::string& token) {
    std::vector<std::unique_ptr<
        grpc::experimental::ClientInterceptorFactoryInterface>>
        creators;
    creators.push_back(std::make_unique<AuthClientInterceptorFactory>(token));
    return grpc::experimental::CreateCustomChannelWithInterceptors(
        target,
        grpc::InsecureChannelCredentials(),
        grpc::ChannelArguments(),
        std::move(creators));
}

로깅 인터셉터: RPC 소요 시간·에러 기록

#include <chrono>
#include <iostream>
class LoggingClientInterceptor : public Interceptor {
public:
    void Intercept(InterceptorBatchMethods* methods) override {
        auto start = std::chrono::steady_clock::now();
        if (methods->QueryInterceptionHookPoint(
                InterceptionHookPoints::PRE_SEND_INITIAL_METADATA)) {
            // RPC 메서드명 추출 (ClientRpcInfo에서)
            std::cout << "[gRPC] RPC 시작" << std::endl;
        }
        methods->Proceed();
        if (methods->QueryInterceptionHookPoint(
                InterceptionHookPoints::POST_RECV_STATUS)) {
            auto dur = std::chrono::steady_clock::now() - start;
            auto status = methods->GetRecvStatus();
            std::cout << "[gRPC] RPC 완료: "
                      << (status.ok() ? "OK" : status.error_message())
                      << ", 소요: " << dur.count() / 1000000 << "ms" << std::endl;
        }
    }
};
class LoggingInterceptorFactory
    : public grpc::experimental::ClientInterceptorFactoryInterface {
public:
    Interceptor* CreateClientInterceptor(
        grpc::experimental::ClientRpcInfo* info) override {
        return new LoggingClientInterceptor();
    }
};

서버 인터셉터: 요청 ID 추적

#include <grpcpp/grpcpp.h>
#include <grpcpp/support/server_interceptor.h>
class RequestIdServerInterceptor : public grpc::experimental::Interceptor {
public:
    void Intercept(grpc::experimental::InterceptorBatchMethods* methods) override {
        if (methods->QueryInterceptionHookPoint(
                grpc::experimental::InterceptionHookPoints::PRE_RECV_INITIAL_METADATA)) {
            // 메타데이터에서 x-request-id 읽기 (실제로는 RecvInitialMetadata 훅에서)
        }
        methods->Proceed();
    }
};
// 서버 빌더에 등록 (API는 gRPC 버전별로 확인)
// builder.experimental().SetInterceptorCreators(...);

인터셉터 체인: 순서와 조합

인터셉터는 등록 순서대로 실행됩니다. 애플리케이션에 가까운 것이 먼저, 네트워크에 가까운 것이 나중에 실행됩니다.

클라이언트: [Auth] → [Logging] → [Tracing] → [Network]
서버: [Network] → [Tracing] → [Logging] → [Auth] → [Service]
// 여러 인터셉터 등록
std::vector<std::unique_ptr<
    grpc::experimental::ClientInterceptorFactoryInterface>>
    creators;
creators.push_back(std::make_unique<AuthClientInterceptorFactory>(token));
creators.push_back(std::make_unique<LoggingInterceptorFactory>());
auto channel = grpc::experimental::CreateCustomChannelWithInterceptors(
    target, creds, args, std::move(creators));

round_robin과 Service Config로 로드밸런싱

로드밸런싱 정책 비교

정책동작사용 사례
pick_first (기본)주소 목록에서 첫 연결 성공 시 해당 서버만 사용단일 서버, 개발
round_robin연결된 서버들에 요청을 순환 분배다중 서버, 수평 확장
grpclb외부 로드밸런서 연동 (deprecated)레거시
xDSEnvoy 등 xDS 호환 LB대규모 클러스터

round_robin 완전 구현

#include <grpcpp/grpcpp.h>
// DNS 기반: DNS가 여러 A 레코드 반환 시 round_robin 적용
grpc::ChannelArguments args;
args.SetLoadBalancingPolicyName("round_robin");
auto channel = grpc::CreateCustomChannel(
    "dns:///my-grpc-service.namespace.svc.cluster.local:50051",
    grpc::InsecureChannelCredentials(),
    args);
// 명시적 다중 주소 (ipv4 스키마)
auto channel2 = grpc::CreateCustomChannel(
    "ipv4:///127.0.0.1:50051,127.0.0.1:50052,127.0.0.1:50053",
    grpc::InsecureChannelCredentials(),
    args);

Kubernetes 서비스 연동

Kubernetes Service는 DNS로 여러 Pod IP를 반환합니다. dns:///를 사용하면 자동으로 여러 엔드포인트에 연결됩니다.

// Kubernetes 내부에서: Service 이름으로 연결
// my-grpc-service.namespace.svc.cluster.local → 여러 Pod IP
grpc::ChannelArguments args;
args.SetLoadBalancingPolicyName("round_robin");
std::string service_addr = "dns:///my-grpc-service:50051";
if (const char* ns = std::getenv("POD_NAMESPACE")) {
    service_addr = "dns:///my-grpc-service." + std::string(ns) +
                   ".svc.cluster.local:50051";
}
auto channel = grpc::CreateCustomChannel(
    service_addr,
    grpc::InsecureChannelCredentials(),
    args);

Service Config로 고급 설정

SetLoadBalancingPolicyName은 채널 하나에만 적용되는 코드 설정입니다. 같은 내용을 Service Config라는 JSON으로 표현할 수도 있는데, 이 방식의 장점은 로드밸런싱 정책뿐 아니라 메서드별 타임아웃과 재시도 정책까지 한곳에 모을 수 있다는 점입니다. Service Config는 이름 해석 단계에서 DNS TXT 레코드나 xDS 같은 리졸버가 내려줄 수도 있고, 클라이언트가 채널 인자로 기본값을 직접 넣을 수도 있습니다.

grpc::ChannelArguments args;
args.SetServiceConfigJSON(R"({
  "loadBalancingConfig": [{ "round_robin": {} }],
  "methodConfig": [{
    "name": [{ "service": "myapp.MyService" }],
    "timeout": "30s"
  }]
})");
auto channel = grpc::CreateCustomChannel(
    "dns:///my-grpc-service:50051",
    grpc::InsecureChannelCredentials(),
    args);

아래처럼 retryPolicy를 넣으면 gRPC가 채널 안에서 재시도를 직접 수행합니다(내장 재시도).

{
  "loadBalancingConfig": [{ "round_robin": {} }],
  "methodConfig": [
    {
      "name": [{"service": "myapp.MyService"}],
      "timeout": "30s",
      "retryPolicy": {
        "maxAttempts": 3,
        "initialBackoff": "0.1s",
        "maxBackoff": "1s",
        "backoffMultiplier": 2,
        "retryableStatusCodes": ["UNAVAILABLE", "DEADLINE_EXCEEDED"]
      }
    }
  ]
}

내장 재시도의 maxAttempts는 첫 시도를 포함한 횟수이고, 모든 시도가 RPC 하나의 데드라인(timeout 또는 set_deadline)을 나눠 씁니다. 그래서 retryableStatusCodes에 DEADLINE_EXCEEDED를 넣어도 클라이언트 자신의 데드라인이 지난 경우는 재시도되지 않고, 서버가 하위 호출 시간 초과로 이 코드를 돌려준 경우에만 남은 시간 안에서 재시도됩니다. 기대한 효과가 나지 않는 경우가 많으므로 재시도 대상은 UNAVAILABLE 위주로 두는 편이 의도에 맞습니다.

로드밸런싱 동작 다이어그램

flowchart TB
    subgraph 클라이언트
        C[Stub]
    end
    subgraph 채널
        LB[Load Balancer\nround_robin]
        S1[Subchannel 1\nPod A]
        S2[Subchannel 2\nPod B]
        S3[Subchannel 3\nPod C]
        LB --> S1
        LB --> S2
        LB --> S3
    end
    C -->|RPC 1| LB
    LB -->|선택| S1
    C -->|RPC 2| LB
    LB -->|선택| S2
    C -->|RPC 3| LB
    LB -->|선택| S3

데드라인 전파와 지수 백오프 재시도

데드라인 전파

클라이언트가 설정한 데드라인은 서버에 전달됩니다. 서버는 context->IsCancelled()로 확인해 불필요한 작업을 중단할 수 있습니다.

// 클라이언트: RPC 유형별 데드라인
grpc::ClientContext context;
auto now = std::chrono::system_clock::now();
// 단순 조회: 5초
context.set_deadline(now + std::chrono::seconds(5));
stub->GetUser(&context, req, &res);
// 스트리밍: 60초
context.set_deadline(now + std::chrono::seconds(60));
auto stream = stub->StreamLogs(&context, req);
// 서버: 주기적 취소 확인
Status LongRpc(ServerContext* context, ...) override {
    for (int i = 0; i < 10000; ++i) {
        if (context->IsCancelled()) {
            return Status(StatusCode::CANCELLED, "Deadline exceeded");
        }
        DoWork(i);
    }
    return Status::OK;
}

지수 백오프 + Jitter 재시도

동시 재시도 폭주(thundering herd) 방지를 위해 jitter를 추가합니다.

#include <grpcpp/grpcpp.h>
#include <chrono>
#include <random>
#include <thread>
#include <functional>
template <typename Request, typename Response>
grpc::Status CallWithRetry(
    std::function<grpc::Status(grpc::ClientContext*, const Request&, Response*)>
        rpc,
    const Request& request,
    Response* response,
    int max_retries = 3,
    int base_delay_ms = 100,
    int deadline_seconds = 30) {
    grpc::Status status;
    std::random_device rd;
    std::mt19937 gen(rd());
    for (int attempt = 0; attempt < max_retries; ++attempt) {
        grpc::ClientContext context;
        context.set_deadline(std::chrono::system_clock::now() +
                             std::chrono::seconds(deadline_seconds));
        status = rpc(&context, request, response);
        if (status.ok()) return status;
        // 재시도 가능한 에러만
        switch (status.error_code()) {
            case grpc::StatusCode::UNAVAILABLE:
            case grpc::StatusCode::DEADLINE_EXCEEDED:
            case grpc::StatusCode::RESOURCE_EXHAUSTED:
            case grpc::StatusCode::ABORTED:
                break;
            default:
                return status;  // 재시도 불가
        }
        if (attempt < max_retries - 1) {
            // 지수 백오프: 100, 200, 400 ms
            int delay_ms = base_delay_ms * (1 << attempt);
            // Jitter: 0~50% 랜덤 추가
            std::uniform_int_distribution<> dist(0, delay_ms / 2);
            delay_ms += dist(gen);
            std::this_thread::sleep_for(std::chrono::milliseconds(delay_ms));
        }
    }
    return status;
}

멱등성 고려 재시도

bool IsRetryable(const std::string& method, grpc::StatusCode code) {
    // 멱등하지 않은 메서드는 재시도 제외
    static const std::unordered_set<std::string> non_idempotent = {
        "CreateOrder", "SubmitPayment", "Transfer", "CreateUser"};
    if (non_idempotent.count(method)) return false;
    return code == grpc::StatusCode::UNAVAILABLE ||
           code == grpc::StatusCode::DEADLINE_EXCEEDED ||
           code == grpc::StatusCode::RESOURCE_EXHAUSTED ||
           code == grpc::StatusCode::ABORTED;
}

재시도 시 주의사항

재시도 코드는 짧지만, 잘못 걸면 장애를 줄이는 대신 키웁니다. 적용 전에 네 가지를 확인합니다.

  • 멱등성: 여러 번 실행돼도 결과가 같은 RPC인지 먼저 봅니다. 조회는 괜찮지만 주문 생성이나 결제는 위의 IsRetryable처럼 제외하거나, 요청 ID를 메타데이터로 보내 서버가 중복 요청을 걸러내야 합니다.
  • 최대 횟수: 무한 재시도는 서버가 회복하려는 순간에 부하를 더 얹습니다. 보통 3~5회로 제한하고, 전체 소요 시간도 상위 호출자의 데드라인 안에 들어오는지 계산합니다.
  • 재시도 계층 중복: 위의 CallWithRetry와 Service Config의 retryPolicy를 동시에 켜면 시도 횟수가 곱해집니다. 각각 3회면 한 번의 논리 호출이 최대 9번의 RPC가 되고, 서비스 A→B→C처럼 호출 단계마다 재시도가 있으면 맨 아래 서비스가 받는 요청은 단계 수만큼 거듭제곱으로 늘어납니다. 재시도는 한 계층, 가능하면 호출 체인의 가장 바깥쪽 한 곳에만 둡니다.
  • Jitter: 위 구현처럼 대기 시간에 무작위 값을 섞지 않으면, 같은 순간에 실패한 클라이언트들이 같은 순간에 다시 몰려옵니다.

gRPC Health Checking Protocol 구현

gRPC Health Checking Protocol

gRPC 표준 헬스 체크 서비스입니다. grpc/grpc-health-probe와 Kubernetes liveness/readiness에서 사용합니다.

.proto 정의

// grpc/health/v1/health.proto (gRPC 표준)
syntax = "proto3";
package grpc.health.v1;
service Health {
  rpc Check(HealthCheckRequest) returns (HealthCheckResponse);
  rpc Watch(HealthCheckRequest) returns (stream HealthCheckResponse);
}
message HealthCheckRequest {
  string service = 1;
}
message HealthCheckResponse {
  enum ServingStatus {
    UNKNOWN = 0;
    SERVING = 1;
    NOT_SERVING = 2;
    SERVICE_UNKNOWN = 3;
  }
  ServingStatus status = 1;
}

헬스 체크 서버 구현

gRPC C++는 EnableDefaultHealthCheckService로 기본 Health 서비스를 활성화합니다. 빈 서비스명("")은 서버 전체 상태를 나타냅니다.

#include <grpcpp/grpcpp.h>
#include <grpcpp/health_check_service_interface.h>
grpc::ServerBuilder builder;
builder.AddListeningPort("0.0.0.0:50051", grpc::InsecureServerCredentials());
builder.RegisterService(&my_service);
// 기본 Health 서비스 활성화 (gRPC 1.28+)
// Check(service="") 호출 시 서버 전체 상태 반환
grpc::EnableDefaultHealthCheckService(true);
auto server = builder.BuildAndStart();

grpc_health_probe 사용 (Kubernetes)

로컬에서 수동 테스트:

# grpc_health_probe 설치 (Kubernetes 이미지에 포함 또는 별도 설치)
grpc_health_probe -addr=localhost:50051
# 성공 시 exit 0, 실패 시 exit 1

Kubernetes 배포 설정:

# Kubernetes deployment
livenessProbe:
  exec:
    command: ["/bin/grpc_health_probe", "-addr=:50051"]
  initialDelaySeconds: 5
  periodSeconds: 10
  timeoutSeconds: 2
readinessProbe:
  exec:
    command: ["/bin/grpc_health_probe", "-addr=:50051"]
  initialDelaySeconds: 2
  periodSeconds: 5

수동 헬스 체크 서비스 구현

gRPC C++에서 표준 Health 서비스를 수동으로 구현하려면:

// health.grpc.pb.h 사용 (protoc로 생성)
#include "health.grpc.pb.h"
class HealthServiceImpl : public grpc::health::v1::Health::Service {
public:
    grpc::Status Check(
        grpc::ServerContext* context,
        const grpc::health::v1::HealthCheckRequest* request,
        grpc::health::v1::HealthCheckResponse* response) override {
        // 서비스별 상태 확인 (선택)
        if (!request->service().empty()) {
            if (IsServiceHealthy(request->service())) {
                response->set_status(
                    grpc::health::v1::HealthCheckResponse::SERVING);
            } else {
                response->set_status(
                    grpc::health::v1::HealthCheckResponse::NOT_SERVING);
            }
        } else {
            // 빈 서비스명 = 전체 서버 상태
            response->set_status(
                grpc::health::v1::HealthCheckResponse::SERVING);
        }
        return grpc::Status::OK;
    }
private:
    bool IsServiceHealthy(const std::string& service) {
        // DB 연결, 캐시 등 확인
        return true;
    }
};

헬스 체크 시퀀스

sequenceDiagram
    participant K8s as Kubernetes
    participant Probe as grpc_health_probe
    participant Svc as gRPC 서버
    K8s->>Probe: livenessProbe 실행
    Probe->>Svc: Health.Check(service="")
    Svc->>Svc: 상태 확인
    Svc->>Probe: SERVING
    Probe->>K8s: 성공 (exit 0)

재시도 클라이언트·셧다운·스트리밍 채팅 예제

인터셉터 + 로드밸런싱 + 재시도 클라이언트

#include <grpcpp/grpcpp.h>
#include "my_service.grpc.pb.h"
#include <memory>
#include <string>
class ProductionGrpcClient {
public:
    ProductionGrpcClient(const std::string& service_addr,
                        const std::string& auth_token) {
        grpc::ChannelArguments args;
        args.SetLoadBalancingPolicyName("round_robin");
        args.SetInt("grpc.keepalive_time_ms", 10000);
        args.SetInt("grpc.keepalive_timeout_ms", 5000);
        std::vector<std::unique_ptr<
            grpc::experimental::ClientInterceptorFactoryInterface>>
            creators;
        creators.push_back(
            std::make_unique<AuthClientInterceptorFactory>(auth_token));
        creators.push_back(std::make_unique<LoggingInterceptorFactory>());
        channel_ = grpc::experimental::CreateCustomChannelWithInterceptors(
            service_addr,
            grpc::InsecureChannelCredentials(),
            args,
            std::move(creators));
        stub_ = myapp::MyService::NewStub(channel_);
    }
    grpc::Status CallWithRetry(const myapp::Request& req,
                               myapp::Response* res) {
        return CallWithRetry<myapp::Request, myapp::Response>(
            [this](grpc::ClientContext* ctx, const myapp::Request& r,
                   myapp::Response* s) { return stub_->MyRpc(ctx, r, s); },
            req, res, 3, 100, 30);
    }
private:
    template <typename Req, typename Res>
    grpc::Status CallWithRetry(
        std::function<grpc::Status(grpc::ClientContext*, const Req&, Res*)> rpc,
        const Req& req, Res* res, int max_retries, int base_delay_ms,
        int deadline_sec);
    std::shared_ptr<grpc::Channel> channel_;
    std::unique_ptr<myapp::MyService::Stub> stub_;
};

그레이스풀 셧다운

#include <grpcpp/grpcpp.h>
#include <csignal>
grpc::Server* g_server = nullptr;
void SignalHandler(int signum) {
    if (g_server) {
        g_server->Shutdown();  // 새 RPC 거부, 기존 RPC 완료 대기
    }
}
int main() {
    grpc::ServerBuilder builder;
    builder.AddListeningPort("0.0.0.0:50051", grpc::InsecureServerCredentials());
    builder.RegisterService(&service);
    g_server = builder.BuildAndStart().release();
    std::signal(SIGINT, SignalHandler);
    std::signal(SIGTERM, SignalHandler);
    g_server->Wait();  // 모든 스레드 종료 대기
    delete g_server;
    return 0;
}

환경별 설정

struct GrpcClientConfig {
    std::string address;
    std::string lb_policy;
    int deadline_seconds;
    int max_retries;
    bool use_tls;
};
GrpcClientConfig LoadConfig() {
    if (std::getenv("KUBERNETES_SERVICE_HOST")) {
        return {
            "dns:///my-grpc-service:50051",
            "round_robin",
            30,
            3,
            false  // 클러스터 내부는 종종 plaintext
        };
    }
    if (std::getenv("STAGING")) {
        return {"localhost:50051", "pick_first", 10, 2, false};
    }
    return {"my-service.example.com:443", "round_robin", 30, 3, true};
}

양방향 스트리밍 브로드캐스트 채팅

43-1의 양방향 스트리밍 예제는 받은 메시지를 보낸 클라이언트에게 되돌려 주는 에코였습니다. 실제 채팅 서버는 한 클라이언트의 메시지를 같은 방의 다른 클라이언트 스트림에 써야 하고, 여기서부터 스트림 하나만 다룰 때는 없던 문제가 생깁니다.

syntax = "proto3";
package chat;
service ChatService {
  rpc Chat(stream ChatMessage) returns (stream ChatMessage);
}
message ChatMessage {
  string user_id = 1;
  string text = 2;
  int64 timestamp = 3;
}
#include <grpcpp/grpcpp.h>
#include "chat.grpc.pb.h"
#include <mutex>
#include <string>
#include <unordered_map>
using ChatStream =
    grpc::ServerReaderWriter<chat::ChatMessage, chat::ChatMessage>;
class ChatRoom {
public:
    void Join(const std::string& user_id, ChatStream* stream) {
        std::lock_guard<std::mutex> lock(mutex_);
        clients_[user_id] = stream;
    }
    void Leave(const std::string& user_id) {
        std::lock_guard<std::mutex> lock(mutex_);
        clients_.erase(user_id);
    }
    void Broadcast(const std::string& sender_id, const chat::ChatMessage& msg) {
        std::lock_guard<std::mutex> lock(mutex_);
        for (auto& [user_id, stream] : clients_) {
            if (user_id != sender_id) {
                stream->Write(msg);  // 모든 Write가 같은 뮤텍스 아래에서 직렬화됨
            }
        }
    }
private:
    std::unordered_map<std::string, ChatStream*> clients_;
    std::mutex mutex_;
};
// 방을 떠나는 처리를 스코프 종료에 묶어, 어느 경로로 반환해도 빠지지 않게 함
class RoomMembership {
public:
    RoomMembership(ChatRoom& room, std::string user_id, ChatStream* stream)
        : room_(room), user_id_(std::move(user_id)) {
        room_.Join(user_id_, stream);
    }
    ~RoomMembership() { room_.Leave(user_id_); }
private:
    ChatRoom& room_;
    std::string user_id_;
};
class ChatServiceImpl final : public chat::ChatService::Service {
public:
    grpc::Status Chat(grpc::ServerContext* context, ChatStream* stream) override {
        std::string user_id = GetUserIdFromMetadata(context);
        if (user_id.empty()) {
            return grpc::Status(grpc::StatusCode::UNAUTHENTICATED, "missing user id");
        }
        RoomMembership membership(room_, user_id, stream);
        chat::ChatMessage msg;
        while (stream->Read(&msg)) {
            if (context->IsCancelled()) break;
            msg.set_user_id(user_id);  // 클라이언트가 보낸 값은 믿지 않음
            room_.Broadcast(user_id, msg);
        }
        return grpc::Status::OK;
    }
private:
    ChatRoom room_;
};

이 구조에서 반드시 짚어야 할 점이 세 가지 있습니다.

  • 스트림 포인터의 수명: ChatStream*는 해당 클라이언트의 Chat() 핸들러가 실행 중일 때만 유효합니다. 핸들러가 반환된 뒤에도 방 목록에 포인터가 남아 있으면 다른 스레드의 Broadcast가 해제된 객체에 씁니다. 위 코드가 Leave를 소멸자로 옮긴 이유이고, 이 줄을 루프 뒤에 직접 쓰면 중간에 return이 하나 추가되는 순간 크래시가 재현하기 어려운 형태로 나타납니다.
  • 동시 Write 금지: 동기 API의 스트림은 동시에 여러 스레드가 Write()하는 것을 허용하지 않습니다. 이 예제는 모든 쓰기가 ChatRoom의 뮤텍스 아래에서만 일어나므로 안전하지만, 핸들러 안에서 자기 스트림에 따로 Write()(예: 입장 환영 메시지)를 추가하면 그 호출도 같은 락을 거쳐야 합니다.
  • 느린 클라이언트 하나가 방 전체를 멈춤: Write()는 상대가 읽지 않아 HTTP/2 흐름 제어 윈도가 차면 블로킹됩니다. 모바일 클라이언트 하나가 터널에 들어가 수신을 멈추면, 그 클라이언트에 대한 Write()가 락을 쥔 채 대기하고 방의 모든 브로드캐스트가 함께 멈춥니다. 규모가 커지면 클라이언트마다 송신 큐와 전용 쓰기 스레드(또는 Callback API의 reactor)를 두고, Broadcast는 큐에 넣기만 하도록 바꿉니다. 이때 큐에 상한을 두지 않으면 아래 장시간 스트리밍 메모리 증가 문제처럼 메모리가 계속 늘어납니다.

양방향 동기화 스트림에 데드라인과 취소 확인 넣기

실시간 협업 에디터가 편집 델타를 주고받는 스트림을 가정합니다. 채팅과 달리 연결이 몇 시간씩 유지되므로, 클라이언트는 짧은 데드라인 대신 Keepalive로 죽은 연결을 감지하고 서버는 매 루프에서 취소 여부를 확인합니다.

// service SyncService { rpc Sync(stream Delta) returns (stream Delta); }
grpc::Status Sync(grpc::ServerContext* context,
                  grpc::ServerReaderWriter<sync::Delta, sync::Delta>* stream) override {
    sync::Delta delta;
    while (stream->Read(&delta)) {
        if (context->IsCancelled()) {
            return grpc::Status(grpc::StatusCode::CANCELLED, "client disconnected");
        }
        sync::Delta merged = ApplyDelta(delta);  // 서버 기준 문서에 병합
        if (!stream->Write(merged)) {
            break;  // 클라이언트 쪽 스트림이 이미 닫힘
        }
    }
    return grpc::Status::OK;
}
// 클라이언트: 데드라인을 세션 길이에 맞춰 잡음
grpc::ClientContext context;
context.set_deadline(std::chrono::system_clock::now() + std::chrono::hours(8));
auto stream = stub->Sync(&context);

단순 조회 RPC에 쓰던 5초짜리 데드라인 헬퍼를 이런 스트림에 그대로 재사용하면, 편집 세션이 5초 만에 DEADLINE_EXCEEDED로 끊기고 클라이언트는 재연결을 반복합니다. 시나리오 5의 증상이 정확히 이 경우입니다.


round_robin 미동작, DEADLINE_EXCEEDED, 재시도 중복 실행: 에러 해결

round_robin이 동작하지 않음

증상: 다중 서버를 두었는데 한 서버에만 요청이 감. 원인: pick_first가 기본값이거나, 단일 IP만 반환되는 주소 사용. 해결법:

// ❌ 잘못된 예
auto channel = grpc::CreateChannel("localhost:50051", ...);
// ✅ 올바른 예
grpc::ChannelArguments args;
args.SetLoadBalancingPolicyName("round_robin");
auto channel = grpc::CreateCustomChannel(
    "dns:///my-service:50051",  // DNS가 여러 IP 반환해야 함
    grpc::InsecureChannelCredentials(),
    args);

DEADLINE_EXCEEDED가 너무 자주 발생

증상: 서버는 정상인데 클라이언트에서 타임아웃. 원인: 데드라인이 너무 짧거나, 서버 처리 시간이 김. 해결법:

// ✅ RPC 유형별 데드라인 분리
// 단순 조회: 5초
context.set_deadline(now + std::chrono::seconds(5));
stub->GetUser(&context, req, &res);
// 스트리밍: 60초 이상
context.set_deadline(now + std::chrono::seconds(60));
auto stream = stub->StreamLogs(&context, req);

재시도 시 중복 실행 (멱등성 위반)

증상: 주문 생성 RPC를 재시도했는데 주문이 2건 생성됩니다. 원인: 생성·수정 RPC는 멱등하지 않음. 해결법:

// ✅ 멱등한 RPC만 재시도
bool IsRetryable(const std::string& method, grpc::StatusCode code) {
    if (method == "CreateOrder" || method == "SubmitPayment") {
        return false;
    }
    return code == grpc::StatusCode::UNAVAILABLE ||
           code == grpc::StatusCode::DEADLINE_EXCEEDED;
}

인터셉터 “experimental” API 컴파일 에러

증상: grpc::experimental::Interceptor를 찾을 수 없음. 원인: gRPC 버전에 따라 API 위치가 다름. 해결법:

// 필요한 헤더
#include <grpcpp/support/client_interceptor.h>
#include <grpcpp/support/interceptor.h>
// vcpkg로 버전 확인
// vcpkg list grpc
// grpc 1.50 이상 권장

헤더를 맞춰도 예제 코드가 그대로 컴파일되지 않을 수 있습니다. 인터셉터 API는 experimental 네임스페이스에 있는 만큼 버전 사이에 시그니처가 조금씩 바뀌어 왔으므로, 블로그 예제보다 지금 쓰는 gRPC 버전 태그의 grpc/examples/cpp/interceptors를 기준으로 맞추는 편이 빠릅니다. 서버 인터셉터 등록(builder.experimental().SetInterceptorCreators(...))도 같은 이유로 버전별 예제를 확인합니다.

grpc_health_probe 연결 실패

증상: Kubernetes에서 grpc_health_probe가 실패함. 원인: 서버에 Health 서비스가 등록되지 않음. 해결법:

// gRPC 내장 Health 서비스 활성화
grpc::EnableDefaultHealthCheckService(true);
// 또는 수동으로 Health 서비스 구현 후 RegisterService

스트리밍 시 “Stream removed”

증상: 스트리밍 중 Write/Read에서 스트림 끊김. 원인: 클라이언트가 연결을 끊었는데 서버가 계속 Write 시도. 해결법:

// ✅ Write 반환값 확인
while (stream->Read(&msg)) {
    if (!stream->Write(response)) break;
}
// ✅ 주기적 IsCancelled() 확인
for (int i = 0; i < 1000; ++i) {
    if (context->IsCancelled()) return Status::OK;
    if (!stream->Write(chunk)) break;
}

채널/스텁 매 요청마다 생성

증상: 초당 100 RPC 시 CPU·메모리 사용량 급증. 원인: 채널 생성 비용이 큼. 해결법:

// ❌ 매 요청마다 새 채널
for (int i = 0; i < 10000; ++i) {
    auto channel = grpc::CreateChannel(...);
    auto stub = Service::NewStub(channel);
    stub->Call(...);
}
// ✅ 채널·스텁 재사용
auto channel = grpc::CreateChannel(...);
auto stub = Service::NewStub(channel);
for (int i = 0; i < 10000; ++i) {
    stub->Call(...);
}

장시간 스트리밍에서 메모리가 계속 증가

증상: 스트리밍 서버를 며칠 띄워 두면 RSS가 꾸준히 올라가고, 재시작하면 원래대로 돌아옵니다. 원인: gRPC 자체보다 애플리케이션 쪽 버퍼가 원인인 경우가 대부분입니다. 동기 API의 Write()는 흐름 제어 윈도가 차면 블로킹되므로 gRPC 내부 버퍼가 무한히 커지지는 않습니다. 대신 앞의 양방향 스트리밍 채팅 예제처럼 느린 클라이언트를 피하려고 클라이언트별 송신 큐를 두었는데 상한이 없으면, 수신을 멈춘 클라이언트의 큐에 메시지가 끝없이 쌓입니다. 비동기·Callback API는 스트림당 진행 중인 쓰기를 하나만 허용하므로 나머지를 애플리케이션이 큐에 보관해야 하고, 여기서 같은 문제가 생깁니다. 끊긴 스트림을 방 목록에서 제거하지 않아 큐와 메시지가 계속 참조되는 경우도 흔합니다. 해결법:

// ✅ 클라이언트별 송신 큐에 상한을 둔다
bool Enqueue(ClientOutbox& box, chat::ChatMessage msg) {
    std::lock_guard<std::mutex> lock(box.mutex);
    if (box.queue.size() >= kMaxPending) {
        return false;  // 호출자가 연결을 끊거나 오래된 메시지를 버림
    }
    box.queue.push_back(std::move(msg));
    box.cv.notify_one();
    return true;
}
// ✅ 읽은 메시지는 루프 안에서 처리하고 보관하지 않는다
while (reader->Read(&chunk)) {
    ProcessChunk(chunk);
}

상한을 넘었을 때 무엇을 할지는 서비스 성격에 따라 정합니다. 채팅은 오래된 메시지를 버리고 재접속 시 이력 API로 채우는 편이 낫고, 편집 델타처럼 하나라도 빠지면 안 되는 스트림은 해당 클라이언트를 RESOURCE_EXHAUSTED로 끊고 재동기화하게 합니다.


데드라인·채널 재사용·Keepalive 운영 원칙

모든 RPC에 데드라인 설정

// ✅ 필수
grpc::ClientContext context;
context.set_deadline(std::chrono::system_clock::now() + std::chrono::seconds(30));
stub->MyRpc(&context, req, &res);

채널·스텁 싱글톤 또는 의존성 주입

// ✅ 앱 전체에서 채널 1개 재사용
class GrpcClientPool {
public:
    static std::shared_ptr<grpc::Channel> GetChannel(
        const std::string& service) {
        std::lock_guard<std::mutex> lock(mutex_);
        if (!channels_[service]) {
            channels_[service] = CreateChannel(service);
        }
        return channels_[service];
    }
private:
    static std::unordered_map<std::string, std::shared_ptr<grpc::Channel>>
        channels_;
    static std::mutex mutex_;
};

Keepalive 설정 (장시간 유휴 연결)

grpc::ChannelArguments args;
args.SetInt("grpc.keepalive_time_ms", 10000);   // 10초마다 ping
args.SetInt("grpc.keepalive_timeout_ms", 5000);
args.SetInt("grpc.keepalive_permit_without_calls", 1);

클라이언트 PING 간격을 이렇게 짧게 잡으면 서버도 그만큼 잦은 PING을 허용하도록 설정해야 합니다. 그렇지 않으면 서버가 GOAWAY(too_many_pings)로 연결을 끊어, keepalive를 켠 뒤 오히려 연결이 자주 끊기는 증상이 나옵니다. 서버 쪽 설정은 43-1의 Keepalive 설정에서 설명합니다.

메시지 크기 제한

args.SetInt("grpc.max_send_message_length", 64 * 1024 * 1024);   // 64MB
args.SetInt("grpc.max_receive_message_length", 64 * 1024 * 1024);

에러 처리 일관성

grpc::Status status = stub->MyRpc(&context, req, &res);
if (!status.ok()) {
    switch (status.error_code()) {
        case grpc::StatusCode::UNAVAILABLE:
            // 재시도 또는 폴백
            break;
        case grpc::StatusCode::DEADLINE_EXCEEDED:
            // 타임아웃 처리
            break;
        case grpc::StatusCode::UNAUTHENTICATED:
            // 토큰 갱신
            break;
        default:
            // 로깅 후 에러 반환
            break;
    }
}

메타데이터 트레이싱

// 클라이언트: 요청 ID 주입
context.AddMetadata("x-request-id", GenerateRequestId());
context.AddMetadata("x-trace-id", GetCurrentTraceId());
// 서버: 로깅에 활용
auto it = context->client_metadata().find("x-request-id");
if (it != context->client_metadata().end()) {
    std::string req_id(it->second.begin(), it->second.end());
    LOG(INFO) << "[" << req_id << "] " << method_name;
}

핸들러마다 위 조회 코드를 반복하지 않도록 헬퍼로 묶어 두면, 요청 ID가 없을 때의 처리도 한곳에서 정할 수 있습니다. client_metadata()의 값은 grpc::string_ref라 널 종료 문자열이 아니므로, 위처럼 반복자 범위로 std::string을 만들어야 합니다. 키는 소문자여야 하고, 바이너리 값을 보내려면 키 이름이 -bin으로 끝나야 합니다.

std::string GetRequestId(const grpc::ServerContext* context) {
    const auto& md = context->client_metadata();
    auto it = md.find("x-request-id");
    if (it == md.end()) return {};
    return std::string(it->second.begin(), it->second.end());
}

클라이언트 쪽에서 요청 ID를 std::rand()로 만들면 여러 인스턴스가 같은 시드로 시작했을 때 ID가 겹쳐 로그 추적이 오히려 꼬입니다. UUID 라이브러리나 std::random_device로 시드한 64비트 난수를 씁니다. 이 ID는 앞의 재시도 주의사항에서 말한 서버 측 중복 요청 제거의 키로도 그대로 쓸 수 있습니다.

성능 설정은 별도 글에서

채널·스텁 재사용, 스트리밍 메시지 크기, 양방향 스트림의 읽기·쓰기 스레드 분리, HTTP/2 멀티플렉싱처럼 처리량에 관한 설정은 gRPC 성능(#52-1)의 성능 최적화 절에서 다룹니다.


Circuit Breaker·Kubernetes 배포·다중 리전

Circuit Breaker (개념)

연속 실패 시 일정 시간 호출 중단 후 재시도.

// 개념: 실패 횟수 카운트, 임계값 초과 시 OPEN 상태로 전환
// 일정 시간 후 HALF_OPEN으로 전환해 테스트 요청
// 성공 시 CLOSED로 복구

그레이스풀 셧다운

void GracefulShutdown(grpc::Server* server) {
    server->Shutdown();  // 새 RPC 거부
    server->Wait();      // 진행 중 RPC 완료 대기
}

Kubernetes 배포 전 확인할 것

- [ ] round_robin 로드밸런싱 (다중 Pod)
- [ ] livenessProbe: grpc_health_probe
- [ ] readinessProbe: grpc_health_probe
- [ ] resource limits 설정
- [ ] 그레이스풀 셧다운 (preStop 훅)
- [ ] HPA (Horizontal Pod Autoscaler) 고려

메트릭 수집

// 인터셉터에서 RPC 지연, 에러율 수집
// Prometheus exposition format으로 노출
// 또는 OpenTelemetry 연동

다중 리전·폴백

// 1차: 로컬 리전
// 실패 시: 원격 리전으로 재시도
std::vector<std::string> endpoints = {
    "dns:///my-service.local:50051",
    "dns:///my-service.remote:50051"};

gRPC 고급 기능 점검 항목

인터셉터

  • 인증·로깅 등 크로스커팅 관심사 식별
  • ClientInterceptorFactoryInterface 구현
  • CreateCustomChannelWithInterceptors 사용
  • 인터셉터 순서 검토

로드밸런싱

  • SetLoadBalancingPolicyName("round_robin") 설정
  • DNS 또는 다중 주소로 여러 백엔드 노출
  • Kubernetes: dns:///service-name:port 사용

데드라인·재시도

  • 모든 RPC에 set_deadline 설정
  • 재시도 가능 에러만 재시도
  • 지수 백오프 + jitter 적용
  • 멱등하지 않은 RPC 재시도 제외
  • 코드 재시도와 Service Config 재시도를 동시에 켜지 않음

헬스 체크

  • gRPC Health Service 등록
  • Kubernetes liveness/readiness에 grpc_health_probe 설정
  • 서비스별 상태 확인 (선택)

프로덕션

  • 그레이스풀 셧다운
  • Keepalive 설정
  • 메시지 크기 제한
  • 메타데이터 트레이싱
  • 채널·스텁 재사용

기능별 요약

항목요약
인터셉터인증·로깅·메트릭 중앙 처리, experimental API, CreateCustomChannelWithInterceptors
로드밸런싱round_robin, ChannelArguments, dns:/// 다중 주소
데드라인set_deadline, IsCancelled, RPC 유형별 분리
재시도지수 백오프, jitter, 멱등성 고려
헬스 체크gRPC Health Protocol, grpc_health_probe, Kubernetes 프로브
브로드캐스트 스트리밍스트림 포인터 수명은 RAII로, Write는 한 락 아래서, 느린 클라이언트용 큐에는 상한
베스트 프랙티스데드라인 필수, 채널 재사용, Keepalive, 에러 처리
프로덕션그레이스풀 셧다운, 메트릭, 환경별 설정

핵심 원칙:

  1. 인터셉터로 비즈니스 로직과 인증·로깅 분리
  2. 다중 서버 환경에서는 round_robin 필수
  3. 데드라인과 재시도로 일시 장애에 대비
  4. 멱등하지 않은 RPC는 재시도 제외
  5. Kubernetes 배포 시 grpc_health_probe 연동

자주 묻는 질문 (FAQ)

Q. gRPC Health Checking Protocol과 HTTP 헬스 체크 차이는?

A. gRPC Health는 gRPC 서비스로 구현되어, 서버가 실제로 RPC를 처리할 수 있는지 확인합니다. HTTP /health는 프로세스만 확인할 수 있습니다.

Q. 인터셉터 API가 experimental인데 프로덕션에서 써도 되나요?

A. gRPC C++ 인터셉터는 수년간 사용되어 왔으나, API 변경 가능성이 있습니다. 버전 고정 및 테스트를 권장합니다.

Q. round_robin과 pick_first 성능 차이는?

A. 단일 서버에서는 차이 없습니다. 다중 서버에서 round_robin이 연결을 분산해 처리량을 높입니다. 인터셉터·로드밸런싱·데드라인·재시도·헬스체크로 프로덕션급 gRPC를 구축할 수 있습니다. 다음 글: C++ 시리즈 목차에서 다음 주제를 확인하세요. 이전 글: gRPC 성능: Callback API·스트리밍 메시지 크기·HTTP/2 멀티플렉싱(#52-1)


같이 보면 좋은 글