Node.js/NestJS

[NestJS - Microservice] NestJS로 마이크로서비스 구축해보기(RabbitMQ, MongoDB, Docker) - 2. MongoDB 추가, Orders 모듈 세팅

턴태 2022. 11. 18. 14:43

MongoDB 추가

몽고디비를 사용하여 데이터를 지속적으로 보관할 수 있도록 하겠습니다. 우리의 애플리케이션의 경우는 유저가 생성하는  orders를 보관하기 위해 사용합니다.

 

강좌에서는 이전에 작성한 코드를 그대로 복사 붙여넣기해서, 일단 파일에 어떤 코드가 작성되었는지를 먼저 보겠습니다. MongoDB 관련해서 abstract.repository.ts, abstract.schema.ts, database.module.ts 총 세 개의 파일을 추가할 예정입니다.

 

1. database.module.ts

import { Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { MongooseModule } from '@nestjs/mongoose';

@Module({
  imports: [
    MongooseModule.forRootAsync({
      useFactory: (configService: ConfigService) => ({
        uri: configService.get<string>('MONGODB_URI'),
      }),
      inject: [ConfigService],
    }),
  ],
})
export class DatabaseModule {}

의존성을 주입하기 위해서 MongooseModule을 등록합니다. ConfigModule의 ConfigService를 통해서 .env 파일의 MONGODB_URI 상수를 가져옵니다. 그리고 이를 uri로 등록해줍시다. 지금 패키지를 설치하지 않았으므로 패키지도 같이 설치해주어야 합니다.

yarn add mongoose @nestjs/mongoose @nestjs/config
# npm i mongoose @nestjs/mongoose @nestjs/config

 

2. abstract.schema.ts

import { Prop, Schema } from '@nestjs/mongoose';
import { SchemaTypes, Types } from 'mongoose';

@Schema()
export class AbstractDocument {
  @Prop({ type: SchemaTypes.ObjectId })
  _id: Types.ObjectId;
}

그 다음은 스키마를 정의하는 파일입니다. MongoDB의 스키마를 정의하기 위해 @Schema 데코레이터를 사용하였으며 @Prop 데코레이터로 property를 정의합니다.

 

RDBMS에서 PK로 ID를 정하는 것과 같이 NoSQL인 MongoDB에서는 _id가 고유한 식별자의 역할을 하게 됩니다. 데코레이터 안에 타입을 객체로 넣어서 지정합니다.

 

3. abstract.repository.ts

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;
  }
}

레포지토리에는 기본적으로 갖고 있어야 할 메서드들이 있습니다. 그렇기 때문에 메서드가 대부분 겹치게 되므로, 추상 클래스로 하나의 기반 레포지토리를 만들어주면 좋습니다. 다른 엔티티의 레포지토리에 사용할 템플릿이 되는 겁니다.

 

findOne 메서드를 한 번 자세히 살펴보겠습니다.

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;
  }

findOne은 원하는 데이터 레코드 하나를 조회하기 위한 메서드입니다. findOne에서의 필수적인 매개변수는 filterQuery입니다. 내가 원하는 데이터를 찾아내기 위하여 조건을 부여하는 것입니다. abstract 레포지토리의 findOne를 호출하기 위해서는 filterQuery로 사용할 인자를 반드시 넣어주어야 합니다. 그렇게 하면 내부 프로세스에서 mongoose의 findOne이 실행되며, 인자로 전달한 filterQuery가 사용됩니다.

 

mongoose의 findOne은 총 3가지 매개변수를 가질 수 있는데, filterQuery, projectionType, queryOptions입니다. projectionType에 객체나 문자열을 넣어서 조회할 레코드의 특정 프로퍼티 값만 추출해낼 수 있습니다. 마지막으로 queryOptions에서는 옵션을 선택할 수 있는데, 예를 들어, take와 skip을 활용하여 pagination하거나 sort로 정렬을 할 수도 있습니다. 여기서는 lean을 true로 하여 MongoDB의 도큐먼트가 아니라 자바스크립트의 오브젝트를 도큐먼트로 반환합니다.

 

예외도 같이 처리해주었습니다. 레코드가 존재하지 않는다면 NotFoundException 예외를 일으킵니다.

 

다른 메서드도 같은 방법으로 정의했기 때문에 직접 코드를 작성한 다음 해당 메서드를 클릭하여 패키지에서 뜯어보시면 더 이해하는 데 도움이 될 겁니다.

 

이렇게 정의한 추상 클래스는 extends를 통해서 유용하게 사용합니다. 예를 들어, 유저 레포지토리를 만들고자 한다면 아래와 같이 활용할 수 있습니다.

import { Injectable } from '@nestjs/common';
import { User, UserDocument } from '@app/common/src/abstract.repository';

