본문 바로가기

gRPC를 이용한 서비스 간 통신

Protocol Buffers로 서비스 계약을 정의하고 NestJS gRPC 서버와 클라이언트를 연결해 타입 기반 RPC를 실행합니다.

지난 절에서는 NestJS로 기본 마이크로서비스를 만들고 TCP 기반 서비스 간 통신을 확인했습니다.

이번 절에서는 마이크로서비스 환경에서 gRPC를 NestJS에 적용하는 방법을 살펴봅니다.

마이크로서비스 간 통신은 아키텍처의 핵심 요소이며, 선택하는 통신 프로토콜은 시스템의 성능과 효율성에 큰 영향을 미칩니다.

HTTP/1.1 기반의 RESTful API도 많은 서비스에 충분하지만, 스키마 기반 이진 메시지와 다중 스트림이 필요한 서비스 간 경계에서는 다른 선택지를 함께 검토할 수 있습니다.

gRPC는 이러한 요구사항을 충족시키기 위해 설계된 현대적인 RPC(Remote Procedure Call) 프레임워크입니다.


gRPC란 무엇인가?

gRPC는 Google에서 개발한 오픈소스 고성능 RPC(Remote Procedure Call) 프레임워크입니다.

HTTP/2 프로토콜을 기반으로 하며, 프로토콜 버퍼(Protocol Buffers)를 인터페이스 정의 언어(IDL)로 사용하여 서비스 인터페이스를 정의합니다.

gRPC의 주요 특징
  • HTTP/2 기반: HTTP/2는 다음과 같은 이점을 제공합니다.
    • 멀티플렉싱(Multiplexing): 단일 TCP 연결에서 여러 요청/응답 스트림을 동시에 처리할 수 있어, 네트워크 효율성이 높습니다.
    • 헤더 압축(Header Compression): HTTP 헤더를 효율적으로 압축하여 대역폭 사용량을 줄입니다.
  • 프로토콜 버퍼(Protocol Buffers)
    • 언어 중립적이고 플랫폼 중립적인 직렬화 메커니즘입니다.
    • 데이터를 이진 형태로 직렬화하므로 많은 서비스 간 workload에서 JSON보다 payload와 처리 비용을 줄일 수 있지만, 실제 차이는 schema와 데이터 및 구현으로 측정해야 합니다.
    • .proto 파일을 사용하여 서비스 인터페이스와 메시지 구조를 정의하고, 다양한 언어로 클라이언트 및 서버 스텁 코드를 자동으로 생성할 수 있습니다.
  • 스트리밍(Streaming): 단항(Unary) 호출 외에도 클라이언트 스트리밍, 서버 스트리밍, 양방향 스트리밍을 지원하여 실시간 통신 및 대용량 데이터 전송에 효율적입니다.
  • 타입 시스템: .proto는 클라이언트-서버 사이의 명확한 schema 계약을 제공합니다. Code generation을 도입하면 생성 타입으로 컴파일 시점 검사를 강화할 수 있지만, 이 절처럼 proto를 동적으로 로드하고 TypeScript interface를 직접 작성하는 방식에서는 별도 계약 검증이 필요합니다.
  • 언어 다양성: Go, Java, Python, Node.js, C++ 등 다양한 프로그래밍 언어를 지원하여 마이크로서비스 환경에서 폴리글랏(Polyglot) 아키텍처 구현에 용이합니다.
gRPC가 RESTful API보다 유리한 경우
  • 서비스 간 지연 시간과 처리량 개선이 목표이고 실제 workload로 이점을 검증할 수 있는 경우
  • 대용량 데이터 스트리밍이 필요한 경우
  • 폴리글랏(Polyglot) 마이크로서비스 아키텍처를 구축하는 경우
  • 명확한 인터페이스 정의를 통해 팀 간 협업을 강화하려는 경우

gRPC의 장점은 .proto 계약이 서버 구현, 클라이언트 프록시, 런타임 호출까지 같은 기준으로 이어질 때 드러납니다.

다음 다이어그램은 외부 HTTP 요청이 공유 proto 계약을 따라 Users 서비스의 unary 응답으로 돌아오는 흐름과 이름·경로·endpoint 경계를 정리합니다.

NestJS gRPC 공유 계약과 unary 호출 흐름

공유 proto는 정적 의존성으로 남기고 Orders의 HTTP 진입·unary 요청·Users 응답/status·deadline 안의 HTTP 변환을 단계로 보여 준다.

Nest gRPC · unary 계약

같은 proto 계약을 각 런타임의 이름·파일·네트워크에 연결합니다

공유 package·service·rpc·message가 기준입니다. 로컬 DI token과 파일 경로는 별도이며 dial target은 bind endpoint에 도달해야 합니다. 이 예제는 동적 로딩과 수기 interface를 사용합니다.

