백엔드/NestJS

[NestJS] 컨트롤러 파라미터 유효성 검증 방법 (Pipes, DTO, @Transform)

SparkIT 2024. 12. 4. 21:29

 

컨트롤러 단에서 유효성 검증하기

저는 nest js를 이용한 백엔드 개발 시 컨트롤러에 전달받을 수 있는 쿼리 스트링, 라우트 파라미터, body 값 등에 대한 유효성 검증이 필요한 경우가 종종 있었습니다. 예를 들어 컨트롤러에서 intValue를 숫자형으로 받고 이를 활용한 비즈니스 로직을 설계했다고 가정합니다. 이때 컨트롤러에서 전달받은 intValue에 'hi'라는 문자열이 전달되면 어떻게 될까요? 예상치 못한 에러가 발생할 수 있죠. 이를 위해 저는 컨트롤러에서 전달받은 파라미터에 대한 유효성 검증 방법을 조사해보았습니다.

 

 


컨트롤러 파라미터 특징

  • 파라미터는 문자열로 전달된다
  • 기본적인 유효성 검증은 없다

 

controller에서 @Query 혹은 @Param, @Body 등의 파라미터는 모두 문자열로 들어옵니다. 예를 들어, 클라이언트가 nest js로 만든 API로 쿼리 스트링에 숫자 1을 포함시켜 전송해도 nest js 서버에서는 해당 값을 문자열 1로 인식합니다. 또한 @Query, @Body, @Param 파라미터에 타입을 선언해 놔도 이는 타입스크립트 컴파일 시 참조하는 역할일 뿐이지, 이 타입이 실제 런타임 시 검증 및 변환까지 해주지는 않습니다. 그렇기에 number 타입을 적용한 @Query 값에 문자열인 '안녕'이라는 값을 전달해도 문제없이 실행됩니다. 다만 해당 쿼리 스트링 값이 '안녕'이라는 문자열로 인식되지는 않고 대신 number 타입의 NaN 값이 적용됩니다. 이는 유효성 검증이 아니라 자바스크립트의 암묵적 형변환입니다. 아래는 간단한 예시들입니다.

@Get('test')
async test(
    @Query('intValue') intValue: number,
    @Query('stringValue') stringValue: string,
) {
    console.log(
    	'intValue 값과 타입은 : ',
        intValue,
        typeof intValue,
    );
    console.log(
        'stringValue 값과 타입은 : ',
        stringValue,
        typeof stringValue,
    );
    return 'test success';
}

먼저 컨트롤러에서 위와 같은 테스트 API를 생성합니다. 이때 쿼리 스트링으로 intValue와 stringValue 두 값을 받습니다. 그리고 각 파라미터는 number 타입, string 타입입니다. 확인을 위해 각 파라미터 값과 타입을 console창에 출력하게 했습니다. 다음은 각 API 요청에 대한 쿼리 스트링값과 콘솔창 출력 내역입니다.

  • intValue: 안녕, stringValue: 1
    intValue 값과 타입은 :  NaN number
    stringValue 값과 타입은 :  1 string
  • intValue: 1, stringValue: 1
    intValue 값과 타입은 :  1 number
    stringValue 값과 타입은 :  1 string
  • intValue: 1, stringValue: 안녕
    intValue 값과 타입은 :  1 number
    stringValue 값과 타입은 :  안녕 string
  • intValue: 안녕, stringValue: 안녕
    intValue 값과 타입은 :  NaN number
    stringValue 값과 타입은 :  안녕 string

( 앞서 설명했듯 intValue는 number 타입으로 선언되어 있기 때문에 '안녕'이라는 문자열을 넘겨주면 자바스크립트의 암묵적 형변환을 통해 이 값이 NaN으로 변환됩니다. )

 

저는 숫자 타입의 쿼리 스트링을 받고 싶은데요? 🤔

