← 목록으로 돌아가기

gRPC vs REST: 내부 서비스 통신에 gRPC를 선택했을 때 실제로 달라지는 것들

Backend

gRPC vs REST internal service communication Protobuf streaming

내부 서비스 통신을 바꾼다는 것의 실제 무게

2026년 현재, 대부분의 백엔드 팀은 서비스 간 통신을 JSON over HTTP/1.1 REST로 운영합니다. 빠르게 검증하고 배포하기에는 이보다 편한 선택이 없습니다. 하지만 서비스 수가 20개를 넘어가고, 한 요청이 다섯 개 이상의 내부 서비스를 순차 혹은 병렬로 호출하기 시작하면, 조용히 쌓이는 문제들이 있습니다. 직렬화 비용, 불명확한 타입 계약, timeout이 전파되지 않는 호출 트리, 각 서비스마다 제각각인 에러 표현 방식.

우리 팀은 결제 도메인 서비스를 분리하는 프로젝트에서 이 문제를 정면으로 마주했습니다. 결제 서비스 하나가 내부적으로 잔액 조회, 한도 검증, 사기 탐지, 포인트 적립, 영수증 발행 등 다섯 개 서비스를 연달아 호출했고, 각 서비스의 timeout이 서로 다른 방식으로 설정되어 있었습니다. 사기 탐지 서비스가 3초를 넘기면 결제 서비스는 "5초 타임아웃"이 만료될 때까지 기다리다가 504를 내놓았고, 사용자는 결제가 실패했는지 처리 중인지 알 수 없었습니다.

gRPC로 전환을 결정한 이유는 단순히 "빠르다"는 이유가 아니었습니다. Deadline 전파, 스트리밍 지원, Protobuf의 schema-first 계약, Interceptor 기반의 횡단 관심사 처리가 실제 운영 문제를 구조적으로 해결했기 때문입니다. 이 글에서는 gRPC를 내부 서비스 통신에 도입했을 때 실제로 달라지는 것들을 코드와 함께 정리합니다.


1. gRPC란 무엇이고 REST와 어디서부터 갈라지는가

gRPC는 Google이 개발하고 2016년 오픈소스로 공개한 RPC(Remote Procedure Call) 프레임워크입니다. HTTP/2를 전송 계층으로, Protocol Buffers를 기본 직렬화 포맷으로 사용합니다. "gRPC Remote Procedure Calls"라는 재귀적 약어에서 이름이 왔습니다.

REST와 gRPC의 가장 큰 차이는 통신의 단위입니다. REST는 리소스를 URL로 표현하고 HTTP method(GET, POST, PUT, DELETE)로 행위를 정의합니다. 반면 gRPC는 서비스와 메서드를 .proto 파일로 정의하고, 클라이언트는 원격 서버의 함수를 로컬 함수처럼 호출합니다. 클라이언트 코드 상에서 client.GetUser(req) 같은 호출은 내부적으로 HTTP/2 스트림 위에 직렬화된 바이너리를 보내는 것이지만, 호출하는 측은 그것을 의식할 필요가 없습니다.

gRPC 공식 문서 - Core Concepts에 따르면, gRPC의 서비스 정의는 언어 중립적인 IDL(Interface Definition Language)인 Protocol Buffers로 작성되며, 이 정의에서 서버 stub과 클라이언트 stub이 자동 생성됩니다. 타입 불일치와 필드 누락은 컴파일 타임에 잡히고, JSON에서 흔히 발생하는 "필드명 오타로 인한 런타임 null" 같은 문제가 구조적으로 사라집니다.

HTTP/2 기반이라는 점도 핵심입니다. HTTP/1.1에서는 하나의 TCP 연결에서 요청과 응답이 순차적으로 처리됩니다. 연결을 여러 개 열어야 병렬 처리가 가능합니다. HTTP/2는 하나의 TCP 연결에서 다중 스트림을 동시에 처리하는 멀티플렉싱을 지원합니다. gRPC는 이 특성을 그대로 활용해 연결 수를 최소화하면서 높은 동시성을 달성합니다.