공유 proto와 원격 unary 요청·응답 proto 파일은 ClientGrpc proxy와 Users handler가 공유하는 계약의 의존성이다. 점선은 계약 로딩이며 실제 요청 화살표가 아니다. HTTP 입력을 받은 Orders proxy가 unary 요청을 HTTP/2로 보내고 Users handler의 응답 또는 status를 다시 Orders가 deadline 안에서 기다려 HTTP로 번역한다. 수기 TypeScript interface는 proto와 자동 검증되지 않으며 파일 위치와 DI token은 별도 조건이다. 계약 요청 응답 users.proto package · service · rpc OrdersController HTTP 입력 검증 ClientGrpc proxy getUserById({ id }) HTTP/2 transport 이진 unary 요청 Users @GrpcMethod UserService GetUserById deadline · 응답 유한한 시간 예산 HTTP 결과 번역 User 또는 실패
공유 계약 의존성과 실제 unary 호출을 구분한다 proto 파일은 ClientGrpc proxy와 Users handler가 공유하는 계약의 의존성이다. 점선은 계약 로딩이며 실제 요청 화살표가 아니다. HTTP 입력을 받은 Orders proxy가 unary 요청을 HTTP/2로 보내고 Users handler의 응답 또는 status를 다시 Orders가 deadline 안에서 기다려 HTTP로 번역한다. 수기 TypeScript interface는 proto와 자동 검증되지 않으며 파일 위치와 DI token은 별도 조건이다. 계약 users.proto package · service · rpc ClientGrpc proxy 로컬 token으로 주입 getUserById({ id }) HTTP 입력에서 호출 HTTP/2 transport 직렬화 · unary 요청 Users @GrpcMethod UserService GetUserById Orders에서 응답 대기 유한한 deadline HTTP 결과 번역 User → 200 gRPC status → 오류
Unary request–response

외부 HTTP 요청에서 Users 응답까지

  1. HTTP client

    GET /orders/1/user로 Orders의 HTTP 계약을 호출합니다. 일반 브라우저에는 API Gateway나 gRPC-Web 같은 경계가 필요합니다.

  2. OrdersController

    orderId를 검증하고 Users 호출을 시작합니다. 원격 status와 timeout은 여기서 적절한 HTTP 상태로 바꿉니다.

  3. ClientGrpc proxy

    getService에 타입 UserService와 서비스 이름 'UserService'를 지정해 프록시를 만들고, getUserById({ id })가 unary Observable<User>을 반환합니다.

  4. HTTP/2 + Protocol Buffers

    UserByIdRequest가 이진 message로 직렬화되어 도달 가능한 Users endpoint로 전송됩니다.

  5. Users @GrpcMethod()

    @GrpcMethod에 서비스 'UserService'와 메서드 'GetUserById'를 지정해 같은 rpc를 처리하고, User를 반환하거나 명시적인 gRPC status를 던집니다.

  6. Unary response → HTTP

    lastValueFrom()으로 하나의 응답을 기다린 뒤 성공 본문 또는 NOT_FOUND·UNAVAILABLE 같은 실패를 HTTP 의미로 변환합니다.

proto

package·service·rpc를 공유한다

proto의 package 값 users와 양쪽 options.package 값 'users'가 일치해야 합니다. service 이름 UserService는 getService와 @GrpcMethod에 지정한 같은 서비스 이름과 연결됩니다. proto의 GetUserById는 서버 decorator와 맞추되 client proxy에서는 lower camel-case getUserById를 호출합니다.

local wiring

DI token은 proto 이름이 아니다

ClientsModule의 name: 'USERS_SERVICE'는 @Inject('USERS_SERVICE')와만 맞추는 로컬 Nest 이름이며 proto의 UserService와 독립입니다.

filesystem

경로 문자열보다 같은 계약인지 본다

각 프로세스는 같은 논리 users.proto 계약을 읽어야 합니다. 배포 디렉터리가 다르면 양쪽 protoPath 문자열은 달라도 됩니다.

network

bind와 dial은 도달 가능해야 한다

서버는 bind endpoint에서 듣고 클라이언트는 dial target으로 연결합니다. 주소 문자열이 같을 필요는 없지만 실제 서비스와 포트까지 네트워크로 도달해야 합니다.

Internal RPC

서비스 간 unary 호출

명확한 schema, 이진 message, HTTP/2 multiplexing이 유리할 수 있습니다. 실제 지연과 크기는 workload로 측정합니다.

Browser boundary

일반 브라우저에는 변환 계층이 필요하다

외부 HTTP API를 유지하려면 Orders 같은 Gateway가 status와 payload를 변환하거나 gRPC-Web 계층을 둡니다.

추적 순서: HTTP 진입 → Orders controller → ClientGrpc proxy → HTTP/2 transport → Users handler → unary response. 같은 trace ID와 rpc 이름을 남기면 계약 불일치와 원격 장애를 구분하기 쉽습니다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • proto 파일은 ClientGrpc proxy와 Users handler가 공유하는 계약의 의존성이다. 점선은 계약 로딩이며 실제 요청 화살표가 아니다. HTTP 입력을 받은 Orders proxy가 unary 요청을 HTTP/2로 보내고 Users handler의 응답 또는 status를 다시 Orders가 deadline 안에서 기다려 HTTP로 번역한다. 수기 TypeScript interface는 proto와 자동 검증되지 않으며 파일 위치와 DI token은 별도 조건이다.
관계도의 보충 설명
원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
1NestJS gRPC 공유 계약과 unary 호출 흐름
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
공유 proto 준비

proxy·handler가 proto를 각각 독립적으로 로딩합니다.

계약: proto · 점선: 정적 로딩양쪽 runtime 계약이 준비됩니다.
unary 왕복

Orders가 HTTP 입력으로 unary를 보내 Users 결과·status를 기다립니다.

전송: HTTP/2 unary · 제한: deadline다른 프로세스의 응답을 유한 시간 기다립니다.
HTTP 결과 번역

Orders가 응답·status를 HTTP로 바꿉니다.

결과: HTTP 표현 · 수기 TS interface: 자동 검증 아님내부 RPC와 외부 계약을 분리합니다.

NestJS gRPC 공유 계약과 unary 호출 흐름

공유 proto는 정적 의존성으로 남기고 Orders의 HTTP 진입·unary 요청·Users 응답/status·deadline 안의 HTTP 변환을 단계로 보여 준다.

Nest gRPC · unary 계약

