gRPC를 이용한 서비스 간 통신
Protocol Buffers로 서비스 계약을 정의하고 NestJS gRPC 서버와 클라이언트를 연결해 타입 기반 RPC를 실행합니다.
앞 글에서는 NestJS로 기본 마이크로서비스를 만들고 TCP로 서비스 사이의 통신을 확인했습니다. 이 글에서는 같은 사용자 조회를 gRPC로 옮겨 봅니다.
서비스 사이에 어떤 통신 프로토콜을 쓰느냐는 시스템의 성능과 운영 방식에 큰 영향을 줍니다. HTTP/1.1 기반의 RESTful API로도 충분한 서비스가 많지만, 스키마 기반의 이진 메시지나 하나의 연결 위에서 여러 스트림이 필요한 서비스 사이에서는 다른 선택지를 함께 검토할 만합니다. gRPC는 이런 요구에 맞춰 설계된 RPC(Remote Procedure Call) 프레임워크입니다.
gRPC란
gRPC는 Google이 만든 오픈소스 고성능 RPC 프레임워크입니다. HTTP/2 위에서 동작하며, 프로토콜 버퍼(Protocol Buffers)를 인터페이스 정의 언어(IDL)로 써서 서비스 인터페이스를 정의합니다.
- HTTP/2 기반: 하나의 TCP 연결에서 여러 요청·응답 스트림을 동시에 처리하는 멀티플렉싱(multiplexing)과, HTTP 헤더를 압축해 대역폭을 줄이는 헤더 압축을 제공합니다. 프레임과 스트림, HPACK 헤더 압축이 실제로 어떻게 동작하는지는 HTTP/2에서 다룹니다.
- 프로토콜 버퍼: 언어와 플랫폼에 중립적인 직렬화 방식입니다. 데이터를 이진 형태로 직렬화하므로 많은 경우 JSON보다 payload 크기와 처리 비용을 줄일 수 있지만, 실제 차이는 스키마·데이터·구현에 따라 달라지므로 측정해야 합니다.
.proto파일로 서비스 인터페이스와 메시지 구조를 정의하고, 여러 언어의 클라이언트·서버 코드를 자동으로 생성할 수 있습니다. - 스트리밍: 단항(unary) 호출 외에도 클라이언트 스트리밍, 서버 스트리밍, 양방향 스트리밍을 지원합니다.
- 타입 시스템:
.proto는 클라이언트와 서버가 함께 따르는 분명한 스키마를 제공합니다. 코드 생성(code generation)을 도입하면 생성된 타입으로 컴파일 시점 검사를 강화할 수 있지만, 이 글처럼 proto를 실행 중에 불러오고 TypeScript interface를 직접 작성하는 방식에서는 둘이 일치하는지 따로 확인해야 합니다. - 언어 다양성: Go, Java, Python, Node.js, C++ 등 여러 언어를 지원하므로 서비스마다 다른 언어를 쓰는 환경에 잘 맞습니다.
gRPC가 RESTful API보다 유리한 경우는 다음과 같습니다.
- 서비스 사이의 지연 시간과 처리량을 개선해야 하고, 실제 부하로 그 이점을 검증할 수 있는 경우
- 대용량 데이터 스트리밍이 필요한 경우
- 여러 언어로 만든 서비스가 섞여 있는 경우
- 분명한 인터페이스 정의로 팀 사이의 협업을 강화하려는 경우
gRPC의 장점은 .proto 정의가 서버 구현, 클라이언트 프록시, 실행 중의 호출까지 같은 기준으로 이어질 때 드러납니다.
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를 사용합니다.
외부 HTTP 요청에서 Users 응답까지
-
HTTP client
GET /orders/1/user로 Orders의 HTTP 계약을 호출합니다. 일반 브라우저에는 API Gateway나 gRPC-Web 같은 경계가 필요합니다. -
OrdersController
orderId를 검증하고 Users 호출을 시작합니다. 원격 status와 timeout은 여기서 적절한 HTTP 상태로 바꿉니다. -
ClientGrpc proxy
getService에 타입UserService와 서비스 이름'UserService'를 지정해 프록시를 만들고,getUserById({ id })가 unaryObservable<User>을 반환합니다. -
HTTP/2 + Protocol Buffers
UserByIdRequest가 이진 message로 직렬화되어 도달 가능한 Users endpoint로 전송됩니다. -
Users
@GrpcMethod()@GrpcMethod에 서비스'UserService'와 메서드'GetUserById'를 지정해 같은 rpc를 처리하고,User를 반환하거나 명시적인 gRPC status를 던집니다. -
Unary response → HTTP
lastValueFrom()으로 하나의 응답을 기다린 뒤 성공 본문 또는NOT_FOUND·UNAVAILABLE같은 실패를 HTTP 의미로 변환합니다.
package·service·rpc를 공유한다
proto의 package 값 users와 양쪽 options.package 값 'users'가 일치해야 합니다. service 이름 UserService는 getService와 @GrpcMethod에 지정한 같은 서비스 이름과 연결됩니다. proto의 GetUserById는 서버 decorator와 맞추되 client proxy에서는 lower camel-case getUserById를 호출합니다.
DI token은 proto 이름이 아니다
ClientsModule의 name: 'USERS_SERVICE'는 @Inject('USERS_SERVICE')와만 맞추는 로컬 Nest 이름이며 proto의 UserService와 독립입니다.
경로 문자열보다 같은 계약인지 본다
각 프로세스는 같은 논리 users.proto 계약을 읽어야 합니다. 배포 디렉터리가 다르면 양쪽 protoPath 문자열은 달라도 됩니다.
bind와 dial은 도달 가능해야 한다
서버는 bind endpoint에서 듣고 클라이언트는 dial target으로 연결합니다. 주소 문자열이 같을 필요는 없지만 실제 서비스와 포트까지 네트워크로 도달해야 합니다.
서비스 간 unary 호출
명확한 schema, 이진 message, HTTP/2 multiplexing이 유리할 수 있습니다. 실제 지연과 크기는 workload로 측정합니다.
일반 브라우저에는 변환 계층이 필요하다
외부 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은 별도 조건이다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 1 | NestJS 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를 사용합니다.
외부 HTTP 요청에서 Users 응답까지
-
HTTP client
GET /orders/1/user로 Orders의 HTTP 계약을 호출합니다. 일반 브라우저에는 API Gateway나 gRPC-Web 같은 경계가 필요합니다. -
OrdersController
orderId를 검증하고 Users 호출을 시작합니다. 원격 status와 timeout은 여기서 적절한 HTTP 상태로 바꿉니다. -
ClientGrpc proxy
getService에 타입UserService와 서비스 이름'UserService'를 지정해 프록시를 만들고,getUserById({ id })가 unaryObservable<User>을 반환합니다. -
HTTP/2 + Protocol Buffers
UserByIdRequest가 이진 message로 직렬화되어 도달 가능한 Users endpoint로 전송됩니다. -
Users
@GrpcMethod()@GrpcMethod에 서비스'UserService'와 메서드'GetUserById'를 지정해 같은 rpc를 처리하고,User를 반환하거나 명시적인 gRPC status를 던집니다. -
Unary response → HTTP
lastValueFrom()으로 하나의 응답을 기다린 뒤 성공 본문 또는NOT_FOUND·UNAVAILABLE같은 실패를 HTTP 의미로 변환합니다.
package·service·rpc를 공유한다
proto의 package 값 users와 양쪽 options.package 값 'users'가 일치해야 합니다. service 이름 UserService는 getService와 @GrpcMethod에 지정한 같은 서비스 이름과 연결됩니다. proto의 GetUserById는 서버 decorator와 맞추되 client proxy에서는 lower camel-case getUserById를 호출합니다.
DI token은 proto 이름이 아니다
ClientsModule의 name: 'USERS_SERVICE'는 @Inject('USERS_SERVICE')와만 맞추는 로컬 Nest 이름이며 proto의 UserService와 독립입니다.
경로 문자열보다 같은 계약인지 본다
각 프로세스는 같은 논리 users.proto 계약을 읽어야 합니다. 배포 디렉터리가 다르면 양쪽 protoPath 문자열은 달라도 됩니다.
bind와 dial은 도달 가능해야 한다
서버는 bind endpoint에서 듣고 클라이언트는 dial target으로 연결합니다. 주소 문자열이 같을 필요는 없지만 실제 서비스와 포트까지 네트워크로 도달해야 합니다.
서비스 간 unary 호출
명확한 schema, 이진 message, HTTP/2 multiplexing이 유리할 수 있습니다. 실제 지연과 크기는 workload로 측정합니다.
일반 브라우저에는 변환 계층이 필요하다
외부 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은 별도 조건이다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 1 | NestJS 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와 외부 계약을 분리합니다. |
위 그림은 외부 HTTP 요청이 Orders에서 gRPC unary 호출로 바뀌어 Users까지 갔다가 응답으로 돌아오는 흐름입니다. 점선은 두 프로세스가 같은 users.proto를 각자 불러온다는 의존 관계이고, 실제 요청은 ClientGrpc 프록시의 getUserById()가 HTTP/2로 보낸 이진 메시지가 Users의 @GrpcMethod() handler에 도착하는 경로입니다. 아래쪽 표는 proto의 package·service·rpc 이름, Nest 안에서만 쓰는 DI token, 파일 경로, 네트워크 주소가 각각 무엇과 일치해야 하는지 구분해 보여 줍니다.
NestJS에서 gRPC 설정과 구현
NestJS는 @nestjs/microservices 패키지로 gRPC 전송 계층을 지원합니다. 시나리오는 앞 글과 같습니다. Users 서비스와 Orders 서비스가 있고, Orders 서비스가 gRPC로 Users 서비스의 사용자 정보를 요청합니다. 두 프로젝트는 앞 글에서 만든 NestJS 12 프로젝트를 그대로 씁니다.
Protobuf 파일 정의
가장 먼저 gRPC 서비스의 인터페이스와 메시지 구조를 정의하는 .proto 파일을 만듭니다. 이 파일은 클라이언트와 서버가 함께 지켜야 할 약속입니다. 두 프로젝트의 src/proto/users.proto에 같은 내용으로 둡니다.
syntax = "proto3"; // Protocol Buffers 3 문법 사용
package users; // 패키지 이름
// UserService 정의
service UserService {
// UserByIdRequest 메시지를 받아 User 메시지를 반환
rpc GetUserById (UserByIdRequest) returns (User);
// CreateUserRequest 메시지를 받아 User 메시지를 반환
rpc CreateUser (CreateUserRequest) returns (User);
}
message UserByIdRequest {
int32 id = 1; // 필드 번호 1
}
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 { ... }: 데이터 구조를 정의합니다. 각 필드는 타입, 이름, 고유한 필드 번호를 가집니다.
메시지가 이진으로 전송될 때 필드를 구분하는 것은 이름보다 필드 번호입니다. 그래서 지운 필드의 번호를 다른 의미로 다시 쓰지 않고 reserved로 남겨 두어야 합니다.
.proto는 TypeScript 파일이 아니므로 nest build가 자동으로 dist에 복사하지 않습니다. 공식 gRPC 문서의 안내대로 두 프로젝트의 nest-cli.json에 assets와 watchAssets를 추가해, src 아래의 .proto 파일이 같은 상대 위치로 dist에 복사되게 합니다.
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true,
"assets": ["**/*.proto"],
"watchAssets": true
}
}이렇게 하면 빌드 뒤 두 프로젝트 모두 dist/proto/users.proto에 proto 파일이 생깁니다. 코드에서는 실행되는 파일의 __dirname을 기준으로 이 위치를 가리킵니다. dist/main.js에서는 proto/users.proto, dist/orders/orders.module.js에서는 ../proto/users.proto입니다.
Nest proto · 진화와 실패 예산
field tag를 보존하고 원격 호출에 유한한 시간 예산을 둡니다
이름·tag·type·rpc 경로의 호환성을 검토하고 dynamic loader와 codegen을 구분합니다. 예제의 deadline·HTTP 매핑과 추가 구현할 retry·trace 정책을 함께 확인합니다.
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 이름은 호출 경로를 구성하므로 변경 시 양쪽 배포의 호환 계획이 필요합니다.
새 tag를 쓰고 이름 변경의 표면을 확인한다
필드 추가는 새 고유 tag를 사용합니다. 이전 reader는 unknown field를 건너뛰고 새 reader는 누락된 scalar 기본값 또는 명시적 optional presence를 처리합니다. 이름 변경은 같은 tag와 호환 type이면 binary wire를 유지할 수 있지만 생성 API와 JSON/text 소비자는 달라질 수 있습니다.
삭제한 이름과 번호를 재사용하지 않는다
삭제한 이름과 tag는 reserved로 남깁니다. tag를 다시 번호 매기거나 다른 의미에 재사용하지 않고, type 변경은 wire type과 도메인 의미 모두에서 breaking change로 검토합니다.
service 경로와 타입 산출물을 함께 버전 관리한다
service·rpc 변경은 호출 경로를 바꾸므로 old/new method를 겹쳐 배포하거나 명시적 버전 경계를 두고 decorator·proxy·호환성 테스트를 갱신합니다. @grpc/proto-loader는 런타임 계약을 읽을 뿐 수기 interface를 검증하지 않으므로 codegen에는 별도 생성·검증 단계가 필요합니다.
원격 호출에 유한한 시간 예산을 둔다
클라이언트가 유한한 deadline을 설정하고 상위 HTTP budget 안에서 전파합니다. 초과는 DEADLINE_EXCEEDED로 구분하고 attempt·latency와 함께 관측합니다.
일시 오류와 멱등성을 함께 확인한다
UNAVAILABLE 같은 일시 오류는 deadline 안의 제한된 횟수와 backoff·jitter 정책을 별도로 구성한 경우에만 재시도 후보입니다. 멱등 rpc 또는 idempotency key가 있는 작업만 후보이며 CreateUser를 조건 없이 반복하지 않습니다. circuit breaker는 반복 실패에 대한 별도 정책이고, fallback은 도메인상 허용되는 값에 degraded 신호를 붙일 때만 사용합니다.
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로 장애 재현.
위 그림은 .proto를 바꿀 때 지켜야 할 규칙과 원격 호출의 실패 처리를 함께 정리합니다. 필드를 추가할 때는 새 번호를 쓰고, 이름만 바꾸면 이진 형식은 유지되지만 생성된 API나 JSON 소비자는 달라질 수 있으며, 지운 이름과 번호는 reserved로 남깁니다. 오른쪽 아래 칸들은 호출마다 유한한 deadline을 두고, 일시적인 UNAVAILABLE만 멱등한 호출에 한해 재시도 후보로 삼으며, gRPC status를 HTTP 상태로 바꾸는 규칙은 이 예제의 게이트웨이가 정한 것일 뿐 표준이 아니라는 점을 설명합니다.
gRPC 사용자 서비스 만들기
users-service 프로젝트에 gRPC 패키지를 설치합니다. Nest의 gRPC 전송 계층은 이 두 패키지를 함께 설치하지 않고 프로젝트에 설치된 것을 불러다 쓰므로, 앞 글에서 설치한 @nestjs/microservices와 별도로 설치해야 합니다.
npm install @grpc/grpc-js @grpc/proto-loader@grpc/grpc-js: gRPC의 Node.js 핵심 라이브러리입니다. 현재 버전은 1.14입니다.@grpc/proto-loader:.proto파일을 읽어 JavaScript 객체로 바꾸는 도구입니다. 현재 버전은 0.8입니다.
main.ts를 gRPC 마이크로서비스 서버로 바꿉니다.
import { NestFactory } from '@nestjs/core';
import {
GrpcExceptionFilter,
MicroserviceOptions,
Transport,
} from '@nestjs/microservices';
import { join } from 'node:path';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.createMicroservice<MicroserviceOptions>(
AppModule,
{
transport: Transport.GRPC, // gRPC 전송 방식 사용
options: {
package: 'users', // .proto 파일의 package 이름
protoPath: join(__dirname, 'proto/users.proto'), // dist/proto/users.proto
url: 'localhost:50051', // gRPC 서버가 리스닝할 주소와 포트
loader: {
keepCase: true, // 필드 이름을 proto에 적힌 그대로 유지
longs: String, // 64비트 정수를 문자열로 처리해 정밀도 손실 방지
enums: String, // enum 값을 문자열로 처리
defaults: true, // 기본값 포함
oneofs: true, // oneof 필드 처리
},
},
},
);
// gRPC 전용 예외를 알맞은 gRPC status로 바꿔 보냅니다.
app.useGlobalFilters(new GrpcExceptionFilter());
await app.listen();
console.log('Users gRPC Microservice is listening on localhost:50051');
}
void bootstrap();transport: Transport.GRPC: gRPC 전송 방식을 씁니다.package:.proto파일의package이름과 같아야 합니다.protoPath: 실행되는 파일의__dirname을 기준으로.proto파일을 찾습니다.nest-cli.json의assets설정이 빌드 결과에 파일을 복사해 두므로, 배포할 때도dist폴더 전체를 함께 옮기면 됩니다.url: gRPC 서버가 바인딩할 주소입니다.loader:proto-loader에 넘기는 추가 옵션으로, 데이터 타입 변환과 필드 이름 처리 방식을 정합니다.GrpcExceptionFilter: NestJS 12에 새로 생긴 필터입니다. 공식 문서에 따르면 일반RpcException에는 gRPC status 코드가 담기지 않아 클라이언트가 모든 실패를UNKNOWN으로 받으므로, 상태별 gRPC 예외와 이 필터를 함께 써서 올바른 status를 보냅니다.
@GrpcMethod() 데코레이터로 gRPC 메서드를 구현합니다.
import { Controller } from '@nestjs/common';
import { GrpcMethod, GrpcNotFoundException } from '@nestjs/microservices';
import { UsersService } from './users.service';
interface User { // proto의 User 메시지와 맞춘 타입
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 GrpcNotFoundException(`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)}`);
return this.usersService.create(data);
}
}@GrpcMethod('ServiceName', 'MethodName'): 첫 번째 인자는.proto에 정의된 서비스 이름(UserService), 두 번째 인자는 그 서비스 안의 메서드 이름(GetUserById)입니다.- 메서드의 인자는
.proto의 요청 메시지와, 반환값은 응답 메시지와 같은 모양이어야 합니다. - 사용자가 없을 때
id: 0같은 가짜 성공 응답을 만들지 않고GrpcNotFoundException을 던집니다.0도 올바른int32값이므로 “없음”을 뜻하는 값으로 쓰면 안 됩니다. 필터가 이 예외를NOT_FOUNDstatus로 바꿔 보냅니다.
users.service.ts, UsersModule, AppModule은 앞 글의 TCP 예제와 같습니다.
주문 서비스(gRPC 클라이언트) 만들기
orders-service 프로젝트에도 같은 패키지를 설치하고, src/proto/users.proto와 nest-cli.json의 assets 설정을 똑같이 둡니다. 클라이언트도 서버와 같은 proto 정의를 읽어야 요청과 응답 메시지를 이진 형식으로 바꾸고 해석할 수 있습니다. 두 프로젝트의 proto 내용이 서로 달라지면 필드 번호가 어긋나 값이 다른 필드로 해석될 수 있으므로, 같은 파일을 복사하거나 공유 저장소에서 가져옵니다.
npm install @grpc/grpc-js @grpc/proto-loaderClientsModule로 gRPC 클라이언트를 등록합니다. 앞 글의 TCP 클라이언트와 같은 USERS_SERVICE 토큰을 쓰고, transport와 options만 gRPC용으로 바꿉니다. 그래서 controller의 @Inject('USERS_SERVICE')는 그대로 두지만, 주입받는 객체의 타입은 ClientProxy가 아니라 ClientGrpc가 됩니다.
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'), // dist/proto/users.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는 같은 내용의 proto 파일을 가리키기만 하면 되며, 서버와 문자열이 달라도 됩니다. 이 파일은dist/orders/에서 실행되므로 한 단계 위의../proto/users.proto를 가리킵니다.- 서버의
url은 바인딩할 주소이고, 클라이언트의url은 연결할 대상입니다. 컨테이너나 서비스 DNS를 쓰면 두 문자열이 달라도 되지만, 클라이언트가 실제 서버의 주소와 포트에 도달할 수 있어야 합니다. name: 'USERS_SERVICE'는@Inject('USERS_SERVICE')와 연결되는 Nest 내부의 DI token이며, proto의UserService라는 이름과는 상관없습니다.
주입받은 ClientGrpc에서 getService()로 서비스 프록시를 얻어 gRPC 메서드를 호출합니다.
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 type { 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()수명 주기 hook에서 gRPC 서비스 프록시를 준비합니다.@Inject('USERS_SERVICE') private readonly client: ClientGrpc:orders.module.ts에서 등록한 DI token으로 클라이언트를 주입받습니다.ClientGrpc는 클래스가 아니라 인터페이스이므로import type으로 가져옵니다. CLI가 만든tsconfig.json은isolatedModules와emitDecoratorMetadata를 함께 켜 두므로, 데코레이터가 붙은 클래스의 생성자에 일반import로 가져온 인터페이스를 쓰면 TypeScript가import type을 요구하는 컴파일 오류를 냅니다.this.client.getService<UserService>('UserService'): proto에 정의된UserService의 프록시 객체를 가져옵니다. proto의GetUserById와CreateUser는 프록시에서getUserById,createUser처럼 첫 글자가 소문자인 메서드로 노출됩니다.- unary 메서드는
Observable을 반환하므로lastValueFrom()으로 하나의 완료 응답을 기다릴 수 있습니다. 서버·클라이언트 스트리밍은 값을 차례로 소비해야 하므로 같은 방식으로 일반화하면 안 됩니다. - gRPC는 기본 deadline을 설정하지 않습니다. 예제는 각 호출의 두 번째 인자인
CallOptions에 유한한 deadline을 넣으며, 실제 값은 상위 HTTP 요청의 시간 예산과 부하 테스트 결과에 맞춰 정합니다. - 서버가 gRPC status를 담은 오류를 보내면 Orders가 이를 HTTP 예외로 바꿉니다. 위의 status와 HTTP 코드 대응은 이 게이트웨이가 고른 예시일 뿐 보편적인 표준이 아닙니다. 오류 문자열이나
{ message }객체를 성공 응답으로 돌려 HTTP 200 뒤에 숨기지 않습니다. - 재시도 정책을 설정하지 않으면 gRPC는 일반적인 status 실패를 다시 시도하지 않고, 서버 애플리케이션이 요청을 처리하기 전의 제한된 경우에만 투명하게 재시도합니다. 조회처럼 멱등한 RPC는 deadline 안에서 제한된 재시도 후보가 될 수 있지만,
createUser처럼 멱등하지 않은 작업은 idempotency key와 중복 방지 장치 없이 자동으로 재시도하지 않습니다. - 서킷 브레이커와 대체 응답(fallback)은 gRPC가 자동으로 제공하는 기능이 아닙니다. 애플리케이션이나 게이트웨이 쪽에서 따로 구현하고, 대체 응답은 오래된 값을 허용하는 등 도메인 규칙이 있을 때만 품질이 떨어진 응답이라는 신호와 함께 돌려줍니다.
UNIMPLEMENTED는 서버에 해당 메서드가 없거나, 구현·지원·활성화되지 않았다는 뜻입니다. proto의 package·service·rpc와 배포 버전을 먼저 확인하고, 일시적인UNAVAILABLE과 구분해 5xx로 바꿉니다.
orders.service.ts와 AppModule은 앞 글과 같습니다.
더 깊이: proto3에서 0과 '값 없음'을 구분하지 못하는 이유
proto3의 단일 스칼라 필드는 optional을 붙이지 않으면 암묵적 존재(implicit presence) 방식으로 다뤄집니다. 필드 존재 문서에 따르면 이런 필드는 기본값을 담고 있을 때 아예 직렬화되지 않으며, 숫자의 기본값은 0이고 문자열의 기본값은 빈 문자열입니다. 그래서 받는 쪽에서는 필드가 처음부터 없었는지, 보낸 쪽이 0을 넣었는지 알 수 있는 신호가 없습니다. 이 예제의 int32 id도 마찬가지라서, 클라이언트가 id를 빠뜨린 요청과 id: 0을 보낸 요청은 서버에서 똑같이 보입니다.
이 예제의 loader 옵션도 같은 방향으로 작용합니다. @grpc/proto-loader의 defaults: true는 빠진 필드에 기본값을 채워 넣은 객체를 만들어 주므로, handler가 받는 객체에는 id 속성이 언제나 숫자로 들어 있습니다. 존재 여부가 업무상 의미를 가지는 필드라면 proto3에서 optional을 붙여 명시적 존재(explicit presence)를 켭니다. 문서는 protobuf 3.15.0부터 protoc에서 이 존재 추적을 쓰는 데 실험 플래그가 더는 필요하지 않다고 설명하고, 특별한 이유가 없다면 optional을 쓰라고 권합니다.
통신 경로 확인
같은 users-service를 TCP 서버에서 gRPC 서버로 바꾸는 실습이므로, 앞 글의 사용자 서비스 프로세스를 종료하고 지금의 gRPC 설정으로 다시 시작합니다. 주문 서비스도 지금의 gRPC 클라이언트 설정으로 다시 시작합니다. 두 서버의 터미널은 따로 두고, HTTP 3000번을 쓰는 이전 프로세스와 겹치지 않게 합니다.
users-service에서 npm run start:dev를 실행하면 콘솔에 Users gRPC Microservice is listening on localhost:50051이, orders-service에서 실행하면 Orders Service is listening on port 3000 (HTTP)가 출력됩니다. 서버가 시작되기 전에 proto 파일을 찾지 못했다는 오류가 나면 네트워크를 의심하기 전에 dist/proto/users.proto가 실제로 있는지, 즉 assets 설정이 들어갔는지부터 확인합니다. 파일이 있다면 그 안의 package users, service UserService, rpc GetUserById가 코드와 맞는지 본 뒤에 주소와 gRPC status를 점검합니다.
먼저 GET http://localhost:3000/orders/1/user를 호출합니다. {"id":1,"name":"Alice","email":"[email protected]"}가 돌아오고, 사용자 서비스 콘솔에 Users gRPC Service: Received request for user ID: 1이 찍히면 gRPC 통신이 성공한 것입니다.
다음으로 POST http://localhost:3000/orders/create-order-with-user-grpc에 Content-Type: application/json 헤더와 다음 본문을 보내 새 사용자를 만들면서 주문합니다.
{
"userName": "Charlie",
"userEmail": "[email protected]",
"item": "Keyboard"
}응답은 {"order":{"id":1,"userId":3,"item":"Keyboard","createdAt":"..."},"user":{"id":3,"name":"Charlie","email":"[email protected]"}} 모양이고, 사용자 서비스 콘솔에는 Users gRPC Service: Received request to create user: {"name":"Charlie","email":"[email protected]"}가 찍힙니다.
gRPC에서는 HTTP 엔드포인트가 성공했는지만 보지 말고 .proto 정의, package·service·rpc 이름, 클라이언트 프록시, Observable의 완료와 오류 변환까지 같은 추적 흐름에서 확인해야 합니다.
gRPC는 내부 서비스 사이의 통신에 잘 맞지만, 일반 브라우저 같은 외부 클라이언트와 통신하려면 API 게이트웨이나 gRPC-Web 같은 계층이 더 필요할 수 있습니다. 설계와 운영 단계에서는 메시지 스키마의 호환성, deadline, 재시도와 멱등성, gRPC status와 HTTP 상태의 대응, 분산 추적 정보의 전달을 함께 검토해야 합니다.
정리
- gRPC는 HTTP/2와 Protocol Buffers 위에서 동작하는 RPC 프레임워크로,
.proto파일이 서비스와 메시지 구조를 정의합니다. - NestJS 12에서는
@grpc/grpc-js와@grpc/proto-loader를 설치하고,Transport.GRPC의package·protoPath·url로 서버와 클라이언트를 설정합니다. .proto는 빌드 결과로 복사되지 않으므로nest-cli.json의assets에.proto파일 패턴을 추가하고,__dirname기준 경로로 읽습니다.- 서버에서는
GrpcNotFoundException같은 상태별 예외와GrpcExceptionFilter를 함께 써야 클라이언트가UNKNOWN대신 올바른 status를 받습니다. - 클라이언트는 호출마다 deadline을 두고, gRPC status를 HTTP 상태로 바꾸는 규칙과 재시도 대상을 멱등성 기준으로 정합니다.
연습 문제
1. 파일을 못 찾는 실패와 시간 초과
다음 두 상황에서 먼저 고칠 곳과 예상되는 실패를 설명해 보세요. ① users-service의 nest-cli.json에 assets 설정을 빠뜨린 채 실행했습니다. ② 파일과 RPC 이름은 모두 맞지만, 사용자 서비스가 조회 응답을 2초 뒤에 돌려주고 주문 서비스의 deadline은 1,500ms입니다. 이 요청은 사용자 조회이며 데이터를 바꾸지 않습니다.
해설 보기
①은 dist/proto/users.proto가 만들어지지 않아, 서버가 시작할 때 읽을 proto 정의를 찾지 못하고 실패하는 경우입니다. 아직 RPC가 한 번도 실행되기 전이므로 원격 지연이나 deadline을 고칠 일이 아니라, assets 설정과 빌드 결과에 파일이 있는지를 확인합니다.
②는 올바른 서비스를 호출했지만 시간 예산을 넘긴 경우입니다. 클라이언트는 DEADLINE_EXCEEDED를 받고, 이 글의 대응 규칙에 따라 HTTP 504 Gateway Timeout이 됩니다. deadline 초과는 응답을 제때 받지 못했다는 뜻일 뿐이며, 생성 RPC였다면 서버에서 이미 저장되었을 가능성을 따로 판단해야 합니다.
2. 필터를 등록하지 않았다면
users-service의 main.ts에서 app.useGlobalFilters(new GrpcExceptionFilter()); 줄을 빼고 실행했습니다. GET /orders/999/user를 호출하면 주문 서비스는 어떤 HTTP 응답을 돌려줄까요?
해설 보기
handler는 그대로 GrpcNotFoundException을 던지지만, 이를 gRPC status로 바꿔 줄 필터가 없습니다. 공식 문서가 필터를 등록해야 UNKNOWN 대신 올바른 status가 간다고 설명하는 것처럼, 클라이언트는 이 실패를 NOT_FOUND가 아닌 UNKNOWN으로 받게 됩니다.
주문 서비스의 mapGrpcError()에는 UNKNOWN에 대한 별도 분기가 없으므로 default로 가서 502 Bad Gateway를 돌려줍니다. 사용자가 없다는 사실이 게이트웨이 오류처럼 보이게 되므로, 서버에 필터를 등록해 404가 나오는지 확인해야 합니다.
3. 생성 요청이 시간 초과된 뒤 다시 보내면
create-order-with-user-grpc로 Charlie를 만드는 요청이 사용자 서비스의 일시적인 지연 때문에 504로 끝났습니다. 클라이언트가 같은 요청을 곧바로 한 번 더 보냈고, 이번에는 성공했습니다. 사용자 서비스에는 Charlie가 몇 명 있을 수 있을까요?
해설 보기
첫 요청의 deadline 초과는 주문 서비스가 응답을 기다리지 못했다는 뜻일 뿐, 사용자 서비스가 처리를 멈췄다는 뜻이 아닙니다. 사용자 서비스가 늦게라도 첫 요청을 처리했다면 Charlie가 이미 하나 만들어졌고, 두 번째 요청이 또 하나를 만들어 ID 3과 4의 Charlie가 생길 수 있습니다.
CreateUser는 멱등하지 않으므로, 클라이언트가 만든 idempotency key를 요청에 담고 사용자 서비스가 같은 키의 결과를 돌려주게 하거나, 이메일에 고유 제약을 두어 중복을 막아야 안전하게 다시 시도할 수 있습니다.
다음 글 Apache Kafka와 이벤트 기반 아키텍처에서는 요청-응답 대신 이벤트를 발행하고 구독하는 방식으로 서비스를 연결하는 방법을 다룹니다.