C++ 고성능 RPC 시스템: gRPC와 Protocol Buffers를 이용한 마이크로서비스 구축

HTTP/JSON만으로는 부족해지는 때

웹소켓·SSL·프로토콜 직렬화를 다뤘다면, 이번에는 서비스끼리 주고받는 내부 통신입니다. 마이크로서비스 사이의 호출에는 gRPC(Google이 만든 RPC 프레임워크)와 Protocol Buffers(바이너리 직렬화 포맷)가 널리 쓰입니다.

HTTP/JSON으로 시작한 내부 통신은 몇 가지 지점에서 한계에 부딪힙니다. 요청이 많은 C++ 서비스를 프로파일링하면 필드 이름 문자열 비교, 숫자와 텍스트의 상호 변환, 이스케이프 처리 같은 JSON 파싱·생성 비용이 상위에 올라오는 경우가 있습니다. 스키마가 코드에 강제되지 않으니 user_id와 userId 같은 필드 이름 불일치가 런타임에야 데이터 누락으로 드러나고, 수백 MB짜리 결과를 요청 하나에 담거나 채팅처럼 양쪽이 동시에 메시지를 보내는 통신은 요청-응답 모델로 표현하기 어렵습니다.

gRPC와 Protobuf는 이 문제들에 각각 답을 줍니다. 필드를 이름 대신 번호로, 숫자를 텍스트 대신 varint나 고정 폭으로 인코딩해 직렬화 비용을 줄이고(얼마나 줄어드는지는 메시지 구조와 기존 JSON 라이브러리에 따라 다르므로 전환 전후로 프로파일링해야 합니다), .proto 스키마에서 생성한 코드가 필드 이름과 타입을 컴파일 시점에 강제하며, 서버·클라이언트·양방향 스트리밍을 같은 HTTP/2 연결 위에서 표준으로 지원합니다. 이 글은 .proto 작성과 코드 생성부터 동기·비동기 서버, 네 가지 RPC 유형, 상태 코드별 대응, 운영에서 챙길 설정까지 차례로 다룹니다.


Protocol Buffers 기초

.proto와 코드 생성

Protocol Buffers는 IDL(Interface Definition Language)로 메시지와 서비스를 정의하고, protoc 컴파일러로 C++·Java·Go 등 여러 언어의 코드를 생성합니다. 직렬화된 데이터에는 필드 이름이 아니라 필드 번호가 들어가므로, 필드 번호가 곧 스키마 호환성의 기준입니다. 기존 번호를 바꾸지 않고 새 필드만 새 번호로 추가하면, 옛 코드는 모르는 필드를 건너뛰고 새 코드는 없는 필드를 기본값으로 읽어 양방향 호환이 유지됩니다.

gRPC + Protobuf 아키텍처

flowchart TB
    subgraph Client[클라이언트]
        C1[.proto 정의]
        C2[Stub 생성]
        C3[비즈니스 로직]
        C1 --> C2 --> C3
    end
    subgraph Server[서버]
        S1[.proto 정의]
        S2[Service 베이스]
        S3[구현체 Override]
        S1 --> S2 --> S3
    end
    C3 -->|HTTP/2 + Protobuf| S3

기본 .proto 예시

syntax = "proto3"는 Protocol Buffers 3 문법을 쓰겠다는 선언이고, message는 직렬화될 필드와 그 번호(1, 2, …)를, service는 RPC 메서드를 정의합니다.

syntax = "proto3";
package myapp;
// 메시지 정의: 필드 번호는 절대 변경하지 않음
message UserRequest {
  int32 user_id = 1;
  string name = 2;
  repeated string tags = 3;  // 반복 필드
}
message UserResponse {
  int32 user_id = 1;
  string name = 2;
  int64 created_at = 3;
  map<string, string> metadata = 4;  // 맵 타입
}
// 서비스 정의
service UserService {
  rpc GetUser(UserRequest) returns (UserResponse);
  rpc ListUsers(UserRequest) returns (stream UserResponse);  // 서버 스트리밍
}

oneof·enum·import

syntax = "proto3";
package myapp;
// enum: 숫자로 직렬화되어 효율적
enum UserStatus {
  UNKNOWN = 0;
  ACTIVE = 1;
  INACTIVE = 2;
  BANNED = 3;
}
// oneof: 여러 필드 중 하나만 설정
message Event {
  oneof payload {
    string message = 1;
    int32 code = 2;
    bytes binary_data = 3;
  }
}
// 다른 .proto 파일 import
// import "google/protobuf/timestamp.proto";

protoc로 C++ 코드 생성

# protoc 설치 (macOS)
brew install protobuf
# gRPC C++ 플러그인 (vcpkg)
vcpkg install grpc
# 코드 생성
protoc --cpp_out=./generated --grpc_out=./generated \
  --plugin=protoc-gen-grpc=`which grpc_cpp_plugin` \
  user_service.proto

생성되는 파일:

  • user_service.pb.h, user_service.pb.cc: 메시지 클래스
  • user_service.grpc.pb.h, user_service.grpc.pb.cc: Stub·Service 클래스

CMake 통합