같은 proto 계약을 각 런타임의 이름·파일·네트워크에 연결합니다

공유 package·service·rpc·message가 기준입니다. 로컬 DI token과 파일 경로는 별도이며 dial target은 bind endpoint에 도달해야 합니다. 이 예제는 동적 로딩과 수기 interface를 사용합니다.

공유 proto와 원격 unary 요청·응답 proto 파일은 ClientGrpc proxy와 Users handler가 공유하는 계약의 의존성이다. 점선은 계약 로딩이며 실제 요청 화살표가 아니다. HTTP 입력을 받은 Orders proxy가 unary 요청을 HTTP/2로 보내고 Users handler의 응답 또는 status를 다시 Orders가 deadline 안에서 기다려 HTTP로 번역한다. 수기 TypeScript interface는 proto와 자동 검증되지 않으며 파일 위치와 DI token은 별도 조건이다. 계약 요청 응답 users.proto package · service · rpc OrdersController HTTP 입력 검증 ClientGrpc proxy getUserById({ id }) HTTP/2 transport 이진 unary 요청 Users @GrpcMethod UserService GetUserById deadline · 응답 유한한 시간 예산 HTTP 결과 번역 User 또는 실패
공유 계약 의존성과 실제 unary 호출을 구분한다 proto 파일은 ClientGrpc proxy와 Users handler가 공유하는 계약의 의존성이다. 점선은 계약 로딩이며 실제 요청 화살표가 아니다. HTTP 입력을 받은 Orders proxy가 unary 요청을 HTTP/2로 보내고 Users handler의 응답 또는 status를 다시 Orders가 deadline 안에서 기다려 HTTP로 번역한다. 수기 TypeScript interface는 proto와 자동 검증되지 않으며 파일 위치와 DI token은 별도 조건이다. 계약 users.proto package · service · rpc ClientGrpc proxy 로컬 token으로 주입 getUserById({ id }) HTTP 입력에서 호출 HTTP/2 transport 직렬화 · unary 요청 Users @GrpcMethod UserService GetUserById Orders에서 응답 대기 유한한 deadline HTTP 결과 번역 User → 200 gRPC status → 오류
Unary request–response

외부 HTTP 요청에서 Users 응답까지

  1. HTTP client

    GET /orders/1/user로 Orders의 HTTP 계약을 호출합니다. 일반 브라우저에는 API Gateway나 gRPC-Web 같은 경계가 필요합니다.

  2. OrdersController

    orderId를 검증하고 Users 호출을 시작합니다. 원격 status와 timeout은 여기서 적절한 HTTP 상태로 바꿉니다.

  3. ClientGrpc proxy

    getService에 타입 UserService와 서비스 이름 'UserService'를 지정해 프록시를 만들고, getUserById({ id })가 unary Observable<User>을 반환합니다.

  4. HTTP/2 + Protocol Buffers

    UserByIdRequest가 이진 message로 직렬화되어 도달 가능한 Users endpoint로 전송됩니다.

  5. Users @GrpcMethod()

    @GrpcMethod에 서비스 'UserService'와 메서드 'GetUserById'를 지정해 같은 rpc를 처리하고, User를 반환하거나 명시적인 gRPC status를 던집니다.

  6. Unary response → HTTP

    lastValueFrom()으로 하나의 응답을 기다린 뒤 성공 본문 또는 NOT_FOUND·UNAVAILABLE 같은 실패를 HTTP 의미로 변환합니다.

proto

package·service·rpc를 공유한다

proto의 package 값 users와 양쪽 options.package 값 'users'가 일치해야 합니다. service 이름 UserService는 getService와 @GrpcMethod에 지정한 같은 서비스 이름과 연결됩니다. proto의 GetUserById는 서버 decorator와 맞추되 client proxy에서는 lower camel-case getUserById를 호출합니다.

local wiring

DI token은 proto 이름이 아니다

ClientsModule의 name: 'USERS_SERVICE'는 @Inject('USERS_SERVICE')와만 맞추는 로컬 Nest 이름이며 proto의 UserService와 독립입니다.

filesystem

경로 문자열보다 같은 계약인지 본다

각 프로세스는 같은 논리 users.proto 계약을 읽어야 합니다. 배포 디렉터리가 다르면 양쪽 protoPath 문자열은 달라도 됩니다.

network

bind와 dial은 도달 가능해야 한다

서버는 bind endpoint에서 듣고 클라이언트는 dial target으로 연결합니다. 주소 문자열이 같을 필요는 없지만 실제 서비스와 포트까지 네트워크로 도달해야 합니다.

Internal RPC

서비스 간 unary 호출

명확한 schema, 이진 message, HTTP/2 multiplexing이 유리할 수 있습니다. 실제 지연과 크기는 workload로 측정합니다.

Browser boundary

일반 브라우저에는 변환 계층이 필요하다

외부 HTTP API를 유지하려면 Orders 같은 Gateway가 status와 payload를 변환하거나 gRPC-Web 계층을 둡니다.

추적 순서: HTTP 진입 → Orders controller → ClientGrpc proxy → HTTP/2 transport → Users handler → unary response. 같은 trace ID와 rpc 이름을 남기면 계약 불일치와 원격 장애를 구분하기 쉽습니다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • proto 파일은 ClientGrpc proxy와 Users handler가 공유하는 계약의 의존성이다. 점선은 계약 로딩이며 실제 요청 화살표가 아니다. HTTP 입력을 받은 Orders proxy가 unary 요청을 HTTP/2로 보내고 Users handler의 응답 또는 status를 다시 Orders가 deadline 안에서 기다려 HTTP로 번역한다. 수기 TypeScript interface는 proto와 자동 검증되지 않으며 파일 위치와 DI token은 별도 조건이다.