@Injectable()
export class UserRepository extends AbstractRepository<UserDocument> {
  constructor (@InjectModel(User.name) userModel: Model<UserDocument>) {
  	super(userModel)
  }
}

Abstract의 제너릭이 도큐먼트이므로 extends할 때도 해당 도큐먼트를 제너릭에 넣습니다. constructor에서는 모델을 주입해줍니다. 이때, userModel은 constructor에서만 사용하기 때문에 private이나 readonly옵션을 추가로 작성하지 않았습니다. 추상 클래스의 constructor에서 model 을 필요로 하므로 super로 부모 추상 클래스에 모델을 전달합니다.

 

4. index.ts

이제 앞서 작성한 코드들을 외부에서 사용할 수 있도록 export 해야 합니다. 이를 담당할 중간자 역할로 index.ts를 두었습니다

export * from './database/database.module';
export * from './database/abstract.repository';
export * from './database/abstract.schema';

1, 2, 3 번에 작성한 추상 스키마, 추상 레포지터리, 데이터베이스 모듈은 common/src/database안에 넣어주면 됩니다.

 

여기까지 작성했으면 아래와 같은 디렉터리 구조를 가집니다.

.
├── README.md
├── apps
│   ├── auth
│   │   ├── src
│   │   │   ├── auth.controller.spec.ts
│   │   │   ├── auth.controller.ts
│   │   │   ├── auth.module.ts
│   │   │   ├── auth.service.ts
│   │   │   └── main.ts
│   │   ├── test
│   │   │   ├── app.e2e-spec.ts
│   │   │   └── jest-e2e.json
│   │   └── tsconfig.app.json
│   ├── billing
│   │   ├── src
│   │   │   ├── billing.controller.spec.ts
│   │   │   ├── billing.controller.ts
│   │   │   ├── billing.module.ts
│   │   │   ├── billing.service.ts
│   │   │   └── main.ts
│   │   ├── test
│   │   │   ├── app.e2e-spec.ts
│   │   │   └── jest-e2e.json
│   │   └── tsconfig.app.json
│   └── orders
│       ├── src
│       │   ├── main.ts
│       │   ├── orders.controller.spec.ts
│       │   ├── orders.controller.ts
│       │   ├── orders.module.ts
│       │   └── orders.service.ts
│       ├── test
│       │   ├── app.e2e-spec.ts
│       │   └── jest-e2e.json
│       └── tsconfig.app.json
├── dist
│   └── apps
│       ├── billing
│       │   └── main.js
│       └── orders
│           └── main.js
├── libs
│   └── common
│       ├── src
│       │   ├── database
│       │   │   ├── abstract.repository.ts
│       │   │   ├── abstract.schema.ts
│       │   │   └── database.module.ts
│       │   └── index.ts
│       └── tsconfig.lib.json
├── nest-cli.json
├── package.json
├── tsconfig.build.json
├── tsconfig.json
└── yarn.lock

 

추가적으로 관련된 패키지를 설치하지 않았으므로, mongoose, @nestjs/mongoose, @nestjs/config 패키지를 설치해줍시다.

yarn add mongoose @nestjs/mongoose @nestjs/config
# npm i mongoose @nestjs/mongoose @nestjs/config

메인 애플리케이션(Orders app) 세팅 및 API 구현

 

1. orders.module.ts 설정

먼저 공용 라이브러리에 있는 데이터베이스 관련 파일을 사용하기 위해서는 내부적으로 사용한 Config 모듈부터 임포트 해주어야 합니다. ConfigModule을 등록시켜주기 위해서 .forRoot 메서드를 붙입니다. forRoot 메서드 안에 몇 가지 옵션을 적용할 수 있습니다.

imports: [ConfigModule.forRoot({ 옵션 })]
  • isGlobal: ConfigModule을 전역으로 적용할지 정하는 옵션입니다. true로 설정하여 ConfigModule을 Global 스코프로 사용합니다.
  • validationSchema: 애플리케이션을 실행할 때, 환경 변수에 어떠한 값이 들어가야 하는지 원하는 값을 정의할 수 있으며 유효한지 검사해주는 스키마를 옵션으로 지정합니다. 주로 Joi 모듈을 사용하여 검사합니다.
  • envFilePath: .env 파일의 경로를 지정합니다. 우리의 저장소는 여러 개의 애플리케이션을 가지기 때문에 각 애플리케이션마다 다른 환경변수를 가질 수 있습니다. 따라서, .env 파일을 문자열로 지정해주도록 합시다.

원하는 환경 변수를 지정하고 유효성 검사를 하기 위해 Joi 패키지를 설치합니다.

yarn add joi
# npm i joi