# CMakeLists.txt
find_package(Protobuf REQUIRED)
find_package(gRPC CONFIG REQUIRED)
# Protobuf 생성
set(PROTO_PATH "${CMAKE_CURRENT_SOURCE_DIR}/proto")
set(GENERATED_PROTOBUF_PATH "${CMAKE_BINARY_DIR}/generated")
file(MAKE_DIRECTORY ${GENERATED_PROTOBUF_PATH})
set(PROTO_FILES user_service.proto)
foreach(PROTO_FILE ${PROTO_FILES})
  get_filename_component(PROTO_NAME ${PROTO_FILE} NAME_WE)
  set(PROTO_FULL "${PROTO_PATH}/${PROTO_FILE}")
  add_custom_command(
    OUTPUT
      "${GENERATED_PROTOBUF_PATH}/${PROTO_NAME}.pb.cc"
      "${GENERATED_PROTOBUF_PATH}/${PROTO_NAME}.pb.h"
      "${GENERATED_PROTOBUF_PATH}/${PROTO_NAME}.grpc.pb.cc"
      "${GENERATED_PROTOBUF_PATH}/${PROTO_NAME}.grpc.pb.h"
    COMMAND protobuf::protoc
    ARGS --cpp_out=${GENERATED_PROTOBUF_PATH}
         --grpc_out=${GENERATED_PROTOBUF_PATH}
         --plugin=protoc-gen-grpc=$<TARGET_FILE:gRPC::grpc_cpp_plugin>
         -I${PROTO_PATH}
         ${PROTO_FULL}
    DEPENDS ${PROTO_FULL}
  )
  list(APPEND GENERATED_SOURCES
    "${GENERATED_PROTOBUF_PATH}/${PROTO_NAME}.pb.cc"
    "${GENERATED_PROTOBUF_PATH}/${PROTO_NAME}.grpc.pb.cc"
  )
endforeach()
add_executable(grpc_server ${SOURCES} ${GENERATED_SOURCES})
target_include_directories(grpc_server PRIVATE ${GENERATED_PROTOBUF_PATH})
target_link_libraries(grpc_server PRIVATE
  protobuf::libprotobuf
  gRPC::grpc++
  gRPC::grpc++_reflection
)

gRPC C++ 서버·클라이언트

동기 vs 비동기 API

구분동기비동기
서버gRPC 내부 스레드 풀이 핸들러를 호출 (RPC마다 스레드를 점유)CompletionQueue 이벤트 루프로 스레드 수와 무관하게 다수 RPC 처리
클라이언트블로킹 호출Async* + CompletionQueue
적합대부분의 서비스, 단순한 코드동시 연결이 매우 많거나 스레드 수를 엄격히 제한해야 할 때

동기 서버라고 해서 요청을 한 번에 하나씩 처리하는 것은 아닙니다. 동기 API에서는 gRPC 라이브러리가 내부에 스레드 풀을 두고, 들어온 RPC마다 풀의 스레드에서 GetUser 같은 핸들러를 호출합니다. 그래서 핸들러는 여러 스레드에서 동시에 실행되고, 서비스 객체의 멤버(캐시, 카운터 등)를 건드린다면 뮤텍스나 atomic으로 직접 보호해야 합니다. 차이는 “동시성이 있느냐”가 아니라 “동시성을 누가 관리하느냐”입니다. 동기 서버는 RPC 하나가 끝날 때까지 스레드 하나를 붙잡으므로 핸들러 안에서 DB를 오래 기다리면 스레드가 늘어나고, 풀 크기는 ResourceQuota의 최대 스레드 수나 SetSyncServerOption(아래 성능 절)으로 조절합니다. 비동기 API는 스레드를 직접 만들고 CompletionQueue에서 이벤트를 꺼내 상태 기계를 돌리는 대신, 적은 스레드로 많은 RPC를 다룰 수 있습니다.

gRPC 요청-응답 시퀀스

sequenceDiagram
    participant C as 클라이언트
    participant S as 서버
    C->>S: GetUser(Request)
    S->>S: 비즈니스 로직
    S->>C: Response + Status
    Note over C: status.ok() 확인

동기 서버 구현

#include <grpcpp/grpcpp.h>
#include "user_service.grpc.pb.h"
using grpc::Server;
using grpc::ServerBuilder;
using grpc::ServerContext;
using grpc::Status;
class UserServiceImpl final : public myapp::UserService::Service {
 public:
  Status GetUser(ServerContext* context,
                 const myapp::UserRequest* request,
                 myapp::UserResponse* response) override {
    // 1. 요청 검증
    if (request->user_id() <= 0) {
      return Status(grpc::StatusCode::INVALID_ARGUMENT, "user_id must be positive");
    }
    // 2. 비즈니스 로직 (DB 조회 등)
    response->set_user_id(request->user_id());
    response->set_name("User_" + std::to_string(request->user_id()));
    response->set_created_at(1700000000);
    return Status::OK;
  }
};
int main() {
  std::string server_address("0.0.0.0:50051");
  UserServiceImpl service;
  ServerBuilder builder;
  builder.AddListeningPort(server_address, grpc::InsecureServerCredentials());
  builder.RegisterService(&service);
  std::unique_ptr<Server> server(builder.BuildAndStart());
  std::cout << "Server listening on " << server_address << std::endl;
  server->Wait();
  return 0;
}

동기 클라이언트 구현

#include <grpcpp/grpcpp.h>
#include "user_service.grpc.pb.h"
using grpc::Channel;
using grpc::ClientContext;
using grpc::Status;
int main() {
  auto channel = grpc::CreateChannel(
      "localhost:50051",
      grpc::InsecureChannelCredentials());
  auto stub = myapp::UserService::NewStub(channel);
  myapp::UserRequest request;
  request.set_user_id(123);
  request.set_name("test");
  myapp::UserResponse response;
  ClientContext context;
  // 데드라인 설정 (5초)
  context.set_deadline(std::chrono::system_clock::now() + std::chrono::seconds(5));
  Status status = stub->GetUser(&context, request, &response);
  if (status.ok()) {
    std::cout << "User: " << response.name() << std::endl;
  } else {
    std::cerr << "RPC failed: " << status.error_code() << " - "
              << status.error_message() << std::endl;
  }
  return 0;
}

비동기 서버 (CompletionQueue)

