C++ gRPC 성능 다루기: Callback API, 스트리밍 메시지 크기, HTTP/2 멀티플렉싱

들어가며: gRPC로 옮겼는데 기대만큼 빠르지 않다

REST·JSON 호출을 gRPC로 옮기면 페이로드가 작아지고 HTTP/2로 연결을 재사용하므로 대체로 빨라집니다. 그런데 부하 테스트를 해 보면 서버 스레드 수가 요청 수만큼 늘어나 있거나, 동시 요청을 늘려도 처리량이 어느 지점에서 멈추거나, 대용량 스트리밍에서 메모리가 튀는 경우가 있습니다. 이 글은 그런 성능 문제의 원인을 다룹니다.

.proto 정의와 코드 생성, vcpkg·CMake 설정, 동기 서버·클라이언트, 네 가지 RPC 유형(unary·서버·클라이언트·양방향 스트리밍)의 기본 코드, 기본 상태 코드 대처는 gRPC와 Protocol Buffers #43-1에서 다뤘습니다. 이 글의 예제도 그 글의 EchoService(Echo unary, ServerStream 서버 스트리밍)를 그대로 씁니다.

이 글에서 다루는 것:

  • Sync API의 스레드 모델과 한계
  • Callback API(Reactor)로 서버·클라이언트 작성
  • HTTP/2 멀티플렉싱: 채널 하나가 실제로 무엇인지, 동시 스트림 한도
  • 스트리밍 메시지 크기, 흐름 제어, 최대 메시지 크기
  • keepalive와 재시도 설정의 함정
  • 측정 방법

요구 환경: C++17, gRPC 1.50+ (Callback API가 정식 API가 된 버전 이후)


Sync API는 무엇을 소비하는가

동기(Sync) 서버에서 서비스 메서드는 gRPC가 관리하는 스레드 풀의 스레드에서 호출되고, 메서드가 반환할 때까지 그 스레드를 점유합니다. 처리 중에 다른 서비스를 호출하거나 DB를 기다리면 그동안 스레드는 아무 일도 하지 않으면서 묶여 있습니다. 동시 요청이 늘어나면 gRPC는 스레드를 더 만들고(ResourceQuota로 상한을 둘 수 있음), 스레드가 수백 개가 되면 스택 메모리와 컨텍스트 스위칭 비용이 처리량을 깎습니다.

grpc::ResourceQuota quota("server_quota");
quota.SetMaxThreads(64);                 // Sync 서버의 스레드 상한
builder.SetResourceQuota(quota);

상한을 걸면 스레드 폭증은 막지만, 64개가 모두 대기 중인 요청으로 묶이면 새 요청은 RESOURCE_EXHAUSTED를 받거나 큐에서 기다립니다. 즉 Sync API에서 동시에 처리 중인 요청 수 = 스레드 수라는 관계는 바뀌지 않습니다.

서비스 메서드가 CPU만 짧게 쓰고 끝난다면 이 모델도 충분히 빠릅니다. 문제가 되는 것은 요청 처리 중에 I/O를 기다리는 서버, 즉 대부분의 마이크로서비스입니다. 이런 서버에서 동시성을 늘리려면 기다리는 동안 스레드를 반납하는 비동기 모델이 필요합니다. gRPC C++에는 두 가지가 있습니다.

  • Async API (CompletionQueue): 가장 오래된 비동기 API. 태그와 상태 기계를 직접 관리해야 해서 코드가 길고 실수가 잦습니다. 기본 형태는 #43-1의 비동기 서버에 있습니다.
  • Callback API (Reactor): RPC 이벤트마다 콜백이 호출되는 방식. 스레드 관리는 gRPC가 하고, 사용자는 이벤트 처리만 작성합니다. gRPC 팀이 새 코드에 권장하는 방식입니다.

Callback API 서버