즉, 컨트롤러 레벨에서 타입을 정해봤자 검증에 한계가 있습니다. number 타입의 쿼리 스트링을 받고 싶어서 @Query 파라미터를 number 타입으로 설정했어도 문자열 값도 받을 수는 있기 때문이죠.(NaN 값 처리되긴 함) 이때 컨트롤러 레벨에서 타입 검증을 위해서는 Pipes@Transform을 활용할 수 있습니다.

 

 


유효성 검증 방법

Pipes

NestJS에서 제공하는 Pipes는 런타임 시 유효성 검증에 유용하게 사용될 수 있습니다. 기본적으로 제공되는 Pipes 몇 개를 사용해 보겠습니다.

@Get('test')
async test(
    @Query('intValue', ParseIntPipe) intValue: number, // ParseIntPipe로 유효성 검증 실행!!!
    @Query('stringValue') stringValue: string,
) {
    console.log(
        'intValue 값과 타입은 : ',
        intValue,
        typeof intValue
    );
    console.log(
        'stringValue 값과 타입은 : ',
        stringValue,
        typeof stringValue,
    );
    return 'test success';
}

위 예시는 number 타입으로 선언된 intValue값을 전달받을 때 int 타입인지 아닌지를 확실하게 하기 위해 ParseIntPipe를 사용합니다. 이때 intValue값에 '안녕'이라는 문자열을 전달하면 어떻게 될까요? 반환 결과는 다음과 같습니다.

{
    "message": "Validation failed (numeric string is expected)",
    "error": "Bad Request",
    "statusCode": 400
}

위 에러는 ParseIntPipe로 인한 결과입니다. intValue 값으로 '안녕'이라는 문자열을 전달받았지만 이것을 ParseIntPipe에서 int 타입으로 변환하려 했지만 '안녕'은 문자열이기 때문에 int 타입으로 수정되지 못하고 에러를 발생시킨 것입니다. 결과적으로 컨트롤러 레벨에서 API로 전달되는 파라미터로 내가 원하는 타입만 전달받을 수 있게 설계할 수 있습니다.

하지만 컨트롤러에서 받을 파라미터가 단순 number, string, boolean 같은 원시 타입이 아닐 수 있습니다. 또한 굉장히 많은 파라미터를 받을 것이고 해당 파라미터들의 조합을 여러 컨트롤러에서 사용할 것이라면 하나의 객체로 만들어 놓고 호출해서 사용하면 좋을 것 같습니다. 이를 DTO라는 형태로 만들고 이를 사용하는 것은 굉장히 흔한 방법입니다.

 

DTO

먼저 테스트에 사용할 간단한 DTO를 만들어보겠습니다. DTO에서는 class-validator를 활용해 구성합니다.

import { IsInt, IsNotEmpty, IsString } from 'class-validator';

export class testDTO {
    @IsInt()
    @IsNotEmpty()
    intValue: number;

    @IsString()
    @IsNotEmpty()
    stringValue: string;
}

해당 DTO 객체안에는 두 가지 파라미터가 존재합니다. number 타입의 intValue와 string 타입의 stringValue입니다. 이를 검증하기 위해 @IsInt(), @IstString() 데코레이터를 활용했습니다. 또한 두 값 중 하나라도 전달받지 못하면 에러를 발생시키기 위해 @IsNotEmpty() 데코레이터도 모두 적용했습니다. 위 DTO를 컨트롤러에 다음과 같이 적용했습니다.

@Get('test')
async test(@Query() testDto: testDTO) {
    console.log('testDto 값과 타입은 : ', testDto, typeof testDto);
    return 'test success';
}

그리고 해당 API GET 요청 시 쿼리 스트링에 intValue는 '안녕', stringValue도 '안녕'이라는 값을 넣어 요청했습니다. 그리고 반환된 에러는 다음과 같습니다.

{
    "message": [
        "intValue must be an integer number"
    ],
    "error": "Bad Request",
    "statusCode": 400
}

DTO에서 intValue에 설정한 @IsInt()로 인해 해당 값이 int인지 아닌지를 검증을 진행했고, intValue는 '안녕'이라는 문자열이었으므로 에러를 반환한 것입니다. 그럼 이때 intValue만 1 같은 숫자값을 넣어주면 문제없이 진행되겠죠?