#include <grpcpp/grpcpp.h>
#include "user_service.grpc.pb.h"
#include <thread>
// 비동기 서버는 Service가 아니라 코드 생성된 AsyncService를 사용
void RunAsyncServer() {
  std::string server_address("0.0.0.0:50051");
  myapp::UserService::AsyncService service;
  grpc::ServerBuilder builder;
  builder.AddListeningPort(server_address, grpc::InsecureServerCredentials());
  builder.RegisterService(&service);
  std::unique_ptr<grpc::ServerCompletionQueue> cq = builder.AddCompletionQueue();
  std::unique_ptr<grpc::Server> server(builder.BuildAndStart());
  // 실제로는 여기서 service.RequestGetUser(...)로 첫 요청 대기를 등록하고,
  // 각 RPC 상태를 담은 CallData 객체의 포인터를 tag로 넘긴다.
  // CompletionQueue 폴링 (별도 스레드)
  std::thread poll_thread([&cq]() {
    void* tag;
    bool ok;
    while (cq->Next(&tag, &ok)) {
      // tag(CallData*)를 꺼내 상태를 진행: 새 요청 등록 → 처리 → Finish
      if (!ok) continue;  // 취소·종료된 이벤트
    }
  });
  // 종료: 다른 스레드(시그널 핸들러 등)에서 Shutdown을 호출한 뒤 큐를 비운다
  server->Wait();
  cq->Shutdown();
  poll_thread.join();
}

위 코드는 뼈대만 보여 줍니다. 비동기 서버의 핵심은 AsyncService::RequestGetUser()로 “다음 요청을 받을 준비”를 등록하고, cq->Next()가 돌려주는 tag로 해당 RPC의 상태 객체를 찾아 다음 단계(응답 작성, responder.Finish(), 새 요청 재등록)로 넘기는 상태 기계입니다. 이 재등록을 빠뜨리면 첫 요청 하나만 처리되고 서버가 조용히 멈춘 것처럼 보이는데, 비동기 API를 처음 쓸 때 가장 흔히 겪는 증상입니다. server->Wait()는 다른 스레드가 server->Shutdown()을 호출해야 반환되므로, 실제 코드에서는 종료 신호를 받는 쪽에서 Shutdown()을 부르고 그 뒤에 cq->Shutdown()으로 큐를 닫은 다음 Next()가 false를 돌려줄 때까지 남은 이벤트를 비워야 합니다. 새 코드라면 콜백 API(UserService::CallbackService)가 CompletionQueue를 직접 다루지 않고도 비슷한 확장성을 주므로 먼저 검토할 만합니다.


네 가지 RPC 유형 (Unary·서버·클라이언트·양방향 스트리밍)

유형 비교

gRPC 메서드는 요청과 응답 각각이 “메시지 하나”인지 “스트림”인지에 따라 네 가지로 나뉩니다. 앞 절의 GetUser가 가장 기본인 Unary RPC(요청 1개 → 응답 1개)이고, 나머지 세 가지는 .proto에서 stream 키워드를 어디에 붙이느냐로 결정됩니다.

유형.proto 정의C++ 서버 쪽 타입사용 사례
Unaryrpc M(Req) returns (Resp)요청 포인터 + 응답 포인터조회, 단건 명령
서버 스트리밍rpc M(Req) returns (stream Resp)ServerWriter<Resp>*로그 스트리밍, 대용량 목록
클라이언트 스트리밍rpc M(stream Req) returns (Resp)ServerReader<Req>*대용량 업로드, 배치 전송
양방향 스트리밍rpc M(stream Req) returns (stream Resp)ServerReaderWriter<Resp, Req>*채팅, 실시간 게임

스트리밍이라고 해서 연결을 따로 여는 것은 아닙니다. 네 유형 모두 같은 HTTP/2 채널 위의 스트림 하나로 처리되고, 차이는 “어느 쪽이 몇 번 쓰고 언제 끝났다고 알리는가”뿐입니다. 그래서 스트리밍 RPC에서 가장 흔한 버그도 대부분 종료 처리(WritesDone, Finish, Write 반환값)를 빠뜨리는 데서 나옵니다.

서버 스트리밍

클라이언트가 요청을 한 번 보내면 서버가 응답을 여러 번 씁니다. 스트림이 끝났다는 신호는 서버 핸들러가 Status를 반환하는 순간 전달됩니다.

sequenceDiagram
    participant C as 클라이언트
    participant S as 서버
    C->>S: LogRequest (1회)
    loop 스트리밍
        S->>C: LogChunk
    end
    S->>C: Status (스트림 종료)

서버 스트리밍 .proto

syntax = "proto3";
package myapp;
message LogRequest {
  string filter = 1;
  int32 max_lines = 2;
}
message LogChunk {
  string line = 1;
  int64 timestamp = 2;
}
service LogService {
  rpc StreamLogs(LogRequest) returns (stream LogChunk);
}

서버 스트리밍 구현

Status StreamLogs(ServerContext* context,
                  const myapp::LogRequest* request,
                  grpc::ServerWriter<myapp::LogChunk>* writer) override {
  // 클라이언트가 연결을 끊으면 context->IsCancelled()가 true
  for (int i = 0; i < request->max_lines() && !context->IsCancelled(); ++i) {
    myapp::LogChunk chunk;
    chunk.set_line("log line " + std::to_string(i));
    chunk.set_timestamp(std::time(nullptr));
    if (!writer->Write(chunk)) {
      break;  // 클라이언트 연결 끊김
    }
    std::this_thread::sleep_for(std::chrono::milliseconds(100));
  }
  return Status::OK;
}

서버 스트리밍 응답을 클라이언트에서 받기

reader->Read()는 다음 메시지가 올 때까지 블로킹하고, 서버가 스트림을 닫으면 false를 반환합니다. 루프가 끝났다고 해서 성공한 것은 아니므로 Finish()로 최종 Status를 반드시 확인해야 합니다. 서버가 도중에 에러를 반환해도 Read()는 그냥 false를 돌려줄 뿐이라, Finish()를 빼먹으면 “중간에 끊긴 스트림”과 “정상 종료”를 구분할 방법이 없습니다.

