class-validator의 IsMongoId: MongoDB ObjectId 검증

class-validator의 IsMongoId: MongoDB ObjectId 검증

D
dongAuthor
7 min read

NestJS로 API를 개발하다 보면 MongoDB ObjectId 형식의 파라미터를 검증해야 하는 경우가 자주 생깁니다. 잘못된 형식의 ID가 들어오면 데이터베이스 쿼리 오류가 발생하고, 사용자에게는 불친절한 에러 메시지가 전달되죠.

class-validator@IsMongoId() 데코레이터를 사용하면 이 문제를 간단하게 해결할 수 있습니다. DTO에 데코레이터 하나만 추가하면 MongoDB ObjectId 검증이 자동으로 이루어지고, 잘못된 요청은 컨트롤러에 도달하기 전에 차단됩니다.

이 글에서는 @IsMongoId()의 기본 사용법부터 내부 구현, 실전 활용법, 그리고 커스텀 검증까지 모두 다뤄볼게요.

Class-Validator와 NestJS의 만남

NestJS는 TypeScript 기반의 강력한 서버 프레임워크입니다. 그중에서도 class-validator는 NestJS 공식 문서에서 권장하는 검증 라이브러리로, 데코레이터 기반의 직관적인 문법으로 입력 데이터를 검증할 수 있게 해줍니다.

class-validator를 도입하면 서비스 레이어에서 일일이 검증 로직을 작성할 필요가 없어집니다. DTO에 데코레이터를 추가하는 것만으로 요청 데이터의 구조를 제어할 수 있죠. 실제로 많은 개발자들이 class-validator 도입 후 제품의 안정성과 개발 편의성이 크게 향상되었다고 이야기합니다.

@IsMongoId()란 무엇인가?

정의와 목적

@IsMongoId()class-validator에서 제공하는 내장 데코레이터로, 문자열이 MongoDB ObjectId 형식(24자 16진수)인지 검증합니다.

MongoDB에서 각 문서는 고유한 _id 필드를 가지며, 이는 12바이트 BSON 타입의 ObjectId입니다. 문자열로 표현하면 24자리 16진수 형태가 되는데, @IsMongoId()는 바로 이 형식을 검증하는 역할을 합니다.

기본 사용 예제

import { IsMongoId, IsNotEmpty } from 'class-validator';

export class FindUserDto {
  @IsNotEmpty()
  @IsMongoId()
  userId: string;
}

위 예제에서 userId는 반드시 비어있지 않은 MongoDB ObjectId 형식이어야 합니다. 만약 잘못된 형식의 ID가 전달되면 다음과 같은 에러 응답이 반환됩니다:

{
  "statusCode": 400,
  "message": ["userId must be a mongodb id"],
  "error": "Bad Request"
}

validator.js 활용

@IsMongoId()의 내부 구현을 살펴보면 흥미로운 점을 발견할 수 있습니다:

import { ValidationOptions } from '../ValidationOptions';
import { buildMessage, ValidateBy } from '../common/ValidateBy';
import isMongoIdValidator from 'validator/lib/isMongoId';

export const IS_MONGO_ID = 'isMongoId';

export function isMongoId(value: unknown): boolean {
  return typeof value === 'string' && isMongoIdValidator(value);
}

export function IsMongoId(validationOptions?: ValidationOptions): PropertyDecorator {
  return ValidateBy(
    {
      name: IS_MONGO_ID,
      validator: {
        validate: (value): boolean => isMongoId(value),
        defaultMessage: buildMessage(
          eachPrefix => eachPrefix + '$property must be a mongodb id',
          validationOptions
        ),
      },
    },
    validationOptions
  );
}

핵심 포인트는 다음과 같습니다:

  • validator.js 의존: validator/lib/isMongoId를 사용해 실제 검증을 수행합니다.
  • 타입 제한: string 타입만 허용합니다.
  • 정규식 기반: 24자 16진수 형식을 정규식으로 검증합니다.
  • 커스터마이징 가능: validationOptions를 통해 에러 메시지를 변경할 수 있습니다.

NestJS 프로젝트에 Class-Validator 설정하기

설치

먼저 필요한 패키지를 설치합니다:

npm i --save class-validator class-transformer

class-transformer는 plain object를 class instance로 변환하는 데 필요한 라이브러리입니다.

ValidationPipe 적용

NestJS에서 class-validator의 데코레이터를 인식하려면 ValidationPipe를 사용해야 합니다. 전역으로 적용하거나 특정 컨트롤러/라우트에만 적용할 수 있습니다.

전역 적용 (main.ts):

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

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalPipes(
    new ValidationPipe({
      whitelist: true,
      forbidNonWhitelisted: true,
      transform: true,
    })
  );
  await app.listen(3000);
}
bootstrap();

컨트롤러 레벨 적용:

import { Controller, Post, Body, UsePipes, ValidationPipe } from '@nestjs/common';

@Controller('users')
export class UserController {
  @Post('find')
  @UsePipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true }))
  async findUser(@Body() dto: FindUserDto) {
    return this.userService.findUserById(dto.userId);
  }
}

주요 옵션:

  • whitelist: DTO에 정의되지 않은 속성을 자동으로 제거합니다.
  • forbidNonWhitelisted: DTO에 없는 속성이 포함되면 요청을 거부합니다.
  • transform: plain object를 DTO 클래스 인스턴스로 자동 변환합니다.

DTO에서 @IsMongoId() 활용하기

실전 예제: 사용자 조회 API

// user.dto.ts
import { IsMongoId, IsNotEmpty } from 'class-validator';

