NestJS 도입기

본 블로그 글에서는 NestJS 프레임워크 도입 과정과 프로젝트 세팅 경험을 공유하고, Application Builder, Secret Manager, Database 모듈 구성에 대해 자세히 설명합니다.

1. 기존 시스템의 역사

회사의 백엔드 시스템은 다음과 같은 역사를 지니고 있다.

1.(Java) Struts2 + Stored Procedure 기반의 프레임워크 처음 입사하고 난 후에 보게 된 백엔드 형태이다. Java 기반의 백엔드 프레임워크는 Spring 만 접해왔었는데, Struts2 라는 프레임워크를 처음 보게 되어서 매우 놀랐다. Spring 의 모태가 되는 프레임워크라는 느낌도 어느정도 받았다. Spring 과 유사하다고 느낀 점은 다음과 같았다.

2.(Java) Spring Boot 를 기반으로 한 MSA System 위에서 나타난 문제점들을 팀장님께 지속적으로 리포트를 했고, 팀장님께서도 Spring 기반의 시스템을 구축하는 것에 동의하셨다. 이에 도메인별로 어플리케이션을 쪼개 MSA 형태의 시스템을 구축하는 것을 목표로 하였다. Spring Boot 기반의 프로젝트로 회원 Domain / 리뷰 Domain 의 기능들을 별도의 어플리케이션으로 분리하였다.

지금 생각해보면, 이 때는 회사의 시스템이 MSA 를 시도하기 적합한 타이밍은 아니었던 것 같다. MSA 를 통해 얻을 수 있는 이점은 작았던 데에 비해, MSA 를 구축하면서 얻게 될 비효율적인 구조는 눈에 띄게 커져 갔다. 시스템 아키텍쳐 자체가 Legacy 가 되어갈 무렵, 새로운 백엔드 시스템을 구축하게 되었다.

3.(Typescript) tsoa 프레임워크 회사에서 새로운 언어로 Typescript 를 선택하게 되었다. FE 와 같은 언어를 사용하게 되서 오는 이점이 꽤 크고, 팀의 비즈니스 도메인상 Dynamic Language 를 통해서 얻을 수 있는 개발 효율성이 클 것이라고 생각했기 때문이다. 이 과정에서 MSA 로 분산되어 있던 4개의 서버 (결제 / 회원 / 리뷰 / 백오피스) 를 하나의 Monolitic Application 으로 통일하였다. (진짜 MSA 가 이점으로 다가오는 시점에 다시 MSA 를 구축하는 것으로..) 또한, NodeJS 의 싱글코어 런타임 특성상, 높은 성능의 EC2 위에서 어플리케이션을 운영하는 것이 비효율적이었다. 인프라 구성을 상대적으로 낮은 스펙의 ECS 여러대로 대체하였다. NodeJS 위에서 어플리케이션을 실무에서 구축하는 것이 처음이었어서, 안정성에 대해 불안한 점도 일부 있었지만, 아직까지 문제가 되었던 점은 딱히 없었다.

Typescript 는 대학생 때 Angular 를 독학하다가 처음 접하게 되었는데, 이번에는 백엔드 어플리케이션 개발을 위해 새로 학습하였다. 대학생 때에 공부했던 내용보다, 훨씬 더 심오하게 타입스크립트를 이해할 수 있었다. 하지만 여전히 Vanilla Javascript 에 대한 학습이 부족하다고 느껴저서, Javascript 언어 자체에 대해서 깊이 공부해야 할 것 같다.

이때부터, 백엔드 프레임워크에 Swagger Docs / Swagger Codegen 을 도입해서, FE 에서 API 를 사용할 때에 직접 호출을 하지 않고, 백엔드에서 지정해준 Codegen 함수를 통해서 API 를 호출하게 바꾸었다. 백엔드에서 할 일은 좀 더 늘어났지만, 생각보다 FE 개발자분들의 반응이 좋았다. 가장 만족하셨던 부분은 Typescript 기반으로 API Codegen 을 해 드리니, Parameter 와 Response 의 Type Check & Type Reference 가 IDE를 통해서 가능하다는 점이었다. (404 에러나 400 에러가 발생할 일이 없음)

