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

43-1과 52-1에서 gRPC 기초와 스트리밍을 다뤘다면, 이 글은 서비스를 실제로 운영할 때 필요한 기능을 다룹니다. RPC마다 인증 메타데이터를 손으로 넣는 대신 인터셉터로 모으는 법, 서버를 여러 대 띄웠는데 기본 정책(pick_first) 때문에 한 대에만 트래픽이 몰리는 문제, 롤링 업데이트 중의 일시적 UNAVAILABLE을 재시도로 흡수하되 주문이 두 번 생기지 않게 하는 법, Kubernetes 프로브와 연동되는 표준 헬스 체크, 그리고 여러 클라이언트가 한 스트림 서버에 붙는 브로드캐스트 구조의 함정을 차례로 살펴봅니다.

예제는 C++17 이상과 비교적 최근의 gRPC(1.50 이상)를 기준으로 합니다.


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

인터셉터가 끼어드는 지점

인터셉터는 RPC 호출 전후의 정해진 지점(훅)에서 실행되는 미들웨어입니다. 인증, 로깅, 메트릭, 메타데이터 처리처럼 모든 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 소요 시간·에러 기록

인터셉터 객체는 RPC 하나마다 새로 만들어지고, Intercept()는 그 RPC의 여러 훅 지점마다 따로 호출됩니다. 그래서 시작 시각은 지역 변수가 아니라 멤버에 저장해야 하고, 훅에 딸린 데이터(GetRecvStatus() 등)는 Proceed()를 부르기 전에 읽어야 합니다. Proceed() 이후에는 배치가 다음 단계로 넘어가므로 methods를 더 만지면 안 됩니다.

#include <chrono>
#include <iostream>
class LoggingClientInterceptor : public Interceptor {
public:
    explicit LoggingClientInterceptor(grpc::experimental::ClientRpcInfo* info)
        : method_(info->method()) {}
    void Intercept(InterceptorBatchMethods* methods) override {
        if (methods->QueryInterceptionHookPoint(
                InterceptionHookPoints::PRE_SEND_INITIAL_METADATA)) {
            start_ = std::chrono::steady_clock::now();
        }
        if (methods->QueryInterceptionHookPoint(
                InterceptionHookPoints::POST_RECV_STATUS)) {
            auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(
                std::chrono::steady_clock::now() - start_).count();
            grpc::Status* status = methods->GetRecvStatus();
            std::cout << "[gRPC] " << method_ << " "
                      << (status->ok() ? "OK" : status->error_message())
                      << ", " << ms << "ms" << std::endl;
        }
        methods->Proceed();
    }
private:
    std::string method_;
    std::chrono::steady_clock::time_point start_;
};
class LoggingInterceptorFactory
    : public grpc::experimental::ClientInterceptorFactoryInterface {
public:
    Interceptor* CreateClientInterceptor(
        grpc::experimental::ClientRpcInfo* info) override {
        return new LoggingClientInterceptor(info);
    }
};

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

서버 쪽에서 클라이언트가 보낸 메타데이터는 POST_RECV_INITIAL_METADATA 훅에서 GetRecvInitialMetadata()로 읽습니다.

#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::POST_RECV_INITIAL_METADATA)) {
            auto* md = methods->GetRecvInitialMetadata();
            auto it = md->find("x-request-id");
            if (it != md->end()) {
                request_id_.assign(it->second.begin(), it->second.end());
            }
        }
        methods->Proceed();
    }
private:
    std::string request_id_;
};
class RequestIdServerInterceptorFactory
    : public grpc::experimental::ServerInterceptorFactoryInterface {
public:
    grpc::experimental::Interceptor* CreateServerInterceptor(
        grpc::experimental::ServerRpcInfo* info) override {
        return new RequestIdServerInterceptor();
    }
};
// 서버 빌더에 등록
std::vector<std::unique_ptr<grpc::experimental::ServerInterceptorFactoryInterface>> creators;
creators.push_back(std::make_unique<RequestIdServerInterceptorFactory>());
builder.experimental().SetInterceptorCreators(std::move(creators));

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