MongoDB와 연결하기 위해 uri를 환경 변수로 저장해야 합니다. 이때, 이 MongoDB uri를 joi를 통해 검사합니다. Joi는 원하는 변수의 타입을 메서드로 지정하면 되기 때문에 편리합니다.

// orders.module.ts

import * as Joi from 'joi';

...

validationSchema: Joi.object({
  MONGODB_URI: Joi.string().required(),
}),

...

그 다음 몽고디비 uri를 환경변수에서 불러와야 하므로 orders 애플리케이션 디렉터리에 .env 파일을 생성합니다. 아직 uri는 적지 않고 파일만 생성합니다. 그후 해당 .env파일의 경로를 옵션으로 적어줍니다.

ConfigModule.forRoot({
  isGlobal: true,
  validationSchema: Joi.object({
    MONGODB_URI: Joi.string().required(),
  }),
  envFilePath: './apps/orders/.env',
}),

이제 옵션 설정이 끝났으므로 DatabaseModule을 임포트 해주면 정상적으로 임포트할 수 있습니다.

import { DatabaseModule } from '@app/common';

  imports: [
  ...
    DatabaseModule,
  ...
  ],

이제 데이터베이스 모듈을 임포트해주고 스키마를 생성하러 가봅시다~!

2. 스키마 생성

orders 애플리케이션 src 폴더 아래에 schemas 폴더를 만들어줍니다. 우리가 orders 경로에서 서비스에 사용할 스키마는 이름, 가격, 연락처를 기록합니다. 따라서 아래와 같이 스키마를 정의합니다.

import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { AbstractDocument } from '@app/common';

@Schema({ versionKey: false })
export class Order extends AbstractDocument {
  @Prop()
  name: string;

  @Prop()
  price: number;

  @Prop()
  phoneNumber: string;
}

export const OrderSchema = SchemaFactory.createForClass(Order);

먼저 버저닝을 하지 않기 때문에 @Schema 데코레이터의 옵션으로 versionKey를 false로 지정합니다.

 

그리고 앞서 추상 도큐먼트를 생성했기 때문에 extends로 불러와 class를 작성합니다. 각각 @Prop() 데코레이터를 통해 프로퍼티를 만들고 타입을 지정합니다.

 

그 후에 스키마로 노출시키기 위해서 SchemaFactory에서 createForClass 메서드를 통해 Order 도큐먼트를 스키마로 저장합니다. 이제 스키마도 정의했으므로 레포지토리를 만들러 갑시다.

 

2. 레포지토리 생성

orders 애플리케이션 src 폴더에 orders.repository.ts 파일을 만들어 레포지토리를 생성합니다. 앞서 공용 라이브러리에 추상 레포지토리를 미리 만들어놨으므로 위의 스키마와 동일한 과정으로 레포지토리를 정의하면 됩니다.

import { Injectable, Logger } from '@nestjs/common';
import { AbstractRepository } from '@app/common';
import { Order } from './schemas/order.schema';

@Injectable()
export class OrdersRepository extends AbstractRepository<Order> {}

추상 레포지토리의 제너릭에는 도큐먼트가 들어갑니다. 그렇기 때문에, OrdersRepository에 사용할 도큐먼트를 적어주어야 하며, 앞서 만든 Order를 입력해줍니다.

 

import { Injectable, Logger } from '@nestjs/common';
import { AbstractRepository } from '@app/common';
import { Order } from './schemas/order.schema';

@Injectable()
export class OrdersRepository extends AbstractRepository<Order> {
  protected readonly logger = new Logger(OrdersRepository.name);
}

또한, logger를 필요로 하므로 logger를 생성해주고, OrdersRepository.name을 인자로 넣습니다. OrdersRepository를 문자열로 넣는 것입니다.

 

레포지토리에서 사용할 모델과 트랜잭션을 위한 커넥션을 주입해줍니다. 생성자 함수의 인자로 넣어줍시다.

constructor(
  @InjectModel(Order.name) orderModel: Model<Order>,
  @InjectConnection() connection: Connection,
) {}

주입한 다음 이 두 가지 프로퍼티를 부모 추상 레포지토리에 전달하기 위해서 super 함수에 값을 넣어줍시다.

constructor(
  @InjectModel(Order.name) orderModel: Model<Order>,
  @InjectConnection() connection: Connection,
) {
  super(orderModel, connection);
}

레포지토리를 만들 때 Injectable로 만들었으며, 다른 파일에서 이를 사용하기 위하여 orders.module.ts에 provider로 저장해주어야 합니다. 추가적으로 MongooseModule을 임포트하여 해당 모듈에서 사용할 스키마들을 지정해주면 정상적으로 레포지토리를 활용할 수 있게 됩니다.

 