코드 생성기는 서비스마다 CallbackService 베이스 클래스를 만들어 줍니다. 메서드는 Status 대신 Reactor를 반환하며, Finish()를 호출하는 시점이 RPC가 끝나는 시점입니다. 메서드가 반환된 뒤에 Finish()를 호출해도 되므로, 비동기 작업의 완료 콜백에서 응답을 채우고 끝낼 수 있습니다.

#include <grpcpp/grpcpp.h>
#include "echo.grpc.pb.h"

class EchoCallbackImpl final : public echo::EchoService::CallbackService {
public:
    // Unary: 응답이 바로 준비되면 즉시 Finish
    grpc::ServerUnaryReactor* Echo(grpc::CallbackServerContext* ctx,
                                   const echo::EchoRequest* req,
                                   echo::EchoResponse* resp) override {
        auto* reactor = ctx->DefaultReactor();
        if (req->message().empty()) {
            reactor->Finish(grpc::Status(grpc::StatusCode::INVALID_ARGUMENT, "empty message"));
            return reactor;
        }
        // I/O가 필요한 경우: 비동기 작업에 콜백을 넘기고 메서드는 즉시 반환
        backend_.LookupAsync(req->message(), [reactor, resp](std::string value) {
            resp->set_message(std::move(value));
            reactor->Finish(grpc::Status::OK);      // 다른 스레드에서 호출돼도 됨
        });
        return reactor;
    }
private:
    Backend backend_;   // 자체 비동기 클라이언트(DB, 다른 서비스 등)
};

핵심은 콜백 안에서 블로킹하지 않는 것입니다. Callback API의 콜백은 gRPC 내부의 소수 스레드에서 실행되므로, 콜백 안에서 동기 DB 호출이나 sleep을 하면 그 스레드가 다른 모든 RPC의 이벤트 처리를 막습니다. Sync API에서는 느린 요청 하나가 스레드 하나만 잡았지만, Callback API에서는 서버 전체를 느리게 만들 수 있습니다. 블로킹 작업이 불가피하다면 별도 스레드 풀로 넘기고 완료 시점에 Finish()를 호출합니다.

서버 스트리밍 Reactor

스트리밍은 ServerWriteReactor를 상속해 이벤트를 처리합니다. 다음 메시지는 이전 쓰기가 완료된 뒤(OnWriteDone)에만 보낼 수 있습니다. 이 규칙이 곧 흐름 제어입니다. 클라이언트가 느리게 읽으면 OnWriteDone이 늦게 오고, 서버는 그만큼 천천히 씁니다.

class NumberStreamer : public grpc::ServerWriteReactor<echo::EchoResponse> {
public:
    NumberStreamer(const echo::EchoRequest* req, int count)
        : prefix_(req->message()), remaining_(count) { NextWrite(); }

    void OnWriteDone(bool ok) override {
        if (!ok) { Finish(grpc::Status(grpc::StatusCode::UNKNOWN, "write failed")); return; }
        NextWrite();
    }
    void OnDone() override { delete this; }         // RPC가 완전히 끝나면 스스로 삭제
    void OnCancel() override { /* 클라이언트 취소: 진행 중 작업 중단 신호 */ }

private:
    void NextWrite() {
        if (remaining_ == 0) { Finish(grpc::Status::OK); return; }
        resp_.set_message(prefix_ + " #" + std::to_string(remaining_));
        resp_.set_sequence(remaining_--);
        StartWrite(&resp_);                          // resp_는 OnWriteDone까지 살아 있어야 함
    }
    std::string prefix_;
    int remaining_;
    echo::EchoResponse resp_;
};

grpc::ServerWriteReactor<echo::EchoResponse>*
EchoCallbackImpl::ServerStream(grpc::CallbackServerContext*, const echo::EchoRequest* req) {
    return new NumberStreamer(req, 5);
}

Reactor 코드에서 가장 흔한 버그는 수명 관리입니다. StartWrite에 넘긴 메시지는 OnWriteDone이 호출될 때까지 유효해야 하므로 지역 변수를 넘기면 안 되고, Reactor 객체는 OnDone 전에 삭제하면 안 됩니다. OnDone에서 delete this를 하는 패턴이 공식 예제의 방식입니다.