여러 인터셉터를 등록하면 보내는 쪽 훅(PRE_SEND_*)은 등록 순서대로, 받는 쪽 훅(POST_RECV_*)은 역순으로 실행됩니다. 위 시퀀스 다이어그램처럼 먼저 등록한 인터셉터가 애플리케이션에 가장 가깝게 놓이는 구조입니다. 인증 인터셉터를 로깅보다 먼저 등록하면, 로깅 인터셉터는 토큰이 이미 붙은 메타데이터를 보게 됩니다.

// 여러 인터셉터 등록
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)레거시
xDS컨트롤 플레인(Istio 등)이 xDS로 내려 주는 정책 사용대규모 클러스터, 서비스 메시

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 서비스 연동

여기서 가장 흔한 실수는 일반 Service(ClusterIP)를 그대로 쓰는 것입니다. ClusterIP 서비스의 DNS 이름은 Pod IP 목록이 아니라 가상 IP 하나를 돌려주고, kube-proxy는 TCP 연결 단위로만 Pod를 고릅니다. gRPC는 HTTP/2 연결 하나에 모든 RPC를 실어 보내므로, 클라이언트에서 round_robin을 켜도 주소가 하나뿐이라 결국 Pod 한 대에만 요청이 갑니다. 클라이언트 측 로드밸런싱을 하려면 clusterIP: None으로 만든 헤드리스 서비스를 써서 DNS가 Pod IP 전체를 돌려주게 해야 합니다.

apiVersion: v1
kind: Service
metadata:
  name: my-grpc-service
spec:
  clusterIP: None        # 헤드리스: DNS가 Pod IP 목록(A 레코드 여러 개)을 반환
  selector:
    app: my-grpc-server
  ports:
    - port: 50051
// 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);

헤드리스 서비스를 써도 남는 문제가 있습니다. gRPC 클라이언트는 DNS를 주기적으로 다시 조회하지 않고 연결이 끊기는 등의 계기가 있을 때 재조회하므로, 스케일 아웃으로 새로 뜬 Pod는 기존 클라이언트에게 한동안 보이지 않습니다. 서버에 grpc.max_connection_age_ms를 설정해 연결을 주기적으로 닫게 하면 클라이언트가 재연결하면서 DNS를 다시 조회합니다.

Service Config로 고급 설정

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

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

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

데드라인 전파

클라이언트가 설정한 데드라인은 grpc-timeout 헤더로 서버에 전달됩니다. 서버는 데드라인이 지나거나 클라이언트가 취소하면 context->IsCancelled()가 참이 되므로, 오래 걸리는 작업은 중간중간 이를 확인해 멈출 수 있습니다. ClientContext는 RPC 하나에만 쓸 수 있으므로 RPC마다 새로 만들어야 합니다.

// 클라이언트: RPC 유형별 데드라인 (ClientContext는 RPC마다 새로)
auto now = std::chrono::system_clock::now();
grpc::ClientContext get_ctx;
get_ctx.set_deadline(now + std::chrono::seconds(5));     // 단순 조회
stub->GetUser(&get_ctx, req, &res);

grpc::ClientContext stream_ctx;
stream_ctx.set_deadline(now + std::chrono::seconds(60)); // 스트리밍
auto stream = stub->StreamLogs(&stream_ctx, req);

// 서버: 주기적 취소 확인
Status LongRpc(ServerContext* context, ...) override {
    for (int i = 0; i < 10000; ++i) {
        if (context->IsCancelled()) {
            return Status(StatusCode::CANCELLED, "cancelled or deadline exceeded");
        }
        DoWork(i);
    }
    return Status::OK;
}

서버가 다른 서비스를 다시 호출한다면, 하위 호출에 새 데드라인을 따로 잡지 말고 받은 데드라인을 그대로 넘겨야 합니다. 그래야 상위 클라이언트가 이미 포기한 요청을 하위 서비스가 계속 처리하는 일이 없습니다. gRPC C++에서는 grpc::ClientContext::FromServerContext(*server_context)로 하위 호출용 컨텍스트를 만들면 데드라인과 취소가 함께 전파됩니다.

Status Checkout(ServerContext* context, const CheckoutRequest* req,
                CheckoutReply* reply) override {
    auto downstream = grpc::ClientContext::FromServerContext(*context);
    PaymentReply pay;
    return payment_stub_->Charge(downstream.get(), MakeChargeRequest(*req), &pay);
}