비교 항목REST (HTTP/1.1 기본)gRPC
전송 프로토콜HTTP/1.1 (또는 HTTP/2 선택)HTTP/2 (강제)
직렬화JSON (텍스트)Protocol Buffers (바이너리)
API 계약OpenAPI/Swagger (선택).proto (강제)
스트리밍제한적 (SSE, WebSocket 별도)단방향·양방향 네이티브
브라우저 지원네이티브gRPC-Web 프록시 필요
코드 생성선택 사항.proto → stub 자동 생성
Timeout 전파수동 구현 필요Deadline 네이티브 지원

REST가 브라우저 친화적이고 도구 지원이 방대한 반면, gRPC는 내부 서비스 간 통신에서 타입 안전성, 성능, 운영 가시성 측면에서 구조적으로 유리합니다.


2. Protobuf 직렬화 원리: schema-first 통신의 비용과 보상

Protocol Buffers는 구조화된 데이터를 바이너리로 직렬화하는 Google의 포맷입니다. Proto3 공식 가이드에 따르면, 각 필드는 고유한 번호(field number)와 타입으로 정의되며, 이 번호가 직렬화된 바이트에서 필드를 식별하는 키로 사용됩니다.

// payment/v1/payment.proto
syntax = "proto3";

package payment.v1;

import "google/protobuf/timestamp.proto";

option go_package = "github.com/waylog/internal/payment/v1;paymentv1";

service PaymentService {
  rpc ProcessPayment(ProcessPaymentRequest) returns (ProcessPaymentResponse);
  rpc StreamTransactions(StreamTransactionsRequest) returns (stream Transaction);
  rpc BatchValidate(stream ValidateRequest) returns (BatchValidateResponse);
}

message ProcessPaymentRequest {
  string order_id          = 1;
  string user_id           = 2;
  int64  amount_krw        = 3;
  string payment_method    = 4;
  map<string, string> meta = 5;
}

message ProcessPaymentResponse {
  string  transaction_id = 1;
  bool    success        = 2;
  string  fail_reason    = 3;
  google.protobuf.Timestamp processed_at = 4;
}

message Transaction {
  string  transaction_id = 1;
  int64   amount_krw     = 2;
  string  status         = 3;
  google.protobuf.Timestamp created_at = 4;
}

Protobuf의 직렬화 원리는 필드 번호를 기반으로 합니다. amount_krw = 3에서 숫자 3이 바이너리에서 "세 번째 필드"를 의미하는 태그가 됩니다. 이 덕분에 필드 이름 문자열을 바이트에 포함할 필요가 없어 크기가 크게 줄어듭니다. 같은 데이터를 JSON으로 표현하면 "amount_krw": 150000이지만, Protobuf에서는 varint 인코딩을 활용해 필드 태그 1바이트 + 값 3바이트 수준으로 줄어듭니다.

schema-first의 보상: .proto 파일 하나에서 Go, TypeScript, Python, Kotlin, Swift 클라이언트 stub이 동시에 생성됩니다. 서버 팀이 .proto를 수정하면 클라이언트 팀은 stub을 재생성하는 것만으로 타입 변경을 바로 인식합니다. 공유 JSON 스키마나 Swagger 문서가 실제 구현과 괴리되는 문제가 구조적으로 해결됩니다.

schema-first의 비용: 초기 설정이 필요합니다. protoc 컴파일러, 언어별 플러그인(protoc-gen-go, protoc-gen-grpc-web 등), CI 파이프라인 통합이 필요합니다. .proto 파일의 버전 관리와 배포 전략(어느 팀이 소유하고 어떻게 공유하는지)을 팀 간에 합의해야 합니다. 우리 팀은 별도의 proto 모노레포를 두고 각 서비스가 의존하는 방식을 선택했습니다.


3. 단방향·서버 스트리밍·클라이언트 스트리밍·양방향 차이

gRPC가 REST보다 구조적으로 우월한 영역 중 하나가 스트리밍입니다. REST에서 대용량 데이터를 전달하려면 페이지네이션(cursor 기반 또는 offset 기반, 자세한 내용은 API 페이지네이션 설계 참고)이나 SSE를 별도로 구성해야 합니다. gRPC는 스트리밍을 프로토콜 수준에서 지원합니다.