Callback API 클라이언트

클라이언트도 stub->async()로 콜백 버전을 쓸 수 있습니다. 한 스레드에서 수천 개의 RPC를 동시에 띄울 수 있다는 점이 동기 스텁과의 차이입니다.

void SendMany(echo::EchoService::Stub* stub, int n) {
    std::atomic<int> pending{n};
    std::mutex mu;
    std::condition_variable cv;
    struct Call { grpc::ClientContext ctx; echo::EchoRequest req; echo::EchoResponse resp; };

    for (int i = 0; i < n; ++i) {
        auto* call = new Call;
        call->req.set_message("hello");
        call->ctx.set_deadline(std::chrono::system_clock::now() + std::chrono::seconds(2));
        stub->async()->Echo(&call->ctx, &call->req, &call->resp,
            [call, &pending, &mu, &cv](grpc::Status s) {
                // s.ok() 확인 후 call->resp 사용
                delete call;
                if (--pending == 0) { std::lock_guard lk(mu); cv.notify_one(); }
            });
    }
    std::unique_lock lk(mu);
    cv.wait(lk, [&] { return pending == 0; });
}

ClientContext, 요청, 응답 객체는 콜백이 호출될 때까지 살아 있어야 하므로 힙에 묶어 두고 콜백에서 해제합니다.


HTTP/2 멀티플렉싱: 채널 하나의 실체

gRPC의 Channel은 대상 주소로 가는 논리적 연결이고, 내부적으로 서브채널(백엔드 하나당 HTTP/2 연결 하나)을 관리합니다. 한 HTTP/2 연결 위에서 RPC 하나는 스트림 하나이며, 여러 스트림이 한 TCP 연결을 나눠 씁니다. 그래서 채널은 만들어서 재사용해야 합니다. 요청마다 채널을 만들면 매번 TCP·TLS 핸드셰이크를 하게 되어 gRPC를 쓰는 이점이 사라집니다.

// ❌ 요청마다 채널 생성: 매번 새 연결
for (int i = 0; i < 1000; ++i) {
    auto stub = echo::EchoService::NewStub(grpc::CreateChannel(addr, creds));
    // RPC ...
}
// ✅ 채널·스텁 재사용 (둘 다 스레드 안전)
auto channel = grpc::CreateChannel(addr, creds);
auto stub = echo::EchoService::NewStub(channel);

동시 스트림 한도

HTTP/2 연결에는 서버가 광고하는 동시 스트림 최대 수(SETTINGS_MAX_CONCURRENT_STREAMS)가 있습니다. gRPC C++ 서버 자체는 기본값이 사실상 무제한이지만, 앞단의 프록시나 로드밸런서, 다른 언어의 서버 구현은 100 정도로 제한하는 경우가 흔합니다. 한도에 도달하면 클라이언트의 새 RPC는 에러 없이 클라이언트 쪽에서 대기합니다. 그래서 “동시 요청을 늘려도 처리량이 어느 지점에서 멈추고, 지연만 늘어난다”는 증상으로 나타납니다.

이 경우 채널을 여러 개 만들어 연결을 늘립니다. 주의할 점은, 같은 인자로 만든 채널들은 내부적으로 서브채널(연결)을 공유할 수 있다는 것입니다. 연결을 실제로 분리하려면 채널 인자를 다르게 주거나 로컬 서브채널 풀을 쓰도록 지정해야 합니다.

std::vector<std::unique_ptr<echo::EchoService::Stub>> stubs;
for (int i = 0; i < 4; ++i) {
    grpc::ChannelArguments args;
    args.SetInt(GRPC_ARG_USE_LOCAL_SUBCHANNEL_POOL, 1);   // 채널마다 독립된 연결
    args.SetInt("channel_index", i);                      // 인자를 달리해 공유 방지
    stubs.push_back(echo::EchoService::NewStub(
        grpc::CreateCustomChannel(addr, creds, args)));
}
// 요청마다 round-robin으로 스텁 선택