스키마를 생성하였지만, 별도로 모듈에 임포트하지 않았으니까 레포지토리에서 사용하기 위한 당연한 과정이라고 생각하시면 됩니다. 이는 레포지토리도 서비스에서 사용해야 하므로 provider로 등록해야하는 것과 같은 이유입니다.

 

결과적으로 orders.module.ts는 아래와 같은 형태가 됩니다.

import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import * as Joi from 'joi';
import { DatabaseModule } from '@app/common';
import { OrdersController } from './orders.controller';
import { OrdersService } from './orders.service';
import { OrdersRepository } from './orders.repository';
import { MongooseModule } from '@nestjs/mongoose';
import { Order, OrderSchema } from './schemas/order.schema';

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
      validationSchema: Joi.object({
        MONGODB_URI: Joi.string().required(),
      }),
      envFilePath: './apps/orders/.env',
    }),
    DatabaseModule,
    MongooseModule.forFeature([{ name: Order.name, schema: OrderSchema }]),
  ],
  controllers: [OrdersController],
  providers: [OrdersService, OrdersRepository],
})
export class OrdersModule {}

이제 orders 애플리케이션을 구동시키면, Joi가 MONGODB_URI를 검사하는데, 아직 값을 넣지 않았으므로 에러를 일으키는 것을 볼 수 있습니다. 좋습니다!

 

Docker-compose

애플리케이션과 MongoDB를 사용할 준비가 완료되었습니다. 도커 컴포즈는 여러 개의 컨테이너를 한 번에 구동할 수 있도록 해주는 도구입니다. 도커에 대한 내용은 아래 링크에 정리해두었습니다.

https://dev-scratch.tistory.com/138

 

[Docker] 도커란?

도커 예를 들어, Node.js로 웹 서버 및 웹 서버 서비스를 만들었다고 할 때, 나의 운영체제와 Node.js 버전 및 DB 버전에서 코드가 정상적으로 작동할 수 있습니다. 하지만, 사람마다 사용하고 있는 운

dev-scratch.tistory.com

 

프로젝트 루트 디렉터리에 docker-compose.yml 파일을 생성합니다. 우리의 프로젝트는 NestJS와 MongoDB를 사용합니다. NestJS와 MongoDB 모두 컨테이너로 구성해야 하는 것이므로 docker-compose 파일을 통해서 Node.js와 MongoDB를 구축하도록 합시다.

 

1. MongoDB 도커 컨테이너 빌드

MongoDB 컨테이너에 사용할 도커 이미지로는 bitnami/mongodb를 사용합니다. bitnami/mongodb는 몽고디비 레플리카셋을 쉽게 관리할 수 있습니다. 해당 깃허브 주소에서 docker-compose-replicas.yml 파일로 들어가봅시다.

https://github.com/bitnami/bitnami-docker-mongodb

 

GitHub - bitnami/bitnami-docker-mongodb: Bitnami Docker Image for MongoDB

Bitnami Docker Image for MongoDB. Contribute to bitnami/bitnami-docker-mongodb development by creating an account on GitHub.

github.com

 

version: '2'

services:
  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'

  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

간단하게 야믈 파일을 살펴보면 3개의 서비스가 있으며 각각 mongodb-primary, mongodb-secondary, mongodb-arbiter라는 이름의 노드로 레플리카셋을 형성합니다. 내부 프로세스는 알아서 설계되기 때문에 우리는 단순히 사용하기만 하면 됩니다.

 

  • 각각의 서비스는 도커 허브에서 bitnami/mongodb 이미지의 5.0 태그 버전을 풀링받습니다. 그리고 환경변수도 적용합니다. 호스트 이름과 레플리카셋 모드, 주 호스트와 패스워드, 레플리카셋 키들을 환경변수로 저장해줍니다.
  • 또한, 애플리케이션이 재시작하더라도 데이터를 지속적으로 유지할 수 있도록 도커 볼륨도 지정해주었습니다.
  • 도커 볼륨을 미리 만들어서 primary 노드에서 해당 볼륨과 컨테이너 디렉터리를 연결해줍니다.

도커 볼륨과 컨테이너 디렉터리를 연결함으로써 몽고디비의 마스터 데이터를 지속적으로 보관할 수 있도록 할 수 있습니다.

 

이미 잘 작성이 되어 있으므로 복사해서 우리의 프로젝트 docker-compose.yml파일에 넣어줍시다.

 

📌 여기서 추가할 내용은 포트를 설정하는 것입니다. 보통 몽고디비에서 사용하는 27017번 포트를 할당해서 몽고디비를 사용할 수 있도록 하고, 호스트에 접속했을 때 컨테이너로 연결할 수 있도록 27017:27017로 포트를 설정합니다.

더보기

