NestJS 帶你飛! 시리즈 번역 22# MongoDB

2023. 6. 20. 00:10개발 문서 번역/NestJS

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

 

백엔드단의 코드를 작성할 때 반드시 사용되는것 중 하나는 데이터베이스 입니다.

데이터베이스는 주로 유저와 관련있는 자료들을 저장하는데 쓰입니다. 종류도 무척이나 다양하구요.

본 포스팅에선 가장 핫한 NoSQL 데이터베이스인 MongoDB를 사용할 예정이고, Nest와 어떻게 상호 작용을 하는지에 대해 소개하겠습니다.

MongoDB에 익숙하지 않거나, 클라우드 환경에서 MongoDB를 사용하고자 한다면 이 포스팅(중국어)을 확인해주세요.

바로 시작해봅시다!

 

mongoose 설치

node.js와 MongoDB간에 가장 유명한 라이브러리는 Mongoose입니다.
Mongoose는 schema-based의 ODM 패키지로, Node.js 환경에서 MongoDB간 소통하는 데 사용됩니다.

Nest는 mongoose를 래핑하여 Mongoose를 개발자가 좀 더 쉽고 편리하게 사용할 수 있도록 MongooseModule을 제공하고 있습니다.

설치 방법은 npm을 통해 설치할 수 있는데
여기서 주의할것은 Nest가 제작한 모듈들 외에 mongoose 자체를 설치해주어야 합니다. 명령어는 아래와 같습니다.

$ npm install @nestjs/mongoose mongoose

 

MongoDB 연결

관련 패키지를 설치한 후 데이터베이스 연결을 구현해보겠습니다. 방법도 무척 간단하니 안심하고 따라와주세요!

AppModule에서 MongooseModule을 가져와 forRoot 메서드를 사용해 연결을 설정할 수 있습니다. 이 메서드는 mongooseconnect 메서드와 같은 역할을 하고 있습니다.

아래는 app.module.ts의 예제입니다. 상수 MONGO를 선언하여 연결과 관련된 정보를 정의한 후 MongooseModule.forRoot에서 getUrl 메서드를 호출하겠습니다.

주의: 일반적으로 민감한 정보들은 코드에 적지 않습니다. 환경변수에 따로 분리하여 놓는게 일반적입니다.
import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { AppController } from './app.controller';
import { AppService } from './app.service';

const MONGO = {
  username: '<Example>',
  password: encodeURIComponent('<YOUR_PASSWORD>'),
  getUrl: function () {
    return `mongodb+srv://${this.username}:${this.password}@<YOUR_DB>`
  }
};

@Module({
  imports: [
    MongooseModule.forRoot(MONGO.getUrl())
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

환경변수 사용하기

MongooseModuleforRootAsync 메서드를 제공합니다. 해당 메서드를 통해 의존성 항목들을 주입하여 MongooseModule이 생성될 때 해당 의존성 항목들의 값을 가져올 수 있습니다.

해당 특성을 활용하여 ConfigModule을 가져와 ConfigService를 주입 후 ConfigService를 통해 환경 변수를 가져와 MongoDB 환경을 구성할 수 있습니다.

 

.env 파일을 하나 생성하고, 아래와 같이 작성해보겠습니다. 이는 MongoDB와 관련된 정보들입니다.

MONGO_USERNAME=YOUR_USERNAME
MONGO_PASSWORD=YOUR_PASSWORD
MONGO_RESOURCE=YOUR_RESOURCE

이어서 앞의 포스팅에서 배웠던 네임스페이스를 활용해보겠습니다.
MongoDB와 관련있는 환경 변수들을 mongo라는 네임스페이스로 그룹화하여 사용하고, 환경 변수를 처리해보겠습니다.

src/config 경로에 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 };
});

app.module.ts를 수정하고 ConfigModuleMongooseModule을 설정해봅시다.
forRootAsync 메서드에 의존성을 전달한 후 ConfigService에서 가져온 값을 useFactory를 통해 반환하겠습니다.

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

import { AppController } from './app.controller';
import { AppService } from './app.service';

import MongoConfigFactory from './config/mongo.config';