한 연결의 한계

HTTP/2는 애플리케이션 수준의 head-of-line blocking은 없애지만, 모든 스트림이 TCP 연결 하나를 공유하므로 패킷 손실이 생기면 그 연결의 모든 스트림이 재전송을 기다립니다. 또 연결 하나는 대개 한 코어에서 처리되므로, 매우 높은 처리량에서는 연결 수 자체가 병목이 됩니다. 위의 다중 채널은 이런 경우에도 도움이 됩니다.

반대로 연결이 오래 유지되는 것은 로드밸런싱에 불리합니다. L4 로드밸런서는 연결 단위로 분산하므로, 클라이언트가 채널 하나로 모든 RPC를 보내면 모든 요청이 백엔드 하나로 갑니다. 서버를 늘려도 기존 클라이언트는 새 서버로 가지 않습니다. 해결책은 클라이언트 측 로드밸런싱(round_robin 정책과 여러 주소를 돌려주는 DNS 또는 xDS)이나 L7(HTTP/2 인식) 프록시이며, 서버 쪽에서 grpc.max_connection_age_ms로 연결 수명을 제한해 주기적으로 재연결하게 만드는 방법도 함께 씁니다.

// 클라이언트: DNS가 돌려준 모든 주소로 RPC를 분산
grpc::ChannelArguments args;
args.SetLoadBalancingPolicyName("round_robin");
auto channel = grpc::CreateCustomChannel("dns:///echo.internal:50051", creds, args);
// 서버: 연결을 최대 5분만 유지하고 새 연결을 유도 (진행 중 RPC는 grace 동안 마무리)
builder.AddChannelArgument(GRPC_ARG_MAX_CONNECTION_AGE_MS, 5 * 60 * 1000);
builder.AddChannelArgument(GRPC_ARG_MAX_CONNECTION_AGE_GRACE_MS, 30 * 1000);

스트리밍 메시지 크기와 흐름 제어

최대 메시지 크기

gRPC는 기본적으로 수신 메시지 하나의 최대 크기가 4MB이고, 이를 넘으면 RESOURCE_EXHAUSTED: Received message larger than max로 실패합니다. 큰 응답을 unary로 보내다가 데이터가 늘어나 어느 날 갑자기 실패하는 경우가 이것입니다.

builder.SetMaxReceiveMessageSize(16 * 1024 * 1024);   // 서버 수신 한도
grpc::ChannelArguments args;
args.SetMaxReceiveMessageSize(16 * 1024 * 1024);       // 클라이언트 수신 한도

한도를 올리는 것은 쉬운 해결책이지만, 메시지 하나는 통째로 메모리에 올라온 뒤에야 역직렬화되므로 한도를 올릴수록 요청 하나가 쓰는 메모리도 커집니다. 동시 요청이 많으면 이것이 메모리 폭증의 원인이 됩니다. 크기가 계속 커지는 데이터라면 한도를 올리기보다 스트리밍으로 나누는 편이 맞습니다.

청크 크기

스트림으로 나눌 때 메시지(청크) 하나의 크기는 트레이드오프입니다.

  • 너무 작으면: 메시지마다 붙는 프레이밍, 직렬화, Write 호출, 콜백 비용이 데이터보다 커집니다. 1KB 청크로 1GB를 보내면 백만 번의 쓰기가 필요합니다.
  • 너무 크면: 청크 하나를 다 받기 전에는 처리를 시작할 수 없어 첫 데이터까지의 지연이 늘고, 동시 스트림 수 × 청크 크기만큼 메모리를 씁니다.

수십 KB에서 수백 KB 사이에서 시작해 처리량과 메모리를 측정하며 조정하는 것이 일반적입니다. 파일 전송이라면 청크에 오프셋을 넣어 두면 중단된 전송을 이어받을 수 있습니다.

message FileChunk {
  bytes data = 1;
  int64 offset = 2;   // 재개 지점
}