네 가지 통신 패턴이 있습니다.

단방향 RPC(Unary RPC): 클라이언트가 요청 하나를 보내고 응답 하나를 받습니다. REST의 일반 요청/응답과 가장 유사합니다. rpc ProcessPayment(Request) returns (Response) 형태입니다.

서버 스트리밍 RPC: 클라이언트가 요청 하나를 보내면 서버가 응답 스트림을 연속으로 보냅니다. 사용자의 거래 내역을 조회할 때 수만 건을 한 번에 보내지 않고 배치로 스트리밍하는 데 적합합니다. rpc StreamTransactions(Request) returns (stream Transaction) 형태입니다.

클라이언트 스트리밍 RPC: 클라이언트가 요청 스트림을 보내고 서버가 하나의 응답을 반환합니다. 파일 업로드나 배치 검증처럼 여러 입력을 모아 하나의 결과를 받는 시나리오에 맞습니다. rpc BatchValidate(stream Request) returns (Response) 형태입니다.

양방향 스트리밍 RPC: 클라이언트와 서버 모두 독립적으로 스트림을 보내고 받습니다. 실시간 채팅, 협업 편집, 라이브 모니터링 구현에 적합합니다. 두 스트림이 서로 독립적이므로 처리 순서는 구현이 결정합니다.

스트리밍의 실제 이점은 "첫 바이트까지의 대기 시간"을 줄이는 데 있습니다. 10만 건의 거래를 한 번에 직렬화해 보내면 클라이언트는 모든 데이터가 도착할 때까지 기다려야 합니다. 서버 스트리밍이면 처음 100건이 오는 즉시 화면에 표시할 수 있습니다.


4. Deadline과 Cancellation 전파: timeout이 호출 트리를 따라간다

gRPC Cancellation 공식 가이드에서 명시하듯, gRPC의 Deadline은 요청이 완료되어야 하는 절대 시각을 HTTP/2 헤더에 포함시켜 모든 홉을 통해 전파합니다. REST에서 timeout을 전파하려면 X-Request-Deadline 같은 커스텀 헤더를 직접 만들고, 각 서비스가 그것을 읽어 자신의 하위 요청에 수동으로 붙여야 합니다. 코드 규율이 없으면 금세 사라집니다.

gRPC에서는 클라이언트가 설정한 Deadline이 HTTP/2 grpc-timeout 헤더로 담겨 첫 번째 서비스에 전달됩니다. 이 서비스가 두 번째 서비스를 호출할 때 "남은 시간"을 다시 Deadline으로 계산해 전파합니다. 어느 홉에서든 Deadline이 만료되면 해당 호출은 DEADLINE_EXCEEDED 상태로 즉시 종료되고, 이미 시작된 하위 작업들도 Context 취소 신호를 받아 중단됩니다.

// Go 서버 예시: Deadline을 상위에서 받아 하위 서비스로 전파
func (s *PaymentServer) ProcessPayment(
    ctx context.Context,
    req *pb.ProcessPaymentRequest,
) (*pb.ProcessPaymentResponse, error) {
    // 상위 클라이언트에서 설정한 Deadline이 ctx에 담겨 있습니다.
    deadline, ok := ctx.Deadline()
    if ok {
        remaining := time.Until(deadline)
        if remaining < 100*time.Millisecond {
            return nil, status.Errorf(
                codes.DeadlineExceeded,
                "insufficient time remaining: %v", remaining,
            )
        }
    }

    // ctx를 그대로 전달 → 사기 탐지 서비스도 동일한 Deadline을 공유합니다.
    fraudResp, err := s.fraudClient.Evaluate(ctx, &fraudpb.EvaluateRequest{
        UserId:    req.UserId,
        AmountKrw: req.AmountKrw,
        OrderId:   req.OrderId,
    })
    if err != nil {
        st, _ := status.FromError(err)
        if st.Code() == codes.DeadlineExceeded {
            return nil, status.Errorf(codes.DeadlineExceeded, "fraud check timed out")
        }
        return nil, status.Errorf(codes.Internal, "fraud evaluation failed: %v", err)
    }

    return &pb.ProcessPaymentResponse{
        TransactionId: generateTxID(),
        Success:       true,
    }, nil
}

