Nestjs: MongoDB and Mongoose

대현·2021년 12월 6일
1

nestjs-reference

목록 보기
6/6

Mongoose

MongoDB를 사용하는 방법은 크게 두 가지인데 nest에 내장된 TypeORM을 사용하는 방법과 @nestjs/mongoose 패키지를 통해 Mongoose를 사용하는 방법으로 나뉜다. Express에서부터 Mongoose를 사용했기 때문에 이 글에서는 Mongoose만을 다룰 것이다.

절차

Mongoose를 이용해 MongoDB를 활용하기 위해서는 다음과 같은 절차가 필요하다.
1. 모듈에 MongoDB의 DB 주소를 등록한다.
2. 어떤 컬렉션을 선택할지를 지정한다.
3. 의존성을 주입해서 사용한다.

Database

기본적으로 MongoDB는 Database > Collection > Document 순으로 위계를 가지고 있기 때문에 도큐먼트에 접근하기 위해서는 데이터베이스에 먼저 연결하고 그 다음으로 컬렉션에 연결해야한다.

우선, 아래와 같이 모듈에 DB를 등록한다.

import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { ConfigModule } from '@nestjs/config';
import { UsersModule } from './users/users.module';

@Module({
  imports: [
    ConfigModule.forRoot(),
    MongooseModule.forRoot(process.env.MONGO_URL, {
      useCreateIndex: true,
    })],
  	UsersModule,
})
export class AppModule {}

MongooseModuleforRoot 메소드를 통해서 입력할 DB 주소를 지정하고 옵션을 추가할 수 있다. 필수 옵션은 버전 따라 다른데, 내가 사용했던 버전은 useCreateIndex를 필수로 요구했다. Mongoose 6 버전 이후부터는 매번 지정해야했던 필수 옵션들이 default로 들어가 있어 생략이 가능하지만, nest 8 버전에서 6 버전이 정상 작동하지 않아서 5 버전으로 다운그레이드해서 사용했다. 아래는 공식문서의 설명이다.

useNewUrlParser, useUnifiedTopology, useFindAndModify, and useCreateIndex are no longer supported options. Mongoose 6 always behaves as if useNewUrlParser, useUnifiedTopology, and useCreateIndex are true, and useFindAndModify is false. Please remove these options from your code.

추가로, process.env를 사용하기 위해 ConfigModule을 등록했다. 자세한 사항은 공식문서를 참조하자.

이로써 위의 모듈에 import된 모듈인 UserModule은 DB에 접근할 수 있게 되었다.

Collection

이제, UsersModule에서 어떤 컬렉션을 사용할지 지정해준다.

import { User, UserSchema } from './users.schema';
import { Module } from '@nestjs/common';
import { UsersService } from './users.service';
import { UsersController } from './users.controller';
import { MongooseModule } from '@nestjs/mongoose';

@Module({
  imports: [MongooseModule.forFeature([{ name: User.name, schema: UserSchema }])],
  providers: [UsersService],
  controllers: [UsersController],
})
export class UserssModule {}

MongooseModuleforFeature 메소드를 통해 컬렉션 이름과 스키마를 지정한다. 이를 통해 provider들은 이 컬렉션을 주입해서 사용할 수 있다.

스키마는 도큐먼트의 형태를 지정해주는 클래스인데 @Prop 데코레이터를 통해 각각의 프로퍼티(property)에 여러가지 옵션을 설정할 수 있다. 대표적으로 required, type, lowercase, default, get, set, index, unique등이 있다. 더 자세한 옵션 설명은 공식문서를 참조하자.

UserSchema의 코드의 예시는 다음과 같다.

import { Prop, Schema, SchemaOptions, SchemaFactory } from '@nestjs/mongoose';
import { IsString, IsEmail, IsNotEmpty, IsOptional } from 'class-validator';
import { Document } from 'mongoose';

const options: SchemaOptions = {
  timestamps: true,
  id: false,
};
@Schema(options)
export class User extends Document {
  @Prop({
    required: true,
    unique: true,
  })
  @IsEmail()
  @IsNotEmpty()
  email: string;

  @Prop()
  @IsString()
  @IsOptional()
  name: string;

  @Prop({
    required: true,
  })
  @IsString()
  @IsNotEmpty()
  password: string;
}
export const UserSchema = SchemaFactory.createForClass(User);