만약 몽고디비 컨테이너의 포트를 27017로만 설정하게 된다면 어떻게 될까요? 우리의 몽고디비는 컨테이너에서 구축되었습니다. 해당 컨테이너는 컨테이너 엔진 위에서 작동하는 환경이기 때문에 직접 접속할 수 없습니다. 그렇기 때문에 호스트 OS의 IP에서 및 포트를 통해서 컨테이너 포트로 접속할 수 있도록 포트 포워딩을 해주어야 합니다. 그렇기 때문에 27017번 호스트의 Port로 접속을 하면 해당 호스트가 컨테이너의 포트와 연결되어 컨테이너로 접속할 수 있도록 27017:27017로 포트를 설정하는 것입니다.

이제 본격적으로 도커 컴포즈 파일을 통해 도커 컨테이너를 빌드하겠습니다. 그런데 먼저 docker-compose가 설치되어야 하므로 도커 컴포즈를 설치하도록 합시다.

https://docs.docker.com/compose/install/

 

Overview

 

docs.docker.com

더보기

도커 컴포즈는 Window나 Mac에서 Docker Desktop 애플리케이션을 설치하면 자동으로 설치됩니다. 그런데, 제 환경(M1)에서는 개인적으로 docker-compose 플러그인이 설치되지 않아서 직접 설치해야 했습니다.

 

https://docs.docker.com/compose/install/linux/

 

Install the Compose plugin

 

docs.docker.com

 

해당 사이트에서 플러그인을 curl 명령어를 사용하여 설치해주었습니다. 해당 예제에서는 

DOCKER_CONFIG=${DOCKER_CONFIG:-$HOME/.docker}
mkdir -p $DOCKER_CONFIG/cli-plugins
curl -SL https://github.com/docker/compose/releases/download/v2.12.2/docker-compose-linux-x86_64 -o $DOCKER_CONFIG/cli-plugins/docker-compose

위와 같이 설치하는데, m1과 다른 환경의 플러그인 파일이므로 아래와 같이 수정하여 설치해주면 정상적으로 작동됩니다. 혹은 https://github.com/docker/compose/releases 에서 원하는 파일을 찾아서 설치해도 됩니다.

DOCKER_CONFIG=${DOCKER_CONFIG:-$HOME/.docker}
mkdir -p $DOCKER_CONFIG/cli-plugins
curl -SL https://github.com/docker/compose/releases/download/v2.12.2/docker-compose-darwin-aarch64
 -o $DOCKER_CONFIG/cli-plugins/docker-compose

 

2. 도커 컴포즈로 컨테이너 빌드

도커 컴포즈로 한꺼번에 몽고디비 레플리카셋을 컨테이너로 빌드합니다. 성공적으로 몽고디비가 실행된 것을 도커 데스크탑에서도 확인할 수 있습니다.

 

3. 도커 파일 작성

 

이제 도커 컴포즈를 사용해봤기에 우리의 어플리케이션도 도커 컴포즈로 한꺼번에 같이 빌드하면, 마이크로서비스 애플리케이션을 동시에 구동시킬 수 있습니다. 각각의 애플리케이션을 도커 컨테이너로 구성하기 위해 일단 각 애플리케이션에 Dockerfile을 만들어봅시다.

 

더보기

Docker는 Dockerfile로 우리가 원하는 애플리케이션의 스냅샷을 찍어 이미지로 만들 수 있고, 이 이미지를 통해서 컨테이너를 빌드할 수 있습니다. 즉, Dockerfile -> Docker Image -> Docker Container의 순서로 빌드되는 것입니다. 앞선 도커 컴포즈에서는 Dockerfile로 Docker Image를 만드는 과정이 없었습니다. 그 이유는 이미 도커 허브에 해당 이미지를 만들어 놓았으며 우리는 단순히 그 이미지를 git과 비슷하게 Pull하여 사용하였기 때문입니다. 하지만, 프로젝트 애플리케이션은 직접 작성한 코드들의 집합이므로, 개별적으로 이미지를 만들고 그 이미지를 사용해야 합니다.

 

먼저 orders 애플리케이션의 루트 디렉터리에서 Dockerfile을 만듭니다. Dockerfile은 도메인 특화 언어인 DSL로 선언적으로 파일을 작성하여 이해하기 쉽고 명확하게 작성된 파일입니다. 이 도커 파일에 각각 속성과 값을 입력하여 사용합니다.

 

입력할 내용은 아래와 같습니다.

FROM node:alpine As development

WORKDIR /usr/src/app

COPY package*.json ./

RUN yarn install

COPY . .

RUN yarn run build

FROM node:alpine as production

ARG NODE_ENV=production
ENV NODE_ENV=${NODE_ENV}

WORKDIR /usr/src/app

COPY package*.json ./