Cancellation도 같은 메커니즘으로 동작합니다. 클라이언트가 연결을 끊거나 context.Cancel()을 호출하면, 호출 트리 전체에 취소 신호가 전파됩니다. 불필요한 리소스 소비(DB 쿼리, 외부 API 호출)가 즉시 중단됩니다. 결합된 타임아웃·재시도·서킷브레이커 전략에 대한 더 깊은 논의는 API 복원력 설계에서 다룹니다.


5. Interceptor로 인증·로깅·메트릭 붙이기

gRPC의 Interceptor는 Express의 미들웨어, ASP.NET의 필터와 동일한 개념입니다. 서버 사이드 또는 클라이언트 사이드에서 모든 RPC 호출의 앞뒤를 감쌀 수 있습니다. 인증, 요청 로깅, 메트릭 기록, 에러 변환, 재시도 같은 횡단 관심사를 각 핸들러에 중복 작성하지 않아도 됩니다.

// UnaryServerInterceptor는 단방향 RPC에 적용하는 미들웨어입니다.
func ChainedUnaryInterceptor(logger *zap.Logger) grpc.UnaryServerInterceptor {
    return func(
        ctx context.Context,
        req interface{},
        info *grpc.UnaryServerInfo,
        handler grpc.UnaryHandler,
    ) (interface{}, error) {
        start := time.Now()

        // 1단계: 인증 — Authorization 메타데이터에서 토큰을 검증합니다.
        md, ok := metadata.FromIncomingContext(ctx)
        if !ok {
            return nil, status.Error(codes.Unauthenticated, "missing metadata")
        }
        tokens := md.Get("authorization")
        if len(tokens) == 0 {
            return nil, status.Error(codes.Unauthenticated, "missing authorization token")
        }
        callerID, err := validateBearerToken(tokens[0])
        if err != nil {
            return nil, status.Errorf(codes.Unauthenticated, "invalid token: %v", err)
        }

        // 검증된 caller ID를 Context에 심어 핸들러에서 사용합니다.
        ctx = context.WithValue(ctx, callerIDKey{}, callerID)

        // 2단계: 핸들러 실행
        resp, handlerErr := handler(ctx, req)

        // 3단계: 구조화 로깅
        elapsed := time.Since(start)
        code := codes.OK
        if handlerErr != nil {
            code = status.Code(handlerErr)
        }

        logger.Info("grpc_request",
            zap.String("method", info.FullMethod),
            zap.String("caller", callerID),
            zap.String("code", code.String()),
            zap.Duration("latency", elapsed),
        )

        return resp, handlerErr
    }
}

스트리밍 RPC에는 grpc.StreamServerInterceptor를 별도로 구현해야 합니다. 하나의 RPC에 여러 Interceptor를 적용하려면 grpc.ChainUnaryInterceptorgrpc.ChainStreamInterceptor로 체인을 구성합니다.


6. 서비스 디스커버리와 클라이언트 측 로드밸런싱

gRPC 클라이언트는 단일 서버 주소가 아니라 서비스 이름으로 연결합니다. Kubernetes 환경에서는 DNS 기반 서비스 디스커버리가 자연스럽게 작동하지만, gRPC에는 HTTP/1.1과 다른 중요한 포인트가 있습니다. HTTP/1.1에서는 Kubernetes Service(L4 로드밸런서)가 각 요청을 다른 Pod로 분배합니다. HTTP/2에서는 한 번 연결된 TCP 커넥션이 오래 유지되기 때문에, L4 로드밸런서를 그대로 쓰면 처음 연결된 Pod 하나에 모든 요청이 몰리는 현상이 생깁니다.

해결책은 두 가지입니다. 첫째, Linkerd, Istio 같은 서비스 메시를 사용해 L7 수준에서 gRPC 스트림을 분산합니다. 둘째, 클라이언트 측 로드밸런싱을 구현합니다. gRPC Go 클라이언트는 grpc.WithDefaultServiceConfig로 라운드로빈 정책을 설정할 수 있습니다.

