NestJS 帶你飛! 시리즈 번역 29# 실전 응용(하)

2023. 7. 2. 20:03개발 문서 번역/NestJS

728x90
이 포스팅은 「NestJS 기초실무 가이드 : 강력하고 쉬운 Node.js 웹 프레임워크로 웹사이트 만들기」
(서명: NestJS 基礎必學實務指南:使用強大且易擴展的 Node.js 框架打造網頁應用程式)
책이 출간되었습니다.

 

이 시리즈도 막바지에 접어들었습니다.
그말인 즉슨 앞에 배웠던 모든 기능들을 활용하여 뭔가를 만들어봐야 온전한 자기것으로 만들 수 있겠죠?

정말 모든 기능을 사용하여 이번 연습에서 사용하진 못하겠지만
제가 느끼는 자주 쓰이는 기능들을 전부 집어넣을 수 있도록 고려해보겠습니다. 

그럼 시작해보겠습니다!

 

시스템 계획 구성도

이번 실전 연습에서는 간단한 TodoList를 만들어보겠습니다.
이 TodoList는 기본적인 역할 권한 관리를 갖고 있으며,  사용자(user)할 일(todo) 두개의 주요 자원이 있습니다.

역할은 시스템 관리자(admin), 관리자(manager), 구성원(member)로 나뉘며 상세 작업 권한은 다음과 같습니다.

  • 시스템 관리자: 모든 작업에 대한 엑세스 권한을 가지고 있습니다.
  • 관리자: Todo 자원에 대한 접근과 사용자 정보에 접근은 가능하지만 사용자와 관련있는 작업의 권한은 없습니다.
  • 구성원: Todo의 읽기와 수정 권한만이 있으며, 그외 작업에는 모두 권한이 없습니다.

 

프로젝트의 구조는 대략 아래와 같은 모습으로 분류할 예정이며 이중 중요한 항목들을 나열해보겠습니다.

.
├─ .env
├─ src
|  ├─ common/
|  ├─ configs/
|  ├─ core/
|  ├─ features/
|  ├─ app.module.ts
|  └─ main.ts
└─ rbac
   ├─ model.conf
   └─ policy.csv
  • .env: 환경 변수 구성 파일입니다.
  • src/common: 공통적으로 쓰이는 항목들에 대한 파일들을 보관합니다. (ex: constants, enums, models 등)
  • src/configs: 환경변수와 관련있는 팩토리 함수들을 보관합니다.
  • src/core: 애플리케이션과 직접적으로 관련있을만한 컴포넌트들을 보관합니다. (ex: guards, interceptors,pipes 등)
  • src/features: 주요 기능들이 이곳에 보관됩니다. (ex: user, todo, auth 등)
  • src/app.module.ts: 루트 모듈입니다.
  • src/main.ts: 진입점(엔트리 포인트, Entry Point, 載入點)입니다.
  • rbac: Casbin이 사용하는 modelpolicy를 보관합니다.

 

프로젝트 생성

먼저 CLI로 빈 프로젝트를 하나 생성하겠습니다.

$ nest new <PROJECT_NAME>

 

이어서 사용될 패키지들을 npm으로 설치하겠습니다.

$ npm install @nestjs/config // 환경 변수 관리 모듈
$ npm install @nestjs/mapped-types // DTO 매핑 타입
$ npm install @nestjs/mongoose mongoose // MongoDB와 상호작용
$ npm install @nestjs/passport passport // 인증 모듈
$ npm install @nestjs/jwt passport-jwt // JWT와 해당 전략
$ npm install @types/passport-jwt -D // passport-jwt의 타입 정의
$ npm install passport-local // 로컬 인증 정책
$ npm install @types/passport-local -D // 로컬 인증 정책의 타입 정의
$ npm install casbin // 역할 기반 엑세스 제어 라이브러리
$ npm install class-validator class-transformer // DTO가 사용할 데코레이터

 

환경 변수 설정

먼저 MongoDB와 관련있는 민감한 정보와 JWT의 Secret Key를 보관해야할 장소가 필요합니다.

어딘지 이젠 말 안해도 아시겠죠? .env 파일입니다.

MONGO_USERNAME=<YOUR_USERNAME>
MONGO_PASSWORD=<YOUR_PASSWORD>
MONGO_RESOURCE=<YOUR_RESOURCE>

JWT_SECRET=<YOUR_JWT_SECRET_KEY>
알림: 혹시 잘 기억이 나지 않는다면? DAY16 - Configuration을 참고해주세요!

 