흐름 제어와 Write

HTTP/2에는 스트림별·연결별 흐름 제어 윈도가 있어, 받는 쪽이 읽지 않으면 보내는 쪽이 더 보낼 수 없습니다. Sync API에서 writer->Write()가 오래 블로킹되는 것은 대부분 이 때문이며, 이는 버그가 아니라 느린 소비자로부터 서버 메모리를 보호하는 백프레셔입니다. 반대로 Write()가 false를 반환하는 것은 스트림이 끝났다는 뜻(클라이언트 취소, 데드라인, 연결 끊김)이므로 즉시 루프를 빠져나와야 합니다.

while (source.HasMore()) {
    chunk.set_data(source.Next(64 * 1024));
    if (!writer->Write(chunk)) break;      // 스트림 종료: 더 쓰지 말 것
}

백프레셔를 무시하고 다른 스레드에서 데이터를 큐에 계속 쌓아 두는 설계(생산자가 소비자보다 빠른 구조)는 결국 서버 메모리를 채웁니다. Callback API에서는 OnWriteDone이 올 때까지 다음 쓰기를 하지 않는 구조가 자연스럽게 백프레셔를 따르므로, 생산자도 이 신호에 맞춰 속도를 조절해야 합니다.


keepalive와 재시도의 함정

keepalive: 클라이언트만 바꾸면 끊긴다

유휴 연결이 NAT나 방화벽에서 조용히 끊기는 것을 막으려고 클라이언트에 keepalive를 켜는 경우가 많습니다. 그런데 gRPC 서버는 기본적으로 데이터가 없을 때 5분보다 자주 오는 ping을 악성으로 간주해, 몇 번 받으면 GOAWAY(too_many_pings)를 보내고 연결을 닫습니다. 클라이언트를 10초 간격으로 설정하면 연결이 오히려 주기적으로 끊기고, 로그에는 UNAVAILABLE만 남습니다.

// 클라이언트
grpc::ChannelArguments args;
args.SetInt(GRPC_ARG_KEEPALIVE_TIME_MS, 60 * 1000);          // 60초마다 ping
args.SetInt(GRPC_ARG_KEEPALIVE_TIMEOUT_MS, 10 * 1000);
args.SetInt(GRPC_ARG_KEEPALIVE_PERMIT_WITHOUT_CALLS, 1);     // RPC가 없어도 ping

// 서버: 위 클라이언트 설정을 허용하도록 함께 조정
builder.AddChannelArgument(GRPC_ARG_HTTP2_MIN_RECV_PING_INTERVAL_WITHOUT_DATA_MS, 30 * 1000);
builder.AddChannelArgument(GRPC_ARG_KEEPALIVE_PERMIT_WITHOUT_CALLS, 1);

keepalive는 클라이언트와 서버가 짝을 맞춰야 하는 설정입니다. 클라이언트 쪽 값만 바꾸는 변경은 서버 설정과 함께 리뷰해야 합니다.

재시도: 직접 루프보다 서비스 설정

UNAVAILABLE에 대해 지수 백오프로 재시도하는 루프를 직접 작성할 수도 있지만, gRPC에는 서비스 설정(service config)으로 선언하는 재시도 정책이 있습니다. 직접 작성한 루프는 재시도마다 새 데드라인을 잡아 전체 대기 시간이 의도보다 길어지기 쉽고, 여러 계층에서 각자 재시도하면 장애 시 요청이 기하급수적으로 불어납니다(재시도 폭풍).

const char* kServiceConfig = R"({
  "methodConfig": [{
    "name": [{ "service": "echo.EchoService" }],
    "retryPolicy": {
      "maxAttempts": 3,
      "initialBackoff": "0.1s",
      "maxBackoff": "1s",
      "backoffMultiplier": 2,
      "retryableStatusCodes": ["UNAVAILABLE"]
    }
  }]
})";
grpc::ChannelArguments args;
args.SetServiceConfigJSON(kServiceConfig);
auto channel = grpc::CreateCustomChannel(addr, creds, args);

