NestJS

Implementation gRPC MSA with Separated Projects in NestJS

kahnco 2023. 1. 31. 15:28

개요

 

예전부터 MSA 아키텍처로 서버를 구성해보고 싶다는 생각이 들었는데, 좋은 기회가 있어서 구현해보게 되었습니다.

 

다양한 프레임워크 중에서도 제가 그나마 친숙한 NestJS로 진행해보고자 하는데, NestJS는 한국어로 잘 번역된 공식 문서도 있고 프레임워크에 MicroServices 파트가 공식적으로 지원하기 때문입니다.

 

정식으로 MSA 아키텍처를 구현해보기 전에, NestJS 공식 도큐먼트에 있는 간단한 MSA 예제를 먼저 실습해보았습니다. 아직 gRPC 프로토콜에 대해서 제대로 이해하지를 못해서, 이해를 위한 과정입니다.

 

여러 삽질과 구글링 끝에 MSA 실습을 성공하였고, 그 과정에서 생긴 오류와 해결 과정을 공유하기 위해서 이 글을 작성하게 되었습니다.

 


문제

NestJS 공식 도큐먼트에서 제공하는 gRPC를 활용한 MSA 예제를 그대로 따라해보아도 오류가 나면서 제대로 실행이 되지 않습니다.

 

Documentation | NestJS - A progressive Node.js framework

Nest is a framework for building efficient, scalable Node.js server-side applications. It uses progressive JavaScript, is built with TypeScript and combines elements of OOP (Object Oriented Progamming), FP (Functional Programming), and FRP (Functional Reac

docs.nestjs.com

 

게다가 해당 예제는 하나의 프로젝트 내부에서, service를 분리하고 이를 NestFactory에서 Microservice를 활용해서 서비스끼리의 통신을 처리하는 방식이여서, 프로젝트를 여러 개 생성하고 각각의 프로젝트들끼리 gRPC로 통신하고자 하는 제 목적과는 맞지 않았습니다.

 

그래서 해당 예제를 참조만 하고, 2개의 프로젝트를 생성해서 Server (gRPC 데이터를 제공하는 쪽), Client (gRPC 데이터를 제공받는 쪽) 으로 구분지어 구현하였습니다.

 


Server 구현

 

먼저 nest cli를 통해서 새로운 프로젝트를 만들어줍니다.

// path: /{Client 프로젝트 생성을 희망하는 경로}
nest new msa-server

 

생성된 파일 중에서, 최상단 디렉터리에 있는 nest-cli.json을 다음과 같이 수정해줍니다. gRPC 통신을 위해서는 proto라는 파일을 통해서 프로토콜 타입을 지정해줘야하는데, 이 파일들을 컴파일러에게 고지해주는 옵션입니다.

// path: /{Project Root}/nest-cli.json
{
  "$schema": "https://json.schemastore.org/nest-cli",
  "collection": "@nestjs/schematics",
  "sourceRoot": "src",
  // 아래 부분을 추가해주었습니다
  "compilerOptions": {
    "assets": ["**/*.proto"],
    "watchAssets": true
  }
}

 

그리고, main.ts와 같은 경로에, 다음 파일을 생성해줍니다. (없는 모듈들은 직접 설치해주시면 됩니다)

// path: /{Project Root}/src/grpc.options.ts
import { ClientOptions, Transport } from '@nestjs/microservices';
import { join } from 'path';

export const grpcClientOptions: ClientOptions = {
  transport: Transport.GRPC,	// 서비스 간의 통신 프로토콜을 gRPC로 설정
  options: {
    url: 'localhost:50051',		// 이 서비스의 url
    package: 'hero',			// 이 서비스의 패키지 명 (다른 서비스에서 인덱싱 할때에 필요)
    protoPath: join(__dirname, './hero/hero.proto'),
  },
};

 

그리고, main.ts를 다음과 같이 수정해줍니다.

// path: /{Project Root}/src/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { grpcClientOptions } from './grpc.options';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.connectMicroservice(grpcClientOptions);
  await app.startAllMicroservices();
}
bootstrap();

앞에서 설정한 Options으로 MicroService를 실행시키는 코드입니다.

 

그리고, src 하위에 hero라는 이름의 폴더를 생성해줍니다. hero 라는 서비스에 대한 동작을 정의할 폴더입니다.

// path: /{Project Root}/src
mkdir hero

 

생성한 hero 폴더로 이동해서, hero.proto 라는 파일을 생성해줍니다. gRPC는 일반 Http와 다르게, 자체 프로토콜을 사용하는데 이 프로토콜에서 데이터 객체 타입 및 함수를 정의하는 파일입니다.

// path: /{Project Root}/src/hero/hero.proto
syntax = "proto3";		// 생략하면 proto2로 잡힙니다

package hero;			// 사용할 패키지를 정의합니다. (아까 생성한 grpc-hero.options.ts 파일 참조)

service HeroService {	// 사용할 서비스명과 함수명을 정의합니다.
  rpc FindOne (HeroById) returns (Hero) {}
}

message HeroById {		// 함수에서 사용할 req, res 들의 타입을 정의합니다.
  int32 id = 1;
}

message Hero {
  int32 id = 1;
  string name = 2;
}

 