우선 스키마 옵션으로 id를 제거하고 timestamps를 통해 생성/수정된 시간을 저장하기로 했다. 스키마로 사용할 클래스에는 반드시 @Schema 데코레이터를 붙여줘야한다. 참고로 User 클래스의 베이스가 되는 Document는 id와 같이 개발자가 지정하지 않았더라도 mongodb에서 기본적으로 사용하는 프로퍼티 등을 제공해준다. 위의 예시에서는 id를 false로 설정했기 때문에 Document가 없어도 된다.

이제 @Prop 데코레이터를 통해서 프로퍼티들을 등록하고 세부적인 옵션들을 지정한다. class-validator는 사용자의 입력 해당 프로퍼티에 맞게 들어왔는지 확인하기 위해서 넣어준 것뿐이다.

Repository

이제, UserModule에 등록된 provider들은 주입을 통해서 컬렉션에 접근할 수 있다. 디자인 패턴을 고려해 다음과 같이 DB만을 관리하는 repository를 service로부터 분리해서 만들었다. 아래는 회원가입 서비스의 예시이다.

// users.repository.ts
import { CreateUserDto } from './dto/user.dto';
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { Model } from 'mongoose';
import { User } from './users.schema';

@Injectable()
export class UsersRepository {
  constructor(@InjectModel(User.name) private readonly userModel: Model<User>) {}

  async create(createUserDto: CreateUserDto): Promise<User> {
    // 내장된 exception filter가 해결할 수 있는 문제이기 때문에 trycatch를 하지 않아도 괜찮다.
    return await this.userModel.create(createUserDto);
  }
}

@InjextModel 데코레이터를 통해서 어떤 모델(스키마)를 가져올 것인지 지정할 수 있다.

// users.service.ts
import { CreateUserDto } from './dto/user.dto';
import { Injectable } from '@nestjs/common';
import { UsersRepository } from './uses.repository';
@Injectable()
export class UsersService {
  constructor(private readonly usersRepository: UsersRepository) {}
  
  async signup(createUserDto: CreateUserDto) {
    const { email, name, password } = createUserDto;
    return await this.usersRepository.create({
      email,
      name,
      password,
    });
  }
}

password는 해싱처리라도 해야하지만 이 글에서는 과정을 생략했다.

Protected Data

위의 방법대로 하면, 사용자를 생성한 뒤 반환값에 패스워드가 노출 된다. 이를 방지하기 위해서 다음과 같이 schema와 service를 변경할 수 있다.

import { Prop, Schema, SchemaOptions, SchemaFactory } from '@nestjs/mongoose';
import { IsString, IsEmail, IsNotEmpty, IsOptional } from 'class-validator';
import { Document } from 'mongoose';

const options: SchemaOptions = {
  timestamps: true,
  id: false,
};
@Schema(options)
export class User extends Document {
  @Prop({
    required: true,
    unique: true,
  })
  @IsEmail()
  @IsNotEmpty()
  email: string;

  @Prop()
  @IsString()
  @IsOptional()
  name: string;

  @Prop({
    required: true,
  })
  @IsString()
  @IsNotEmpty()
  password: string;

  // virtual 처리를 할지라도 반환값의 타입은 알아야하기 때문에 이렇게 지정해준다.
  readonly protectedData: {
    email: string;
    name: string;
  };
}
export const UserSchema = SchemaFactory.createForClass(User);

// virtual할 필드를 선택해서 어떤 값을 반환할지 설정해준다.
// password를 제외하고 나머지 필드만 선택한다.
// 스키마 접근은 this로 해야하기 때문에 화살표함수는 사용할 수 없다.
UserSchema.virtual('protectedData').get(function (this: User) {
  return {
    email: this.email,
    name: this.name,
  };
});

스키마에 virtual 필드 영역을 만든다. 이 필드는 DB에 저장되지 않는 특징을 가지고 있다. 클래스에 protectedData를 따로 설정한 이유는 단순히 타입 때문이다.

// users.service.ts
import { CreateUserDto } from './dto/user.dto';
import { Injectable } from '@nestjs/common';
import { UsersRepository } from './uses.repository';
@Injectable()
export class UsersService {
  constructor(private readonly usersRepository: UsersRepository) {}
  
  async signup(createUserDto: CreateUserDto) {
    const { email, name, password } = createUserDto;
    const user = await this.usersRepository.create({
      email,
      name,
      password,
    });
    return user.protectedData;
  }
}

반환값이었던 user에 protectedData로 접근을 해서 패스워드를 반환하지 못하도록 막으면 끝난다.

profile
백일까 프론트일까

0개의 댓글