이 방식은 원래 호출의 데드라인 안에서만 재시도하고, 서버가 과부하를 알리면 재시도를 억제하는 스로틀링(retryThrottling)도 설정할 수 있습니다. 재시도는 멱등한 메서드에만 걸어야 한다는 점은 어느 방식이든 같습니다. 결제처럼 두 번 실행되면 안 되는 호출은 요청 ID로 서버에서 중복을 걸러야 합니다.

데드라인은 항상 설정

데드라인이 없는 RPC는 서버가 응답하지 않으면 영원히 기다리고, Callback API에서는 그동안 관련 객체가 해제되지 않습니다. 서버 쪽에서는 ctx->IsCancelled()(Sync)나 OnCancel()(Callback)로 데드라인 초과를 감지해 남은 작업을 멈춰야, 이미 포기한 요청에 자원을 계속 쓰지 않습니다. 다른 서비스를 연쇄 호출한다면 들어온 요청의 남은 데드라인을 하위 호출에 그대로 전파합니다.


측정

gRPC 성능 문제는 추측으로 고치기 어렵습니다. 서버 처리 시간, 큐 대기, 직렬화, 네트워크가 모두 섞여 있기 때문입니다.

  • ghz: gRPC 전용 부하 도구. 동시성(-c)과 총 요청 수(-n)를 바꿔 가며 지연 분포(p50/p99)와 처리량을 봅니다. 동시성을 올려도 처리량이 늘지 않고 p99만 늘어나는 지점이 한계입니다.
ghz --insecure --proto echo.proto --call echo.EchoService.Echo \
    -d '{"message":"hi"}' -c 200 -n 100000 localhost:50051
  • 채널 트레이싱: GRPC_TRACE=http,flowctl과 GRPC_VERBOSITY=debug 환경 변수로 HTTP/2 프레임과 흐름 제어 윈도 변화를 볼 수 있습니다. 출력이 매우 많으므로 짧은 재현에서만 켭니다.
  • 서버 스레드 수: Sync 서버에서 부하 중 스레드 수가 계속 늘어난다면 1절의 상황입니다. Callback 서버에서 처리량이 낮고 CPU도 낮다면, 콜백 안에서 블로킹하는 코드가 있는지 먼저 봅니다.

비교 기준도 중요합니다. 같은 데이터센터 안에서 작은 메시지를 주고받는다면 직렬화나 HTTP/2 차이보다 서버 처리 시간이 지연의 대부분일 수 있습니다. gRPC로 바꾼 뒤 기대만큼 빨라지지 않았다면, 먼저 서버 처리 시간을 분리해 측정해 보는 것이 좋습니다.


정리

증상원인대응
부하 시 서버 스레드 폭증Sync API는 RPC당 스레드 점유Callback API, 블로킹 작업은 별도 풀
동시성을 올려도 처리량 정체HTTP/2 동시 스트림 한도, 연결 하나다중 채널(독립 서브채널)
서버를 늘려도 분산 안 됨장수명 연결 + L4 분산round_robin, max_connection_age
RESOURCE_EXHAUSTED ... larger than max4MB 기본 메시지 한도스트리밍으로 분할, 필요 시 한도 조정
스트리밍 중 메모리 증가백프레셔 무시OnWriteDone·Write 결과에 맞춰 생산
keepalive 후 주기적 UNAVAILABLE서버의 too_many_pings서버 ping 허용 간격도 함께 조정
장애 시 요청 폭증계층별 수동 재시도서비스 설정 재시도 + 스로틀링, 멱등 메서드만

인터셉터, 로드밸런싱, 헬스 체크 서비스 같은 운영 기능은 gRPC 고급 #52-3에서, 메시지 설계와 직렬화 비용은 Protocol Buffers #52-8에서 이어집니다.

다음 글: gRPC 고급: 스트리밍·인터셉터·로드밸런싱(#52-3) 이전 글: C++ 시리즈 목차


같이 보면 좋은 글