nestjs와 함께하는 릭트쇼

redjen·2024년 7월 30일
1

월간 딥다이브

목록 보기
7/11
post-thumbnail

nestjs로 사이드 프로젝트를 하던 중 특정 api를 추가했는데, 해당 api로 라우팅이 제대로 되지 않는 요상한 현상을 발견했다.

문제 현상

추가한 controller

  @UseGuards(JwtAuthGuard)
  @ApiOperation({ summary: '주문 목록 조회', description: '사용자가 생성한 주문 목록 조회' })
  @ApiOkResponse({ type: OrderDto, isArray: true })
  @Get('/orders')
  getOrders(@Query() pageOption: OffsetPaginationOption, @Auth() user: User): Promise<PageResponse<OrderDto>> {
    const userId = user.id;
    return this.shopService.getOrdersByUserId(userId, pageOption);
  }

분명 /shop/orders 로 request를 보냈는데, 엉뚱한 controller가 응답을 받고 있었다.

요청을 handling한 controller

  @ApiOperation({ summary: '매장 단건 조회', description: '매장 단건 조회' })
  @ApiOkResponse({ type: ShopDto })
  @Get('/:shopId')
  getShopByShopId(@Param('shopId') shopId: number): Promise<ShopDto> {
    return this.shopService.getShopByShopId(shopId);
  }

shopId 인자의 타입이 number 였기 때문에, 다음의 에러를 보게 되었다.

[Nest] 1 - 07/22/2024, 2:18:49 PM ERROR [ExceptionsHandler] invalid input syntax for type integer: "NaN" QueryFailedError: invalid input syntax for type integer: "NaN" 
at PostgresQueryRunner.query (/node_modules/typeorm/driver/postgres/PostgresQueryRunner.js:219:19) 
at process.processTicksAndRejections (node:internal/process/task_queues:95:5) 
at async SelectQueryBuilder.loadRawResults (/node_modules/typeorm/query-builder/SelectQueryBuilder.js:2192:25) 
at async SelectQueryBuilder.executeEntitiesAndRawResults (/node_modules/typeorm/query-builder/SelectQueryBuilder.js:2040:26) 
at async SelectQueryBuilder.getRawAndEntities (/node_modules/typeorm/query-builder/SelectQueryBuilder.js:684:29) 
at async SelectQueryBuilder.getOne (/node_modules/typeorm/query-builder/SelectQueryBuilder.js:711:25) 
at async ShopService.getShopByShopId (/dist/shop/shop.service.js:39:22) 
at async /node_modules/@nestjs/core/router/router-execution-context.js:46:28 at async /node_modules/@nestjs/core/router/router-proxy.js:9:17

원인 파악

다행히도 이와 비슷한 경험을 겪었던 팀원이 있어, 빠르게 해결할 수 있었다.

기본적으로 nestjs의 controller는 배치 순서에 영향을 받는다.

문제가 생겼던 controller 메서드를 아래와 같이 변경하였다.

AS-IS

  @ApiOperation({ summary: '매장 단건 조회', description: '매장 단건 조회' })
  @ApiOkResponse({ type: ShopDto })
  @Get('/:shopId')
  getShopByShopId(@Param('shopId') shopId: number): Promise<ShopDto> {
    return this.shopService.getShopByShopId(shopId);
  }

  //...

  @UseGuards(JwtAuthGuard)
  @ApiOperation({ summary: '주문 목록 조회', description: '사용자가 생성한 주문 목록 조회' })
  @ApiOkResponse({ type: OrderDto, isArray: true })
  @Get('/orders')
  getOrders(@Query() pageOption: OffsetPaginationOption, @Auth() user: User): Promise<PageResponse<OrderDto>> {
    const userId = user.id;
    return this.shopService.getOrdersByUserId(userId, pageOption);
  }

TO-BE

  @UseGuards(JwtAuthGuard)
  @ApiOperation({ summary: '주문 목록 조회', description: '사용자가 생성한 주문 목록 조회' })
  @ApiOkResponse({ type: OrderDto, isArray: true })
  @Get('/orders')
  getOrders(@Query() pageOption: OffsetPaginationOption, @Auth() user: User): Promise<PageResponse<OrderDto>> {
    const userId = user.id;
    return this.shopService.getOrdersByUserId(userId, pageOption);
  }

  // ... 

  @ApiOperation({ summary: '매장 단건 조회', description: '매장 단건 조회' })
  @ApiOkResponse({ type: ShopDto })
  @Get('/:shopId')
  getShopByShopId(@Param('shopId') shopId: number): Promise<ShopDto> {
    return this.shopService.getShopByShopId(shopId);
  }

눈치가 빠른 개발자라면 이미 알았겠지만, shopId를 path variable로 받는 api가 먼저 등장하여 /ordersgetShopByShopId 메서드의 path variable로 들어가게 된 것이었다.

그 다음 날 조금 더 찾아보니, 아래와 같은 github issue를 발견할 수 있었다.

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

조금 더 찾아보니.. 이런 stackoverflow 글도 볼 수 있었다.

https://stackoverflow.com/questions/32603818/order-of-router-precedence-in-express-js