Mongoose 설정

MongooseModuleAppModule에 구성해줍니다.

팩토리 함수를 사용하여 환경 변수의 네임스페이스를 구성하고, MongoDB와 관련있는 환경변수는 mongo 라는 네임스페이스로 묶어 한곳에 모읍니다. configs 폴더 아래 mongo.config.ts 파일을 생성해봅시다.

import { registerAs } from '@nestjs/config';

export default registerAs('mongo', () => {
  const username = process.env.MONGO_USERNAME;
  const password = encodeURIComponent(process.env.MONGO_PASSWORD);
  const resource = process.env.MONGO_RESOURCE;
  const uri = `mongodb+srv://${username}:${password}@${resource}?retryWrites=true&w=majority`;
  return { username, password, resource, uri };
});

이어서 AppModuleConfigModule을 불러와 관련 설정을 하겠습니다.

MongoDB에서 사용될 환경변수들을 MongooseModule에 전달해주어 연결을 설정하도록 하겠습니다.

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

import mongoConfigFactory from './configs/mongo.config';

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
      load: [mongoConfigFactory],
    }),
    MongooseModule.forRootAsync({
      imports: [ConfigModule],
      inject: [ConfigService],
      useFactory: (config: ConfigService) => ({
        uri: config.get<string>('mongo.uri'),
        //자신의 Mongoose 버전이 6 이상이라면, 해당 값은 지워주어야 오류가 없습니다.
        // Mongoose 6 이상에서는 디폴트로 해당 값을 지원합니다.
        useFindAndModify: false,
      }),
    }),
  ],
})
export class AppModule {}
알림: 자세한 mongoose의 사용방법은 DAY22 - MongoDB,
팩토리 함수 설정과 환경변수, 네임스페이스와 관련된 글은 DAY16 - Configuration을 참고해주세요.

 

시크릿 키 설정

JWT를 사용하여 인증을 구현할 때는 인증에 필요한 키를 secrets라는 네임스페이스에 그룹화하여 저장할 수 있습니다.

configs 폴더에 secret.config.ts 파일을 추가하겠습니다.

 

import { registerAs } from '@nestjs/config';

export default registerAs('secrets', () => {
  const jwt = process.env.JWT_SECRET;
  return { jwt };
});

 

이어서 AppModuleConfigModule을 추가해 방금 작성했던 위의 팩토리 함수를 load에 넣어줍니다.

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

import mongoConfigFactory from './configs/mongo.config';
import secretConfigFactory from './configs/secret.config';

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
      load: [mongoConfigFactory, secretConfigFactory],
    }),
    MongooseModule.forRootAsync({
      imports: [ConfigModule],
      inject: [ConfigService],
      useFactory: (config: ConfigService) => ({
        uri: config.get<string>('mongo.uri'),
        //자신의 Mongoose 버전이 6 이상이라면, useFindAndModify 옵션을 지워주어야 오류가 없습니다.
        // Mongoose 6 이상에서는 디폴트로 해당 값을 지원합니다.
        // useFindAndModify: false,
      }),
    }),
  ],
})
export class AppModule {}

 

전역 Pipe 구현

Pipe를 통해 API에서 타입 검사를 진행할 수 있습니다.

여기서는 ValidationPipe를 전역으로 구성하도록 하겠습니다. AppModule에서 수정하면 됩니다.

provider에 커스텀 Provider를 구성해 구성하면 되는데

이때 provide의 값은 APP_PIPE, useClassValidationPipe로 지정해주면 됩니다.

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

import mongoConfigFactory from './configs/mongo.config';
import secretConfigFactory from './configs/secret.config';
import { APP_PIPE } from '@nestjs/core';

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
      load: [mongoConfigFactory, secretConfigFactory],
    }),
    MongooseModule.forRootAsync({
      imports: [ConfigModule],
      inject: [ConfigService],
      useFactory: (config: ConfigService) => ({
        uri: config.get<string>('mongo.uri'),
        //자신의 Mongoose 버전이 6 이상이라면, useFindAndModify 옵션을 지워주어야 오류가 없습니다.
        // Mongoose 6 이상에서는 디폴트로 해당 값을 지원합니다.
        // useFindAndModify: false,
      }),
    }),
  ],
  providers: [
    {
      provide: APP_PIPE,
      useClass: ValidationPipe,
    },
  ],
})
export class AppModule {}
알림: 전역 Pipe 사용 방법은 DAY10 - Pipe (하) 포스팅을 참고해주세요.

 