RUN yarn install --only=production

COPY . .

COPY --from=development /usr/src/app/dist ./dist

CMD ["node", "dist/apps/orders/main"]

해당 파일의 전반적인 내용은 어떻게 컨테이너를 빌드할지에 관한 내용입니다. 

 

각각 FROM을 통해서 해당 이미지를 위해 사용할 기초 이미지를 정합니다. 기초 이미지를 사용하여 최종적으로 컨테이너를 위한 이미지를 빌드해야 하므로 Dockerfile은 반드시 FROM으로 시작해야 합니다. 보통 도커 이미지를 Dockerfile로 빌드할 때는 용량이 가장 중요합니다. 도커 이미지를 빌드하는 방법을 어떻게 하고 어떤 이미지를 기초 이미지로 사용했느냐에 따라 용량이 매우 달라지기 때문입니다. 그렇기 때문에 가벼운 node인 alpine node를 사용하였습니다. 그리고 이렇게 각각 기초 이미지를 사용하여 이미지를 빌드하는 각 단계들을 스테이지라고 합니다. 해당 파일에서는 첫 번째 스테이지를 development로, 두 번째 스테이지를 production으로 명명하였습니다.

 

node먼저 루트 디렉터리에 있는 package.json을 이미지의 경로에 복사하고, yarn을 통해 패키지를 설치합니다. 그 후에 모든 프로젝트를 복사하고, js로 컴파일하여 빌드하는 과정으로 진행됩니다. 최종적으로 가장 하단에서 dist/apps/orders/main을 node 명령어로 하여 애플리케이션을 구동합니다.

 

💡 한 가지 취약점이라고 한다면, 우리의 모든 마이크로 서비스가 하나의 package.json을 공유하여 사용하고 같은 의존성을 사용하기 때문에, 한 애플리케이션에서 필요한 의존성이 다른 애플리케이션에는 필요하지 않은 경우가 발생할 수도 있습니다. 이런 경우에는 분리된 package.json을 고려할 수도 있습니다. 하지만, 우리의 애플리케이션은 따로 package.json을 분리하지 않고 공유하도록 하겠습니다.

 

.dockerignore

 

이제 우리는 우리의 애플리케이션이 어떻게 빌드되어야 하는지에 대한 선언을 도커파일로 작성했습니다. 그런데, git에서 github와 같은 외부 저장소에 파일을 푸시할 때, .env나 node_modules 등 푸시하면 안되거나 푸시할 필요가 없는 파일을 .gitignore에 작성하는 것과 같이, docker도 이미지에 남기고 싶지 않은 파일을 .dockerignore로 작성할 수 있습니다.

 

# Versioning and metadata
.git
.gitignore
.dockerignore

# Build dependencies
dist.do
node_modules

# Environment (contains sensitive data)
*.env

# Misc
.eslintrc.js
.prettierrc
README.md

환경 변수도 .dockerignore에 추가했습니다. 왜냐하면 도커 컴포즈 자체에서 여러 환경 변수를 이미 사용하기 때문입니다.

 

4. 도커 컴포즈에 orders 애플리케이션 추가

 

이제 도커 이미지를 위한 Dockerfile도 작성했으니까 다시 docker-compose.yml 파일로 돌아가 애플리케이션을 도커 컴포즈에 추가합시다! 추가한 내용은 아래와 같습니다.

 

services:
  orders:
    build:
      context: .
      dockerfile: ./apps/orders/Dockerfile
      target: development
    command: yarn start:dev orders
    env_file:
      - ./apps/orders/.env
    depends_on:
      - mongodb-primary
      - mongodb-secondary
      - mongodb-arbiter
    volumes:
      - .:/usr/src/app
      - /usr/src/app/node_modules
    ports:
      - '3000:3000'

 

도커 컴포즈 가장 상단에 우리의 orders를 서비스로 작성합니다. build 섹션을 작성하고, build 할 관련 context(속성)을 작성하여 특히 어느 디렉터리에서 이 빌드를 실행할지 설정합니다. orders에서는 루트 디렉터리로 설정했습니다.

 

또한, dockerfile 속성 을 작성합니다. 앞서 작성한 Dockerfile은 orders에 있으므로 docker-compose.yml 파일을 기준으로 하여 ./apps/orders/Dockerfile로 작성합니다.

 

target은 우리가 구동시키고자 하는 이미지의 도커 일부분을 지정합니다. dockerfile에서 두 개의 스테이지를 작성한 것을 기억하시나요? development와 production으로 나뉘어 각각의 태스크를 작성했습니다. production 스테이지에서는 오직 production일 때만의 의존성 패키지를 설치했었습니다. 하지만, 우리는 development 의존성을 포함하여 모든 의존성을 원하므로 development 스테이지를 target의 값으로 적어줍시다.

 

