개발일지
Nest.js MSA + Docker로 실행
index.ys
2023. 11. 25. 21:03
구조
- index.ts에서는 앱에서 공통적으로 사용할 모듈을 정의함 Jwt 모듈, RabbitMQ, 데이터베이스 모듈 등
- Application은 각각의 독립된 컨테이너 환경에서 빌드되고 실행됨 , 의존성을 분리하여 새로운 기능을 추가하거나 유지, 보수하기에 효율적인 구조
- 특정이벤트게 발생하면 RabbitMQ를 호출하여 데이터베이스 관련 작업을 비동기적으로 처리하고, 지정한 이벤트를 비동기적으로 처리하도록 구현 => 카카오톡 알림, 배송 등
orders
orders.controller.ts
- 주문발생시 createOrder메서드 실행
import { Body, Controller, Get, Post, Req, UseGuards } from '@nestjs/common';
import { JwtAuthGuard } from '@app/common';
import { CreateOrderRequest } from './dto/create-order.request';
import { OrdersService } from './orders.service';
@Controller('orders')
export class OrdersController {
constructor(private readonly ordersService: OrdersService) {}
@Post()
@UseGuards(JwtAuthGuard)
async createOrder(@Body() request: CreateOrderRequest, @Req() req: any) {
//request에는 body로 들어온 요청 데이터가 있음 , req에는 요청 메타데이터가 들어있음
//req.cookies?.Authentication문법은 요청쿠키가 존재하지 않을때 에러를 발생시키지 않고
//Authentication에 undefined를 할당함
return this.ordersService.createOrder(request, req.cookies?.Authentication);
}
@Get()
async getOrders() {
return this.ordersService.getOrders();
}
}
Order.service.ts
- controller에서 전달된 요청발생의 비즈니스 로직을 수행함
- ordersRepository에서 create메서드를 실행하여 전달된 데이터를 데이터베이스에 저장하는 로직
- 이후 billingClient.emit 메서드로 메세지 큐를 호출하여 order_created로 등록된 이벤트를 처리함
import { Inject, Injectable } from '@nestjs/common';
import { ClientProxy } from '@nestjs/microservices';
import { lastValueFrom } from 'rxjs';
import { BILLING_SERVICE } from './constants/services';
import { CreateOrderRequest } from './dto/create-order.request';
import { OrdersRepository } from './orders.repository';
@Injectable()
export class OrdersService {
constructor(
private readonly ordersRepository: OrdersRepository,
@Inject(BILLING_SERVICE) private billingClient: ClientProxy,
) {}
//createOrder 메서드는 주문을 생성하고 RabbitMQ를 통해 order_created 이벤트를 발송
async createOrder(request: CreateOrderRequest, authentication: string) {
//주문 생성시 트랜잭션 시작
const session = await this.ordersRepository.startTransaction();
try {
//주문 생성 ordersRepository.create메서드 실행
const order = await this.ordersRepository.create(request, { session });
// RabbitMQ를 통해 'order_created' 이벤트를 전송
// 주문 발생시 특정 이벤트를 비동기로 처리함.
await lastValueFrom(
this.billingClient.emit('order_created', {
request,
Authentication: authentication,
}),
);
//트랜잭션이 에러없이 수행시 트랜잭션 커ㅓ밋
await session.commitTransaction();
return order;
} catch (err) {
//트랜잭션 에러 발생시 트랜잭션 내용 롤백
await session.abortTransaction();
throw err;
}
}
async getOrders() {
//모든 주문 조회 메서드
return this.ordersRepository.find({});
}
}
orders.repository.ts
- AbstractRepository를 확장하여 공통적인 데이터베이스 작업을 추상화함
- 데이터베이스 관련작업을 일관성있게 처리가능
import { Injectable, Logger } from '@nestjs/common';
import { AbstractRepository } from '@app/common';
import { InjectModel, InjectConnection } from '@nestjs/mongoose';
import { Model, Connection } from 'mongoose';
import { Order } from './schemas/order.schema';
@Injectable()
//AbstractRepsitory를 확장하여 공통적인 데이터베이스 작업을 추상화함
export class OrdersRepository extends AbstractRepository<Order> {
protected readonly logger = new Logger(OrdersRepository.name);
constructor(
@InjectModel(Order.name) orderModel: Model<Order>,
@InjectConnection() connection: Connection,
) {
super(orderModel, connection);
}
}
abstract.repository.ts
- order.repository로 들어온 실제 데이터 베이스 작업을 처리함
import { Logger, NotFoundException } from '@nestjs/common';
import {
FilterQuery,
Model,
Types,
UpdateQuery,
SaveOptions,
Connection,
} from 'mongoose';
import { AbstractDocument } from './abstract.schema';
export abstract class AbstractRepository<TDocument extends AbstractDocument> {
protected abstract readonly logger: Logger;
constructor(
protected readonly model: Model<TDocument>,
private readonly connection: Connection,
) {}
async create(
document: Omit<TDocument, '_id'>,
options?: SaveOptions,
): Promise<TDocument> {
const createdDocument = new this.model({
...document,
_id: new Types.ObjectId(),
});
return (
await createdDocument.save(options)
).toJSON() as unknown as TDocument;
}
async findOne(filterQuery: FilterQuery<TDocument>): Promise<TDocument> {
const document = await this.model.findOne(filterQuery, {}, { lean: true });
if (!document) {
this.logger.warn('Document not found with filterQuery', filterQuery);
throw new NotFoundException('Document not found.');
}
return document;
}
async findOneAndUpdate(
filterQuery: FilterQuery<TDocument>,
update: UpdateQuery<TDocument>,
) {
const document = await this.model.findOneAndUpdate(filterQuery, update, {
lean: true,
new: true,
});
if (!document) {
this.logger.warn(`Document not found with filterQuery:`, filterQuery);
throw new NotFoundException('Document not found.');
}
return document;
}
async upsert(
filterQuery: FilterQuery<TDocument>,
document: Partial<TDocument>,
) {
return this.model.findOneAndUpdate(filterQuery, document, {
lean: true,
upsert: true,
new: true,
});
}
async find(filterQuery: FilterQuery<TDocument>) {
return this.model.find(filterQuery, {}, { lean: true });
}
async startTransaction() {
const session = await this.connection.startSession();
session.startTransaction();
return session;
}
}
billing
main.ts
- RabbitMQ옵션을 사용하여 각각의 애플리케이션을 마이크로서비스로 연결하고 모든 마이크로 서비스를 시작함
import { NestFactory } from '@nestjs/core';
import { RmqService } from '@app/common';
import { BillingModule } from './billing.module';
async function bootstrap() {
const app = await NestFactory.create(BillingModule);
//RmqService의 인스턴스를 가져옴
const rmqService = app.get<RmqService>(RmqService);
//RabbitMQ옵션을 사용하여 마이크로 서비스를 연결하고 모든마이크로 서비스를 시작함
app.connectMicroservice(rmqService.getOptions('BILLING'));
await app.startAllMicroservices();
}
bootstrap();
billing.module.ts
import { Module } from '@nestjs/common';
import { RmqModule, AuthModule } from '@app/common';
import * as Joi from 'joi';
import { BillingController } from './billing.controller';
import { BillingService } from './billing.service';
import { ConfigModule } from '@nestjs/config';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
validationSchema: Joi.object({
RABBIT_MQ_URI: Joi.string().required(),
RABBIT_MQ_BILLING_QUEUE: Joi.string().required(),
}),
}),
RmqModule,
AuthModule,
],
controllers: [BillingController],
providers: [BillingService],
})
export class BillingModule {}
billing.controller.ts
- order_created 이벤트의 이벤트핸들러로 동작함
- orders 서비스에서 order_created 이벤트를 호출하면 billing.controller로 발생한 이벤트가 전달되어 이벤트를 메세지 큐에서 처리함
- 여기서는 billing.service의 bill메서드를 전달받은 data를 인자로 넘겨 처리함
import { Controller, Get, UseGuards } from '@nestjs/common';
import { Ctx, EventPattern, Payload, RmqContext } from '@nestjs/microservices';
import { RmqService, JwtAuthGuard } from '@app/common';
import { BillingService } from './billing.service';
@Controller()
export class BillingController {
constructor(
private readonly billingService: BillingService,
private readonly rmqService: RmqService,
) {}
@Get()
getHello(): string {
return this.billingService.getHello();
}
@EventPattern('order_created')
@UseGuards(JwtAuthGuard)
//order_created 이벤트의 이벤트 핸들러로 동작함
//이벤트를 받으면 JwtAuthGuard를 사용하여 인증을 수행하고
//BillingService의 bill 메서드를 호출하여 RabbitMQ메세지를 this.rmqService.ack(context)를 사용하여 확인
async handleOrderCreated(@Payload() data: any, @Ctx() context: RmqContext) {
this.billingService.bill(data);
this.rmqService.ack(context);
}
}
billing.service.ts
- bill메서드 에서 실제 이벤트를 처리함
import { Injectable, Logger } from '@nestjs/common';
@Injectable()
export class BillingService {
private readonly logger = new Logger(BillingService.name);
getHello(): string {
return 'Hello World!';
}
//제공된 데이터와 함꼐 Billing을 로깅함
bill(data: any) {
this.logger.log('Billing...', data);
}
}
rmq
rmq.module.ts
- RabbitMQ 관련 모듈설정
- RmqModuleOptions에서 rmqmodule을 등록할 떄 필요한 옵션 정의, name이라는 프로퍼티 정의
import { DynamicModule, Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { ClientsModule, Transport } from '@nestjs/microservices';
import { RmqService } from './rmq.service';
interface RmqModuleOptions {
//RmqModule 모듈을 등록할때 필요한 옵션 정의
//name이라는 프로퍼티가 필요함
name: string;
}
@Module({
//RmqServcie를 제공함
providers: [RmqService],
//외부 모듈에서 RmqService를 사용가능 하도록 ㅊ ㅜ가
exports: [RmqService],
})
export class RmqModule {
//register 메서드는 동적 모듈을 생성하는 정적메서드,
//RmqModleOptions를 매개변수로 받음
static register({ name }: RmqModuleOptions): DynamicModule {
return {
module: RmqModule,
//ClientsModule.registerAsync를 사용하여 RabbitMQ클라이언트를 등록
imports: [
ClientsModule.registerAsync([
{
//name은 클라이언트의 이름을 지정함
name,
//클라이언트 옵션을 동적으로 생성하는 팩토리 함수를 제공
useFactory: (configService: ConfigService) => ({
transport: Transport.RMQ,
options: {
//RabbitMQ URI를 환경변수에서 가져옴
urls: [configService.get<string>('RABBIT_MQ_URI')],
//MQ의 이름을 설정함
queue: configService.get<string>(`RABBIT_MQ_${name}_QUEUE`),
},
}),
//RabbitMQ의 연결정보를 동적으로 가져옴
inject: [ConfigService],
},
]),
],
//외부에서 ClientsModule을 사용가능 하도록 exports
exports: [ClientsModule],
};
}
}
rmq.service.ts
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { RmqContext, RmqOptions, Transport } from '@nestjs/microservices';
@Injectable()
export class RmqService {
//ConfigServic는 환경설정에 필요한 의존성 주입
constructor(private readonly configService: ConfigService) {}
//getOptions는 RabbitMQ 연결 옵션을 설정하기 위한 메서드
getOptions(queue: string, noAck = false): RmqOptions {
return {
transport: Transport.RMQ,
options: {
urls: [this.configService.get<string>('RABBIT_MQ_URI')],
//quere : RabbitMQ의 이름을 지정함
queue: this.configService.get<string>(`RABBIT_MQ_${queue}_QUEUE`),
//noAck: acknolwedgment를 사용할지 여부를 나타냄 기본값은 false
noAck,
//persistent : 메시지를 지속적으로 유지할지 여부를 나타냄
persistent: true,
},
};
}
//메시지에 대한 acknoewledgment를 처리하는 메서드
ack(context: RmqContext) {
//RabbitMQ 메시지에 대한 acknowledgment를 처리하는 메서드
const channel = context.getChannelRef();
//RabbitMQ에서 수신한 원본 메시지를 가져옴
const originalMessage = context.getMessage();
//RabbitMQ채널을 통해 acknowledgment를 전송
channel.ack(originalMessage);
}
}
index.ts
- 마이크로서비스 아키텍처에서 공통으로 사용되는 여러 컴포넌트를 내보내는 모듈
- MSA 프로젝트에서 각 앱들이 공통적으로 사용할 수 있는 기능들이 정의 되어있음
- 데이터베이스 접근, JWT관련 모듈, RabbitMQ에 대한 모듈
- 코드의 재사용성이 향상되고 일관성 있는 기능을 제공함
//MSA에서 공통으로 사용되는 컴포넌트
export * from './database/database.module';
export * from './database/abstract.repository';
export * from './database/abstract.schema';
export * from './rmq/rmq.service';
export * from './rmq/rmq.module';
export * from './auth/auth.module';
export * from './auth/jwt-auth.guard';
Dockerfile
- 각 애플리케이션 auth , order, billing 에 대한 환경 Docker 환경설정 파일
FROM node:alpine As development
- node : alpine as development => node:alpine 기반의 도커 이미지를 사용하여 두 단계로 빌드하는 다중 단계 빌드를 시작
- 개발 단계로서 앱을 빌드하고 의존성을 설치하기 위함
WORKDIR
- 작업 디렉토리를 /usr/src/app 로 설정함
COPY package*.json ./
- 프로젝트의 package.json 및 package-lock.json 파일을 현재 작업 디렉토리로 복사함
RUN npm install
- npm을 사용하여 애플리케이션의 종속성을 설치 (yarn 가능)
COPY . .
- 나머지 애플리케이션 코드 및 파일을 현재 작업 디렉토리로 복사함
RUN npm run build
- Nest.js 애플리케이션을 빌드함, 타입스크립트 => 자바스크립트로 변환하고 애플리케이션을 실행할 수 있도록 빌드
FROM node:alpine as production
- 애플리케이션을 실행할 때 필요한 최소한의 라이브러리만 포함한 경량화된 Node.js 도커 이미지를 사용
ARG NODE_ENV=production
- Docker 빌드 인수를 사용하여 빌드 시점에 환경변수를 설정함 default : production
ENV NODE_ENV=${NODE_ENV}
- 런타임 환경변수로 NODE=ENV를 설정
WORKDIR /usr/src/app
- 작업 디렉토리를 /usr/src/app으로 설정함
COPY package*.json ./
- 개발 단계에서 이미 복사된 package.json 및 package-lock.json 파일을 현재 작업 디렉토리로 복사함
RUN npm install --only=production
- 실제 배포 환경 애플리케이션 종속성을 설치함 , --only=production플래그는 개발 의존성을 설치하지 않도록함
COPY . .
- 나머지 애플리케이션 코드 및 파일을 현재 작업 디렉토리로 복사함
COPY --from=development /usr/src/app/dist ./dist
- 빌드한 애플리케이션의 dist 폴더를 현재 작업 디렉토리의 dist 폴더로 복사함
- 타입스크립트에서 컴파일된 자바스크립트 코드가 포함되어 있음
CMD ["node", "dist/apps/orders/main"]
- 컨테이너가 시작될 때 실행되는 명령어를 정의함
- 빌드된 애플리케이션을 실행함, node dist/apps/orders/main 명령어를 사용하여 Nest.js 애플리케이션 실행
FROM node:alpine As development
WORKDIR /usr/src/app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
//RUN npm run build billing
//RUN npm run build auth
FROM node:alpine as production
ARG NODE_ENV=production
ENV NODE_ENV=${NODE_ENV}
WORKDIR /usr/src/app
COPY package*.json ./
RUN npm install --only=production
COPY . .
COPY --from=development /usr/src/app/dist ./dist
CMD ["node", "dist/apps/orders/main"]
docker-compose.yml
- services섹션 : 여러 서비스를 정의함, 각 서비스는 독립적인 컨테이너로 실행됨
- orders 서비스 등록
- bulld 섹션에서 현재디렉토리 (context : . )에서 ./apps/orders/Dockerfile 파일을 사용하여 이미지를 빌드하고 빌드대상으로 development를 선택
- command : 에서 npm run start:dev orders 명령을 실행하여 주문 서비스를 개발모드로 실행
- env_file : 환경변수 파일의 경로를 지정
- depneds_on 은 해당 서비스가 의존하는 다른 서비스들을 나열함
- mongodb-primary , mongodb-secondary , mongodb -arbiter , billing , auth , rabbitmq 등
- volumes : 에서는 현재 디렉토리와 노드 모듈 디렉토리를 컨테이너 내에 마운트
- ports : 에서는 호스트포트와 컨테이너 포트를 매핑함
- billing 서비스와 auth 서비스는 orders 서비스와 유사한 구조
- rabbitmq 서비스
- RabbitMQ를 사용하는 서비스를 정의함
- 이미지로 rabbitmq를 사용하며 호스트포트와 컨테이너 포트를 매핑함
- MongoDB를 사용하는 서비스를 정의함
- docker.io/bitnami/mongodb:5.0 이미지를 사용하여 복제셋을 구성하기 위한 환경변수들을 설정함
- vloums 에서는 mongoDB 데이터를 저장하기 위한 볼륨을 설정
- mongodb_master_data라는 볼륨을 정의함, MongoDB의 데이터를 저장하는데 사용
services:
orders:
build:
context: .
dockerfile: ./apps/orders/Dockerfile
target: development
command: npm run start:dev orders
env_file:
- ./apps/orders/.env
depends_on:
- mongodb-primary
- mongodb-secondary
- mongodb-arbiter
- billing
- auth
- rabbitmq
volumes:
- .:/usr/src/app
- /usr/src/app/node_modules
ports:
- '3000:3000'
billing:
build:
context: .
dockerfile: ./apps/billing/Dockerfile
target: development
command: npm run start:dev billing
env_file:
- ./apps/billing/.env
depends_on:
- mongodb-primary
- mongodb-secondary
- mongodb-arbiter
- rabbitmq
- auth
volumes:
- .:/usr/src/app
- /usr/src/app/node_modules
auth:
build:
context: .
dockerfile: ./apps/auth/Dockerfile
target: development
command: npm run start:dev auth
ports:
- '3001:3001'
env_file:
- ./apps/auth/.env
depends_on:
- mongodb-primary
- mongodb-secondary
- mongodb-arbiter
- rabbitmq
volumes:
- .:/usr/src/app
- /usr/src/app/node_modules
rabbitmq:
image: rabbitmq
ports:
- '5672:5672'
mongodb-primary:
image: docker.io/bitnami/mongodb:5.0
environment:
- MONGODB_ADVERTISED_HOSTNAME=mongodb-primary
- MONGODB_REPLICA_SET_MODE=primary
- MONGODB_ROOT_PASSWORD=password123
- MONGODB_REPLICA_SET_KEY=replicasetkey123
volumes:
- 'mongodb_master_data:/bitnami/mongodb'
ports:
- '27017:27017'
mongodb-secondary:
image: docker.io/bitnami/mongodb:5.0
depends_on:
- mongodb-primary
environment:
- MONGODB_ADVERTISED_HOSTNAME=mongodb-secondary
- MONGODB_REPLICA_SET_MODE=secondary
- MONGODB_INITIAL_PRIMARY_HOST=mongodb-primary
- MONGODB_INITIAL_PRIMARY_ROOT_PASSWORD=password123
- MONGODB_REPLICA_SET_KEY=replicasetkey123
mongodb-arbiter:
image: docker.io/bitnami/mongodb:5.0
depends_on:
- mongodb-primary
environment:
- MONGODB_ADVERTISED_HOSTNAME=mongodb-arbiter
- MONGODB_REPLICA_SET_MODE=arbiter
- MONGODB_INITIAL_PRIMARY_HOST=mongodb-primary
- MONGODB_INITIAL_PRIMARY_ROOT_PASSWORD=password123
- MONGODB_REPLICA_SET_KEY=replicasetkey123
volumes:
mongodb_master_data:
driver: local
docker-compose up
- 컴포즈 파일에 정의된 서비스를 시작
- 필요한 경우 이미지를 빌드하고 컨테이너를 실행함
- 이미지를 다시 빌드하지 않고 기존이미지를 사용함
docker-compose up --build -V
- --build 옵션은 컴포즈 파일에 정의된 서비스를 시작하기전에 항상 이미지를 새로 빌드하도록 강제함
- -V 옵션은 모든 볼륨을 리셋하고 모든 데이터를 삭제한 후에 서비스를 시작함
- 볼륨에 저장된 데이터를 모두 삭제하기 때문에 주의해야함 , 개발환경에서 테스트 용도로 사용가능
컨테이너 실행
- 명령어로 도커 컨테이너 실행함
- 애플리케이션에서 Dockerfile에 설정한 환경에서 각 애플리케이션이 개별 컨테이너에서 실행됨
docker-compose up
생성된 이미지
생성된 볼륨
참고 문서
https://www.youtube.com/watch?v=yuVVKB0EaOQ
https://github.com/mguay22/nestjs-rabbitmq-microservices
GitHub - mguay22/nestjs-rabbitmq-microservices
Contribute to mguay22/nestjs-rabbitmq-microservices development by creating an account on GitHub.
github.com