관계도의 보충 설명

원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
1NestJS gRPC 공유 계약과 unary 호출 흐름
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
공유 proto 준비

proxy·handler가 proto를 각각 독립적으로 로딩합니다.

계약: proto · 점선: 정적 로딩양쪽 runtime 계약이 준비됩니다.
unary 왕복

Orders가 HTTP 입력으로 unary를 보내 Users 결과·status를 기다립니다.

전송: HTTP/2 unary · 제한: deadline다른 프로세스의 응답을 유한 시간 기다립니다.
HTTP 결과 번역

Orders가 응답·status를 HTTP로 바꿉니다.

결과: HTTP 표현 · 수기 TS interface: 자동 검증 아님내부 RPC와 외부 계약을 분리합니다.

NestJS에서 gRPC 설정 및 구현

gRPC 서비스 간 통신은 proto 계약, 채널 설정, 메서드 타입, deadline, 오류 상태 매핑 기준으로 읽습니다.

NestJS는 @nestjs/microservices 패키지를 통해 gRPC 전송 계층을 지원합니다.

시나리오: 이전 절과 동일하게 Users 서비스와 Orders 서비스가 있으며, Orders 서비스가 gRPC를 통해 Users 서비스의 사용자 정보를 요청합니다.

Protobuf 파일 정의

가장 먼저 gRPC 서비스의 인터페이스와 메시지 구조를 정의하는 .proto 파일을 생성합니다.

이 파일은 클라이언트와 서버 간의 계약서 역할을 합니다.

두 프로젝트는 앞 절의 Nest 11 구성을 이어 사용합니다. 원래 설치 명령은 아래에 보존했으며, Nest 11 프로젝트에서는 @nestjs/microservices를 @nestjs/microservices@11로 선택해 설치합니다. 동일 프로젝트에 두 버전의 설치 명령을 연속 실행하는 단계가 아닙니다. @grpc/grpc-js와 @grpc/proto-loader는 두 프로젝트에 필요하고, 실제 설치 버전은 lockfile로 기록합니다.

실행 파일 위치와 proto 위치 먼저 맞추기

기본 단일 프로젝트가 dist/main.js와 dist/orders/orders.module.js로 빌드되는 경우를 기준으로 읽습니다. 다음 원래 코드는 서버와 클라이언트의 파일 깊이가 다르기 때문에 같은 ../../proto/users.proto 문자열이 서로 다른 위치를 가리킵니다.

코드가 실행되는 위치원래 상대 경로가 가리키는 파일
상위폴더/users-service/dist/main.js상위폴더/proto/users.proto
상위폴더/orders-service/dist/orders/orders.module.js상위폴더/orders-service/proto/users.proto

원래 코드를 그대로 실습하려면 공통 상위 폴더의 proto/users.proto를 서버가 읽고, 같은 계약을 orders-service/proto/users.proto에도 복사합니다. 각 프로젝트 안의 proto 폴더로 통일하려면, 원래 서버 예시를 읽은 뒤 서버의 경로 설정만 다음 변형으로 선택합니다. 클라이언트의 원래 경로는 그대로입니다.

users-service/src/main.ts (프로젝트 내부 proto 배치의 경로 변형)
protoPath: join(__dirname, '../proto/users.proto'),

이 한 줄은 완성 파일이 아니라 뒤의 options.protoPath에 적용할 대안입니다. 프로젝트 내부 배치를 선택했다면 users-service/proto/users.proto와 orders-service/proto/users.proto가 같은 계약이어야 합니다. 빌드 출력 구조를 바꾼 프로젝트는 실제 __dirname에서 다시 계산하고, 배포 패키지에도 선택한 .proto 파일을 포함합니다. 기본 TypeScript 빌드가 임의의 루트 proto 폴더를 자동으로 복사한다고 가정하지 않습니다. Nest의 공식 gRPC 샘플은 소스 안의 proto 파일을 assets로 복사하는 별도 구성이며, 이 절의 루트 proto 배치에 그 경로를 그대로 대입하지 않습니다.

// proto/users.proto (프로젝트 루트 또는 shared/proto 폴더에 생성)

syntax = "proto3"; // Protocol Buffers 3 문법 사용

package users; // 패키지 이름

// UserService 정의
service UserService {
  // GetUserById RPC 메서드 정의
  // UserByIdRequest 메시지를 받아 User 메시지를 반환
  rpc GetUserById (UserByIdRequest) returns (User);
  // CreateUser RPC 메서드 정의
  // CreateUserRequest 메시지를 받아 User 메시지를 반환
  rpc CreateUser (CreateUserRequest) returns (User);
}

// UserByIdRequest 메시지 정의
message UserByIdRequest {
  int32 id = 1; // 필드 번호 1
}

// CreateUserRequest 메시지 정의
message CreateUserRequest {
  string name = 1;
  string email = 2;
}

// User 메시지 정의 (응답 및 반환 타입)
message User {
  int32 id = 1;
  string name = 2;
  string email = 3;
}
  • syntax = "proto3";: Protocol Buffers 3 문법을 사용합니다.
  • package users;: 네임스페이스를 정의하여 메시지 이름 충돌을 방지합니다.
  • service UserService { ... }: gRPC 서비스와 그 안에 포함될 RPC 메서드를 정의합니다.
  • rpc MethodName (RequestMessage) returns (ResponseMessage);: RPC 메서드를 정의합니다.
  • message MessageName { ... }: 데이터 구조를 정의합니다. 각 필드는 타입, 이름, 그리고 고유한 필드 번호를 가집니다.

