icon
7장 : 마이크로서비스 아키텍처

gRPC를 이용한 서비스 간 통신


지난 절에서는 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 헤더를 효율적으로 압축하여 대역폭 사용량을 줄입니다.
    • 서버 푸시(Server Push): 클라이언트가 요청하지 않아도 서버가 필요한 리소스를 미리 푸시할 수 있습니다.
  • 프로토콜 버퍼(Protocol Buffers)
    • 언어 중립적이고 플랫폼 중립적인 직렬화 메커니즘입니다.
    • 데이터를 이진 형태로 직렬화하여 JSON보다 훨씬 작고 빠릅니다.
    • .proto 파일을 사용하여 서비스 인터페이스와 메시지 구조를 정의하고, 다양한 언어로 클라이언트 및 서버 스텁 코드를 자동으로 생성할 수 있습니다.
  • 스트리밍(Streaming): 단항(Unary) 호출 외에도 클라이언트 스트리밍, 서버 스트리밍, 양방향 스트리밍을 지원하여 실시간 통신 및 대용량 데이터 전송에 효율적입니다.
  • 강력한 타입 시스템: .proto 파일에 정의된 명확한 스키마 덕분에 컴파일 시점에 타입 안정성을 보장하고, 클라이언트-서버 간 계약이 명확합니다.
  • 언어 다양성: Go, Java, Python, Node.js, C++ 등 다양한 프로그래밍 언어를 지원하여 마이크로서비스 환경에서 폴리글랏(Polyglot) 아키텍처 구현에 용이합니다.

gRPC가 RESTful API보다 유리한 경우

  • 서비스 간 고성능 통신이 필요한 경우 (낮은 지연 시간, 높은 처리량)
  • 대용량 데이터 스트리밍이 필요한 경우
  • 폴리글랏(Polyglot) 마이크로서비스 아키텍처를 구축하는 경우
  • 명확한 인터페이스 정의를 통해 팀 간 협업을 강화하려는 경우

NestJS에서 gRPC 설정 및 구현

NestJS는 @nestjs/microservices 패키지를 통해 gRPC를 완벽하게 지원합니다.

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

Protobuf 파일 정의

가장 먼저 gRPC 서비스의 인터페이스와 메시지 구조를 정의하는 .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 { ... }: 데이터 구조를 정의합니다. 각 필드는 타입, 이름, 그리고 고유한 필드 번호를 가집니다.

사용자 서비스 구축

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

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

npm install @nestjs/microservices @grpc/grpc-js @grpc/proto-loader
npm install --save-dev @types/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: .proto 파일의 실제 경로를 지정합니다. join(__dirname, '../../proto/users.proto')users-service/src에서 두 단계 위로 올라가 proto 폴더를 찾는 경로입니다. 필요에 따라 조정하세요.
  • url: gRPC 서버가 바인딩될 주소입니다.
  • loader: proto-loader의 추가 옵션으로, 데이터 타입 변환 및 필드명 처리 방식을 설정할 수 있습니다.

단계 3: 사용자 마이크로서비스 컨트롤러 (gRPC 핸들러) 구현

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

// users-service/src/users/users.controller.ts (수정)
import { Controller } from '@nestjs/common';
import { GrpcMethod } from '@nestjs/microservices'; // GrpcMethod 임포트
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) {
      // gRPC는 에러 코드와 메시지를 통해 에러를 전달합니다.
      // throw new RpcException({ code: status.NOT_FOUND, message: 'User not found' });
      // 간단한 예제에서는 null 반환 또는 특정 값으로 대체
      return { id: 0, name: '', email: '' }; // .proto에서 non-nullable이므로 빈 객체 반환
    }
    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 파일에 정의된 응답 메시지의 타입과 일치해야 합니다.

단계 4: 사용자 서비스 구현 (동일)

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

단계 5: UsersModuleAppModule 구성 (동일)

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

주문 서비스 구축

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

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

npm install @nestjs/microservices @grpc/grpc-js @grpc/proto-loader rxjs
npm install --save-dev @types/grpc__proto-loader

단계 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 클라이언트를 설정합니다.
  • protoPath, package, url, loader 옵션은 서버 설정과 동일하게 연결할 gRPC 서비스의 정보를 정확히 지정해야 합니다.

단계 3: 주문 서비스 컨트롤러 (gRPC 클라이언트 호출)

ClientGrpc 인터페이스와 @GrpcService() 데코레이터를 사용하여 gRPC 서비스를 호출합니다.

// orders-service/src/orders/orders.controller.ts (수정)
import { Controller, Get, Post, Body, Param, Inject, OnModuleInit } from '@nestjs/common';
import { ClientGrpc } from '@nestjs/microservices'; // ClientGrpc 임포트
import { OrdersService } from './orders.service';
import { Observable } from 'rxjs'; // Observable 임포트
import { toPromise } from 'rxjs-await'; // toPromise 임포트 (RxJS 6+에서 toPromise()는 직접 제공되지 않을 수 있음)