// Kubernetes 환경에서 클라이언트 측 라운드로빈 로드밸런싱 설정
conn, err := grpc.NewClient(
    "dns:///fraud-service.payment.svc.cluster.local:50051",
    grpc.WithTransportCredentials(insecure.NewCredentials()),
    grpc.WithDefaultServiceConfig(`{
        "loadBalancingPolicy": "round_robin",
        "methodConfig": [{
            "name": [{"service": "fraud.v1.FraudService"}],
            "retryPolicy": {
                "maxAttempts": 3,
                "initialBackoff": "0.1s",
                "maxBackoff": "1s",
                "backoffMultiplier": 2.0,
                "retryableStatusCodes": ["UNAVAILABLE", "RESOURCE_EXHAUSTED"]
            }
        }]
    }`),
)

Headless Service를 사용하면 Kubernetes DNS가 Pod IP 목록 전체를 반환합니다. gRPC DNS resolver는 이 목록을 모두 연결 풀에 등록하고 라운드로빈으로 분산합니다.


7. gRPC-Web으로 브라우저와 통신하기

브라우저는 HTTP/2를 지원하지만, HTTP/2의 트레일러(trailer) 헤더를 직접 제어할 수 없습니다. gRPC는 스트림 종료 메타데이터를 트레일러로 전달하기 때문에, 브라우저에서 네이티브 gRPC를 직접 쓸 수 없습니다. 이 문제를 해결하는 것이 gRPC-Web 프로토콜입니다. 트레일러를 바디에 인라인으로 포함시키는 방식으로 브라우저 제약을 우회합니다.

실제 운영에서 가장 많이 쓰이는 두 가지 접근법이 있습니다. 첫째, Envoy나 Nginx 프록시를 앞에 두어 gRPC-Web 요청을 네이티브 gRPC로 변환합니다. 둘째, ConnectRPC를 사용합니다. ConnectRPC는 동일한 Go 서버가 gRPC, gRPC-Web, 그리고 일반 HTTP+JSON을 모두 처리할 수 있게 해주는 프로토콜 라이브러리입니다.


8. 에러 코드 설계: HTTP status가 아닌 gRPC status code

gRPC 에러 공식 가이드는 gRPC가 HTTP status code 대신 독자적인 status code 체계를 사용한다고 명시합니다. 17개의 코드가 있으며, 각각 다른 의미를 가집니다.

gRPC Status Code의미재시도 가능 여부
OK (0)성공-
CANCELLED (1)클라이언트 취소불가
INVALID_ARGUMENT (3)잘못된 입력불가 (입력 수정 필요)
DEADLINE_EXCEEDED (4)Deadline 초과상황에 따라
NOT_FOUND (5)리소스 없음불가
ALREADY_EXISTS (6)이미 존재불가
PERMISSION_DENIED (7)권한 없음불가
RESOURCE_EXHAUSTED (8)rate limit 등가능 (backoff)
INTERNAL (13)서버 내부 오류상황에 따라
UNAVAILABLE (14)서비스 사용 불가가능 (backoff)
UNAUTHENTICATED (16)인증 실패불가 (재인증 필요)

Status code만으로 표현하기 어려운 세부 정보는 google.rpc.Statusdetails 필드를 통해 구조화된 메시지로 전달합니다. 예를 들어 입력 유효성 검사 실패 시 어느 필드가 잘못됐는지를 BadRequest 메시지로, 재시도 가능한 에러에는 RetryInfo로 권장 대기 시간을 명시할 수 있습니다.


9. REST와 실제 벤치마크 비교: 무엇이 빨라지고 무엇이 느려지는가

"gRPC는 REST보다 빠르다"는 말은 조건부로 사실입니다.

Protobuf vs JSON 직렬화: Protobuf는 동일한 데이터를 JSON 대비 3~10배 작게 직렬화하고, 직렬화/역직렬화 속도도 일반적으로 빠릅니다. 다만 이 이점은 메시지 크기가 크고 필드 수가 많을수록 두드러집니다.