@Module({
  imports: [
    ConfigModule.forRoot({
      load: [MongoConfigFactory]
    }),
    MongooseModule.forRootAsync({
      imports: [ConfigModule],
      inject: [ConfigService],
      useFactory: async (config: ConfigService) => ({
        uri: config.get<string>('mongo.uri')
      })
    })
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

 

이제 환경변수를 통해 MongooseModule을 설정할수 있게 되었습니다!

 

mongoose의 개념

데이터베이스를 통해 자료를 저장하기 전에, 먼저 mongoose의 기본 개념에 대해 알고가야 합니다. 

먼저 mongoose는 크게 2개 schema, model로 나눌 수 있습니다.

 

Schema

MongoDB의 가장 기본이 되는 요소는 document 입니다. 이는 하나의 데이터 항목을 나타내며,
각 문서들이 모여 하나의 collection이 됩니다.

그림으로 표현한다면 아래와 같습니다.

왜 MongoDB의 기본개념에 대해 한번 더 짚고 넘어가야 할까요?

바로 schema와 밀접한 관련이 있기 때문입니다. 각각의 schemacollection에 대응하는데 해당 collection 내의 모든 document의 필드와 필드 규칙을 정의합니다. 

 

Model

schema로 데이터 구조를 정의했지만 해당 schema로는 데이터베이스에 접근할수는 없습니다.

schema는 단지 규칙만을 정의했을 뿐이고 데이터베이스에 접근, 조작하는 요소는 model이기 때문이죠.

모든 modelschema를 기반으로 생성되며 model을 통해 schema가 관리하는 collection을 조작할 수 있습니다.
또한 model을 사용하여 해당 collection에 대해 생성,수정,조회 등의 작업을 수행할 수 있으며 이 작업들은 schema가 정의한 필드에 따라 수행됩니다.

Schema 설계

Nest에서 schema를 설계하는 방법은 두가지가 있습니다.
하나는 mongoose의 방식으로 설계하거나, 다른 하나는 Nest에서 제공하는 데코레이터를 사용해 정의할 수 있습니다.
이 포스팅에선 Nest 데코레이터를 사용한 방법을 위주로 소개하겠습니다.

mongoose를 통해 설계하는 방법이 궁금하시다면 작년에 작성한 글(중국어)을 참고해주세요.

Nest 데코레이터를 사용해 설계된 schema@Schema@Prop으로 구성됩니다.

 

Schema 데코레이터

@Schema 데코레이터는 classschema 식으로 정의하고 하나의 파라미터를 받을 수 있는데요.

해당 파라미터는 mongooseschema 옵션 매개변수를 받을 수 있습니다. 자세한 내용은 공식 문서를 참고해주세요.

먼저 간단하게 Todo라는 이름의 class를 선언하고 @Schema 데코레이터를 사용해 src/common/models 경로에 todo.model.ts라는 파일을 생성해보겠습니다.

import { Schema } from '@nestjs/mongoose';

@Schema()
export class Todo {}

Prop 데코레이터

@Prop 데코레이터는 document의 필드를 정의합니다.
이 데코레이터는 class의 속성으로 사용되며 기본적으로 타입 추론 기능을 제공하여 개발자가 간단한 타입은
별도로 지정해주지 않고도 사용할수 있게 해줍니다.
하지만 배열이나 중첩된 객체와 같이 복잡한 타입의 경우 @Prop에 매개변수를 전달하여 명시적으로 타입을 지정해주어야 합니다.

이 매개변수는 mongooseSchemaType과 대응됩니다. 보다 자세한 내용은 공식 문서를 참고해주세요.

 

이 포스팅에서는 todo.model.ts의 내용을 수정하여 @Prop을 구현해보겠습니다.

title, descriptioncompleted 세개의 필드를 가지고있으며, titlecompleted는 필수 입력, title은 최대 20글자, description은 최대 200글자 입력을 허용하겠습니다.

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

@Schema()
export class Todo {

  @Prop({ required: true, maxlength: 20 })
  title: string;

  @Prop({ maxlength: 200 })
  description: string;

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

}

Document 타입 생성하기

schemadocument의 데이터 구조를 정의하고 modelschema를 기반으로 생성되는것을 배웠습니다.

여기서 model이 최종적으로 반환하는것이 document라는 것을 간단히 유추해낼 수 있습니다.

mongoose는 기본적인 Document 타입을 제공하지만, documentschema에 정의된 데이터 구조에 따라 다를 수 있기때문에 type을 정의하여 modelschema에 정의된 필드들을 제대로 가져올 수 있도록 하여야 합니다. 

 

먼저 todo.model.ts를 수정하여 TodDocument의 타입을 정의 하겠습니다.

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

export type TodoDocument = Todo & Document;

@Schema()
export class Todo {

  @Prop({ required: true, maxlength: 20 })
  title: string;

  @Prop({ maxlength: 200 })
  description: string;

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

}

 

중첩 객체 타입

필드중 일부가 중첩 객체(이중 객체)인 경우에는 어떻게 처리해야 할까요? 이 경우에는 raw라는 함수를 사용하여 처리할 수 있습니다. 이 함수를 사용하면 개발자가 추가로 class를 정의하지 않고도 mongoose의 작성 방식으로 필드의 타입과 규칙을 정의할 수 있게 됩니다.

 

위의 설명이 조금 이해하기 어려울수도 있습니다. 실제 예제를 통해서 이해하는것이 좀 더 이해하기 쉬울것입니다.

여기서는 src/common/models 폴더에 user.model.ts파일을 만들고 해당 스키마를 설계 하겠습니다. name 필드를정의하고 해당 필드에는 firstName, lastName, fullName의 필드를 구성해 중첩 객체를 만들어보겠습니다.

import { Prop, raw, Schema, SchemaFactory } from '@nestjs/mongoose';
import { Document } from 'mongoose';

export type UserDocument = User & Document;

@Schema()
export class User {

  @Prop(
    raw({
      firstName: { type: String },
      lastName: { type: String },
      fullName: { type: String }
    })
  )
  name: Record<string, any>;
  
  @Prop({ required: true })
  email: string;

}

관계 생성

mongoosepopulate 메서드를 사용하여
다른 collection의 데이터를 찾고 관련 collection 사이의 관계를 형성할 수 있습니다.
이를 통해 다른 collection의 데이터를 참조하여 가져올 수 있습니다.

todo.model.ts을 수정하여 owner 필드를 추가하고 mongooseObjectIdUser간의 관계를 생성해보겠습니다.

 

Schema 생성

schema를 설계한 후 SchemaFactorycreateForClass 메서드를 사용해 해당 schema를 생성해주어야 합니다.

아래는 todo.model.tsuser.model.ts의 예제입니다.

 

import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { Document, Types } from 'mongoose';
import { User } from './user.model';

export type TodoDocument = Todo & Document;

@Schema()
export class Todo {

  @Prop({ required: true, maxlength: 20 })
  title: string;

  @Prop({ maxlength: 200 })
  description: string;

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

  @Prop({ type: Types.ObjectId, ref: 'User' })
  owner: User;

}

export const TodoSchema = SchemaFactory.createForClass(Todo);

 

 

import { Prop, raw, Schema, SchemaFactory } from '@nestjs/mongoose';
import { Document } from 'mongoose';

export type UserDocument = User & Document;

@Schema()
export class User {

  @Prop(
    raw({
      firstName: { type: String },
      lastName: { type: String },
      fullName: { type: String }
    })
  )
  name: Record<string, any>;
  
  @Prop({ required: true })
  email: string;

}

export const UserSchema = SchemaFactory.createForClass(User);

 

이렇게 schema를 구현해보았습니다. 정말 편리하지 않나요?

 

Model 사용

schema를 완성시킨 후에는 model을 구현해야겠죠?
먼저 UserModule, UserControllerUserService를 생성하여 API를 구현할 준비를 해봅시다.

$ nest generate module features/user
$ nest generate controller features/user
$ nest generate service features/user

MongooseModuleforFeature 메서드를 제공하여 MongooseModule을 구성하고
해당 범위내 필요한 model을 정의할 수 있습니다. 사용 방법은 정말 간단합니다.

사용할 schema와 대응하는 해당 collection의 이름이 포함된 배열을 제공하면 되는데요.
일반적으로 우리는 schemaclass 이름을 값으로 사용하는걸 선호합니다.

최종적으로 대응되는 collection은 명칭 + s 라는 말입니다.
예를 하나 들면 User에 대응되는것은 collectionusers입니다.

 

user.module.ts 파일을 수정하여 MongooseModule을 가져오고
UsermodelUserModule의 범위 내에서 사용하도록 변경하겠습니다.

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

import { User, UserSchema } from '../../common/models/user.model';
import { UserController } from './user.controller';
import { UserService } from './user.service';

@Module({
  imports: [
    MongooseModule.forFeature([
      { name: User.name, schema: UserSchema }
    ])
  ],
  controllers: [UserController],
  providers: [UserService]
})
export class UserModule {}

 

정의한 후에 @InjectModel을 사용해 User modelUserService에 주입하고 UserDocument 형식을 지정하겠습니다.

import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';

import { Model } from 'mongoose';

import { User, UserDocument } from '../../common/models/user.model';

@Injectable()
export class UserService {

  constructor(
    @InjectModel(User.name) private readonly userModel: Model<UserDocument>
  ) {}

}

 

생성 (Create)

user.service.ts를 수정해 create 메서드를 생성하고

userModelcreate 메서드를 호출하여 user collection에 사용자를 생성해보겠습니다.

import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';

import { Model } from 'mongoose';

import { User, UserDocument } from '../../common/models/user.model';

@Injectable()
export class UserService {

  constructor(
    @InjectModel(User.name) private readonly userModel: Model<UserDocument>
  ) {}

  create(user: any) {
    return this.userModel.create(user);
  }

}

 

이번에는 user.controller.ts를 수정하여 사용자를 생성하기 위한
POST 메서드를 하나 설계한 후 UserDocument를 프론트단에 반환해주겠습니다.

import { Body, Controller, Post } from '@nestjs/common';
import { UserService } from './user.service';

@Controller('users')
export class UserController {
  
  constructor(
    private readonly userService: UserService
  ) {}

  @Post()
  create(@Body() body: any) {
    return this.userService.create(body);
  }

}

Postman을 통해 사용자를 생성하는 작업을 성공했다면, 결과는 아래와 같아야 합니다.

 

읽기 (Read)

user.service.ts의 내용을 수정하여 이번엔 읽기 기능을 구현해보겠습니다.

findById 메서드를 추가하고 userModelfindById를 메서드를 통해 id를 기준으로 유저의 자료를 읽어오겠습니다.

import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';

import { Model } from 'mongoose';

import { User, UserDocument } from '../../common/models/user.model';

@Injectable()
export class UserService {

  constructor(
    @InjectModel(User.name) private readonly userModel: Model<UserDocument>
  ) {}

  findById(id: string) {
    return this.userModel.findById(id);
  }

}

 

user.controller.ts를 수정하여 GET 메서드를 통해 사용자를 불러오고 UserDocument를 프론트단에 반환해주는 로직을 작성해보겠습니다.

import { Body, Controller, Get, Param } from '@nestjs/common';
import { UserService } from './user.service';

@Controller('users')
export class UserController {
  
  constructor(
    private readonly userService: UserService
  ) {}

  @Get(':id')
  findById(@Param('id') id: string) {
    return this.userService.findById(id);
  }

}

 

Postman을 통한 호출에 성공하였다면 결과는 아래와 같아야 합니다.

업데이트 (Update)

user.service.ts의 내용을 수정해 updateById 메서드를 하나 만들고, userModelfindByIdAndUpdate 메서드를 호출하여 id를 통해 사용자의 자료를 업데이트 하겠습니다.

import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';

import { Model } from 'mongoose';

import { User, UserDocument } from '../../common/models/user.model';

@Injectable()
export class UserService {

  constructor(
    @InjectModel(User.name) private readonly userModel: Model<UserDocument>
  ) {}

  updateById(id: string, data: any) {
    return this.userModel.findByIdAndUpdate(id, data, { new: true });
  }

}
주의: 위의 new 파라미터는 mongoose로 하여금 업데이트 후의 결과를 반환하게 합니다. 기본 값은 false입니다.

 

user.controller.ts의 내용을 수정하여 PATCH 메서드를 통해 사용자의 자료를 업데이트 하고, UserDocument를 프론트단에 반환해주도록 해보겠습니다.

 

import { Body, Controller, Param, Patch } from '@nestjs/common';
import { UserService } from './user.service';

@Controller('users')
export class UserController {
  
  constructor(
    private readonly userService: UserService
  ) {}

  @Patch(':id')
  updateById(
    @Param('id') id: string,
    @Body() body: any
  ) {
    return this.userService.updateById(id, body);
  }

}

 

Postman을 통한 호출에 성공하였다면 결과는 아래와 같아야 합니다.

삭제 (Delete)

user.service.ts의 내용을 수정하여 removeById 메서드를 하나 만들고
userModelremove 메서드를 호출하여 id를 통해 사용자의 자료를 삭제하도록 해보겠습니다.

import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';

import { Model } from 'mongoose';

import { User, UserDocument } from '../../common/models/user.model';

@Injectable()
export class UserService {

	constructor(
		@InjectModel(User.name) private readonly userModel: Model<UserDocument>
	) {}

	removeById(id: string) {
		return this.userModel.findOneAndRemove({ _id: id });
	}

}
역자 설명 : 원문 예제에서는 remove 메서드를 사용하였습니다.
하지만 최신 버전에서는 remove 메서드가 존재하지 않아 임시로 findOneAndRemove로 대체하였습니다.

 

user.controller.ts를 수정하여 DELETE 메서드로 사용자의 자료를 삭제하고 삭제된 자료를 클라이언트단에 반환해주겠습니다.

import { Controller, Delete, Param } from '@nestjs/common';
import { UserService } from './user.service';

@Controller('users')
export class UserController {
  
  constructor(
    private readonly userService: UserService
  ) {}

  @Delete(':id')
  removeById(@Param('id') id: string) {
    return this.userService.removeById(id);
  }

}

 

Postman을 통한 호출에 성공하였다면 결과는 아래와 같아야 합니다.

 

Hook 기능

mongooseschema 계층에서 사용할 수 있는 hook을 제공하고 있습니다. 

hook은 많은 기능들을 구현하는데 사용될 수 있습니다.

(ex: 저장하기 전 console에 내용을 출력하거나, 저장하기 전에 timestamp를 추가하는 등의 작업)

hook은 model이 생성 되기 전 등록되어야 하며, MongooseModuleforFeatureAsync 메서드를 사용해
팩토리 함수를 구현할 수 있고, 해당 팩토리 함수 내에서 hook을 등록할 수 있습니다.

 

아래는 user.module.ts의 예입니다.

UserSchemapre 메서드를 사용해 저장되기 전에 실행될 hook을 등록해보겠습니다.
아래는 save의 예제입니다.

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

import { User, UserDocument, UserSchema } from '../../common/models/user.model';
import { UserController } from './user.controller';
import { UserService } from './user.service';

@Module({
  imports: [
    MongooseModule.forFeatureAsync([
      {
        name: User.name,
        useFactory: () => {
          UserSchema.pre('save', function(this: UserDocument, next) {
            console.log(this);
            next();
          });
          return UserSchema;
        }
      }
    ])
  ],
  controllers: [UserController],
  providers: [UserService]
})
export class UserModule {}

 

Postman을 통해 사용자를 하나 생성하면, 터미널에는 아래와 같은 결과가 출력됩니다. 이는 hook이 성공적으로 실행되었음을 의미합니다.

{
  name: { firstName: 'Woo', lastName: 'TaeHyeon', fullName: 'Woo TaeHyeon' },
  email: 'test@example.com',
  _id: new ObjectId("64906e7d5227a46b3b406ebe")
}

 

마치며

데이터베이스는 백엔드의 없어서는 안될 존재입니다.
본 포스팅에서는 MongoDB를 예로 들었고 Nest에 패키징되어있는 mongoose를 통해 접근하였습니다.

아래는 오늘 학습의 요약본입니다.

 

1. mongooseschema-based의 ODM 패키지입니다.

2. schema는 특정 collection에 속하는 document의 데이터 구조를 정의하는데 사용됩니다.

3. schema를 사용하여 model을 생성하고 MongoDB에 엑세스 할 수 있습니다.

4. 각종 플러그인을 구현하기 위해선 hook을 잘 활용할줄 알아야합니다.
(ex: 데이터 저장 전 timestamp를 추가하는 등의 작업..)