회사에서는 고객의 별도의 요구가 있지 않을 경우 API 서버는 MSA (Microservices Architecture 이하 MSA)로 구성합니다.
서버는 대부분 한개의 테이블을 가지고, 하나의 테이블은 One Table Design Pattern으로 구성한 여러 모델의 데이터 레코드로 구성 합니다.
데이터베이스는 DynamoDB를 사용합니다. DynamoDB에 대해서는 나중에 다른 글에서 한번 이야기 해 볼까 합니다.
이와같은 구성을 주로 가져가는 이유는, 일반적인 MSA의 장점도 있지만, 각각의 기능별로 분리하여 개발하면서, task의 복잡성을 조금 더 단순화 시킬 수 있고, 개발 과정중의 집중도를 높일 수 있습니다. 코드의 재활용도 높일 수 있을 것이라 예상 됩니다.
MSA의 장점 단점, 여러가지 구성방법 등에 대해서는 이미 많은 글들이 있으니 건너뛰고, MSA의 Gateway에 대해 집중적으로 기술하도록 하겠습니다.
MSA를 구성할때 Gateway를 두는 이유는 다음과 같습니다.
위 내용은 Chat GPT가 설명한 내용을 요약 한 것입니다.
제가 생각하는 Gateway의 가장 큰 장점은 유연성 입니다.
서비스를 운영하다보면 서버의 변경 업그레이드 개발 등이 일어나면서, 파라메터가 변경되기도 하고, API 기능이 분리 또는 병합 되기도 하고, 구버전의 클라이언트와 신 버전의 클라이언트가 같이 운영되기도 하는 등 변수 대해 Gateway가 많은 역할을 할 수 있습니다.
그래서 Gateway를 구성 합니다.
모든 백엔드 서버는 일반적인 HTTP Method(GET, POST, PUT, PATCH, DELETE 등)의 API를 구현 합니다.
gateway는 Client의 요청을 받아 각각의 서버에 Http 통신으로 전달하는 역할만 하게 합니다.
백앤드 API서버들 간의 통신도 HTTP 통신으로 운영 합니다.
이런 간단한 기능이 필요할때는 Express Gateway를 사용 합니다.
개발 없이, 설정만으로 필요한 gateway 기능을 훌륭히 수행 하고, 유용한 부가 기능들도 많습니다.
요청되는 URL만으로 요청을 분기 시켜 각 서버에 전달 하는것이 기본이기 때문에, 분기에 대한 기본 설정 이외에는 작업이 없습니다. 요청과 결과 반환이 1:1일 경우와, 요청되는 parameter를 backend서버에 그대로 전달 할 경우 매우 유용 합니다.
gateway서버에서 보다 많은 역할이 필요하게 되어 최근에, Nest를 이용한 MSA를 구성하게 되었습니다.
gateway 서버는 클라이언트로부터 HTTP로 요청을 받고, 백앤드 API 서버와는 TCP로 통신하는 방식 입니다.
gateway는 클라이언트로부터 HTTP request를 받아, Command와 Payload로 구성된 객체를 만들어 각각 API 서버로 메시지를 보내는 방식으로 이루어 집니다.
실제 프로젝트에서는 이러한 MSA 구성을 도입하면서, API 서버의 설계 개념을 조금 수정하게 되었습니다.
CQRS를 같이 도입하여, gateway가 보내는 message를 command로 처리하는 방식으로 구성 하였습니다.
api 서버에서 처리해야할 모든 서비스를 작은 command들로 나누어 준비하고,
gateway에서 받은 메시지는 한개 또는 여러개의 command를 실행 합니다. 이때 sync로 필요한 부분만 먼저 처리하여 response 하고, async로 처리해도 되는 부분들은 이후에 event를 통해 처리 하게 구성 합니다.
실제로 Nest를 이용하여 MSA 구성해보도록 하겠습니다.
먼저 hello 메시지에 응답하는 서비스를 제공하는 서버를 만듭니다.
pnpm --package=@nestjs/cli@latest dlx nest new nest-hello
# 또는
yarn dlx -p @nestjs/cli@latest nest new nest-hello
# 또는
npm i -g @nestjs/cli
nest new nest-hello
#
# 최근에 package 메니저를 pnpm으로 변경하였습니다. 이후로는 pnpm 명령만으로 예제를 구성 합니다.
#
사용하는 패키지 메니저를 선택하면 프로젝트가 생성 됩니다.
microservice에 필요한 패키지를 설치 합니다.
pnpm add @nestjs/microservices @nestjs/config
# .env
MS_PORT=8001
MS_HOST=127.0.0.1
환경변수를 읽어 객체를 구성하는 config.service.ts 파일을 생성 합니다.
//
// src/config/config.service.ts
//
import { Transport } from '@nestjs/microservices';
export class ConfigService {
private readonly envConfig: { [key: string]: any } = {};
constructor() {
this.envConfig.service = {
transport: Transport.TCP,
options: {
host: process.env.MS_HOST,
port: +process.env.MS_PORT,
},
};
}
get(key: string): any {
return this.envConfig[key];
}
}
.env 파일을 읽을 수 있도록 app.moldule.ts 에 ConfigModule을 import 합니다.
//
// src/app.module.ts
//
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { ConfigModule } from '@nestjs/config';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
}),
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
MSA TCP서버로 실행되도록 main.ts 파일을 구성 합니다.
//
// src/main.ts
//
import { NestFactory } from '@nestjs/core';
import { MicroserviceOptions } from '@nestjs/microservices';
import { AppModule } from './app.module';
import { ConfigService } from './config/config.service';
import { Logger } from '@nestjs/common';
async function bootstrap() {
const config = new ConfigService();
const options = config.get('service');
const app = await NestFactory.createMicroservice<MicroserviceOptions>(
AppModule,
options,
);
await app.listen();
Logger.log(
`🚀 Application is running on: TCP ${JSON.stringify(options)}`,
'bootstrap-msa',
);
}
bootstrap();
실행화면
이제 TCP 요청을 처리할 수 있는 서버가 실행 되었습니다.
가장 아래 라인에는 실행 된 환경변수 값을 확인 할 수 있습니다.
127.0.0.1 호스트에서 TCP 8001 포트를 listen 합니다.
실제로 메시지를 수신하여 처리하는 콘트롤러를 구성 합니다.
//
// src/app.controller.ts
//
import { Controller } from '@nestjs/common';
import { AppService } from './app.service';
import { MessagePattern } from '@nestjs/microservices';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@MessagePattern({ cmd: 'hello' })
executeHello(name: string): string {
return `hello ${name}`;
}
}
메시지로 { cmd: 'hello' }, payload로 name 을 받아 'hello 이름' 을 return 하는 서버를 만들었습니다.
(실제로 사용하는 코드에 가깝게 구성 하느라 샘플이 약간 복잡해졌습니다. 간단한 샘플은 공식 문서를 참고하세요)
서버를 생성하고 필요한 패키지를 설치 합니다.
pnpm --package=@nestjs/cli@latest dlx nest new nest-gateway
cd nest-gateway
pnpm add @nestjs/microservices @nestjs/config
microservice api 서버와 유사하게 configuration을 설정 합니다.
# .env
PORT=8800
HELLO_SERVICE_NAME=HELLO_SERVICE
HELLO_SERVICE_HOST=127.0.0.1
HELLO_SERVICE_PORT=8001
환경설정 처리를 위한 코드를 만듭니다.
//
// src/config/config.service.ts
//
import { Transport } from '@nestjs/microservices';
export class ConfigService {
private readonly envConfig: { [key: string]: any } = {};
constructor() {
this.envConfig.port = +process.env.PORT || 3000;
this.envConfig.helloService = {
name: process.env.HELLO_SERVICE_NAME,
transport: Transport.TCP,
options: {
host: process.env.HELLO_SERVICE_HOST,
port: +process.env.HELLO_SERVICE_PORT,
},
};
}
get(key: string): any {
return this.envConfig[key];
}
}
.env에 PORT로 설정한 값으로 gateway를 실행 합니다.
//
// src/app.main.ts
//
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ConfigService } from './config/config.service';
import { Logger } from '@nestjs/common';
async function bootstrap() {
const config = new ConfigService();
const port = config.get('port');
const app = await NestFactory.create(AppModule);
await app.listen(port);
Logger.log(
`🚀 Application is running on: http://localhost:${port}`,
'bootstrap',
);
}
bootstrap();
dot env 를 읽어서 처리하도록 ConfigModule을 import 하였고,
hello microservice 서버에 메시지를 전송할 수 있도록 프로바이더를 구성 합니다.
//
// src/app.module.ts
//
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { ConfigModule } from '@nestjs/config';
import { ConfigService } from './config/config.service'; //
import { ClientProxyFactory } from '@nestjs/microservices';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
}),
],
controllers: [AppController],
providers: [
//
ConfigService,
{
provide: 'HELLO_SERVICE',
useFactory: (configService: ConfigService) => {
const options = configService.get('helloService');
return ClientProxyFactory.create(options);
},
inject: [ConfigService],
},
//
AppService,
],
})
export class AppModule {}
gateway 환경설정을 하고 실행 합니다.
pnpm start:dev
.env 파일에 PORT로 설정한 8800포트로 정상적으로 실행된것을 확인할 수 있습니다.
이제 클라이언트의 요청을 받아 hello microservice 와 통신하는 부분을 생성 합니다.
//
// src/app.controller.ts
//
import { Controller, Get, Inject, Query } from '@nestjs/common';
import { ClientProxy } from '@nestjs/microservices';
import { Observable } from 'rxjs';
@Controller()
export class AppController {
constructor(
@Inject('HELLO_SERVICE')
private readonly helloProxy: ClientProxy,
) {}
@Get('hello')
getHello(@Query('name') name: string): Observable<string> {
return this.helloProxy.send({ cmd: 'hello' }, name);
}
}
getHello 함수에서는 클라이언트로부터 name을 파라메터로 받아, hello microservices로 데이터를 전송하고 return 받은 결과를 다시 client로 반환합니다.
콘솔에서 gateway로 hello를 호출합니다.
결과값으로 'Hello hohoho'가 출력되었습니다.
gateway는 클라이언트로부터 받은 http 요청을 TCP 프로토콜로 변환하여, hello microservice에 api를 호출하고 결과를 받아 결과값을 반환 합니다.
그런데 이 환경으로 api 개발을 진행하려면, 최소한 gateway 서버와 작업하고 있는 서버 두개 띄우고 양 서버에서 동시에 작업을 해야 합니다.
개발 진행 과정에서 이런 환경은 불편 합니다. 그래서, 개발하는 서버가 직접 HTTP요청을 받아 자기 자신의 TCP로 메시지를 보내 처리하도록 하면, 하나의 서버에서 모든 작업을 할 수 있고, 개발이 완료되면 그다음에 gateway에 적용 하도록 하면 이런 불편함이 많이 줄어 듭니다.
hello (TCP)microservice 서버를 하이브리드 방식으로 수정하도록 하겠습니다.
먼저 dot env에 http로 listen 할 포트를 추가합니다.
##
## .env
##
PORT=8101 #추가
MS_PORT=8001
MS_HOST=127.0.0.1
다음은 config.service 에 http 포트값을 추가 합니다.
//
// src/config/config.service.ts
//
import { Transport } from '@nestjs/microservices';
export class ConfigService {
private readonly envConfig: { [key: string]: any } = {};
constructor() {
this.envConfig.port = +process.env.PORT || 3000; //추가
this.envConfig.service = {
transport: Transport.TCP,
options: {
host: process.env.MS_HOST,
port: +process.env.MS_PORT,
},
};
}
get(key: string): any {
return this.envConfig[key];
}
}
bootstrap 에 TCP microservice와 http 서비스를 모두 실행할 수 있도록 구성 합니다.
//
// src/main.ts
//
import { NestFactory } from '@nestjs/core';
import { MicroserviceOptions } from '@nestjs/microservices';
import { AppModule } from './app.module';
import { ConfigService } from './config/config.service';
import { Logger } from '@nestjs/common';
async function bootstrap() {
const config = new ConfigService();
const options = config.get('service');
const port = config.get('port');
const app = await NestFactory.create(AppModule);
app.connectMicroservice<MicroserviceOptions>(options);
await app.startAllMicroservices();
await app.listen(port);
Logger.log(
`🚀 Application is running on: TCP ${JSON.stringify(
options,
)} with http ${port} port`,
'bootstrap-hybrid',
);
}
bootstrap();
app.module에 gateway에서와 동일하게 client proxy provider를 설정 합니다.
//
// app.module.ts
//
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { ConfigModule } from '@nestjs/config';
import { ConfigService } from './config/config.service';
import { ClientProxyFactory } from '@nestjs/microservices';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
}),
],
controllers: [AppController],
providers: [
ConfigService,
{
provide: 'HELLO_SERVICE',
useFactory: (configService) => {
const options = configService.get('service');
return ClientProxyFactory.create(options);
},
inject: [ConfigService],
},
AppService,
],
})
export class AppModule {}
app.controller를 gateway의 app.controller와 동일하게 http 요청을 받을 수 있도록 만듭니다.
//
// src/app.controller.ts
//
import { Controller, Get, Inject, Query } from '@nestjs/common';
import { ClientProxy, MessagePattern } from '@nestjs/microservices';
import { Observable } from 'rxjs';
@Controller()
export class AppController {
constructor(
@Inject('HELLO_SERVICE')
private readonly helloProxy: ClientProxy,
) {}
@MessagePattern({ cmd: 'hello' })
executeHello(name: string): string {
return `hello ${name}`;
}
// 추가
@Get('hello')
getHello(@Query('name') name: string): Observable<string> {
return this.helloProxy.send({ cmd: 'hello' }, name);
}
}
app.controller는 HTTP의 /hello 요청을 받아 TCP service인 excuteHello로 재전송 합니다.
이렇게 구성하면, 개발중에는 서버 한개만을 띄워놓고 모든 개발을 완료할 수 있습니다.
본 예제에서는, app.controller 한곳에 http 요청과 TCP Message 요청을 모두 처리하도록 구성하였습니다.
실제 프로젝트에서는,
gateway 와 microservice 서버를 하나의 모노레포에 위치시키고,
http 요청을 받는 controller는 공유라이브러리에 구성 하였습니다.
공유라이브러리의 controller를 gateway와 개발중이 microservice 모두에 포함시켜 소스를 공유하는것이 중복작업과 오류 처리에 보다 도움이 됩니다.
gateway를 통한 요청, hello microservice에 직접 요청 모두 정상적으로 출력되는것을 확인할 수 있습니다.
이번 블로그에서는 gateway와 microservice와의 통신은 sync 방식만 소개 하였습니다. response 없이 async로 동작하는 Event 메시지 방식도 크게 다르지 않습니다. 이 부분은 공식 문서를 참고하시면 좋을것 같습니다.
Nest에서는 다양한 종류의 통신 방법을 제공하고 있고, 동시에 여러가지 통신을 하이브리드로 구성할 수 있습니다.
Nest의 microservice는 통신에 redis를 이용하거나, 다양한 message queue 솔루션을 이용하는 등 지속적으로 확장해 나갈수 있는 많은 장점을 가지고 있습니다.
하지만 점점 Nest에 종속 되어간다는 느낌이...
많은 도움이 되었습니다, 감사합니다.