하나 아쉬웠던 점은, tsoa 라는 프레임워크를 선택했다는 점이다. tsoa 는 express 를 wrapping 한 백엔드 프레임워크인데, 이 때 NestJS 를 먼저 도입했다면 더 좋았겠다는 생각이 든다.

4.(Typescript) NestJS 프레임워크 tsoa 프레임워크도 사용에 불편한 점은 없었지만, 다음과 같은 단점이 존재했다.

이러한 단점들이 있었고, 마침 회사에서 새로운 프레임워크를 도전해볼만한 거대 프로젝트가 생겨서, 겸사겸사 NestJS 프레임워크를 도입하게 되었다.




2. 프로젝트 세팅

빠른 시간 내에 프로젝트를 세팅해야 팀원들이 비즈니스 로직을 개발할 수 있어서, 빠른 시간 내에 프로젝트를 세팅해야 했다. 크게 Setup 해야 할 Spec 은 다음과 같았다.

생각보다 설정할 것 이 많았지만, 이 글에서는 일부만 경험을 정리하기로 했다.




2-1. Application Builder

NestJS 의 공식 문서에서는 Application Entry Point (main.ts) 가 꽤 단순히 적혀있지만, 실무에서 어플리케이션을 설정해 보니, 해당 파일이 매우 복잡하게 얽히게 되는 것을 발견하였다.

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

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

NestJS 공식 Document 에 적혀있는 Application Entry Point

Project 를 셋업하고 난 후에 해당 파일을 보니, 가독성이 떨어지고 여러가지 냄새나는 부분이 많아서, Builder Design Pattern 을 적용해서 해당 코드를 다시 작성하였다.

async function bootstrap() {
  // ApplicationBuilder 에서 어플리케이션을 생성한다.
  const useHttps = process.env.HTTPS === "true";
  const port = process.env.PORT || 3000;

  // 어플리케이션이 뜨기 전에, Winston 로거를 초기화해야 한다.
  ApplicationBuilder.initLogger();

  const app = (await new ApplicationBuilder().initialize(useHttps))
    // morgan 설정
    .addMorgan()
    // CORS 설정
    .addCors()
    // 기타 자잘한 설정들 (쿠키 등)
    .addMiscellaneous()
    // Filter 추가
    .addFilters([
      // 예상치 못한 에러가 발생하면 데이터독에 이러 찍어줌
      new ErrorLogFilter(),
      // WcException 이 발생하면, 메세지를 잘 띄워줌
      new WcExceptionFilter(),
    ])
    .addPipes([
      new ValidationPipe({
        transform: true,
      }),
    ])
    // Interceptor 추가
    .addInterceptors([])
    .getInstance();

  if (process.env.LABEL === SECRET.LABEL.DEV) {
    // 개발환경일 때에만 Swagger Document 를 생성한다.
    await setSwagger(app);
  }

  // 서버 실행
  await app.listen(port);
}

bootstrap();

나름 고민한 끝에 작성했지만, 자꾸만 좀 더 이쁘게 쓸 수 있을 것 같다는 생각에 자꾸 만지작거리게 된다.




회사의#많은 Secret 정보들을 AWS Secret Manager 를 통해 관리하고 있다. 과거에는 환경변수를 통해서 많은 Setup 을 하였지만, 보안상 위험할 수 있다는 판단을 한 후, 거의 모든 기밀정보를 Secret Manager 로 이관하였다. DB 연결을 위한 정보와, PG 관련 정보도 Secret Manager 에 저장되어 있어서, DI Container 에서 Secret Manager 의 정보는 가장 먼저 로드되어야 한다.