다음으로, interfaces라는 폴더를 생성해줍니다. 이 폴더는, 위에서 message로써 정의한 HeroByIdHero 타입을 NestJS에서 사용할 수 있는 타입으로 정의하기 위한 파일들이 존재하는 폴더입니다.

// path: /src/hero
mkdir interfaces
// path: /src/hero/interfaces/hero.interface.ts
export interface Hero {
  id: number;
  name: string;
}
// path: /src/hero/interfaces/hero-by-id.interface.ts
export interface HeroById {
  id: number;
}

 

다음으로, HeroController를 생성해줍니다. 여기서는 아까 hero.proto에서 추상화시킨 servicemethod를 구체화시키는 과정이 필요합니다.

// path: /{Project Root}/src/hero/hero.controller.ts
import { Controller } from '@nestjs/common';
import { GrpcMethod } from '@nestjs/microservices';
import { HeroById } from './interfaces/hero-by-id.interface';
import { Hero } from './interfaces/hero.interface';

@Controller()
export class HeroController {
  // @GrpcMethod('HeroService')
  @GrpcMethod('HeroService', 'FindOne')
  findOne(data: HeroById): Hero {
    const items: Hero[] = [
      { id: 1, name: 'Damon' },
      { id: 2, name: 'Ran' },
    ];
    return items.find(({ id }) => id === data.id);
  }
}

이 때에 눈여겨 보아야할 부분이 @GrpcMethod 이 어노테이션인데, 주석으로 된 부분으로 사용해도 가능합니다. 이유는 해당 어노테이션을 컴파일 시킬 때, NestJS MicroServices 쪽에서 @GrpcMethod 어노테이션의 파라미터 중에서 두번째 값이 없으면 자동으로 어노테이션이 붙은 함수의 이름을 파스칼 네이밍으로 바꿔서 인식하기 때문입니다. (findOne -> FindOne)

 

그리고, 이 Controller를 Module 쪽에 선언해줍니다.

// path: /{Project Root}/src/hero/hero.module.ts

import { Module } from '@nestjs/common';
import { HeroController } from './hero.controller';

@Module({
  controllers: [HeroController],
})
export class HeroModule {}

 

위의 HeroModuleAppModule에 Import 시켜줍니다.

// /{Project Root}/src/app.module.ts
import { Module } from '@nestjs/common';
import { HeroModule } from './hero/hero.module';

@Module({
  imports: [HeroModule],
  controllers: [],
  providers: [],
})
export class AppModule {}

 

여기까지 왔으면, Server 쪽에서 해야할 작업은 끝입니다. app.service.ts, app.controller.ts 파일들은 삭제해주셔도 됩니다. 삭제한 디렉터리 전체 구조는 다음과 같습니다.

 

 

다음으로 Client 쪽 작업을 진행해보겠습니다.

 


Client

 

Client로 사용할 프로젝트를 생성해줍니다.

// path: /{Client 프로젝트 생성을 희망하는 경로}
nest new msa-client

 

nest-cli.json 파일을 아까와 동일하게 수정해줍니다.

// path: /{Proejct Root}/src/nest-cli.json
{
  "$schema": "https://json.schemastore.org/nest-cli",
  "collection": "@nestjs/schematics",
  "sourceRoot": "src",
  "compilerOptions": {
    "assets": ["**/*.proto"],
    "watchAssets": true
  }
}

 

그리고, server에서 생성했던 src 하위의 모든 파일들을 동일하게 복사해줍니다.

Client 맞습니다. Server 아닙니다

 

수정되는 부분은 hero.controller.ts 와 main.ts 두 개입니다.

// path: /{Project Root}/src/hero/hero.controller.js
import { Controller, Get, OnModuleInit, Param } from '@nestjs/common';
import { Client, ClientGrpc } from '@nestjs/microservices';
import { Observable } from 'rxjs';
import { grpcClientOptions } from '../grpc-hero.options';

// Hero Server에서 사용하는 서비스를 추상화시켜놓는 코드입니다.
interface HeroService {
  findOne(data: { id: number }): Observable<any>;
}

@Controller('hero')
export class HeroController implements OnModuleInit {
  @Client(grpcClientOptions) private readonly client: ClientGrpc;
  private heroService: HeroService;

  onModuleInit() {
    // 아래 코드를 통해서 실제 Hero Server에서 사용하는 함수를 구체화시킬 수 있습니다.
    // 괄호 간의 값은 Hero Server에서 생성한 hero.proto 파일에서 service 네임과 동일해야합니다
    this.heroService = this.client.getService<HeroService>('HeroService');
  }

  @Get(':id')
  call(@Param() params): Observable<any> {
    return this.heroService.findOne({ id: +params.id });
  }
}
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  await app.listen(3000);
}
bootstrap();

 


Test

 

이렇게 구현해놓고, 테스트를 진행해보겠습니다.

 

먼저, Server를 다음 명령어로 실행시켜줍니다.

npm run start

Server 가동 성공

다음으로, Client를 다음 명령어로 실행시켜줍니다.

npm run start

Client 가동 성공

 

이제, Postman으로 Client 쪽에 요청을 보내봅시다.

 

1번 인덱스의 유저
2번 인덱스의 유저

 

위 그림과 같이 정상적으로 통신됨을 확인했습니다.

수고하셨습니다.

반응형