필드 번호는 wire contract의 핵심이므로 삭제한 번호를 다른 의미로 재사용하지 않고 reserved로 남겨야 합니다. 아래 다이어그램은 schema 변경과 dynamic loading/codegen 선택, deadline·재시도·status·trace 운영 계약을 함께 정리합니다.

Nest proto · 진화와 실패 예산

field tag를 보존하고 원격 호출에 유한한 시간 예산을 둡니다

이름·tag·type·rpc 경로의 호환성을 검토하고 dynamic loader와 codegen을 구분합니다. 예제의 deadline·HTTP 매핑과 추가 구현할 retry·trace 정책을 함께 확인합니다.

Source contract

users.proto의 wire identity

syntax = "proto3";
package users;

service UserService {
  rpc GetUserById(
    UserByIdRequest
  ) returns (User);
}

message UserByIdRequest {
  int32 id = 1;
}

message User {
  int32 id = 1;
  string name = 2;
  string email = 3;
}

message field의 wire identity는 이름보다 tag 번호에 가깝습니다. service와 rpc 이름은 호출 경로를 구성하므로 변경 시 양쪽 배포의 호환 계획이 필요합니다.

add · rename

새 tag를 쓰고 이름 변경의 표면을 확인한다

필드 추가는 새 고유 tag를 사용합니다. 이전 reader는 unknown field를 건너뛰고 새 reader는 누락된 scalar 기본값 또는 명시적 optional presence를 처리합니다. 이름 변경은 같은 tag와 호환 type이면 binary wire를 유지할 수 있지만 생성 API와 JSON/text 소비자는 달라질 수 있습니다.

delete · tag · type

삭제한 이름과 번호를 재사용하지 않는다

삭제한 이름과 tag는 reserved로 남깁니다. tag를 다시 번호 매기거나 다른 의미에 재사용하지 않고, type 변경은 wire type과 도메인 의미 모두에서 breaking change로 검토합니다.

path · loading

service 경로와 타입 산출물을 함께 버전 관리한다

service·rpc 변경은 호출 경로를 바꾸므로 old/new method를 겹쳐 배포하거나 명시적 버전 경계를 두고 decorator·proxy·호환성 테스트를 갱신합니다. @grpc/proto-loader는 런타임 계약을 읽을 뿐 수기 interface를 검증하지 않으므로 codegen에는 별도 생성·검증 단계가 필요합니다.

deadline

원격 호출에 유한한 시간 예산을 둔다

클라이언트가 유한한 deadline을 설정하고 상위 HTTP budget 안에서 전파합니다. 초과는 DEADLINE_EXCEEDED로 구분하고 attempt·latency와 함께 관측합니다.

retry

일시 오류와 멱등성을 함께 확인한다

UNAVAILABLE 같은 일시 오류는 deadline 안의 제한된 횟수와 backoff·jitter 정책을 별도로 구성한 경우에만 재시도 후보입니다. 멱등 rpc 또는 idempotency key가 있는 작업만 후보이며 CreateUser를 조건 없이 반복하지 않습니다. circuit breaker는 반복 실패에 대한 별도 정책이고, fallback은 도메인상 허용되는 값에 degraded 신호를 붙일 때만 사용합니다.

local mapping · trace

Gateway의 HTTP 매핑과 추적을 명시한다

이 예시의 Gateway는 NOT_FOUND → 404, DEADLINE_EXCEEDED → 504, UNAVAILABLE → 503, UNIMPLEMENTED → 502로 변환하지만 이는 보편 표준이 아닌 로컬 정책입니다. UNIMPLEMENTED는 method 부재·미구현·미지원 가능성이므로 proto 경로와 배포 버전을 확인합니다. HTTP request부터 client proxy와 handler까지 같은 trace ID에 package/service/rpc, status, deadline, attempt, latency를 남깁니다.

배포 전 확인: tag 재사용 없음 → dynamic loader 또는 codegen 산출물 검증 → old/new 호환성 테스트 → deadline·retry budget → status mapping → 동일 trace로 장애 재현.

gRPC 사용자 서비스 구축

단계 1: 필요한 패키지 설치

users-service 프로젝트에서 설치합니다.

npm install @nestjs/microservices @grpc/grpc-js @grpc/proto-loader
  • @grpc/grpc-js: gRPC Node.js 핵심 라이브러리입니다.
  • @grpc/proto-loader: .proto 파일을 로드하고 JavaScript 객체로 변환하는 유틸리티입니다.
단계 2: main.ts 파일 수정 (gRPC 마이크로서비스 서버 설정)
users-service/src/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { MicroserviceOptions, Transport } from '@nestjs/microservices';
import { join } from 'path'; // path 모듈 임포트

async function bootstrap() {
  const app = await NestFactory.createMicroservice<MicroserviceOptions>(AppModule, {
    transport: Transport.GRPC, // gRPC 전송 방식 사용
    options: {
      package: 'users', // .proto 파일에 정의된 패키지 이름
      protoPath: join(__dirname, '../../proto/users.proto'), // .proto 파일 경로
      url: 'localhost:50051', // gRPC 서버가 리스닝할 주소와 포트
      loader: {
        keepCase: true, // 필드 이름을 스네이크 케이스로 유지 (선택 사항)
        longs: String, // 64비트 정수를 문자열로 처리 (선택 사항, 데이터 정밀도 이슈 방지)
        enums: String, // enum 값을 문자열로 처리 (선택 사항)
        defaults: true, // 기본값 포함 (선택 사항)
        oneofs: true, // oneof 필드 처리 (선택 사항)
      },
    },
  });
  await app.listen();
  console.log('Users gRPC Microservice is listening on localhost:50051');
}
bootstrap();
  • transport: Transport.GRPC: gRPC 전송 방식을 사용합니다.
  • package: .proto 파일의 package 이름과 일치해야 합니다.
  • protoPath: 실행 시점의 __dirname을 기준으로 .proto 파일을 찾습니다. 빌드 산출물의 디렉터리 구조에 맞춰 경로를 조정하고, 배포 시에도 같은 계약 파일이 그 위치에 복사되도록 설정해야 합니다.
  • url: gRPC 서버가 바인딩될 주소입니다.
  • loader: proto-loader의 추가 옵션으로, 데이터 타입 변환 및 필드명 처리 방식을 설정할 수 있습니다.
