개발일지

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