전역 Interceptor 구현

API가 반환하는 양식이 모두 통일되었으면 좋겠습니다.
이는 API를 사용할 사람들에게는 정말 중요한 사항이며 응답 형식을 통일시키는 일은 Interceptor를 사용하는 것이
가장 적합합니다. 이를 전역으로 구성하게되면 모든 API에 적용할 수 있어 매우 편리해집니다.

양식은 아래와 같도록 통일시킬 예정이며, statusCode는 HttpCode, oData는 반환되는 데이터입니다.

{
  "statusCode": 200,
  "oData": {}
}

 

CLI로 빠르게 ResponseInterceptorcore/interceptors 폴더 아래에 생성하겠습니다.

$ nest generate interceptor core/interceptors/response

이어서 RxJS의 pipemap으로 양식을 일치시키도록 하겠습니다.

import {
  CallHandler,
  ExecutionContext,
  Injectable,
  NestInterceptor,
} from '@nestjs/common';
import { map, Observable } from 'rxjs';

@Injectable()
export class ResponseInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const handler = next.handle();
    return handler.pipe(
      map((data) => {
        const response = context.switchToHttp().getResponse();
        return {
          statusCode: response.statusCode,
          oData: data,
        };
      }),
    );
  }
}

 

index.ts를 생성해 export 경로를 관리하겠습니다.

export { ResponseInterceptor } from './response.interceptor';

 

마지막으로 AppModule에서 커스텀 Provider의 방식으로 전역으로 구성되면 끝입니다.

import { Module, ValidationPipe } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { MongooseModule } from '@nestjs/mongoose';
import mongoConfigFactory from './configs/mongo.config';
import secretConfigFactory from './configs/secret.config';
import { APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core';
import { ResponseInterceptor } from './core/interceptors/response';

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
      load: [mongoConfigFactory, secretConfigFactory],
    }),
    MongooseModule.forRootAsync({
      imports: [ConfigModule],
      inject: [ConfigService],
      useFactory: (config: ConfigService) => ({
        uri: config.get<string>('mongo.uri'),
        //자신의 Mongoose 버전이 6 이상이라면, useFindAndModify 옵션을 지워주어야 오류가 없습니다.
        // Mongoose 6 이상에서는 디폴트로 해당 값을 지원합니다.
        // useFindAndModify: false,
      }),
    }),
  ],
  providers: [
    {
      provide: APP_INTERCEPTOR,
      useClass: ResponseInterceptor,
    },
    {
      provide: APP_PIPE,
      useClass: ValidationPipe,
    },
  ],
})
export class AppModule {}
알림: Interceptor의 사용방법은 DAY12 - Interceptor를 참고해주세요.

 

전역 라우팅 접두사 구성

저희가 설계할 API들은 전부 /api라는 라우팅 접두사를 가지고 있었으면 좋겠습니다.

하지만 ApiController를 새로 만들어 라우팅 시키는것이 싫다면, main.ts에서 app.setGlobalPrefix('api')
전역 라우팅 접두사를 구성할 수 있습니다.

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

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

 

Schema 설계

API를 설계하기 전 MongoDB에 저장할 데이터들의
Schema를 설계하여 데이터베이스와 상호작용을  할 때 Model을 사용할 수 있도록 해야합니다.

이번 포스팅에서는 usertodo 두 개의 스키마를 설계해야 합니다.

알림: Schema의 설계 방법은 DAY22 - MongoDB를 참고해주세요.

 

User Schema

이번 프로젝트에서는 사용자 데이터가 많을 필요가 없습니다. 아래의 몇개 항목정도면 됩니다.

  • username: 사용자 이름, 필수 입력, 최소 6 최대 16의 길이
  • email: 이메일, 필수 입력
  • password: 비밀번호, 필수 입력, 최소 8 최대 20의 길이
  • role: 역할, 필수 입력, 허용되는 값은 admin, manager, member이며 기본값은 member

UserSchema를 설계하기 전에 먼저 필드의 최대값, 최소값 및 역할 목록을 상수와 열거형으로 정의하여 다른 곳에서도 동일한 제한 조건을 사용할 수 있도록 하겠습니다.

common/constants 폴더 아래 user.const.ts 파일을 만들겠습니다.