지수 백오프 + 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;
}

이 헬퍼는 시도마다 새 데드라인(기본 30초)을 잡으므로, 최악의 경우 전체 호출이 30초의 세 배를 넘게 걸립니다. 호출자가 전체 시간 상한을 가지고 있다면, 첫 시도 전에 전체 데드라인을 계산해 두고 시도마다 남은 시간만 쓰도록 바꾸는 편이 안전합니다. 또 템플릿 인자를 std::function에서 추론할 수 없어 람다를 넘길 때는 CallWithRetry<Req, Res>(...)처럼 타입을 명시해야 합니다.

멱등성 고려 재시도

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를 메타데이터로 보내 서버가 중복 요청을 걸러내야 합니다. 서버가 요청을 처리한 뒤 응답만 늦게 도착해도 클라이언트는 DEADLINE_EXCEEDED를 받으므로, 이 코드를 재시도 대상으로 둘 때는 특히 조심해야 합니다.

둘째는 최대 횟수입니다. 무한 재시도는 서버가 회복하려는 순간에 부하를 더 얹습니다. 횟수를 작게 제한하고, 전체 소요 시간이 상위 호출자의 데드라인 안에 들어오는지 계산합니다.

셋째는 재시도 계층의 중복입니다. 위의 CallWithRetry와 Service Config의 retryPolicy를 동시에 켜면 시도 횟수가 곱해집니다. 각각 3회면 한 번의 논리 호출이 최대 9번의 RPC가 되고, 서비스 A→B→C처럼 호출 단계마다 재시도가 있으면 맨 아래 서비스가 받는 요청은 단계 수만큼 거듭제곱으로 늘어납니다. 재시도는 한 계층, 가능하면 호출 체인의 가장 바깥쪽 한 곳에만 둡니다.

넷째는 jitter입니다. 위 구현처럼 대기 시간에 무작위 값을 섞지 않으면, 같은 순간에 실패한 클라이언트들이 같은 순간에 다시 몰려옵니다.


gRPC Health Checking Protocol 구현

gRPC Health Checking Protocol

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 서비스를 활성화합니다. 이 설정은 이후에 만들어지는 서버에 적용되므로 BuildAndStart() 전에 호출해야 합니다. 빈 서비스명("")은 서버 전체 상태를 나타내며, 서비스별 상태는 GetHealthCheckService()->SetServingStatus(...)로 바꿉니다.

#include <grpcpp/grpcpp.h>
#include <grpcpp/health_check_service_interface.h>
grpc::EnableDefaultHealthCheckService(true);  // BuildAndStart 전에
grpc::ServerBuilder builder;
builder.AddListeningPort("0.0.0.0:50051", grpc::InsecureServerCredentials());
builder.RegisterService(&my_service);
auto server = builder.BuildAndStart();
// 예: DB 연결이 끊기면 NOT_SERVING으로 바꿔 readiness에서 빠지게 함
server->GetHealthCheckService()->SetServingStatus("myapp.MyService", false);

grpc_health_probe와 Kubernetes 프로브

로컬에서 수동으로 확인할 때는 다음처럼 실행합니다.

grpc_health_probe -addr=localhost:50051
# SERVING이면 exit 0, 아니면 0이 아닌 코드로 종료

Kubernetes 1.24 이상(1.27에서 GA)은 프로브에 gRPC를 직접 지원하므로, 이미지에 바이너리를 넣지 않고 grpc: 항목만으로도 같은 Health 서비스를 호출할 수 있습니다.

readinessProbe:
  grpc:
    port: 50051
  periodSeconds: 5

그보다 오래된 클러스터에서는 이미지에 grpc_health_probe를 넣고 exec로 실행합니다.

# 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

liveness와 readiness에 같은 검사를 거는 것은 주의해야 합니다. readiness는 실패하면 트래픽에서 빠질 뿐이지만, liveness가 실패하면 컨테이너가 재시작됩니다. DB 장애처럼 재시작해도 낫지 않는 문제를 liveness에 반영하면, 모든 Pod가 함께 재시작을 반복하게 됩니다. 의존성 상태는 readiness에만 반영하는 것이 일반적입니다.

