안녕하세요. 프로젝트에 Nest.js를 적극적으로 사용하고 있는 백엔드 개발자 민경찬입니다.
이번 Nest.js 릴리즈 노트에 Query 데코레이터 관련 버그가 해결된 PR이 올라왔더라구요. 그러다 문득 몇 가지 궁금한 점이 있어서 블로그에 글을 쓰게 되었습니다.
해당 PR은 아래 링크에서 확인할 수 있습니다.
https://github.com/nestjs/nest/pull/14241
숫자로 이루어진 id를 쿼리스트링으로 전달한다는 가정하에 예시로 알아보겠습니다.
http://host?id=1
Query
데코레이터는 크게 2가지 방법으로 사용할 수 있습니다.
첫 번째 방법은 id
값을 명시하여 가져오는 방법입니다. 이 경우 쿼리스트링에서 id
만을 가져올 수 있습니다.
@Get()
findOne(
@Query('id') id: number,
) {}
두 번째 방법은 쿼리스트링을 통째로 가져오는 방법입니다.
@Get()
findOne(
@Query() query: { id: number },
) {}
여기까지는 당연합니다. 너무 쉽다구요? 바로 문제들어갑니다.
typeof id
의 값은 무엇일까요?
@Get()
findOne(
@Query('id') id: number,
) {
console.log(typeof id); // <- 무엇이 나올까요?
}
정답: 3번 상황마다 다름!
어떤 상황에 어떤 값이 나오는지까지 알고있다면 당신은 고수...
app
객체에서 아래 처럼 파이프라인을 걸어주게 될 경우 number
로 나오는 것을 확인할 수 있습니다.
app.useGlobalPipes(
new ValidationPipe({
transform: true, // 변형하겠다는 뜻
}),
);
당연하게도, 핸들러에 trasnform을 허용하는 파이프라인이 걸려있지 않을 때는 타입을 number
라고 명시하더라도 string
이 나오게됩니다.
이미 공식문서에 나와있는거라 너무 쉽다구요? 그럼 바로 다음 문제 드립니다.
공식문서는 여기서 확인할 수 있습니다.
https://docs.nestjs.com/techniques/validation#transform-payload-objects
typeof query.id
의 값은 무엇일까요?
(ValidationPipe
도 잘 걸려있습니다.)
// my-query.ts
class MyQuery {
id: number;
}
// app.controller.ts 일부
@Get()
findOne(
@Query() query: MyQuery,
) {
console.log(typeof query.id); // <- 무엇이 나올까요?
}
// main.ts 일부
app.useGlobalPipes(
new ValidationPipe({
transform: true,
}),
);
정답: 2번 string!
으엣 1번보다 쉽다구요? Nest.js 짬이 대단하시군요...
사실 너무나도 간단합니다.
import { IsNumber } from 'class-validator';
class MyQuery {
@IsNumber()
id: number;
}
Class Validator에서 IsNumber
를 가져와 붙여주기만 하면됩니다. 유효성 검증은 보너스죠.
엣 이건 너무 쉬운거아니냐구요?
맞습니다... 오늘의 진짜 주제는 그럼
IsNumber
안 붙여도 number 타입으로 뽑아주면 되는거 아닌가? 입니다.
Query('id') id: number
는 검증하는 코드 없이도 number로 바꿔줍니다.
그런데 왜 클래스로 묶여있을 때는 그렇게 안 해주는걸까요?
제가 내린 결론은 "불가능하기 때문"이다 입니다.
Nest.js에서 Query
에 데이터를 넣어주기 직전 이런 코드가 돌아갑니다.
if (!metatype || !this.toValidate(metadata)) {
return this.isTransformEnabled
? this.transformPrimitive(value, metadata)
: value;
}
metatype
은 데코레이터가 붙은 파라미터의 타입을 의미합니다. number
일 경우 js에서도 살아있는 Number
클래스가 들어갑니다.
toValidate
는 이 클래스가 Number
나 String
클래스 처럼 기본 클래스인지 아닌지를 확인해줍니다.
이름이 왜 toValidate
냐면 기본 타입 클래스가 아닌 경우 유효성 검증을 해야하는 대상이 되거든요
즉,기본 클래스 타입이면서, trasnsform 옵션이 true일 때 transformPrimitive 함수를 호출해라
라는 뜻이죠.
그 다음 transformPrimitive
을 호출하여 그 값을 반환해줍니다.
// transformPrimitive 구현체 중...
if (metatype === Number) {
return +value;
}
Query
데코레이터는 해당 파라미터의 타입을 읽어 메타데이터로 등록합니다.
@Get()
test(
@Query('id') id: number // 메타데이터 등록
) {}
메타데이터는 API 호출 시점에 꺼내어 확인해서 해당 자리에 어떤 값이 들어갈지 결정해주죠.
그러나 이 시점이면 이미 Class에 타입은 사라져있습니다. 컴파일 타임이 지나 이젠 런타임 시점이거든요.
런타임 시점에서 타입스크립트의 타입은 남아있지 않습니다.
Reflect.getMetadata(
'design:type',
MyQuery.prototype,
'id'
) // undefined
그렇기에 컴파일타임에서 타입에 대한 정보를 메타데이터에 넣어주는 IsNumber
데코레이터가 필요한 것입니다.
IsNumber 붙이면 metadata가 Number로 뜨는 것을 확인할 수 있습니다.
규칙이 명확한 것 같으면서도 헷갈리는 요소가 많습니다.
좀 더 기억하기 쉽게 잊지말아야할 것만 추려보겠습니다.
trasform: true
일 때는 꼭, 검증 하셔야합니다.
예상치못하게 NaN
이 나올 수도 있습니다.
@Get()
findOne(
@Query('id', ParseIntPipe) id: number,
) {}
IsNumber
까먹지마세요!
class MyQuery {
@IsNumber()
id: number;
}
궁금해서 내부 코드 구조를 좀 살펴보다가 다른 분들도 흥미로워 하실 것 같아서 정리해 보았습니다.
생각보다 규칙이 명확해보일 수도 있습니다.
이 이슈를 보기 전까지는 말이죠...
읽어주셔서 감사합니다.
하지만 nestia를 쓰면 해결되는 문제군요