export const USER_USERNAME_MIN_LEN = 6; // username 최소 길이
export const USER_USERNAME_MAX_LEN = 16; // username 최대 길이

export const USER_PASSWORD_MIN_LEN = 8; // password 최소 길이
export const USER_PASSWORD_MAX_LEN = 20; // password 최대 길이

 

이어서 역할 목록을 열거형인 enum으로 정의해보겠습니다. common/enums 폴더 아래 role.enum.ts를 생성합니다.

export enum Role {
  ADMIN = 'admin',
  MANAGER = 'manager',
  MEMBER = 'member',
}

 

마지막으로 UserSchema를 정의하겠습니다.

common/models 폴더 아래 user.schema.ts를 생성하겠습니다.

import {
  ModelDefinition,
  Prop,
  raw,
  Schema,
  SchemaFactory,
} from '@nestjs/mongoose';
import {
  USER_USERNAME_MAX_LEN,
  USER_USERNAME_MIN_LEN,
} from '../constants/user.const';
import { Role } from '../enums/role.enum';

export type UserDocument = User & Document;

@Schema({ versionKey: false })
export class User {
  @Prop({
    required: true,
    minlength: USER_USERNAME_MIN_LEN,
    maxlength: USER_USERNAME_MAX_LEN,
  })
  username: string;

  @Prop({
    required: true,
  })
  email: string;

  @Prop({
    required: true,
    type: raw({
      hash: String,
      salt: String,
    }),
  })
  password: { hash: string; salt: string };

  @Prop({
    required: true,
    enum: Role,
    default: Role.MEMBER,
  })
  role: Role;
}

export const UserSchema = SchemaFactory.createForClass(User);

export const USER_MODEL_TOKEN = User.name;

export const UserDefinition: ModelDefinition = {
  name: USER_MODEL_TOKEN,
  schema: UserSchema,
};

 

password는 우리가 정의한 제한 조건에 정의 되어있지 않은걸 발견할 수 있는데,
이유는 데이터베이스에 입력할 값은 hashsalt이기 때문입니다.
이 제한 조건은 DTO의 데이터를 검증할 때 쓰일 예정입니다.

알림: 솔팅 암호화 기술에 대해서는 DAY23 - Authentication (상)을 참고해주세요.

 

Todo Schema

아래는 Todo에서 필요한 필드들입니다.

 

  • title: 투두 리스트의 제목입니다. 필수 입력, 최소 3자 이상, 최대 20자 이하
  • description: 투두 리스트의 상세 설명입니다. 선택 입력, 최대 길이 200자 이하
  • completed:  일이 완료되었는지의 여부입니다. 필수 입력, 기본값은 false

제한조건을 다시 상수로 선언할 예정입니다. common/constants 폴더 아래 todo.const.ts를 만들어 정의해줍니다.

 

export const TODO_TITLE_MIN_LEN = 3; // title 최소 길이
export const TODO_TITLE_MAX_LEN = 20; // title 최대 길이

export const TODO_DESCRIPTION_MAX_LEN = 200; // description 최대 길이

 

마지막으로 common/modelstodo.model.ts를 정의하겠습니다.

import { ModelDefinition, Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import {
  TODO_DESCRIPTION_MAX_LEN,
  TODO_TITLE_MAX_LEN,
  TODO_TITLE_MIN_LEN,
} from '../constants/todo.const';

export type TodoDocument = Todo & Document;

@Schema({ versionKey: false })
export class Todo {
  @Prop({
    required: true,
    minlength: TODO_TITLE_MIN_LEN,
    maxlength: TODO_TITLE_MAX_LEN,
  })
  title: string;

  @Prop({
    maxlength: TODO_DESCRIPTION_MAX_LEN,
  })
  description?: string;

  @Prop({
    required: true,
    default: false,
  })
  completed: boolean;
}

export const TodoSchema = SchemaFactory.createForClass(Todo);

export const TODO_MODEL_TOKEN = Todo.name;

export const TodoDefinition: ModelDefinition = {
  name: TODO_MODEL_TOKEN,
  schema: TodoSchema,
};

 

마치며

오늘은 먼저 기초 공사를 다졌다고 생각하면 됩니다.
(ex: 환경변수, MongoDB 연결, Schema 구성, 반환 양식 통일 등)

뒤에서 개발할 사항들은 오늘 다졌던 초석을 기반으로 계속해 고도화 해보겠습니다.

다음 포스팅에서는 API 설계가 예정되어 있습니다! 기대해주세요.