void ReceiveStream() {
  myapp::LogRequest request;
  request.set_filter("error");
  request.set_max_lines(100);
  ClientContext context;
  context.set_deadline(std::chrono::system_clock::now() + std::chrono::seconds(30));
  auto reader = stub->StreamLogs(&context, request);
  myapp::LogChunk chunk;
  while (reader->Read(&chunk)) {
    std::cout << chunk.timestamp() << ": " << chunk.line() << std::endl;
  }
  Status status = reader->Finish();
  if (!status.ok()) {
    std::cerr << "Stream failed: " << status.error_message() << std::endl;
  }
}

클라이언트 스트리밍

방향이 반대입니다. 클라이언트가 요청을 여러 번 쓰고 WritesDone()으로 “더 보낼 것이 없다”고 알린 뒤, 서버가 응답 하나를 돌려줍니다. 파일 업로드나 센서 데이터 배치 전송처럼 보내는 쪽이 끝을 아는 작업에 맞습니다.

sequenceDiagram
    participant C as 클라이언트
    participant S as 서버
    loop 스트리밍
        C->>S: UploadChunk
    end
    C->>S: WritesDone()
    S->>C: UploadSummary + Status (1회)
message UploadChunk {
  bytes data = 1;
  int32 sequence = 2;
}
message UploadSummary {
  int64 total_bytes = 1;
  int32 chunk_count = 2;
}
service UploadService {
  rpc Upload(stream UploadChunk) returns (UploadSummary);
}
// 서버 측: Read()가 false를 반환하면 클라이언트가 WritesDone()을 호출한 것
Status Upload(ServerContext* context,
              grpc::ServerReader<myapp::UploadChunk>* reader,
              myapp::UploadSummary* summary) override {
  myapp::UploadChunk chunk;
  int64_t total = 0;
  int count = 0;
  while (reader->Read(&chunk)) {
    total += chunk.data().size();
    ++count;
  }
  summary->set_total_bytes(total);
  summary->set_chunk_count(count);
  return Status::OK;
}
// 클라이언트 측
bool UploadAll(myapp::UploadService::Stub* stub,
               const std::vector<std::string>& blocks) {
  ClientContext context;
  context.set_deadline(std::chrono::system_clock::now() + std::chrono::seconds(30));
  myapp::UploadSummary summary;
  auto writer = stub->Upload(&context, &summary);
  for (size_t i = 0; i < blocks.size(); ++i) {
    myapp::UploadChunk chunk;
    chunk.set_data(blocks[i]);
    chunk.set_sequence(static_cast<int>(i));
    if (!writer->Write(chunk)) break;  // 스트림이 이미 깨짐
  }
  writer->WritesDone();              // 보낼 것이 더 없음을 서버에 알림
  Status status = writer->Finish();  // 서버 응답과 최종 Status 수신
  return status.ok();
}

WritesDone()을 빼먹으면 서버의 Read() 루프가 끝나지 않아 양쪽이 서로를 기다리다 데드라인에 걸립니다. 이 경우 에러는 DEADLINE_EXCEEDED로만 보이기 때문에 서버가 느린 것으로 오해하기 쉽습니다.

양방향 스트리밍

클라이언트와 서버가 각자 원하는 때에 읽고 씁니다. 두 방향의 순서는 서로 독립적이어서, 서버는 요청 하나에 응답 하나를 맞춰 보낼 필요가 없습니다.

sequenceDiagram
    participant C as 클라이언트
    participant S as 서버
    C->>S: msg1
    S->>C: response1
    C->>S: msg2
    C->>S: msg3
    S->>C: response2
    Note over C,S: 읽기와 쓰기 순서는 서로 독립적
    C->>S: WritesDone()
    S->>C: Status (스트림 종료)
service ChatService {
  rpc Chat(stream ChatMessage) returns (stream ChatMessage);
}
message ChatMessage {
  string user = 1;
  string text = 2;
  int64 timestamp = 3;
}
// 서버 측
Status Chat(ServerContext* context,
            grpc::ServerReaderWriter<myapp::ChatMessage, myapp::ChatMessage>* stream) override {
  myapp::ChatMessage msg;
  while (stream->Read(&msg)) {
    // 수신한 메시지 처리 후 응답
    msg.set_user("server");
    msg.set_text("Echo: " + msg.text());
    if (!stream->Write(msg)) {
      break;  // 클라이언트 연결 끊김
    }
  }
  return Status::OK;
}

클라이언트 쪽에서는 읽기와 쓰기를 서로 다른 스레드로 나누는 것이 기본입니다. 동기 API의 Read()와 Write()는 둘 다 블로킹이라, 한 스레드에서 “쓰고 나서 읽기”를 반복하면 서버가 응답을 몰아서 보내거나 먼저 보내는 순간 흐름이 꼬이고, 최악의 경우 양쪽이 서로 상대의 쓰기를 기다리며 멈춥니다. gRPC 동기 스트림은 읽기 스레드 하나와 쓰기 스레드 하나가 동시에 접근하는 것까지는 허용하지만, 같은 방향으로 두 스레드가 동시에 Write()하는 것은 허용하지 않으므로 쓰기 쪽은 한 스레드로 모아야 합니다.

void RunChat(myapp::ChatService::Stub* stub) {
  ClientContext context;
  auto stream = stub->Chat(&context);
  // 쓰기 스레드: 표준 입력을 서버로 전송
  std::thread writer([&stream]() {
    std::string line;
    while (std::getline(std::cin, line)) {
      myapp::ChatMessage msg;
      msg.set_user("me");
      msg.set_text(line);
      if (!stream->Write(msg)) break;
    }
    stream->WritesDone();
  });
  // 현재 스레드: 서버 메시지 수신
  myapp::ChatMessage reply;
  while (stream->Read(&reply)) {
    std::cout << reply.user() << ": " << reply.text() << std::endl;
  }
  writer.join();
  Status status = stream->Finish();
  if (!status.ok()) {
    std::cerr << "Chat failed: " << status.error_message() << std::endl;
  }
}

