Nest.js-헷갈리는 Query 데코레이터 분석하기

민경찬·2024년 11월 30일
13

백엔드

목록 보기
22/23
post-thumbnail

안녕하세요. 프로젝트에 Nest.js를 적극적으로 사용하고 있는 백엔드 개발자 민경찬입니다.

이번 Nest.js 릴리즈 노트에 Query 데코레이터 관련 버그가 해결된 PR이 올라왔더라구요. 그러다 문득 몇 가지 궁금한 점이 있어서 블로그에 글을 쓰게 되었습니다.

해당 PR은 아래 링크에서 확인할 수 있습니다.
https://github.com/nestjs/nest/pull/14241

😎 Query 데코레이터 사용하는 방법

숫자로 이루어진 id를 쿼리스트링으로 전달한다는 가정하에 예시로 알아보겠습니다.

http://host?id=1

Query 데코레이터는 크게 2가지 방법으로 사용할 수 있습니다.

첫 번째 방법은 id값을 명시하여 가져오는 방법입니다. 이 경우 쿼리스트링에서 id만을 가져올 수 있습니다.

@Get()
findOne(
  @Query('id') id: number,
) {}

두 번째 방법은 쿼리스트링을 통째로 가져오는 방법입니다.

@Get()
findOne(
  @Query() query: { id: number },
) {}

여기까지는 당연합니다. 너무 쉽다구요? 바로 문제들어갑니다.

⁉️ 문제 1.

typeof id의 값은 무엇일까요?

  1. number
  2. string
  3. 상황마다 다름
@Get()
findOne(
  @Query('id') id: number,
) {
  console.log(typeof id); // <- 무엇이 나올까요?
}

정답: 3번 상황마다 다름!

어떤 상황에 어떤 값이 나오는지까지 알고있다면 당신은 고수...

언제 number가 되고 언제 string이 되는걸까?

app 객체에서 아래 처럼 파이프라인을 걸어주게 될 경우 number로 나오는 것을 확인할 수 있습니다.

app.useGlobalPipes(
  new ValidationPipe({
    transform: true, // 변형하겠다는 뜻
  }),
);

당연하게도, 핸들러에 trasnform을 허용하는 파이프라인이 걸려있지 않을 때는 타입을 number라고 명시하더라도 string이 나오게됩니다.

이미 공식문서에 나와있는거라 너무 쉽다구요? 그럼 바로 다음 문제 드립니다.

공식문서는 여기서 확인할 수 있습니다.
https://docs.nestjs.com/techniques/validation#transform-payload-objects

⁉️ 문제 2.

typeof query.id의 값은 무엇일까요?
(ValidationPipe도 잘 걸려있습니다.)

  1. number
  2. string
  3. 이것도 때에 따라 다름
// 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 짬이 대단하시군요...

number로 나오게 하려면...?

사실 너무나도 간단합니다.

import { IsNumber } from 'class-validator';

class MyQuery {
  @IsNumber()
  id: number;
}

Class Validator에서 IsNumber를 가져와 붙여주기만 하면됩니다. 유효성 검증은 보너스죠.

엣 이건 너무 쉬운거아니냐구요?

맞습니다... 오늘의 진짜 주제는 그럼 IsNumber 안 붙여도 number 타입으로 뽑아주면 되는거 아닌가? 입니다.

🧐 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는 이 클래스가 NumberString 클래스 처럼 기본 클래스인지 아닌지를 확인해줍니다.

이름이 왜 toValidate냐면 기본 타입 클래스가 아닌 경우 유효성 검증을 해야하는 대상이 되거든요

즉,기본 클래스 타입이면서, trasnsform 옵션이 true일 때 transformPrimitive 함수를 호출해라라는 뜻이죠.

-> Github 코드로 보기

그 다음 transformPrimitive을 호출하여 그 값을 반환해줍니다.

// transformPrimitive 구현체 중...
if (metatype === Number) {
  return +value;
}

-> Github 코드로 보기

클래스는 왜 자동으로 안 해주나요?

Query 데코레이터는 해당 파라미터의 타입을 읽어 메타데이터로 등록합니다.

@Get()
test(
  @Query('id') id: number // 메타데이터 등록
) {}

메타데이터는 API 호출 시점에 꺼내어 확인해서 해당 자리에 어떤 값이 들어갈지 결정해주죠.

그러나 이 시점이면 이미 Class에 타입은 사라져있습니다. 컴파일 타임이 지나 이젠 런타임 시점이거든요.
런타임 시점에서 타입스크립트의 타입은 남아있지 않습니다.

Reflect.getMetadata(
  'design:type', 
  MyQuery.prototype, 
  'id'
) // undefined

그렇기에 컴파일타임에서 타입에 대한 정보를 메타데이터에 넣어주는 IsNumber 데코레이터가 필요한 것입니다.

IsNumber 붙이면 metadata가 Number로 뜨는 것을 확인할 수 있습니다.

🫡 이것만은 잊지말자

규칙이 명확한 것 같으면서도 헷갈리는 요소가 많습니다.
좀 더 기억하기 쉽게 잊지말아야할 것만 추려보겠습니다.

1. 단일 값을 가져올 때는 꼭 검증하도록하자

trasform: true일 때는 꼭, 검증 하셔야합니다.

예상치못하게 NaN이 나올 수도 있습니다.

@Get()
findOne(
  @Query('id', ParseIntPipe) id: number,
) {}

2. 다 가져올 때는 꼭 데코레이터 까먹지말자

IsNumber 까먹지마세요!

class MyQuery {
  @IsNumber()
  id: number;
}

👋 결론

궁금해서 내부 코드 구조를 좀 살펴보다가 다른 분들도 흥미로워 하실 것 같아서 정리해 보았습니다.

생각보다 규칙이 명확해보일 수도 있습니다.

https://github.com/nestjs/nest/issues/3156

이 이슈를 보기 전까지는 말이죠...

읽어주셔서 감사합니다.

1개의 댓글

comment-user-thumbnail
2024년 12월 11일

하지만 nestia를 쓰면 해결되는 문제군요

답글 달기