단계 3: 사용자 마이크로서비스 컨트롤러 (gRPC 핸들러) 구현

@GrpcMethod() 데코레이터를 사용하여 gRPC 메서드를 구현합니다.

users-service/src/users/users.controller.ts (수정)
import { Controller } from '@nestjs/common';
import { status } from '@grpc/grpc-js';
import { GrpcMethod, RpcException } from '@nestjs/microservices';
import { UsersService } from './users.service';

interface User { // proto 파일과 일치하도록 타입 정의 (필요시)
  id: number;
  name: string;
  email: string;
}

@Controller()
export class UsersController {
  constructor(private readonly usersService: UsersService) {}

  // .proto 파일의 UserService.GetUserById 메서드에 매핑
  @GrpcMethod('UserService', 'GetUserById')
  getUserById(data: { id: number }): User { // data는 UserByIdRequest 메시지
    console.log(`Users gRPC Service: Received request for user ID: ${data.id}`);
    const user = this.usersService.findOne(data.id);
    if (!user) {
      throw new RpcException({
        code: status.NOT_FOUND,
        message: `User ${data.id} not found`,
      });
    }
    return user;
  }

  // .proto 파일의 UserService.CreateUser 메서드에 매핑
  @GrpcMethod('UserService', 'CreateUser')
  createUser(data: { name: string; email: string }): User { // data는 CreateUserRequest 메시지
    console.log(`Users gRPC Service: Received request to create user: ${JSON.stringify(data)}`);
    const newUser = this.usersService.create(data);
    return newUser;
  }
}
  • @GrpcMethod('ServiceName', 'MethodName'): 첫 번째 인자는 .proto 파일에 정의된 서비스 이름(UserService), 두 번째 인자는 해당 서비스 내의 메서드 이름(GetUserById)입니다.
  • 메서드의 인자는 .proto 파일에 정의된 요청 메시지의 타입과 일치합니다.
  • 반환 타입도 .proto 파일에 정의된 응답 메시지의 타입과 일치해야 합니다.
  • 사용자가 없으면 id: 0 같은 sentinel 성공 응답을 만들지 않고 status.NOT_FOUND를 담은 RpcException을 던집니다. 0도 유효한 int32 값이므로 부재 의미로 임의 사용하면 안 됩니다.
단계 4: 사용자 서비스 구현 (동일)

users-service/src/users/users.service.ts 파일은 이전과 동일하게 유지됩니다.

단계 5: UsersModule 및 AppModule 구성 (동일)

이전 TCP 예제와 동일하게 UsersModule을 AppModule에 임포트합니다.

주문 서비스(gRPC 클라이언트) 구축

단계 1: 필요한 패키지 설치

orders-service 프로젝트에서 설치합니다.

npm install @nestjs/microservices @grpc/grpc-js @grpc/proto-loader rxjs
단계 2: orders.module.ts 파일 수정 (gRPC 클라이언트 설정)

ClientsModule을 사용하여 gRPC 클라이언트를 등록합니다.

orders-service/src/orders/orders.module.ts (수정)
import { Module } from '@nestjs/common';
import { ClientsModule, Transport } from '@nestjs/microservices';
import { OrdersController } from './orders.controller';
import { OrdersService } from './orders.service';
import { join } from 'path'; // path 모듈 임포트

@Module({
  imports: [
    ClientsModule.register([
      {
        name: 'USERS_SERVICE', // 클라이언트 프록시 토큰
        transport: Transport.GRPC, // gRPC 전송 방식 사용
        options: {
          package: 'users', // 연결할 gRPC 서비스의 패키지 이름
          protoPath: join(__dirname, '../../proto/users.proto'), // .proto 파일 경로
          url: 'localhost:50051', // 연결할 gRPC 서버의 주소와 포트
          loader: {
            keepCase: true,
            longs: String,
            enums: String,
            defaults: true,
            oneofs: true,
          },
        },
      },
    ]),
  ],
  controllers: [OrdersController],
  providers: [OrdersService],
})
export class OrdersModule {}
  • transport: Transport.GRPC: gRPC 클라이언트를 설정합니다.
  • package는 proto의 package users;와 일치해야 합니다. protoPath는 각 배포에서 같은 논리 계약을 가리키면 로컬 경로 문자열이 달라도 됩니다.
  • 서버의 url은 bind endpoint이고 클라이언트의 url은 dial target입니다. 컨테이너나 서비스 DNS를 사용하면 문자열은 달라도 되며, 클라이언트가 실제 서버와 포트에 도달해야 합니다.
  • name: 'USERS_SERVICE'는 @Inject('USERS_SERVICE')와 연결되는 Nest 내부 DI token입니다. proto의 UserService 이름과 독립입니다.
단계 3: 주문 서비스 컨트롤러 (gRPC 클라이언트 호출)

주입받은 ClientGrpc에서 getService()로 서비스 프록시를 얻어 gRPC 메서드를 호출합니다.