대화형 스트림처럼 수명이 긴 RPC에 짧은 데드라인을 걸면 대화 도중에 DEADLINE_EXCEEDED로 끊깁니다. 이런 스트림은 데드라인을 넉넉히 잡거나 두지 않는 대신, 아래 Keepalive 설정으로 죽은 연결을 감지하는 편이 맞습니다. 여러 클라이언트에게 메시지를 브로드캐스트하는 채팅 서버, 인터셉터, 로드밸런싱은 gRPC 고급(#52-3)에서 이어서 다룹니다.


메타데이터 서버, 백오프 재시도 클라이언트, TLS 채널 예제

예제 1: 에러 처리·메타데이터 포함 서버

#include <grpcpp/grpcpp.h>
#include "user_service.grpc.pb.h"
Status GetUser(ServerContext* context,
               const myapp::UserRequest* request,
               myapp::UserResponse* response) override {
  // 메타데이터 읽기 (인증 토큰, 트레이싱 ID 등)
  auto auth = context->client_metadata().find("authorization");
  if (auth == context->client_metadata().end()) {
    return Status(grpc::StatusCode::UNAUTHENTICATED, "Missing authorization");
  }
  // 응답 메타데이터 추가
  context->AddInitialMetadata("x-request-id", "req-12345");
  if (request->user_id() <= 0) {
    return Status(grpc::StatusCode::INVALID_ARGUMENT,
                  "user_id must be positive");
  }
  // NOT_FOUND 예시
  if (request->user_id() == 999) {
    return Status(grpc::StatusCode::NOT_FOUND, "User not found");
  }
  response->set_user_id(request->user_id());
  response->set_name("User_" + std::to_string(request->user_id()));
  return Status::OK;
}

예제 2: 재시도·백오프가 있는 클라이언트

#include <chrono>
#include <thread>
Status CallWithRetry(std::function<Status()> rpc_call, int max_retries = 3) {
  for (int i = 0; i < max_retries; ++i) {
    Status status = rpc_call();
    if (status.ok()) return status;
    // 재시도 가능한 에러만
    if (status.error_code() != grpc::StatusCode::UNAVAILABLE &&
        status.error_code() != grpc::StatusCode::DEADLINE_EXCEEDED &&
        status.error_code() != grpc::StatusCode::RESOURCE_EXHAUSTED) {
      return status;  // 재시도 불가
    }
    // 지수 백오프: 100ms, 200ms, 400ms
    std::this_thread::sleep_for(
        std::chrono::milliseconds(100 * (1 << i)));
  }
  return Status(grpc::StatusCode::UNAVAILABLE, "Max retries exceeded");
}
// 사용
Status status = CallWithRetry([&]() {
  ClientContext ctx;
  ctx.set_deadline(std::chrono::system_clock::now() + std::chrono::seconds(5));
  return stub->GetUser(&ctx, request, &response);
});

예제 3: TLS 보안 채널

// 서버: 자신의 개인 키와 인증서 체인
grpc::SslServerCredentialsOptions::PemKeyCertPair keycert = {
  read_file("server.key"),   // private_key
  read_file("server.crt")    // cert_chain
};
grpc::SslServerCredentialsOptions ssl_opts;
ssl_opts.pem_key_cert_pairs.push_back(keycert);
ServerBuilder builder;
builder.AddListeningPort("0.0.0.0:50051",
                         grpc::SslServerCredentials(ssl_opts));
// 클라이언트: 서버 인증서를 서명한 CA를 신뢰 루트로 지정
grpc::SslCredentialsOptions client_opts;
client_opts.pem_root_certs = read_file("ca.crt");
auto channel = grpc::CreateChannel("localhost:50051",
                                   grpc::SslCredentials(client_opts));

SslCredentialsOptions를 비워 두면 클라이언트는 시스템(또는 gRPC 내장)의 공개 CA 목록만 신뢰하므로, 사내 CA나 자체 서명 인증서로 만든 서버에는 핸드셰이크가 실패합니다. 접속 주소(localhost)가 서버 인증서의 SAN에 들어 있어야 한다는 점도 자주 놓칩니다. 서버 쪽 pem_root_certs는 클라이언트 인증서를 검증하는 데 쓰이므로, 상호 TLS(mTLS)를 쓸 때 client_certificate_request와 함께 설정합니다.


UNAVAILABLE, DEADLINE_EXCEEDED, RESOURCE_EXHAUSTED 같은 상태 코드별 대응

문제 1: “Connection refused” / “UNAVAILABLE”

서버가 실행 중이 아니거나 주소·포트가 잘못된 경우입니다. gRPC 채널은 만들 때 연결하지 않고 첫 RPC에서 연결을 시도하므로, 설정 오류도 첫 호출의 UNAVAILABLE로 나타납니다. 시작 시점에 설정을 검증하고 싶다면 연결을 명시적으로 기다려 볼 수 있습니다.

auto channel = grpc::CreateChannel("localhost:50051",
                                   grpc::InsecureChannelCredentials());
// 시작 시 5초 안에 연결되는지 확인 (실패하면 주소·포트·방화벽 점검)
bool connected = channel->WaitForConnected(
    std::chrono::system_clock::now() + std::chrono::seconds(5));

서버가 아직 뜨는 중일 때 호출이 바로 실패하지 않고 연결될 때까지(데드라인 안에서) 기다리게 하려면 context.set_wait_for_ready(true)를 씁니다.

문제 2: “DEADLINE_EXCEEDED” 타임아웃

서버 처리 시간이 클라이언트가 잡은 데드라인을 넘은 경우입니다. 데드라인을 RPC 성격에 맞게 잡는 것이 먼저이고, 서버는 데드라인이 지난 요청을 계속 처리하지 않도록 IsCancelled()를 확인합니다.

// 클라이언트: RPC 성격에 맞는 데드라인
context.set_deadline(std::chrono::system_clock::now() + std::chrono::seconds(30));
// 서버: 데드라인이 지나거나 취소되면 중단
if (context->IsCancelled()) {
  return Status(grpc::StatusCode::CANCELLED, "Client cancelled or deadline exceeded");
}

문제 3: “INVALID_ARGUMENT” - 필드 누락·잘못된 값

proto3에는 required가 없어서, 필드가 빠진 메시지도 그대로 파싱되고 빠진 필드는 기본값(0, 빈 문자열)으로 읽힙니다. 그래서 “값이 0인 것”과 “보내지 않은 것”을 구분하려면 .proto에서 그 필드를 optional int32 user_id = 1;처럼 선언해야 has_user_id()가 생성됩니다. 앞의 UserRequest처럼 optional 없이 선언한 스칼라 필드에는 has_ 함수가 없어 컴파일 에러가 납니다. 또 proto3 enum은 정의되지 않은 숫자 값도 그대로 받아들이므로, 서버에서 값을 검증해야 합니다.

// optional int32 user_id = 1; 로 선언한 경우
if (!request->has_user_id() || request->user_id() <= 0) {
  return Status(grpc::StatusCode::INVALID_ARGUMENT, "user_id required");
}
// UserStatus status = 4; 같은 enum 필드가 있는 경우
if (!myapp::UserStatus_IsValid(request->status()) ||
    request->status() == myapp::UNKNOWN) {
  return Status(grpc::StatusCode::INVALID_ARGUMENT, "Invalid status");
}

문제 4: “CANCELLED” - 클라이언트 연결 끊김

클라이언트가 RPC를 취소하거나 연결을 종료한 경우입니다. 클라이언트의 데드라인이 지나도 서버 쪽 IsCancelled()는 true가 되므로, 스트리밍 중에 이 상태가 보이면 클라이언트 데드라인이 스트림 길이에 비해 짧은지도 함께 확인합니다. 서버가 취소를 확인하지 않으면 아무도 받지 않을 결과를 계산하느라 CPU와 DB 연결을 계속 씁니다. 긴 루프와 스트리밍 Write() 사이사이에 확인합니다.

// 서버: 주기적으로 취소 여부 확인 (긴 작업에서)
for (int i = 0; i < 1000; ++i) {
  if (context->IsCancelled()) {
    return Status(grpc::StatusCode::CANCELLED, "Client disconnected");
  }
  DoWork(i);
}

문제 5: protoc 컴파일 에러 “field number X has already been used”

같은 메시지 안에서 필드 번호가 중복된 경우입니다. 여러 사람이 동시에 필드를 추가하다 머지에서 자주 생깁니다.

message Bad {
  int32 a = 1;
  int32 b = 1;  // 에러: 1 중복
}
message Good {
  int32 a = 1;
  int32 b = 2;
}

문제 6: “RESOURCE_EXHAUSTED” - 메모리·연결 한도

서버가 처리 용량을 넘었거나, 메시지가 크기 한도를 넘은 경우입니다. gRPC C++의 기본 수신 한도는 4MB이고, 이 한도는 받는 쪽 기준이므로 큰 응답을 받는 클라이언트와 큰 요청을 받는 서버가 각자 설정해야 합니다.

// 채널 옵션: 메시지 크기 한도 늘리기 (기본 4MB)
grpc::ChannelArguments args;
args.SetMaxReceiveMessageSize(64 * 1024 * 1024);  // 64MB
auto channel = grpc::CreateCustomChannel(
    "localhost:50051",
    grpc::InsecureChannelCredentials(),
    args);
// 서버 쪽: builder.SetMaxReceiveMessageSize(64 * 1024 * 1024);

한도를 올리기 전에 정말 한 메시지에 담아야 하는지 먼저 따져 봅니다. 수십 MB 메시지는 직렬화·역직렬화 동안 그만큼의 메모리를 한꺼번에 잡으므로, 대개는 서버 스트리밍으로 나눠 보내는 편이 낫습니다.

문제 7: 스트리밍 시 “Stream removed”

한쪽이 이미 스트림을 닫았는데 상대가 계속 쓰려 할 때 납니다. Write()가 false를 돌려주면 바로 루프를 빠져나옵니다.

while (reader->Read(&msg)) {
  if (!writer->Write(response)) {
    break;  // 클라이언트 연결 끊김
  }
}

문제 8: “UNAVAILABLE” - 서버 재시작·롤링 업데이트 중

문제 1과 상태 코드는 같지만 원인이 다릅니다. 서버는 정상인데 Kubernetes 롤링 업데이트나 재배포로 파드가 교체되는 동안 연결이 끊기거나, 종료 중인 서버가 새 RPC를 거부해서 생깁니다. 설정 실수가 아니라 일시적인 상황이므로 재시도로 흡수하는 것이 맞습니다.

예제 2의 지수 백오프 재시도를 적용하고, 서버 쪽은 그레이스풀 셧다운으로 진행 중인 RPC를 마친 뒤 내려가게 합니다. 주의할 점은 재시도 대상 코드입니다. UNAVAILABLE은 대부분 요청이 서버 로직에 닿기 전에 실패한 것이라 재시도가 비교적 안전하지만, DEADLINE_EXCEEDED는 서버가 이미 처리를 끝냈는데 응답만 늦은 경우일 수 있습니다. 결제나 재고 차감처럼 멱등하지 않은 RPC를 DEADLINE_EXCEEDED에서 그대로 재시도하면 같은 작업이 두 번 실행되므로, 요청 ID를 메타데이터로 보내 서버에서 중복을 걸러내거나 재시도 대상에서 빼야 합니다.

// 멱등하지 않은 호출: UNAVAILABLE만 재시도
bool IsRetryable(const Status& s, bool idempotent) {
  if (s.error_code() == grpc::StatusCode::UNAVAILABLE) return true;
  if (!idempotent) return false;
  return s.error_code() == grpc::StatusCode::DEADLINE_EXCEEDED ||
         s.error_code() == grpc::StatusCode::RESOURCE_EXHAUSTED;
}

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 멀티플렉싱, 메시지 크기, 스레드 풀 조정

HTTP/2 멀티플렉싱 활용

gRPC는 HTTP/2 기반이라 TCP 연결 하나에서 여러 RPC를 동시에 처리합니다. 요청마다 채널을 새로 만들면 매번 새 연결(TLS라면 핸드셰이크까지)이 생겨 지연과 서버 쪽 연결 수가 함께 늘어나므로, 채널은 한 번 만들어 재사용합니다.

// 매 요청마다 새 채널 (비효율)
for (int i = 0; i < 1000; ++i) {
  auto channel = grpc::CreateChannel(...);  // 연결 생성
  auto stub = MyService::NewStub(channel);
  stub->Call(...);
}
// 채널 재사용
auto channel = grpc::CreateChannel("localhost:50051", ...);
auto stub = MyService::NewStub(channel);
for (int i = 0; i < 1000; ++i) {
  stub->Call(...);  // 같은 연결 재사용
}

메시지 필드 타입 고르기

// 이미 직렬화된 데이터를 string에 담음
message Bad {
  string huge_json = 1;
}
// 바이너리·불투명 데이터는 bytes로
message Good {
  bytes payload = 1;
}
// 키로 조회하는 데이터는 map
message Config {
  map<string, string> key_value = 1;
}

proto3의 string 필드는 파싱할 때 UTF-8 유효성 검사를 거치므로, 압축 데이터나 이미지처럼 텍스트가 아닌 데이터를 넣으면 파싱이 실패하고, 큰 텍스트라도 검사 비용이 듭니다. 내용을 해석하지 않고 전달만 하는 데이터는 bytes로 선언합니다. map은 전송 형식상 키·값 쌍의 반복 필드와 같지만, C++에서는 해시 맵(google::protobuf::Map)으로 생성되어 키 조회가 편합니다.

직렬화 비용 줄이기

필드 태그(번호와 타입)는 varint로 인코딩되어 115번은 1바이트, 162047번은 2바이트를 차지하므로, 자주 채워지는 필드에 작은 번호를 줍니다. 원소 수를 알고 있는 repeated 필드는 Reserve()로 미리 공간을 잡아 두면 추가하면서 생기는 재할당을 피할 수 있습니다.

response->mutable_items()->Reserve(1000);
for (int i = 0; i < 1000; ++i) {
  auto* item = response->add_items();
  item->set_id(i);
}

스레드 풀 크기 조정

// 동기 서버: 요청을 기다리는 poller 스레드 수 (핸들러 실행 스레드는 필요에 따라 늘어남)
grpc::ServerBuilder builder;
builder.SetSyncServerOption(grpc::ServerBuilder::MIN_POLLERS, 4);
builder.SetSyncServerOption(grpc::ServerBuilder::MAX_POLLERS, 16);

JSON vs Protobuf: 차이가 나는 이유

항목JSONProtobuf차이를 만드는 것
직렬화 크기큼작음필드 이름 대신 필드 번호, 숫자를 텍스트 대신 varint로 인코딩
직렬화 속도느린 편빠른 편숫자→문자열 변환·이스케이프가 없음
역직렬화 속도느린 편빠른 편키 문자열 비교·숫자 파싱 대신 태그와 길이로 바로 읽음

격차는 메시지 모양에 따라 크게 달라집니다. 숫자 필드가 많고 필드 이름이 긴 메시지에서는 크기·속도 차이가 크고, 긴 문자열이나 바이트 배열이 대부분인 메시지에서는 두 형식 모두 그 데이터를 거의 그대로 복사하므로 차이가 작습니다. simdjson 같은 고성능 JSON 라이브러리와 비교하면 속도 격차도 줄어듭니다.


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

최대 메시지 크기

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)로 데드라인 초과를 감지해 남은 작업을 멈춰야, 이미 포기한 요청에 자원을 계속 쓰지 않습니다. 다른 서비스를 연쇄 호출한다면 들어온 요청의 남은 데드라인을 하위 호출에 그대로 전파합니다.