import { DynamicModule, Global, Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { SECRET } from "../../const";
import { getSecretFromSecretManager } from "../../lib/secrets";
import { Secret } from "../../type/secret";
import { configServiceFactory } from "./wc-config.provider";
import { WcConfigService } from "./wc-config.service";

@Global()
@Module({})
export class WcConfigModule {
  static forRoot(): DynamicModule {
    return {
      module: WcConfigModule,
      imports: [ConfigModule.forRoot({})],
      providers: [configServiceFactory],
      exports: [WcConfigService],
    };
  }
  /**
   * AWS Secret Manager 에서 Application 에 필요한 Resource 받아오기
   * @param label
   * @returns
   */
  static async resolveSecret(label: SECRET.LABEL): Promise<Secret> {
    return {
      mssql: await getSecretFromSecretManager(
        SECRET.SecretManagerKey.mssql[label]
      ),
      pay: await getSecretFromSecretManager(SECRET.SecretManagerKey.pay[label]),
      postgres: await getSecretFromSecretManager(
        SECRET.SecretManagerKey.postgres[label]
      ),
      datadog_api_key: await getSecretFromSecretManager(
        SECRET.SecretManagerKey.datadog_api_key[label],
        true
      ),
      jwt_pem: await getSecretFromSecretManager(
        SECRET.SecretManagerKey.jwt_pem[label],
        true
      ),
      kakao_api: await getSecretFromSecretManager(
        SECRET.SecretManagerKey.kakao_api[label]
      ),
    };
  }
}

...

export const configServiceFactory = {
  provide: WcConfigService,
  useFactory: async (configService: ConfigService) => {
    const label = (process.env[SECRET.ENVIRONMENT_KEY] ??
      die(`label not exists`)) as SECRET.LABEL;

    const secret = await WcConfigModule.resolveSecret(label);
    return new WcConfigService(configService, secret);
  },
  inject: [ConfigService],
};

NestJS 에 설정되어 있는 Config Module 이다. NestJS 에서는 기본적으로 ConfigModule, ConfigService 가 지원되지만, AWS Secret Manager 와 같은 Custom 기능들이 탑재되어서, 새로 선언해서 Config Module 를 구성하였다. AWS 에서 Secret Manager 의 정보를 Fetch 해온 후, DI Container 를 통해 ConfigService 를 생성한다.




2-1. Database

NestJS 셋업 과정에서 가장 핵심적이었던 Database Module 이다. ConfigModule 과 마찬가지로, 어느 정도 커스터마이징해서 사용할 부분이 많아서 Custom Database Module 로 구성하였다.

import { DynamicModule, Global, Module } from "@nestjs/common";
import { DB } from "../../const";
import {
  postgresConnectionFactory,
  sqlServerConnectionFactory,
} from "./wc-database.provier";

@Global()
@Module({})
export class WcDatabaseModule {
  static forRoot(): DynamicModule {
    return {
      module: WcDatabaseModule,
      providers: [
        // MSSQL Connection
        sqlServerConnectionFactory,
        // Postgresql Connection
        postgresConnectionFactory,
      ],
      exports: [
        // MSSQL Connection
        DB.Provider.MSSQL_TYPEORM,
        // Postgresql Connection
        DB.Provider.POSTGRESQL_TYPEORM,
      ],
    };
  }
}

...

export const sqlServerConnectionFactory = {
  provide: DB.Provider.MSSQL_TYPEORM,
  useFactory: async (configService: WcConfigService) => {
    const { host, username, password, dbname } = configService.secret.mssql;
    return createConnection({
      type: "mssql",
      host,
      username: username || "admin",
      password,
      database: dbname || "master",
      synchronize: false,
      logging: configService.get(SECRET.ENVIRONMENT_KEY) !== "prod",
      entities: all,
      options: {
        useUTC: false,
      },
    });
  },
  inject: [WcConfigService],
};

Custom DatabaseModule 의 모습이다. 추가적인 DB (NoSQL 기반의 DB 가 추가될지도 모르겠다.) Connection 이 추가되면, 이 쪽에서 손쉽게 관리할 수 있도록 구성하였다.

목차

1. 기존 시스템의 역사 2. 프로젝트 세팅 2-0. Application Builder 2-1. Secret Manager 2-2. Database

이것도 읽어보세요