HTTP/2 멀티플렉싱: 연결 수가 줄어들고 동시 요청 처리 효율이 높아집니다. 마이크로서비스 환경에서 A → B → C → D 서비스 체인이 있을 때, 각 홉마다 새 TCP 연결을 맺고 끊는 비용이 HTTP/2의 단일 연결 + 멀티플렉싱으로 사라집니다.

스트리밍 지연 시간: 서버 스트리밍 RPC는 첫 번째 메시지를 훨씬 빨리 받을 수 있습니다.

트레이드오프: 느려지는 것은 주로 개발 생산성과 디버깅입니다. curl로 즉시 호출하기 어렵습니다. 브라우저 개발자 도구에서 바이너리 페이로드를 바로 읽기 어렵습니다. Postman 대신 grpcurl이나 Buf Studio를 써야 합니다.


10. REST에서 gRPC로 전환할 때 운영 체크리스트

프로토 파일 관리 전략을 먼저 결정합니다. 각 서비스 레포에 .proto를 두는 방식과, 별도 proto 레포를 중앙 관리하는 방식이 있습니다. 서비스가 5개 이상이라면 중앙 레포 방식이 의존성 추적과 버전 관리에 유리합니다. Buf CLI와 Buf Schema Registry를 도입하면 파일 린팅, 브레이킹 체인지 감지, 자동 코드 생성 CI가 한 번에 해결됩니다.

필드 번호 정책을 팀 내에 공유합니다. Protobuf의 backward compatibility는 필드 번호가 바뀌지 않는다는 전제에 의존합니다. 필드를 삭제할 때는 번호를 예약(reserved)해두어 재사용을 막아야 합니다.

gRPC Reflection을 활성화합니다. 서버에 Reflection 서비스를 등록하면 grpcurl이나 Buf Studio가 .proto 없이도 서버의 서비스 목록과 메서드를 조회할 수 있습니다. 단, 프로덕션에서는 보안상 Reflection 접근을 인증된 클라이언트로 제한하거나 비활성화합니다.

Kubernetes Ingress와 로드밸런서 설정을 점검합니다. 많은 클라우드 로드밸런서와 Ingress 컨트롤러가 HTTP/2를 기본으로 처리하지 않습니다. AWS ALB는 gRPC를 지원하지만 타겟 그룹 프로토콜을 HTTP/2로 명시해야 합니다.

Health check 프로토콜을 gRPC Health Checking Protocol로 통일합니다. Kubernetes의 livenessProbereadinessProbe는 기본적으로 HTTP를 가정합니다. gRPC Health Checking Protocol(grpc.health.v1.Health)을 구현하고, grpc-health-probe 바이너리를 사용하거나 Kubernetes 1.24 이상에서 지원하는 grpcAction probe를 활용합니다.


결론

gRPC 도입은 "빠른 직렬화"를 사러 가는 게 아닙니다. 우리 팀이 실제로 가져간 것은 다음 다섯 가지였습니다.

  • 타입 계약의 강제: .proto 한 파일이 서버와 클라이언트 모두의 타입 소스가 됩니다.
  • Deadline 전파: 호출 트리 전체에 남은 시간이 자동으로 전달됩니다.
  • 스트리밍 네이티브: 대용량 데이터를 REST 페이지네이션 없이 단일 스트림으로 처리합니다.
  • Interceptor 표준화: 인증, 로깅, 메트릭이 단일 체인에 정의되고 모든 RPC에 일관되게 적용됩니다.
  • 에러 코드 통일: DEADLINE_EXCEEDED, UNAVAILABLE 같은 표준 코드가 재시도 정책과 바로 연결됩니다.

반면 브라우저 직접 통신, curl 기반 즉석 테스트, 새 팀원의 온보딩 속도에서는 REST가 여전히 우월합니다. 외부 API나 파트너사 연동에 gRPC를 강제하는 것은 불필요한 부담입니다. 내부 서비스 간 통신에서 타입 안전성과 운영 일관성이 중요해지는 시점, 그 경계에서 gRPC를 꺼내는 것이 적절합니다.