헬스 체크, 트레이싱 메타데이터, 그레이스풀 셧다운, keepalive

헬스 체크

헬스 체크 서비스는 직접 정의하기보다 gRPC가 표준으로 정한 grpc.health.v1.Health를 쓰는 편이 좋습니다. grpc_health_probe나 Kubernetes의 gRPC 프로브가 이 서비스를 그대로 호출하기 때문입니다. gRPC C++에는 기본 구현이 들어 있어 서버를 만들기 전에 한 줄로 켤 수 있습니다.

#include <grpcpp/health_check_service_interface.h>
grpc::EnableDefaultHealthCheckService(true);  // BuildAndStart 전에 호출
grpc::ServerBuilder builder;
// ...
auto server = builder.BuildAndStart();

서비스별 상태 변경과 Kubernetes 프로브 설정은 gRPC 고급(#52-3)에서 다룹니다.

메타데이터로 트레이싱

// 클라이언트: 요청 ID 전달
context.AddMetadata("x-request-id", GenerateUUID());
context.AddMetadata("x-trace-id", GetCurrentTraceId());
// 서버: 메타데이터 읽어 로깅
auto it = context->client_metadata().find("x-request-id");
if (it != context->client_metadata().end()) {
  LOG(INFO) << "Request ID: " << std::string(it->second.begin(), it->second.end());
}

로드 밸런싱 (Round-robin)

DNS가 서버 주소를 여러 개 돌려주더라도, gRPC 클라이언트의 기본 정책은 pick_first라서 처음 연결된 서버 한 대에만 요청을 보냅니다. 여러 서버로 나누려면 정책을 명시해야 합니다.

grpc::ChannelArguments args;
args.SetLoadBalancingPolicyName("round_robin");
auto channel = grpc::CreateCustomChannel(
    "dns:///my-service:50051",  // DNS가 여러 A 레코드를 돌려줘야 함
    grpc::InsecureChannelCredentials(),
    args);

Kubernetes의 일반 ClusterIP 서비스는 가상 IP 하나만 돌려주므로 이 설정만으로는 분산되지 않고, 헤드리스 서비스가 필요합니다. 자세한 내용은 gRPC 고급(#52-3)에서 다룹니다.

서버 그레이스풀 셧다운

void ShutdownServer() {
  // 새 RPC는 거부하고, 진행 중인 RPC는 최대 10초 기다린 뒤 취소
  server->Shutdown(std::chrono::system_clock::now() + std::chrono::seconds(10));
  // 비동기 서버라면: 서버를 내린 뒤 큐를 닫고, Next()가 false를 줄 때까지 비움
  cq->Shutdown();
  void* tag; bool ok;
  while (cq->Next(&tag, &ok)) { /* 남은 CallData 정리 */ }
}

인자 없는 Shutdown()은 진행 중인 RPC가 끝날 때까지 무한히 기다리므로, 끝나지 않는 스트리밍 RPC가 있으면 종료되지 않습니다. 순서도 중요해서, cq->Shutdown()을 server->Shutdown()보다 먼저 부르면 서버가 아직 큐에 이벤트를 넣으려 할 때 프로세스가 비정상 종료됩니다. 다른 스레드에서 server->Wait()로 기다리고 있었다면 Shutdown()이 끝난 뒤 그 호출이 반환됩니다.

Keepalive 설정

오래 유지되는 채널과 스트림은 중간의 NAT·로드밸런서가 유휴 TCP 연결을 조용히 끊어 버리는 문제에 걸립니다. 이때 클라이언트는 연결이 죽은 줄 모르고 다음 RPC에서야 실패를 봅니다. HTTP/2 PING을 주기적으로 보내는 keepalive로 이를 미리 감지합니다.

grpc::ChannelArguments args;
args.SetInt(GRPC_ARG_KEEPALIVE_TIME_MS, 30000);     // 30초마다 PING
args.SetInt(GRPC_ARG_KEEPALIVE_TIMEOUT_MS, 10000);  // 10초 안에 응답 없으면 연결 종료
args.SetInt(GRPC_ARG_KEEPALIVE_PERMIT_WITHOUT_CALLS, 1);
auto channel = grpc::CreateCustomChannel(
    "my-service:50051",
    grpc::InsecureChannelCredentials(),
    args);

클라이언트 값만 줄이는 것은 위험합니다. gRPC 서버는 기본적으로 너무 잦은 PING, 특히 진행 중인 RPC가 없을 때의 PING을 공격으로 간주해 GOAWAY(too_many_pings)로 연결을 끊습니다. 클라이언트 keepalive를 짧게 잡으려면 서버 쪽 GRPC_ARG_HTTP2_MIN_RECV_PING_INTERVAL_WITHOUT_DATA_MS와 GRPC_ARG_KEEPALIVE_PERMIT_WITHOUT_CALLS도 함께 맞춰야 합니다. 이걸 모르고 클라이언트만 바꾸면 “keepalive를 켰더니 오히려 연결이 자주 끊긴다”는 증상이 나옵니다.

호출 단위로 지킬 것

채널과 스텁은 재사용하되 ClientContext는 호출마다 새로 만듭니다. 채널은 연결을 관리하는 비싼 객체라 서비스 수명 동안 하나를 공유하지만, ClientContext는 RPC 한 번 전용이라 같은 컨텍스트로 두 번 호출하면 동작이 정의되지 않습니다. 반면 요청·응답 메시지 객체는 Clear() 후 재사용해도 됩니다.

gRPC의 기본 데드라인은 무한대이므로 모든 RPC에 데드라인을 겁니다. 데드라인이 없으면 서버가 멈추거나 응답이 유실됐을 때 클라이언트 스레드도 무한정 묶입니다. 응답은 status.ok()를 확인한 뒤에만 읽습니다. 실패한 RPC의 응답 객체는 기본값으로 비어 있어, 확인 없이 쓰면 “이름이 빈 사용자” 같은 조용한 버그가 됩니다.

서버는 긴 작업과 스트리밍에서 IsCancelled()와 Write() 반환값을 확인하고, 인증 토큰과 요청 ID는 메시지 필드가 아니라 메타데이터로 주고받습니다. 그래야 인터셉터나 프록시가 메시지 스키마를 몰라도 처리할 수 있습니다.

스키마 버전 관리

필드를 추가할 때는 새 번호를 쓰고, 기존 필드의 번호와 타입은 바꾸지 않습니다. 필드를 삭제할 때는 그 번호와 이름을 reserved로 막아 두어야, 나중에 누군가 같은 번호를 다른 의미로 재사용해 옛 클라이언트가 보낸 데이터를 엉뚱한 필드로 읽는 사고를 막을 수 있습니다.

message User {
  int32 id = 1;
  string name = 2;
  string email = 3;      // 새 필드: 새 번호로 추가
  reserved 4;            // 삭제한 필드의 번호
  reserved "phone";      // 삭제한 필드의 이름
}

서비스 간 통신을 암호화하는 다음 단계는 보안 코딩·OpenSSL(#43-2)에서 이어집니다.

참고 자료


같이 보면 좋은 글


다음 글: [실전 도메인 #43-2] 보안 코딩 가이드: 오버플로우 방지와 암호화 라이브러리(OpenSSL) 실전 연동 이전 글: [실전 도메인 #42-3] 리눅스 시스템 프로그래밍: 시스템 콜 호출과 커널 인터페이스 이해