export class GetUserByIdDto {
  @IsNotEmpty({ message: 'userId는 필수입니다.' })
  @IsMongoId({ message: 'userId는 유효한 MongoDB ObjectId여야 합니다.' })
  userId: string;
}
// user.controller.ts
import { Controller, Post, Body } from '@nestjs/common';

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

  @Post('find')
  async findUser(@Body() dto: GetUserByIdDto) {
    console.log(dto.userId);
    return this.userService.findUserById(dto.userId);
  }
}

유효한 요청:

{
  "userId": "507f191e810c19729de860ea"
}

무효한 요청:

{
  "userId": "12345"
}

응답:

{
  "statusCode": 400,
  "message": ["userId는 유효한 MongoDB ObjectId여야 합니다."],
  "error": "Bad Request"
}

요청 파라미터 검증

쿼리 파라미터나 URL 파라미터에서도 동일하게 사용할 수 있습니다:

import { Controller, Get, Param } from '@nestjs/common';
import { IsMongoId } from 'class-validator';

export class UserIdParam {
  @IsMongoId()
  id: string;
}

@Controller('users')
export class UserController {
  @Get(':id')
  async getUser(@Param() params: UserIdParam) {
    return this.userService.findById(params.id);
  }
}

흔한 문제와 해결책

빈 문자열 처리

@IsMongoId()는 빈 문자열을 검증하지 않습니다. 빈 문자열도 거부하려면 @IsNotEmpty()를 함께 사용하세요:

export class CreateNewsDto {
  @IsNotEmpty()
  @IsString()
  title: string;

  @IsNotEmpty()
  @IsString()
  content: string;

  @IsNotEmpty()
  @IsString()
  @IsMongoId()
  pageId: string;

  @IsOptional()
  @IsString()
  @IsMongoId()
  ownerId: string;
}

@IsOptional()을 사용하면 해당 필드가 없거나 undefined일 때는 검증을 건너뜁니다.

커스텀 에러 메시지

기본 에러 메시지가 마음에 들지 않는다면 커스터마이징할 수 있습니다:

export class FindUserDto {
  @IsMongoId({ message: '올바른 사용자 ID 형식이 아닙니다.' })
  userId: string;
}

다국어 지원이 필요하다면 i18n 라이브러리와 결합해 사용할 수도 있습니다.

고급 활용법과 커스터마이징

다른 검증 데코레이터와 결합

여러 데코레이터를 조합해 더 정교한 검증 규칙을 만들 수 있습니다:

import { IsMongoId, IsOptional, IsArray, ArrayMinSize } from 'class-validator';

export class AssignTasksDto {
  @IsArray()
  @ArrayMinSize(1)
  @IsMongoId({ each: true, message: '각 항목은 유효한 MongoDB ObjectId여야 합니다.' })
  userIds: string[];

  @IsMongoId()
  projectId: string;
}

{ each: true } 옵션을 사용하면 배열의 각 요소를 개별적으로 검증합니다.

커스텀 검증기: DB 존재 여부 확인

@IsMongoId()는 형식만 검증할 뿐, 해당 ID가 실제로 데이터베이스에 존재하는지는 확인하지 않습니다. 이를 위한 커스텀 검증기를 만들어볼까요?

import { registerDecorator, ValidationOptions, ValidationArguments, ValidatorConstraint, ValidatorConstraintInterface } from 'class-validator';
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { Model } from 'mongoose';

@ValidatorConstraint({ name: 'IsExistingMongoId', async: true })
@Injectable()
export class IsExistingMongoIdConstraint implements ValidatorConstraintInterface {
  constructor(
    @InjectModel('User') private readonly userModel: Model<any>,
  ) {}

  async validate(value: string, args: ValidationArguments): Promise<boolean> {
    const modelName = args.constraints[0];
    
    if (modelName === 'User') {
      const user = await this.userModel.findById(value).exec();
      return !!user;
    }
    
    return false;
  }

  defaultMessage(args: ValidationArguments): string {
    return `${args.property}에 해당하는 문서가 존재하지 않습니다.`;
  }
}

export function IsExistingMongoId(
  modelName: string,
  validationOptions?: ValidationOptions,
): PropertyDecorator {
  return (object, propertyName) => {
    registerDecorator({
      name: 'IsExistingMongoId',
      target: object.constructor,
      propertyName,
      constraints: [modelName],
      options: validationOptions,
      validator: IsExistingMongoIdConstraint,
    });
  };
}

사용법:

export class UpdateUserDto {
  @IsExistingMongoId('User', { message: '존재하지 않는 사용자입니다.' })
  userId: string;
}

커스텀 검증기는 비동기 작업을 지원하므로 데이터베이스 쿼리를 통한 검증이 가능합니다. 다만 성능에 영향을 줄 수 있으니 필요한 경우에만 사용하세요.

@IsMongoId()로 더 안전한 API 만들기

@IsMongoId()는 작지만 강력한 도구입니다. 이 데코레이터 하나로 다음과 같은 이점을 얻을 수 있습니다:

  • 코드 간소화: 서비스 레이어에서 반복되는 검증 로직을 제거할 수 있습니다.
  • 일관성: 모든 MongoDB ObjectId 검증이 동일한 방식으로 이루어집니다.
  • 에러 핸들링: 잘못된 요청을 조기에 차단해 불필요한 데이터베이스 쿼리를 방지합니다.
  • 유지보수성: DTO를 보는 것만으로 어떤 검증이 이루어지는지 명확하게 알 수 있습니다.

NestJS 프로젝트에서 MongoDB를 사용한다면 @IsMongoId()는 필수 도구입니다. 기본 사용법부터 커스텀 검증기까지 다양한 활용법을 익혀두면 더 견고하고 안전한 API를 만들 수 있을 거예요.

지금 바로 여러분의 프로젝트에 class-validator를 설치하고 @IsMongoId()를 적용해보세요. 작은 변화가 큰 차이를 만들어낼 겁니다!