아닙니다. GET 요청 시 intValue에 1 같은 숫자를 넣어 요청해도 위와 동일한 "intValue must be an integer number" 에러가 반환됩니다. 왜 그럴까요? 이는 앞서 말씀드린 컨트롤러의 특징과 연관됩니다. 컨트롤러에 전달되는 파라미터는 기본적으로 문자열로 전달된다고 말씀드렸습니다. 그렇기 때문에 intValue에 1을 넣어서 전달해도 컨트롤러에서는 해당 값을 문자열 '1'로 인지하기 때문입니다.

여기서 헷갈릴 수 있는 부분이 있습니다. 그럼 왜 DTO를 사용하지 않았을 때는 에러가 발생하지 않았지? 컨트롤러에서 intValue타입을 number로 설정한 후 API 요청 시 쿼리 스트링에 intValue값을 1을 넣어서 요청하면 문제없이 number 타입 1이 출력됨을 확인할 수 있었죠. 사실 이는 자바스크립트 암묵적 형변환으로 인한 결과입니다. 컨트롤러 자체에서는 문자열로 '1' 값을 전달받지만 해당 intValue가 number 타입으로 지정되어 있기 때문에 자바스크립트 암묵적 형변환을 통해 숫자 1로 변형시키기 위한 시도가 발생한 것입니다. 결과적으로 숫자 값으로 변환될 수 있는 '1, '100' 등의 문자열이 전달되었으면 숫자 1, 100 등으로 성공적인 변환이 완료됩니다. 하지만 숫자 값으로 변환될 수 없는 '안녕' 같은 문자열이 전달되었으면 NaN으로 변환되는 것입니다.

즉, DTO에서의 @IsInt(), @IsString() 등은 타입을 변환시키는데 사용되는 데코레이터가 아니라 타입을 검증만 하는데 사용됩니다. 그렇기에 나는 숫자형 타입을 원했는데 문자열 '1'이라는 값이 전달되면 이것을 숫자 1로 변환시키지 않고 바로 에러를 반환시킵니다. 이때 DTO를 컨트롤러 요청 파라미터 검증을 위해 사용한다면 컨트롤러 요청 파라미터가 문자열로 전달된다는 이유로 인해 실질적인 검증에 어려움이 발생합니다. 이때 이를 해결하기 위한 데코레이터가 바로 @Transform 입니다.

 

@Transform

앞서 말씀드렸듯이 컨트롤러 요청 파라미터는 string으로 전달되기 때문에 이 부분을 자바스크립트 암묵적 형변환이 일어나기 전에 내가 직접 원하는 타입으로 변환한 후 검증하는 방식으로 수정하면 의미 있는 검증이 됩니다. 예를 들면 아래와 같은 방식을 사용해 볼 수 있습니다.

import { Transform } from 'class-transformer';
import { IsInt, IsNotEmpty, IsString } from 'class-validator';

export class testDTO {
    @Transform(({ value }) => {
        if (value === null || value === undefined || value === '') {
            return null;
        }
        return Number(value);
    })
    @IsInt()
    @IsNotEmpty()
    intValue: number;

    @IsString()
    @IsNotEmpty()
    stringValue: string;
}

@Transform을 이용해 intValue값을 체크하는 내용입니다. 내용으로는 null 이거나 undefined 이거나, 빈 문자열이면 null로 변환합니다. 만약 위 조건을 모두 만족하지 않으면 Number()를 이용해 number 타입으로 변환합니다. 이후 @IsInt(), @IsNotEmpty() 데코레이터로 인해 검증이 일어나죠. 결과적으로 해당 DTO가 적용된 API에 요청을 했을 때 intValue 값을 전달하지 않거나 빈 문자열을 넣어주면 위 @Transform 설정으로 인해 null로 변환될 것이고 순차적으로 @IsInt()와 @IsNotEmpty()에서 에러가 발생됩니다. 즉, 정확하게 비어있지 않은 숫자를 넣어줘야만 하도록 설정할 수 있는 것이죠.