C++ 고성능 RPC 시스템: gRPC와 Protocol Buffers를 이용한 마이크로서비스 구축
들어가며: “마이크로서비스 간 통신이 병목이에요”
HTTP/JSON만으로는 부족한 이유
웹소켓·SSL·프로토콜 직렬화를 다뤘다면, 마이크로서비스(작은 서비스 단위로 시스템을 나누는 아키텍처) 간에는 gRPC(Google이 만든 고성능 RPC 프레임워크)와 Protocol Buffers(구글이 만든 바이너리 직렬화 포맷)가 널리 사용됩니다. 스키마가 명확하고 바이너리 직렬화로 효율이 좋으며, 스트리밍(서버·클라이언트·양방향)을 표준으로 지원합니다.
이 글에서 다루는 것:
- Protocol Buffers: .proto 문법·메시지·서비스 정의·C++ 코드 생성
- gRPC C++: 동기/비동기 서버·클라이언트, Unary와 세 가지 스트리밍 RPC
- 에러·메타데이터·인증 개요
- 문제 시나리오·일반적인 에러·성능 최적화·프로덕션 패턴
실제 문제 시나리오
시나리오 1: JSON 직렬화가 CPU를 잡아먹음
상황: 초당 요청 수가 많은 C++ 서비스에서 프로파일을 보니 JSON 파싱·생성이 CPU 상위를 차지
문제: 필드 이름 문자열 비교, 숫자↔텍스트 변환, 이스케이프 처리가 요청마다 반복됨
방향: Protobuf 바이너리 직렬화로 전환하면 필드는 번호로, 숫자는 varint/고정 폭으로 인코딩되어 이 비용이 줄어듦
(효과는 메시지 구조와 기존 JSON 라이브러리 성능에 따라 다르므로 전환 전후 프로파일로 확인)
시나리오 2: 스키마 없이 필드명 오타로 장애
상황: "user_id" vs "userId" 필드명 불일치로 클라이언트·서버 간 데이터 누락
문제: JSON은 런타임에만 검증, 타입 안전성 없음
결과: .proto로 스키마 정의 → 컴파일 타임 검증, 필드 번호로 호환성 유지
시나리오 3: 대용량 로그 스트리밍이 끊김
상황: 수백 MB 로그를 HTTP로 전송 시 타임아웃·메모리 부족
문제: 단일 요청-응답 모델로는 스트리밍 불가
결과: gRPC 서버 스트리밍으로 청크 단위 전송 → 안정적 전달
시나리오 4: REST로 양방향 실시간 통신 구현이 복잡
상황: 채팅·게임 서버처럼 클라이언트·서버가 동시에 메시지 송수신
문제: REST는 요청-응답만 지원, WebSocket은 별도 구현 필요
결과: gRPC 양방향 스트리밍 → 단일 연결로 양방향 통신
이 글에서는 위와 같은 문제를 gRPC·Protobuf로 해결하는 방법을 완전한 예제와 함께 다룹니다.
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, …)를 정의합니다. 번호는 스키마 호환에 중요해, 기존 번호를 바꾸지 않고 필드만 추가하면 하위 호환이 유지됩니다.
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++ 서버 쪽 타입 | 사용 사례 |
|---|---|---|---|
| Unary | rpc 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 보안 채널
// 서버: TLS 인증서 사용
grpc::SslServerCredentialsOptions::PemKeyCertPair keycert = {
read_file("server.key"),
read_file("server.crt")
};
grpc::SslServerCredentialsOptions ssl_opts;
ssl_opts.pem_root_certs = read_file("ca.crt");
ssl_opts.pem_key_cert_pairs.push_back(keycert);
ServerBuilder builder;
builder.AddListeningPort("0.0.0.0:50051",
grpc::SslServerCredentials(ssl_opts));
// 클라이언트: TLS 연결
auto creds = grpc::SslCredentials(grpc::SslCredentialsOptions());
auto channel = grpc::CreateChannel("localhost:50051", creds);
UNAVAILABLE, DEADLINE_EXCEEDED, RESOURCE_EXHAUSTED 같은 상태 코드별 대응
문제 1: “Connection refused” / “UNAVAILABLE”
원인: 서버가 실행 중이 아니거나, 잘못된 주소·포트 해결법:
// ❌ 잘못된 예: 채널 생성만 하고 연결 확인 안 함
auto channel = grpc::CreateChannel("localhost:50051",
grpc::InsecureChannelCredentials());
// ✅ 올바른 예: 연결 상태 확인
grpc_connectivity_state state = channel->GetState(true);
if (state != GRPC_CHANNEL_READY) {
channel->WaitForConnected(
std::chrono::system_clock::now() + std::chrono::seconds(5));
}
문제 2: “DEADLINE_EXCEEDED” 타임아웃
원인: 서버 처리 시간이 클라이언트 데드라인 초과 해결법:
// ✅ 데드라인 충분히 설정
context.set_deadline(std::chrono::system_clock::now() + std::chrono::seconds(30));
// ✅ 서버에서도 데드라인 확인
if (context->IsCancelled()) {
return Status(grpc::StatusCode::CANCELLED, "Client cancelled");
}
문제 3: “INVALID_ARGUMENT” - 필드 누락·타입 오류
원인: .proto 스키마와 실제 데이터 불일치, 또는 required 필드 누락(proto3에서는 required 없음) 해결법:
// ✅ 요청 전 필수 필드 검증
if (!request->has_user_id()) { // proto3에서는 optional이면 has_* 사용
return Status(grpc::StatusCode::INVALID_ARGUMENT, "user_id required");
}
// ✅ enum 값 검증
if (!myapp::UserStatus_IsValid(request->status())) {
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 been used”
원인: .proto에서 필드 번호 중복 해결법:
// ❌ 잘못된 예
message Bad {
int32 a = 1;
int32 b = 1; // 에러: 1 중복
}
// ✅ 올바른 예
message Good {
int32 a = 1;
int32 b = 2;
}
문제 6: “RESOURCE_EXHAUSTED” - 메모리·연결 한도
원인: 서버 처리 용량 초과, 메시지 크기 한도 해결법:
// 채널 옵션: 메시지 크기 한도 늘리기 (기본 4MB)
grpc::ChannelArguments args;
args.SetMaxReceiveMessageSize(64 * 1024 * 1024); // 64MB
auto channel = grpc::CreateCustomChannel(
"localhost:50051",
grpc::InsecureChannelCredentials(),
args);
문제 7: 스트리밍 시 “Stream removed”
원인: 한쪽이 Write/Read를 중단했는데 상대방이 계속 시도 해결법:
// ✅ Write 실패 시 즉시 종료
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;
}
HTTP/2 멀티플렉싱, 메시지 크기, 스레드 풀 조정
HTTP/2 멀티플렉싱 활용
gRPC는 HTTP/2 기반이라 단일 TCP 연결에서 여러 RPC를 동시에 처리합니다. 채널을 재사용하세요.
// ❌ 매 요청마다 새 채널 (비효율)
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(...); // 같은 연결 재사용
}
메시지 크기 최적화
// ❌ 비효율: 문자열로 큰 데이터
message Bad {
string huge_json = 1; // UTF-8 오버헤드
}
// ✅ 효율: bytes로 바이너리
message Good {
bytes payload = 1; // 이미 직렬화된 데이터
}
// ✅ repeated보다 map이 적합한 경우
message Config {
map<string, string> key_value = 1; // 조회 시 O(1)
}
직렬화 비용 줄이기
- 필드 번호는 1~15가 1바이트로 인코딩되므로 자주 쓰는 필드를 앞에
- repeated 필드는 한 번에 설정하지 말고
Reserve()후Add()사용
// ✅ repeated 필드 사전 할당
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: 차이가 나는 이유
| 항목 | JSON | Protobuf | 차이를 만드는 것 |
|---|---|---|---|
| 직렬화 크기 | 큼 | 작음 | 필드 이름 대신 필드 번호, 숫자를 텍스트 대신 varint로 인코딩 |
| 직렬화 속도 | 느린 편 | 빠른 편 | 숫자→문자열 변환·이스케이프가 없음 |
| 역직렬화 속도 | 느린 편 | 빠른 편 | 키 문자열 비교·숫자 파싱 대신 태그와 길이로 바로 읽음 |
격차는 메시지 모양에 따라 크게 달라집니다. 숫자 필드가 많고 필드 이름이 긴 메시지에서는 크기·속도 차이가 크고, 긴 문자열이나 바이트 배열이 대부분인 메시지에서는 두 형식 모두 그 데이터를 거의 그대로 복사하므로 차이가 작습니다. simdjson 같은 고성능 JSON 라이브러리와 비교하면 속도 격차도 줄어듭니다.
헬스 체크, 트레이싱 메타데이터, 그레이스풀 셧다운, keepalive
헬스 체크
service HealthService {
rpc Check(HealthCheckRequest) returns (HealthCheckResponse);
}
// Kubernetes 등에서 liveness/readiness 프로브로 사용
Status Check(ServerContext* ctx,
const HealthCheckRequest* req,
HealthCheckResponse* res) override {
res->set_status(SERVING);
return Status::OK;
}
메타데이터로 트레이싱
// 클라이언트: 요청 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)
// 여러 서버 주소로 채널 생성 시 자동 로드밸런싱
auto channel = grpc::CreateChannel(
"dns:///my-service:50051", // DNS 기반
grpc::InsecureChannelCredentials());
서버 그레이스풀 셧다운
void ShutdownServer() {
server->Shutdown(); // 새 연결 거부, 기존 RPC 완료 대기
cq->Shutdown(); // CompletionQueue 종료
server->Wait(); // 모든 스레드 종료 대기
}
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()후 재사용해도 됩니다. - 모든 RPC에 데드라인을 겁니다. 데드라인이 없으면 서버가 멈췄을 때 클라이언트 스레드도 무한정 묶입니다.
status.ok()를 확인하기 전에는 응답을 읽지 않습니다. 실패한 RPC의 응답 객체는 기본값으로 비어 있어, 확인 없이 쓰면 “이름이 빈 사용자” 같은 조용한 버그가 됩니다.- 서버는 긴 작업과 스트리밍에서
IsCancelled()와Write()반환값을 확인합니다. - 인증 토큰과 요청 ID는 메시지 필드가 아니라 메타데이터로 보냅니다. 그래야 인터셉터나 프록시가 메시지 스키마를 몰라도 처리할 수 있습니다.
스키마 버전 관리
// 필드 추가 시 기존 번호 유지
message User {
int32 id = 1;
string name = 2;
string email = 3; // 새 필드: 3번 추가
// 절대 1, 2번 변경하지 않음
}
Protobuf·서버·클라이언트 점검 항목
Protocol Buffers
-
.proto에syntax = "proto3"명시 - 필드 번호 1~15를 자주 쓰는 필드에 할당
- 기존 필드 번호 변경 금지 (하위 호환)
-
protoc로 C++ 코드 생성 확인 - CMake/빌드 시스템에 생성 코드 통합
gRPC 서버
-
ServerBuilder로 주소·인증 설정 -
RegisterService로 구현체 등록 - 에러 시
Status에 적절한StatusCode반환 -
context->IsCancelled()체크 (긴 작업) - TLS 사용 시 인증서 경로 설정
gRPC 클라이언트
-
CreateChannel후 연결 상태 확인 -
set_deadline으로 타임아웃 설정 -
status.ok()확인 후 응답 사용 - 재시도 가능 에러에 백오프 적용
- 채널 재사용 (연결 풀링),
ClientContext는 호출마다 새로 생성 - 스트리밍:
WritesDone()후Finish()로 최종 Status 확인
프로덕션
- 헬스 체크 RPC 구현
- 메타데이터로 요청 ID·트레이싱
- 로깅·메트릭 연동
- 그레이스풀 셧다운 처리
- 메시지 크기 한도 검토
가장 먼저 챙길 두 가지: 데드라인과 채널 재사용
gRPC C++ 클라이언트의 사고는 기능보다 기본값에서 더 자주 납니다. ClientContext에 set_deadline을 걸지 않으면 데드라인이 없는 상태라서, 서버가 멈추거나 네트워크가 끊긴 채 응답이 오지 않을 때 호출 스레드도 계속 기다립니다. 또 요청마다 grpc::CreateChannel을 호출하면 매번 새 HTTP/2 연결(TLS라면 핸드셰이크까지)이 생겨 지연과 서버 쪽 연결 수가 함께 늘어납니다.
그래서 새 클라이언트 코드를 리뷰할 때는 “모든 호출에 데드라인이 있는가”, “채널을 프로세스 수명 동안 재사용하는가” 두 가지부터 봅니다. 여기까지 갖춘 뒤 서비스 간 통신을 암호화할 차례라면 보안 코딩·OpenSSL(#43-2)이 이어지는 내용입니다.
같이 보면 좋은 글
- C++ vs Rust: 소유권, 메모리 안전성, 에러 처리, 동시성, 성능 비교
- C++26 프리뷰: Reflection과 신규 표준 라이브러리 제안들 [#44-1]
- C++ Boost.Asio 입문 | io_context·async_read
- C++ gRPC 성능 다루기
자주 묻는 질문 (FAQ)
Q. gRPC와 REST/JSON의 차이는?
A. gRPC는 HTTP/2 기반 바이너리 프로토콜로, JSON보다 직렬화가 빠르고 크기가 작습니다. 스키마(.proto)로 타입 안전성이 보장되며, 스트리밍을 기본 지원합니다. REST는 텍스트 기반이라 디버깅이 쉽지만, 마이크로서비스 간 고성능 통신에는 gRPC가 유리합니다.
Q. .proto 필드 번호를 바꾸면 안 되나요?
A. 필드 번호는 직렬화 시 식별자로 사용됩니다. 번호를 바꾸면 기존 클라이언트·서버와 호환성이 깨집니다. 새 필드는 새 번호로 추가하며, 사용하지 않는 필드는 deprecated로 표시만 하세요.
Q. C++에서 비동기 gRPC가 꼭 필요한가요?
A. 초당 수천 건 이상의 고부하에서는 비동기 API와 CompletionQueue가 유리합니다. 저부하나 단순 로직이라면 동기 API만으로도 충분합니다.
Q. 프로덕션에서 TLS는 필수인가요?
A. 외부 노출 서비스나 민감한 데이터를 다룰 때는 TLS를 사용하는 것이 좋습니다. 내부망 전용이라면 InsecureCredentials도 쓰이지만, 보안 정책에 따라 결정하세요. gRPC·Protobuf로 타입 안전한 RPC와 직렬화를 구성할 수 있습니다. 다음으로 보안 코딩·OpenSSL(#43-2)를 읽어보면 좋습니다. 다음 글: [실전 도메인 #43-2] 보안 코딩 가이드: 오버플로우 방지와 암호화 라이브러리(OpenSSL) 실전 연동 이전 글: [실전 도메인 #42-3] 리눅스 시스템 프로그래밍: 시스템 콜 호출과 커널 인터페이스 이해