// toPromise()가 없으면 rxjs/operators의 lastValueFrom()을 사용할 수 있습니다.
import { lastValueFrom } from 'rxjs';

// .proto 파일에 정의된 서비스 인터페이스 정의
interface UserService {
  GetUserById(data: { id: number }): Observable<User>; // gRPC 메서드명과 매핑
  CreateUser(data: { name: string; email: string }): Observable<User>;
}

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

@Controller('orders')
export class OrdersController implements OnModuleInit {
  private userService: UserService; // gRPC 서비스 프록시를 저장할 변수

  constructor(
    private readonly ordersService: OrdersService,
    @Inject('USERS_SERVICE') private readonly client: ClientGrpc, // gRPC 클라이언트 주입
  ) {}

  // 모듈 초기화 시 gRPC 서비스 프록시를 가져옵니다.
  onModuleInit() {
    // getService<T>('ServiceName')을 통해 gRPC 서비스의 프록시를 가져옵니다.
    this.userService = this.client.getService<UserService>('UserService');
  }

  @Get(':orderId/user')
  async getOrderUser(@Param('orderId') orderId: string): Promise<User | string> {
    const userId = parseInt(orderId, 10);

    try {
      // gRPC 서비스 메서드 호출
      const user = await lastValueFrom(this.userService.GetUserById({ id: userId }));
      // gRPC는 존재하지 않는 경우 필드 기본값을 반환할 수 있으므로, 유효성 검사 필요
      if (!user || user.id === 0) { // 예: id가 0이면 찾지 못했다고 가정
        return `User for order ${orderId} not found in Users gRPC Service.`;
      }
      return user;
    } catch (error) {
      console.error('gRPC Error:', error.details);
      return `Error fetching user from gRPC service: ${error.details || error.message}`;
    }
  }

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

    let user: User;
    if (userId) { // 기존 사용자 조회
      try {
        user = await lastValueFrom(this.userService.GetUserById({ id: userId }));
        if (!user || user.id === 0) { // 사용자가 없거나 유효하지 않다면
          return { message: `User with ID ${userId} not found in gRPC service.` };
        }
      } catch (error) {
        console.error('gRPC Error:', error.details);
        return { message: `Error fetching user from gRPC service: ${error.details || error.message}` };
      }
    } else if (userName && userEmail) { // 새 사용자 생성
      try {
        user = await lastValueFrom(this.userService.CreateUser({ name: userName, email: userEmail }));
      } catch (error) {
        console.error('gRPC Error:', error.details);
        return { message: `Error creating user via gRPC service: ${error.details || error.message}` };
      }
    } else {
      return { message: 'Either userId or (userName and userEmail) must be provided.' };
    }

    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에서 등록한 USERS_SERVICE 클라이언트를 주입받습니다. ClientGrpc는 gRPC 클라이언트의 인스턴스입니다.
  • this.client.getService<UserService>('UserService'): 주입받은 ClientGrpc 인스턴스에서 .proto 파일에 정의된 UserService의 프록시 객체를 가져옵니다. 이 프록시 객체는 .proto에 정의된 메서드들을 (GetUserById, CreateUser 등) 가지고 있습니다.
  • this.userService.GetUserById({ id: userId }): 프록시 객체를 통해 gRPC 메서드를 호출합니다. 이 메서드는 Observable을 반환하므로, lastValueFrom()을 사용하여 Promise로 변환하여 비동기 처리합니다.
  • gRPC는 에러 발생 시 RpcException을 발생시키며, details 속성으로 상세 메시지를 전달할 수 있습니다.

단계 4: 주문 서비스 구현 (동일)

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

단계 5: AppModuleOrdersModule 임포트 (동일)

이전과 동일하게 OrdersModuleAppModule에 임포트합니다.


실행 및 테스트

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":"alice@example.com"}
    • 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": "charlie@example.com",
        "item": "Keyboard"
      }
    • 응답: {"order":{"id":1,"userId":3,"item":"Keyboard","createdAt":"2023-06-23T...Z"},"user":{"id":3,"name":"Charlie","email":"charlie@example.com"}}
    • Users Service 콘솔: Users gRPC Service: Received request to create user: {"name":"Charlie","email":"charlie@example.com"} 메시지가 출력되는 것을 확인합니다.

이 예시는 NestJS를 사용하여 gRPC 기반의 마이크로서비스를 구축하고 서비스 간 통신을 구현하는 방법을 보여줍니다. .proto 파일을 통한 명확한 인터페이스 정의와 고성능 HTTP/2 통신은 대규모 분산 시스템에서 큰 장점을 제공합니다.

gRPC는 특히 내부 서비스 간 통신에 강력하며, 외부 클라이언트(브라우저)와 통신하기 위한 API Gateway나 gRPC-Web과 같은 추가적인 계층이 필요할 수 있습니다. 마이크로서비스 아키텍처의 복잡성 속에서 NestJS와 gRPC는 안정적이고 효율적인 통신 인프라를 제공하는 데 기여합니다.

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