IsDecimal은 왜 숫자를 검증하지 못할까?

IsDecimal은 왜 숫자를 검증하지 못할까?

D
dongAuthor
4 min read

NestJS에서 class-validator는 데이터 유효성 검사를 아주 간단하게 만들어주는 강력한 도구입니다. DTO(Data Transfer Object)에 데코레이터 몇 개만 추가하면, 들어오는 요청의 형식을 손쉽게 제어할 수 있죠. 하지만 가끔 예상과 다르게 동작하는 데코레이터 때문에 골머리를 앓기도 합니다. 그중 하나가 바로 @IsDecimal입니다.

분명히 소수점 형태의 숫자를 보냈는데도 "유효한 10진수가 아닙니다"라는 오류가 발생하는 상황, 겪어보셨나요? 이 글에서는 @IsDecimal이 왜 이런 문제를 일으키는지, 그리고 NestJS에서 소수점 숫자를 올바르게 검증하는 방법은 무엇인지 명확하게 알려드리겠습니다.

@IsDecimal의 오해와 진실

많은 개발자가 @IsDecimal을 숫자 타입의 소수점 값을 검증하는 데 사용하려고 시도합니다. 하지만 Stack Overflow의 여러 논의에서 확인할 수 있듯, @IsDecimal문자열(string) 타입의 값이 10진수 형식에 맞는지만 확인하는 데코레이터입니다.

실제로 class-validatorGitHub 이슈(#1423)를 보면, @IsDecimal의 설명 주석이 잘못되어 혼란을 야기했던 과거 기록도 찾아볼 수 있습니다. 현재는 수정되었지만, 여전히 많은 개발자가 이 부분을 헷갈려 합니다.

다음과 같은 코드를 한번 보시죠.

// create-product.dto.ts

import { IsDecimal, IsNotEmpty, Min } from 'class-validator';

export class CreateProductDto {
  @IsDecimal({ decimal_digits: '2' }) // 🚨 이 부분에서 문제가 발생합니다!
  @IsNotEmpty()
  @Min(0)
  price: number; // 타입이 'number'입니다.
}

이 DTO에 JSON 형태로 { "price": 1574.23 }과 같은 요청을 보내면, class-validatorprice 필드가 숫자 타입이기 때문에 @IsDecimal 검사를 통과시키지 못하고 오류를 반환합니다.

그럼 숫자 타입의 소수점은 어떻게 검증해야 할까요? 두 가지 해결책이 있습니다.

해결책 1: @IsNumber 사용하기

가장 간단하고 직관적인 해결책은 @IsNumber 데코레이터를 사용하는 것입니다. @IsNumber는 숫자 타입의 값을 검증하며, maxDecimalPlaces 옵션을 통해 허용할 최대 소수점 자릿수를 지정할 수 있습니다.

// create-product.dto.ts (수정 후)

import { IsNumber, IsNotEmpty, Min } from 'class-validator';

export class CreateProductDto {
  @IsNumber({ maxDecimalPlaces: 2 }) // ✅ 이렇게 수정해주세요!
  @IsNotEmpty()
  @Min(0)
  price: number;
}

이렇게 수정하면 price 필드가 숫자이면서 소수점 이하 두 자리까지만 허용하도록 정확하게 검증할 수 있습니다.

: 데이터베이스의 정밀도를 보장하기 위해, TypeORM 같은 ORM을 사용한다면 엔티티(Entity) 파일에서 @Column('decimal') 데코레이터를 함께 정의해주는 것이 좋습니다.

해결책 2: 커스텀 데코레이터 만들기

조금 더 복잡한 유효성 검사 규칙이 필요하다면 커스텀 데코레이터를 직접 만드는 방법도 있습니다. exonerate와 같은 라이브러리를 사용하면 여러 규칙을 조합하여 강력한 커스텀 데코레이터를 쉽게 만들 수 있습니다.

먼저, 라이브러리를 설치해 주세요.

npm install exonerate

그다음, 다음과 같이 DTO에서 @Exonerate 데코레이터를 사용하여 다양한 규칙을 적용할 수 있습니다.

// create-user.dto.ts (exonerate 사용 예시)

import { Exonerate } from 'exonerate';
import { User } from './user.entity'; // User 엔티티가 있다고 가정
import { AddressDto } from './address.dto'; // Address DTO가 있다고 가정

enum ROLE {
  ADMIN = 'ADMIN',
  USER = 'USER',
}

export class CreateUserDto {
    @Exonerate({ rules: 'required|string|max:20|min:4|exist:name', entity: User })
    name: string;

    @Exonerate({ rules: 'required|email|unique:email', entity: User })
    email: string;

    @Exonerate({
        rules: 'required|max:20|min:8|pattern',
        regexPattern: /^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{8,}$/
    })
    password: string;

    @Exonerate({ rules: 'required|enum', enumType: ROLE })
    role: string;
}

exonerate는 소수점 검증뿐만 아니라, 이메일 중복 확인(unique:email), 특정 패턴 검사(pattern) 등 복합적인 규칙을 간결하게 표현할 수 있게 도와줍니다. 프로젝트 전반에 걸쳐 일관되고 재사용 가능한 유효성 검사 로직이 필요할 때 아주 유용합니다.

전역 파이프 설정하기

class-validator가 애플리케이션 전역에서 동작하게 하려면 main.ts 파일에 ValidationPipe를 추가해야 합니다. 이 설정을 잊지 마세요!

// main.ts

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

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  
  // ✅ 전역 유효성 검사 파이프를 적용합니다.
  app.useGlobalPipes(new ValidationPipe());
  
  await app.listen(3000);
}
bootstrap();

또한, 환경 변수 등을 사용한다면 app.module.tsConfigModule 설정을 추가하는 것도 일반적입니다.

// app.module.ts

import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
      envFilePath: '.env',
    }),
    // ... 다른 모듈들
  ],
  controllers: [],
  providers: [],
})
export class AppModule {}

올바른 도구를 올바른 곳에

이번 여정을 통해 우리는 중요한 교훈을 얻었습니다. @IsDecimal은 문자열을 위한 도구이며, 숫자 타입의 소수점 값을 검증할 때는 @IsNumber를 사용해야 한다는 사실입니다.

코드를 작성할 때 데코레이터나 함수의 설명을 꼼꼼히 확인하는 습관은 예상치 못한 버그를 막아주는 훌륭한 방패가 됩니다. 만약 공식 문서의 설명이 부족하거나 혼란스럽다면, 주저하지 말고 GitHub 이슈나 커뮤니티에 질문하여 명확히 이해하고 넘어가는 것이 좋겠죠 !

References