즉 해당 문제는 express를 기본 Http Adapter로 사용하는 nestjs에서 발생하는 문제였고, 이를 방지하기 위해서 endpoint 구성에 더 신경을 써야 하는 부분이었다.

Nestjs에서의 Http Adapter

https://docs.nestjs.com/faq/http-adapter

  • nestjs는 현재 http 서버로써 express와 fastify를 지원한다.
  • 각각의 http 서버 라이브러리 인스턴스는 adapter라 불리는 녀석에 감싸져 있다.
  • adapter는 애플리케이션 컨텍스트에서 검색될 수 있다.
  • 다른 provider로 주입됙거나 글로벌하게 접근 가능한 provider로써 등록된다.

흔히 많이 사용되는 패턴인 main.ts에서 bootstrap() 메서드를 통해 NestFactory 를 생성할 때 이Http Adapter를 설정할 수 있다.

내가 겪었던 예시처럼 별도로 설정하지 않는다면 기본적으로 express가 http adapter로써 등록된다.

adapter 객체는 http 서버와 상호작용하기 위한 여러 유용한 메서드들을 제공하지만 getInstance() 메서드를 통해서 각각의 라이브러리 인스턴스에 직접적으로 접근할 수도 있다.

그렇다면 Fastify에서는 어떨까?

nestjs에서 기본적으로 설정되는 Http Adapter인 express에서는 controller 순서에 따라 핸들링되는 메서드가 영향을 받았다.

fastify 라이브러리를 Http Adapter로써 사용했을 때에도 동일한 결과가 나올까 싶어 간단하게 테스트해보았다.

프로젝트를 다시 설정하기는 귀찮으니 nest 기본 fastify 적용 예시를 아주 살짝 고쳐보았다.

https://github.com/nestjs/nest/tree/master/sample/10-fastify

/src/cats/cats.controller.ts 파일을 아래와 같이 변경하여 테스트 해보았다.

import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
import { Roles } from '../common/decorators/roles.decorator';
import { RolesGuard } from '../common/guards/roles.guard';
import { ParseIntPipe } from '../common/pipes/parse-int.pipe';
import { CatsService } from './cats.service';
import { CreateCatDto } from './dto/create-cat.dto';
import { Cat } from './interfaces/cat.interface';

@UseGuards(RolesGuard)
@Controller('cats')
export class CatsController {
  constructor(private readonly catsService: CatsService) {}

  @Post()
  @Roles('admin')
  async create(@Body() createCatDto: CreateCatDto) {
    this.catsService.create(createCatDto);
  }

  @Get()
  async findAll(): Promise<Cat[]> {
    return this.catsService.findAll();
  }

  @Get(':id')
  findOne(
    @Param('id', new ParseIntPipe())
    id: number,
  ) {
    // get by ID logic
  }
  
  @Get('/test')
  testRoutePostfix() {
    return "test";
  }
}

위는 express였다면 /cats/test 를 호출했을 때 findOne() 메서드가 요청을 핸들링했을 것이다.

테스트해본 결과 /cats/test 를 호출하여도 예상했던 것과 같이 testRoutePostfix() 메서드가 요청을 핸들링함을 알 수 있었다.

import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
import { Roles } from '../common/decorators/roles.decorator';
import { RolesGuard } from '../common/guards/roles.guard';
import { ParseIntPipe } from '../common/pipes/parse-int.pipe';
import { CatsService } from './cats.service';
import { CreateCatDto } from './dto/create-cat.dto';
import { Cat } from './interfaces/cat.interface';

@UseGuards(RolesGuard)
@Controller('cats')
export class CatsController {
  constructor(private readonly catsService: CatsService) {}

  @Post()
  @Roles('admin')
  async create(@Body() createCatDto: CreateCatDto) {
    this.catsService.create(createCatDto);
  }

  @Get()
  async findAll(): Promise<Cat[]> {
    return this.catsService.findAll();
  }

  @Get('/test')
  testRoutePostfix() {
    return "test";
  }

  @Get(':id')
  findOne(
    @Param('id', new ParseIntPipe())
    id: number,
  ) {
    // get by ID logic
  }
}

순서를 위와 같이 변경하여도 결과는 동일했다.

결론

잠깐의 조사를 통해 아래의 사항들을 알 수 있었다.
1. nestjs에서의 기본 Http Adapter는 express가 된다.
2. express에서 route 별 핸들링하는 메서드는 순서에 영향을 받는다.

때문에 조금 생각해보면 이런 개선 방법을 낼 수 있겠다.

  • 애초에 이런 문제가 생기지 않도록 각 api 별 계층 구조를 보다 체계화 한다.
    • 나의 경우 /orders 가 공통인 api들은 별도의 controller로 분리하는 방법이 있겠다.
  • 불가피하게 엔드포인트 최상위 리소스에 path variable이 와야 하는 경우라면 메서드 순서에 신경써서 코드를 정련한다.
  • 프로젝트가 시작 전이라면 fastify 사용을 고려해보는 것도 옵션이 될 수 있겠다.
  • 애초에 nestjs를 쓰지 않는다 미우나 고우나 맞으면서 배우는 경험이 값지죠 😂
profile
make maketh install

0개의 댓글

관련 채용 정보