두 번째로, command 속성을 작성해줍시다. command는 이미지와 함께 사용하고 싶은 명령어를 작성할 수도 있고, 때로는 도커파일에 작성한 명령어를 덮어쓸 수도 있습니다. orders dockerfile의 CMD ["node", "dist/apps/orders/main"]는 docker run 명령어에서 아무 것도 입력하지 않았을 때 사용하는 기본값인데, 코드에 변동사항이 있으면 자동으로 애플리케이션을 재시작 할 수 있도록 하기 위하여 npm run start:dev orders(yarn start:dev ordres) 를 dockerfile의 CMD로 지정한 기본값 대신 사용합니다.

 

더보기

CMD 사용 예제

 

1. Dockerfile을 작성해줍니다.

FROM node:alpine as Test

CMD ["echo", "hello"]

2. Dockerfile로 도커 이미지를 빌드합니다.

docker build -t test_img .

3. 빌드한 이미지로 컨테이너를 실행합니다.

docker run --name test_echo test_img

 -> hello

CMD로 사용한 기본 값을 대체하는 예제

 

CMD에서 echo hello를 기본 값으로 사용하였는데, docker run에서 다른 명령을 뒤에 붙여보겠습니다.

docker run --name test_con test_img echo Bye

 -> Bye

즉 command를 뒤에 붙여서 기본값을 사용하지 않고 컨테이너를 실행했습니다. 우리의 도커 컴포즈도 이러한 과정을 수행하여 node dist/apps/orders/main의 명령을 실행하지 않고 npm run start:dev orders 혹은 yarn start:dev orders를 수행하도록 지정하는 것입니다.

세 번째로는 env_file을 작성합니다. 환경 변수의 위치를 지정해주는 것으로, 경로를 값으로 전달하고 해당 경로에 위치한 .env의 모든 환경 변수를 사용하도록 선언합니다.

 

네 번째로 depends_on을 작성합니다. 이는 단순하게 애플리케이션을 시작하기 전에 실행해야 하는 서비스의 목록을 지정하는 속성입니다. 해당 프로젝트의 경우 몽고디비 노드를 미리 준비해야 하기 때문에 mongodb-primary, mongodb-secondary, mongodb-arbiter를 미리 수행할 서비스로 선언합니다. 이로서 orders 서비스를 실행하기 전에 몽고디비 레플리카셋을 실행할 수 있습니다.

 

다섯 번째로 volumes를 작성합니다. 컨테이너는 기본적으로 생성과 삭제가 자유로우므로 지속적으로 데이터를 보관하지 못합니다. 그렇기 때문에 지속적으로 데이터를 보관할 볼륨을 설정해주어야 합니다. 호스트에서 원하는 디렉터리를 컨테이너의 디렉터리로 덮어쓴 후에, 동기화하며 볼륨을 공유합니다(바인드 마운트). 호스트와 컨테이너가 깐부를 맺는 것입니다. 이때, 속성에 입력하는 값은 <호스트 디렉터리>:<컨테이너 디렉터리>의 형태를 가집니다. 우리의 경우 현재 디렉터리(도커 컴포즈가 위치한)를 컨테이너의 /usr/src/app과 동기화합니다. /usr/src/app는 apps의 도커파일에서 애플리케이션을 빌드하는 디렉터리 위치입니다. 이렇게 동기화 하는 이유는 호스트에서 파일을 변경시키면 자동으로 컨테이너에서 감지하여 애플리케이션을 재시작하기 위함입니다. 한 가지 볼륨을 더 추가하는데 이번에는 /usr/src/app/node_modules를 값으로 넣었습니다. 이렇게 지정하는 것은 호스트에서 가지고 있는 node_modules를 마운트하지 않고 컨테이너에서 node_modules를 유지하기 위함입니다.

 

마지막으로 ports를 지정합니다. 몽고디비에서 primary 노드를 27017포트로 노출시키고, 호스트에서 27017포트로 요청이 들어오면 컨테이너의 27017포트로 연결하기 위해 27017:27017로 포트를 지정했었습니다. orders 서비스도 마찬가지로 외부의 요청을 받아 동적으로 응답을 제공해야 하므로 포트를 지정해주어야 합니다. orders는 3000번 포트에서 작동하고 3000번 포트를 우리의 호스트로 노출시키기 때문에 3000:3000으로 포트를 지정합니다. 

 

orders 애플리케이션을 시작하기 위해 docker compose up 명령을 입력합니다.

docker compose up --build -V

도커 컴포즈에 새로운 이미지를 갖기 때문에 -V 옵션을 사용하여 모든 볼륨을 리셋해줍니다. 

 