수동 헬스 체크 서비스 구현

기본 서비스 대신 health.proto에서 생성한 코드로 직접 구현할 수도 있습니다. 이때는 같은 서비스가 두 번 등록되지 않도록 기본 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 MyRpc(const myapp::Request& req, myapp::Response* res) {
        // 앞에서 정의한 전역 템플릿 CallWithRetry 사용 (타입 명시 필요)
        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:
    std::shared_ptr<grpc::Channel> channel_;
    std::unique_ptr<myapp::MyService::Stub> stub_;
};

그레이스풀 셧다운

시그널 핸들러 안에서 server->Shutdown()을 직접 부르는 예제가 흔하지만, 시그널 핸들러에서는 async-signal-safe 함수만 호출할 수 있고 Shutdown()은 락을 잡고 스레드를 기다리므로 교착 상태를 일으킬 수 있습니다. POSIX 환경이라면 시그널을 모든 스레드에서 막아 두고, 전용 스레드에서 sigwait()로 받아 처리하는 편이 안전합니다.

#include <grpcpp/grpcpp.h>
#include <csignal>
#include <pthread.h>
#include <thread>
int main() {
    // gRPC 스레드가 생기기 전에 막아야 모든 스레드가 이 마스크를 물려받음
    sigset_t set;
    sigemptyset(&set);
    sigaddset(&set, SIGINT);
    sigaddset(&set, SIGTERM);
    pthread_sigmask(SIG_BLOCK, &set, nullptr);

    grpc::ServerBuilder builder;
    builder.AddListeningPort("0.0.0.0:50051", grpc::InsecureServerCredentials());
    builder.RegisterService(&service);
    auto server = builder.BuildAndStart();

    std::thread waiter([&] {
        int sig = 0;
        sigwait(&set, &sig);
        // 새 RPC는 거부하고, 진행 중인 RPC는 최대 20초까지 기다린 뒤 취소
        server->Shutdown(std::chrono::system_clock::now() + std::chrono::seconds(20));
    });
    server->Wait();
    waiter.join();
    return 0;
}

인자 없는 Shutdown()은 진행 중인 RPC가 끝날 때까지 무한히 기다리므로, 끝나지 않는 스트리밍 RPC가 하나라도 있으면 종료되지 않습니다. Kubernetes는 SIGTERM 후 terminationGracePeriodSeconds(기본 30초)가 지나면 SIGKILL을 보내므로, 셧다운 데드라인은 그보다 짧게 잡습니다.

환경별 설정

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로 끊기고 클라이언트는 재연결을 반복합니다. 스트림을 연 직후부터 같은 간격으로 끊긴다면 가장 먼저 데드라인을 의심할 만합니다.


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

round_robin이 동작하지 않음

서버를 여러 대 두었는데 한 서버에만 요청이 간다면, 기본값인 pick_first를 그대로 쓰고 있거나 주소가 IP 하나로만 해석되는 경우입니다. SetLoadBalancingPolicyName("round_robin")을 설정했는지, 그리고 dns:/// 대상이 실제로 여러 A 레코드를 돌려주는지(nslookup이나 dig로 확인) 봅니다. Kubernetes에서는 앞에서 설명한 대로 ClusterIP 서비스가 가상 IP 하나만 돌려주는 것이 가장 흔한 원인이라 헤드리스 서비스가 필요합니다.

DEADLINE_EXCEEDED가 너무 자주 발생

서버는 정상인데 클라이언트에서 타임아웃이 난다면, 단순 조회용 짧은 데드라인을 스트리밍이나 무거운 RPC에 그대로 쓰고 있지 않은지 먼저 확인합니다. 앞의 데드라인 예제처럼 RPC 유형별로 데드라인을 따로 잡고, 서버가 하위 호출에 데드라인을 제대로 전파하는지도 봅니다. 하위 호출에 별도의 긴 데드라인을 잡으면 상위는 이미 실패했는데 하위는 계속 일하는 상태가 됩니다.

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

주문 생성 RPC를 재시도했더니 주문이 2건 생기는 경우입니다. 앞의 IsRetryable처럼 생성·결제 RPC를 재시도 대상에서 빼거나, 요청 ID를 메타데이터로 보내 서버가 같은 ID의 요청을 한 번만 처리하게 합니다.

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