orders-service/src/orders/orders.controller.ts (수정)
import {
  BadGatewayException,
  BadRequestException,
  Body,
  Controller,
  GatewayTimeoutException,
  Get,
  HttpException,
  Inject,
  NotFoundException,
  OnModuleInit,
  Param,
  ParseIntPipe,
  Post,
  ServiceUnavailableException,
} from '@nestjs/common';
import { status, type CallOptions } from '@grpc/grpc-js';
import { ClientGrpc } from '@nestjs/microservices';
import { OrdersService } from './orders.service';
import { lastValueFrom, Observable } from 'rxjs';

// 현재 예제는 proto를 동적으로 로드하므로 이 interface를 직접 맞춥니다.
interface UserService {
  getUserById(data: { id: number }, options?: CallOptions): Observable<User>;
  createUser(
    data: { name: string; email: string },
    options?: CallOptions,
  ): Observable<User>;
}

interface User {
  id: number;
  name: string;
  email: string;
}

function mapGrpcError(error: unknown): HttpException {
  const grpcError = error as {
    code?: number;
    details?: string;
    message?: string;
  };
  const details = grpcError.details ?? grpcError.message ?? 'gRPC request failed';

  switch (grpcError.code) {
    case status.INVALID_ARGUMENT:
      return new BadRequestException(details);
    case status.NOT_FOUND:
      return new NotFoundException(details);
    case status.DEADLINE_EXCEEDED:
      return new GatewayTimeoutException(details);
    case status.UNAVAILABLE:
      return new ServiceUnavailableException(details);
    case status.UNIMPLEMENTED:
      return new BadGatewayException(details);
    default:
      return new BadGatewayException(details);
  }
}

@Controller('orders')
export class OrdersController implements OnModuleInit {
  private userService!: UserService;

  constructor(
    private readonly ordersService: OrdersService,
    @Inject('USERS_SERVICE') private readonly client: ClientGrpc,
  ) {}

  onModuleInit(): void {
    this.userService = this.client.getService<UserService>('UserService');
  }

  @Get(':orderId/user')
  async getOrderUser(
    @Param('orderId', ParseIntPipe) orderId: number,
  ): Promise<User> {
    try {
      return await lastValueFrom(
        this.userService.getUserById(
          { id: orderId },
          { deadline: Date.now() + 1_500 },
        ),
      );
    } catch (error) {
      throw mapGrpcError(error);
    }
  }

  @Post('/create-order-with-user-grpc')
  async createOrderWithUserGrpc(
    @Body() orderDto: {
      userId?: number;
      item: string;
      userName?: string;
      userEmail?: string;
    },
  ): Promise<unknown> {
    const { userId, item, userName, userEmail } = orderDto;
    let user: User;

    try {
      if (userId !== undefined) {
        user = await lastValueFrom(
          this.userService.getUserById(
            { id: userId },
            { deadline: Date.now() + 1_500 },
          ),
        );
      } else if (userName && userEmail) {
        user = await lastValueFrom(
          this.userService.createUser(
            { name: userName, email: userEmail },
            { deadline: Date.now() + 1_500 },
          ),
        );
      } else {
        throw new BadRequestException(
          'Either userId or (userName and userEmail) must be provided.',
        );
      }
    } catch (error) {
      if (error instanceof HttpException) throw error;
      throw mapGrpcError(error);
    }

    const order = this.ordersService.createOrder(user.id, item);
    return { order, user };
  }
}
  • implements OnModuleInit: onModuleInit() 라이프사이클 훅을 사용하여 모듈 초기화 시점에 gRPC 서비스를 주입받습니다.
  • @Inject('USERS_SERVICE') private readonly client: ClientGrpc: orders.module.ts에서 등록한 로컬 DI token으로 클라이언트를 주입받습니다.
  • this.client.getService<UserService>('UserService'): proto에 정의된 UserService의 프록시 객체를 가져옵니다. Proto의 GetUserById와 CreateUser는 Nest client proxy에서 getUserById와 createUser처럼 lower camel-case 메서드로 노출됩니다.
  • unary 메서드는 Observable을 반환하므로 lastValueFrom()으로 하나의 완료 응답을 기다릴 수 있습니다. Server/client streaming은 값을 순차 소비해야 하므로 같은 방식으로 일반화하면 안 됩니다.
  • gRPC는 기본 deadline을 설정하지 않습니다. 예제는 각 호출의 두 번째 인자인 CallOptions에 유한한 deadline을 넣으며, 실제 값은 상위 HTTP 시간 예산과 부하 테스트 결과에 맞춰 정합니다.
  • 서버 handler가 명시적인 RpcException status를 반환하면 Orders 경계가 이를 HTTP 예외로 매핑합니다. 위 status→HTTP 코드는 이 Gateway가 선택한 계약 예시이며 보편적인 표준 매핑은 아닙니다. 오류 문자열이나 { message } 객체를 성공 응답으로 반환해 HTTP 200으로 숨기지 않습니다.
  • 설정된 retry policy가 없으면 gRPC는 일반 status 실패를 원하는 정책으로 재시도하지 않고, 서버 애플리케이션이 처리하기 전의 제한된 경우에만 transparent retry를 할 수 있습니다. 조회처럼 멱등인 rpc는 deadline 안의 제한된 재시도 후보가 될 수 있지만, createUser처럼 비멱등 작업은 idempotency key와 중복 방지 계약 없이 자동 재시도하지 않습니다.
  • circuit breaker와 fallback은 gRPC status나 retry가 자동으로 제공하는 동작이 아닙니다. 애플리케이션·gateway·resilience 계층에서 별도 정책으로 구현하고, fallback은 오래된 값도 허용되는 등 도메인 계약이 있을 때만 명시적인 degraded 신호와 함께 반환합니다.
  • UNIMPLEMENTED는 method가 서버에 없거나 구현·지원·활성화되지 않았음을 뜻합니다. proto의 package/service/rpc와 배포 버전을 먼저 확인하고, 일시적인 UNAVAILABLE과 구분해 명시적인 5xx로 매핑합니다.