하지만, 중간에 오류가 발생합니다. 이는 환경변수에서 MONGOBD_URI 변수 값이 비어 있기 때문입니다. orders 디렉터리의 .env의 MONGODB_URI 변수값을 넣어줍시다.

MONGODB_URI=mongodb://root:password123@mognodb-primary:27017/
# MONGODB_URI=mongodb://<유저 이름>:<유저 비밀번호>@<호스트 이름>:27017/

docker-compose.yml 파일에서 root 사용자의 패스워드와 호스트 이름을 미리 지정해주었으므로 해당 내용을 반영하여 URI를 작성합니다.

 

이제 다시 docker compose up 명령으로 컨테이너를 구동합니다.

 


⚠️ m1에서는 bitnami/mongodb 이미지의 arm 아키텍처가 없기 때문에 계속해서 접속이 불가합니다. 그렇기 때문에 일부 내용을 수정해야 합니다. 기존에 사용하던 이미지는 docker hub의 공식 이미지를 사용하며, 해당 이미지에 필요한 환경변수는 공식적으로 제안하는 변수명을 사용해주도록 합시다. 수정한 내용은 아래에 올려놓았습니다.

 

services:
  orders:
    build:
      context: .
      dockerfile: ./apps/orders/Dockerfile
      target: development
    command: yarn start:dev orders
    env_file:
      - ./apps/orders/.env
    depends_on:
      - mongodb-primary
      - mongodb-secondary
      - mongodb-arbiter
    volumes:
      - .:/usr/src/app
      - /usr/src/app/node_modules
    ports:
      - '3000:3000'

  mongodb-primary:
    image: mongo:6.0.3
    environment:
      - MONGO_ADVERTISED_HOSTNAME=mongodb-primary
      - MONGO_INITDB_ROOT_USERNAME=root
      - MONGO_INITDB_ROOT_PASSWORD=password123
      - MONGO_REPLICA_SET_MODE=primary
      - MONGO_REPLICA_SET_KEY=replicasetkey123
    volumes:
      - 'mongodb_master_data:/docker.io/mongo'
    ports:
      - '27017:27017'

  mongodb-secondary:
    image: mongo:6.0.3
    depends_on:
      - mongodb-primary
    environment:
      - MONGO_ADVERTISED_HOSTNAME=mongodb-secondary
      - MONGO_INITIAL_PRIMARY_HOST=mongodb-primary
      - MONGO_INITIAL_PRIMARY_ROOT_USERNAME=root
      - MONGO_INITIAL_PRIMARY_ROOT_PASSWORD=password123
      - MONGO_REPLICA_SET_MODE=secondary
      - MONGO_REPLICA_SET_KEY=replicasetkey123

  mongodb-arbiter:
    image: mongo:6.0.3
    depends_on:
      - mongodb-primary
    environment:
      - MONGO_ADVERTISED_HOSTNAME=mongodb-arbiter
      - MONGO_INITIAL_PRIMARY_HOST=mongodb-primary
      - MONGO_INITIAL_PRIMARY_ROOT_USERNAME=root
      - MONGO_INITIAL_PRIMARY_ROOT_PASSWORD=password123
      - MONGO_REPLICA_SET_MODE=arbiter
      - MONGO_REPLICA_SET_KEY=replicasetkey123

volumes:
  mongodb_master_data:
    driver: local

최신 버전보다는 이전에 빌드했던 버전을 사용하기 위해 태그를 같이 사용했습니다.

 

orders 애플리케이션 안에 있는 .env 파일도 수정해줍시다.

MONGODB_URI=mongodb://root:password123@mongodb-primary:27017/admin

루트 사용자 이름과 패스워드, 호스트는 환경변수에 적은 그대로 지정합니다. 그리고 인증을 위한 데이터베이스에는 admin을 기입해주었습니다.

 

다른 OS/아키텍처에서도 오류가 발생하면 m1의 해결 방법과 똑같이 적용하시면 됩니다. :)


이제 모두 실행이 되었으면 요청을 보내서 테스트 해봅시다~~!

응답도 정상적으로 온 것을 확인할 수 있었습니다.

 

마치며

 

인프라 내용이 많아서 난이도도 어려웠고, m1이 이곳 저곳 아직 지원하지 않는 도구나 이미지 등이 많아서 해결하는 데 시간을 정말 많이 썼네요. 하지만, 직접 코드를 수정하고 원리를 이해할 수 있는 좋은 시간이었습니다.

 

그리고, 도커를 구동시키면 갑자기 CPU를 상당히 많이 점유하고 있는 것을 볼 수 있는데, preferences - resources - advanced 에서 CPU 코어를 최소로 낮추면 애플리케이션 이상 없이 CPU 사용량을 드라마틱하게 줄일 수 있습니다.