grpc::experimental::Interceptor를 찾을 수 없다는 에러는 대부분 헤더 누락이나 오래된 gRPC 버전 때문입니다. <grpcpp/support/client_interceptor.h>, <grpcpp/support/server_interceptor.h>, <grpcpp/support/interceptor.h>를 포함했는지 확인하고, 패키지 관리자(vcpkg list grpc 등)로 실제 설치된 버전을 확인합니다.

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

grpc_health_probe 연결 실패

프로브가 Unimplemented나 연결 실패로 끝난다면 서버에 Health 서비스가 등록되지 않은 경우가 많습니다. EnableDefaultHealthCheckService(true)를 BuildAndStart()보다 먼저 호출했는지, 또는 직접 구현한 Health 서비스를 RegisterService했는지 확인합니다. 서버가 127.0.0.1에만 바인딩되어 있으면 Kubernetes의 gRPC 프로브(kubelet이 Pod IP로 접속)가 닿지 않으므로 0.0.0.0으로 바인딩되어 있는지도 봅니다.

스트리밍 시 “Stream removed”

클라이언트가 이미 연결을 끊었는데 서버가 계속 Write()하면 스트림이 끊겼다는 에러가 납니다. Write()의 반환값을 확인하고, 긴 루프에서는 IsCancelled()도 함께 확인합니다.

// ✅ 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;
}

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

요청마다 채널을 새로 만들면 매번 이름 해석, TCP 연결, (TLS라면) 핸드셰이크, HTTP/2 설정 교환을 다시 하게 되어 지연과 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의 기본 데드라인은 무한대입니다. 데드라인 없이 호출한 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& target) {
        std::lock_guard<std::mutex> lock(mutex_);
        auto& ch = channels_[target];
        if (!ch) {
            ch = grpc::CreateChannel(target, grpc::InsecureChannelCredentials());
        }
        return ch;
    }
private:
    inline static std::unordered_map<std::string, std::shared_ptr<grpc::Channel>>
        channels_;
    inline 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 설정에서 설명합니다.

메시지 크기 제한

gRPC C++의 기본 수신 한도는 4MB입니다. 이보다 큰 메시지는 RESOURCE_EXHAUSTED로 거부되므로, 정말 큰 메시지가 필요하다면 한도를 올리기보다 스트리밍으로 나누는 것을 먼저 검토하고, 올린다면 클라이언트와 서버 양쪽을 함께 맞춥니다.

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와 메트릭

재시도는 일시적인 장애를 흡수하지만, 하위 서비스가 오래 죽어 있으면 모든 호출이 데드라인까지 기다렸다가 실패하고 재시도까지 더해 부하만 늘립니다. Circuit Breaker는 최근 실패율이 임계값을 넘으면 일정 시간 동안 호출을 아예 보내지 않고 즉시 실패시키며(OPEN), 시간이 지나면 시험 요청 몇 개만 통과시켜(HALF_OPEN) 성공하면 원래대로 돌아갑니다(CLOSED). gRPC C++에는 이 기능이 내장되어 있지 않으므로 앞의 CallWithRetry 바깥에 직접 두거나, Envoy 같은 사이드카 프록시의 outlier detection을 이용합니다.

이런 판단을 하려면 메서드별 지연 시간과 상태 코드 분포가 필요합니다. 앞의 로깅 인터셉터에서 표준 출력 대신 Prometheus 카운터·히스토그램에 기록하거나, gRPC의 OpenTelemetry 플러그인을 쓰면 서비스 코드를 고치지 않고 수집할 수 있습니다.

인터셉터 API는 아직 experimental 네임스페이스에 있지만 오랫동안 널리 쓰여 왔습니다. 다만 시그니처가 바뀔 수 있으므로 gRPC 버전을 고정하고, 버전을 올릴 때 인터셉터 테스트를 함께 돌리는 편이 안전합니다.

이전 글은 gRPC 성능: Callback API·스트리밍 메시지 크기·HTTP/2 멀티플렉싱(#52-1)이고, 다음 주제는 C++ 시리즈 목차에서 확인할 수 있습니다.

참고 자료


같이 보면 좋은 글