단계 4: 주문 서비스 구현 (동일)

orders-service/src/orders/orders.service.ts 파일은 이전과 동일하게 유지됩니다.

단계 5: AppModule에 OrdersModule 임포트 (동일)

이전과 동일하게 OrdersModule을 AppModule에 임포트합니다.


통신 경로 실행 검증

TCP 서버를 같은 users-service에서 gRPC 서버로 바꾸는 실습입니다. 앞 절의 Users 프로세스를 종료하고 현재 gRPC 설정으로 다시 시작한 뒤 localhost:50051 로그를 확인합니다. Orders도 현재 gRPC 클라이언트 설정으로 다시 시작합니다. 두 서버의 터미널은 따로 유지하고 HTTP 3000을 사용하는 이전 프로세스와 충돌하지 않게 합니다.

서버가 시작되기 전에 proto 파일을 찾지 못한다면 네트워크 재시도보다 위 경로 표를 먼저 확인합니다. protoPath의 파일 존재 여부, 파일의 package users, service UserService, rpc GetUserById가 모두 맞은 뒤 endpoint와 gRPC status를 점검합니다.

Users Service 시작
  • cd users-service
  • npm run start:dev
  • 콘솔에 Users gRPC Microservice is listening on localhost:50051 메시지 확인
Orders Service 시작
  • cd orders-service
  • npm run start:dev
  • 콘솔에 Orders Service is listening on port 3000 (HTTP) 메시지 확인
API 테스트 (Postman 또는 cURL)
  • 사용자 정보 가져오기 (gRPC 통신)
    • GET http://localhost:3000/orders/1/user
    • 응답: {"id":1,"name":"Alice","email":"[email protected]"}
    • Users Service 콘솔: Users gRPC Service: Received request for user ID: 1 메시지가 출력되는 것을 확인하여 gRPC 통신이 성공적으로 이루어졌음을 확인합니다.
  • 새로운 사용자 생성 및 주문 처리 (gRPC 통신)
    • POST http://localhost:3000/orders/create-order-with-user-grpc
    • Headers: Content-Type: application/json
    • Body
      {
        "userName": "Charlie",
        "userEmail": "[email protected]",
        "item": "Keyboard"
      }
    • 응답: {"order":{"id":1,"userId":3,"item":"Keyboard","createdAt":"2023-06-23T...Z"},"user":{"id":3,"name":"Charlie","email":"[email protected]"}}
    • Users Service 콘솔: Users gRPC Service: Received request to create user: {"name":"Charlie","email":"[email protected]"} 메시지가 출력되는 것을 확인합니다.

gRPC에서는 HTTP 엔드포인트의 성공 여부만 보지 말고 .proto 계약, package/service/rpc 이름, 클라이언트 프록시, Observable 완료와 오류 매핑까지 같은 trace에서 확인해야 합니다.

독립 연습: 경로 실패와 deadline 실패 구분하기

프로젝트 내부 proto 배치를 선택했습니다. ① 서버의 dist/main.js에서 원래 ../../proto/users.proto를 그대로 사용했는데 공통 상위 폴더에는 파일이 없습니다. ② 파일과 RPC 이름은 모두 맞지만 Users가 조회 응답을 2초 뒤 반환하고 Orders의 deadline은 1,500 ms입니다. 각각 먼저 고칠 경계와 예상 실패를 설명하세요. 이 요청은 사용자 조회이며 데이터 변경은 없습니다.

풀이 확인

①은 서버 시작 때 읽을 로컬 계약 파일의 경로가 틀린 경우입니다. 프로젝트 내부 배치라면 서버 경로 변형 ../proto/users.proto와 실제 파일 존재 여부를 확인합니다. 아직 RPC가 실행되기 전이므로 이 실패를 원격 지연이나 deadline 문제로 고치지 않습니다. ②는 이미 올바른 경계를 호출했지만 시간 예산을 넘긴 경우이며, 이 절의 매핑은 DEADLINE_EXCEEDED를 HTTP 504로 표현합니다. deadline 초과는 응답을 제때 얻지 못했다는 뜻입니다. 생성 RPC였다면 서버에서 이미 저장됐을 가능성을 따로 판단해야 하고, 이 조회 문제의 조건을 생성에 그대로 적용하면 안 됩니다.


이 예시는 NestJS를 사용하여 gRPC 기반 마이크로서비스를 구축하고 서비스 간 통신을 구현하는 방법을 보여줍니다.

.proto 파일은 서비스 인터페이스를 명시하고, HTTP/2 기반 통신은 내부 서비스 간 요청/응답과 스트리밍 흐름을 구성하는 데 사용됩니다.

gRPC는 내부 서비스 간 통신에 적합하지만, 외부 클라이언트(브라우저)와 통신하려면 API Gateway나 gRPC-Web 같은 추가 계층이 필요할 수 있습니다.

설계와 운영 단계에서는 메시지 스키마의 호환성, deadline, retry/backoff와 멱등성, gRPC status와 HTTP status의 매핑, 분산 trace 전파를 하나의 계약으로 함께 검토해야 합니다.

이것으로 7장 마이크로서비스 아키텍처의 두 번째 절을 마칩니다.