본문 바로가기

NestJS

[강의 후기] [코드팩토리] [초급] NestJS REST API 백엔드 완전 정복 마스터 클래스 - Part 1 NestJS Core (Inflearn)

강의 들은 후기

  • 강의명: [코드팩토리] [초급] NestJS REST API 백엔드 완전 정복 마스터 클래스 - Part 1 NestJS Core
  • 강의 학습 기간: 일주일
  • 이직하는 회사 입사 전 NestJS 에 대한 전체 강의를 듣고 내가 부족한 점이 무엇인지 찾고자 해당 강의를 들었습니다 :)
  • 결론적으로 정말 듣길 잘한 것 같네요.. 원래 알았던 내용이여도 복습겸 듣기도 좋았고 제가 몰랐던 내용들도 정말 많이 알게 되어서 매우 유익하고 기쁩니다!
  • 게다가 강사님이 설명을 야무지게 해주시네요. NestJS 주니어 개발자라면 한번쯤은 들어보라고 추천하고 싶은 강의였습니다.

강의를 들은 후 내가 얻은 기술 키워드

  1. ServeStaticModule: 특정 폴더에 있는 모든 파일들을 외부에서 접근 가능하도록 하는 모듈

    • 해당 모듈이 어메이징 한게 원래는 어떤 폴더에 대한 파일 response API 가 필요했을 텐데 해당 모듈을 app.module.ts 등록만 하면 파일을 가져올 수 있음.

       imports: [
         // 특정 폴더에 있는 모든 파일들은 외부에서 접근 가능하도록 하는 모듈
         ServeStaticModule.forRoot({
           // http://localhosT:3000/public/posts/9febf823-368a-4f62-ae8c-5813a2490e90.JPG
           rootPath: PUBLIC_FOLDER_PATH,
           serveRoot: '/public',
         }),
  2. pagination: 데이터 정보를 한번에 보여주는 것이 아닌 일정 단위로 보내주는 기법

    • Page Based Pagination
      • 페이지 기준으로 데이터를 잘라서 요청하는 pagenation
      • 요청을 보낼때 원하는 데이터 갯수와 몇번째 페이지를 가져올지 명시
      • 페이지 숫자를 누르면 다음 페이지로 넘어가는 형태의 UI에서 많이 사용
      • Pagenation 도중에 데이터베이스에서 데이터가 추가되거나 삭제될경우 저장되는 데이터가 누락되거나 중복될 수 있음.
      • Pagenation 알고리즘이 매우 간단함.
    • Cursor based Pagenation
      • 가장 최근에 가져온 데이터를 기준으로 다음 데이터를 가져오는 Pagenation
      • 요청을 보낼때 마지막 데이터의 기준값과 몇개의 데이터를 가져올지 명시
      • 스크롤 형태의 리스트에서 자주 사용
      • 최근 데이터의 기준값을 기반으로 쿼리가 작성되기 떄문에 데이터가 누락되거나 중복될 확률이 적음
    • 위와 같이 페이징 기법에 대한 구현 방식도 볼수 있지만 이를 모듈화 하여 사용성을 높히는 작업도 하기 떄문에 학습에 매우 도움이 됨.
      • 개인적으로 DTO 에서 쿼리 관련 필드를 {where or order}__{field name}__{method (optional)} 과 같은 형식으로 규격화 하여 split('__') 한 후에 이를 기지고 FindOption을 만드는게 꽤 좋았음.
      • 해당 방법은 내가 앞으로 자주 사용하도록 노력하려고 함.
        • 이유: 검색 조건이 변경될때에 따른 확장성이 너무 좋아보임.
  3. RBAC (Role Based Access Control)

    • 권한에 따라 접근을 제한하는 방식이다. (ex. ADMIN/USER 에 따른 역할 분리)
    • 예전의 나는 이를 모두 컨트롤러 레이터에서 로직을 구현하였다. 하지만 해당 강의에서는 reflect-metadata 를 이용한 RBAC 구현을 하였고 대부분의 로직을 데코레이터와 가드에서 구현하였다.
    • 이러한 개념이 별도로 있다는걸 몰라서 상당히 흥미로웠고, 모든 로직을 컨트롤러 단에서 하는것 보단 가드에서 호출한 데이터의 권한을 확인해서 컨드롤러 로직을 타지 않게 하는게 제일 베스트 인것 같다.
  4. reflect-metadata & Decorator

    • 3번을 작성하면서 자연스럽게 나온 키워드인데 이거는 강좌에서 설명해준 것은 아니고 내가 별도로 정리를 했다.
    • 내가 정리한 블로그 링크: https://alwns28.tistory.com/13
    • 모든 기능이 유용해보이지는 않았지만 그래도 강좌의 RBAC 파트에서 사용된 부분은 정말 유용해 보였다. 그리고 property-decorator 나 accessor-decorator, method-decorator 이 세개는 유용해 보였다.
  5. QueryRunner & Transaction

    • 이전에는 트렌젝션에 대해서 잘 몰랐다. 이번 기회에 트렌젝션이 어떻게 사용되는지를 알게 되었다.

    • 여러개의 DB 생성, 삭제 로직이 있을 경우에 중간에 예기치 못한 에러 발생 시 이전에 작업한 DB 로직에 대해서 rollback 을 해야 한다. 이러한 작업을 트렌젝션이 너무 잘 해내주고 있다.

       // 예시로 언팔 기능에서 언팔 후에 팔로워, 팔로윙 수 decrement 로직을 수행 할 떄 트렌젝션을 사용하게 되면 간단하게 구현이 가능하다.
       @Delete('follow/:id')
       @UseInterceptors(TransactionInterceptor)
       async deleteFollow(
         @User() user: UsersModel,
         @Param('id', ParseIntPipe) followeeId: number,
         @QueryRunner() qr: QR,
       ) {
         await this.usersService.deleteFollow(user.id, followeeId, qr);
         await this.usersService.decrementFollowerCount(user.id, qr);
         await this.usersService.decrementFolloweeCount(followeeId, qr);
         return true;
       }
    • 여기서 쿼리러너를 받은 방식은 request intercepter 에서 쿼리 러너 객체를 만들어서 req 객체 내에 저장해놓으면 이를 꺼내서 쓰는 방식이다.

      // transaction.interceptor.ts
      import { DataSource } from 'typeorm';
      import {
       CallHandler,
       ExecutionContext,
       Injectable,
       InternalServerErrorException,
       NestInterceptor,
      } from '@nestjs/common';
      import { Observable, catchError, tap } from 'rxjs';
      
      @Injectable()
      export class TransactionInterceptor implements NestInterceptor {
       constructor(private readonly dataSource: DataSource) {}
       async intercept(
         context: ExecutionContext,
         next: CallHandler<any>,
       ): Promise<Observable<any>> {
         const req = context.switchToHttp().getRequest();
      
         // 트랜젝션과 관련된 모든 쿼리를 담당할 쿼리 러너를 생성한다.
         const qr = this.dataSource.createQueryRunner();
         // 쿼리 러너에 연결한다.
         await qr.connect();
         // 쿼리 러너에서 트랜잭션을 시작한다.
         // 이 시점부터 같은 쿼리 러너를 사용하면
         // 트랜잭션 안에서 데이터베이스 액션을 실행 할 수 있다.
         await qr.startTransaction();
      
         req.queryRunner = qr;
      
         return next.handle().pipe(
           catchError(async (e) => {
             await qr.rollbackTransaction();
             await qr.release();
             throw new InternalServerErrorException(e.message);
           }), // 에러가 발생할 때 실행되는 부분
           tap(async () => {
             await qr.commitTransaction();
             await qr.release();
           }),
         );
       }
      }
      
      // query-runner.decorator.ts
      import {
       ExecutionContext,
       InternalServerErrorException,
       createParamDecorator,
      } from '@nestjs/common';
      
      export const QueryRunner = createParamDecorator(
       (data: any, context: ExecutionContext) => {
         const req = context.switchToHttp().getRequest();
      
         if (!req.queryRunner) {
           throw new InternalServerErrorException(
             `QueryRunner Decorator를 사용하려면 TransactionInterceptor를 적용해야 합니다.`,
           );
         }
      
         return req.queryRunner;
       },
      );
  6. ClassSerializerInterceptor 의 사용 방법

    • ClassSerializerInterceptor 는 정말 유용한 기능이였다..
    • ClassSerializerInterceptor 를 전역적으로 등록하고 response dto 들에다가 @Expose 또는 @Exclude 를 사용하면 인터셉터에서 해당 필드를 포함 또는 제외 시키고 데이터를 보내준다.
    • 별도의 로직 구현 없이 이렇게 사용이 가능핟다는 점이 너무 편리하고 좋아보였다.
  1. 토큰 처리 방식에 대한 구현

    • 나는 기존에는 passportStratege를 사용한 localStrategy, AccessTokenStrategy, RefreshTokenStrategy 를 구현했다. 그런데 해당 강의에서는 JwtService 만을 사용해서 토큰 파싱, 검증을 수행하게 구현햇다.
    • 이를 비교하면서 좀 안목이 확장되는 느낌이 들었다.
    • 내가 정리한 블로그 링크: https://alwns28.tistory.com/11
  2. TypeORM Enum type Setting

    • TypeORM 의 entity 에서도 enum 타입을 사용할 수 있었다!!. 너무 간단한데 나는 왜 이걸 몰랐는가... 구현 방법을 보면 정말 간단하다..

      export enum ImageModelType {
       POST_IMAGE,
      }
      
      @Entity()
      export class ImageModel extends BaseModel {
      ...
      
       @Column({ enum: ImageModelType })
       @IsEnum(ImageModelType)
       @IsString()
       type: ImageModelType;
      
      ...
      }
      

강의를 들은 후 내가 얻은 노하우

  1. AccessToken으로 인한 가드는 일단 모든 API 에 적용시키고, OpenAPI 로 설정할 API만 별도의 Public 로직을 구현하는 방식으로 가는게 보안성에 좋다.

    // app.module.ts
     providers: [
       AppService,
       {
         provide: APP_INTERCEPTOR, // NestJS APP 전체에서 해당 interceptor 가 적용된다.
         useClass: ClassSerializerInterceptor,
       },
       {
         provide: APP_GUARD, // <-- AccessToken 가드를 전역적으로 등록
         useClass: AccessTokenGuard,
       },
       {
         provide: APP_GUARD,
         useClass: RolesGuard,
       },
     ],
    })
  2. 고정 변수 관리 방법

    • 본 강좌에서는 고정 변수가 필요한 경우 {이름}.const.ts 으로 파일을 만들어서 이를 선언하였다. 상당히 깔끔해 보여서 나도 이 방법을 자주 사용하려고 한다.

      import { join } from 'path';
      
      // 서버 프로젝트의 루트 폴더
      export const PROJECT_ROOT_PATH = process.cwd();
      // 외부에서 접근 가능한 파일들을 모아둔 폴더 이름
      export const PUBLIC_FOLDER_NAME = 'public';
      // 포스트 이미지들을 저장할 폴더 이름
      export const POSTS_FOLDER_NAME = 'posts';
      // 임시 폴더 이름
      export const TEMP_FOLDER_NAME = 'temp';
      
      // 실제 공개 폴더의 절대경로
      export const PUBLIC_FOLDER_PATH = join(PROJECT_ROOT_PATH, PUBLIC_FOLDER_NAME);
      // posts 이미지를 저장할 전체 절대 경로
      export const POST_IMAGE_PATH = join(PUBLIC_FOLDER_PATH, POSTS_FOLDER_NAME);
      // posts 이미지를 저장할 ROOT_PATH 를 제외한 경로
      export const POST_PUBLIC_IMAGE_PATH = join(
       PUBLIC_FOLDER_NAME,
       POSTS_FOLDER_NAME,
      );
      // 임시파일들을 저장할 폴더
      export const TEMP_FOLDER_PATH = join(PUBLIC_FOLDER_PATH, TEMP_FOLDER_NAME);
  3. Repository layer 다루는 방법

    • 원래 내가 사용했던 방식은 DB 접근 로직이 간단하더라도 repository.ts 파일을 별도로 만들고 이를 서비스로직에서 injection 해서 사용하는 방식을 선호했다. (소스코드 관리 측면에서는 이게 좋다고 생각을 했었다.)

      • 이게 좋은건가 라고 의구심이 들었던 계기는 나의 코드를 다시 봤을 때, 간단한 메소드만 사용하는 거라면 파일을 분리하는 것이 오히려 관리 포인트가 늘어나는게 아닐까 하는 생각이 들었다.
    • 본 강의에서는 별도의 Repository 파일을 사용하지는 않고 @InjectRepository 를 이용하여 구현하였다. 이렇게 쓰는게 깔끔해 보이긴 한다...

           @InjectRepository(PostsModel)
           private readonly postsRepository: Repository<PostsModel>,
  4. Transaction 사용하는 로직에서 repository 셋팅하는 메소드 구현

    • 와 이 함수는 간단한데 정말 유용하다. 트렌젝션을 사용할 경우와 사용하지 않을 경우에 대해서 repository 를 셋팅할 수 있는 메소드를 사용하는데 너무 유용했다. 이를 자주 사용할 것 같다.

       getUsersRepository(qr?: QueryRunner) {
         return qr
           ? qr.manager.getRepository<UsersModel>(UsersModel)
           : this.userReporitory;
       }
      
       getUserFollowRepository(qr?: QueryRunner) {
         return qr
           ? qr.manager.getRepository<UserFollowersModel>(UserFollowersModel)
           : this.userFollowersRepository;
       }
  5. class-validator 에서 error message 규격화

    • class-validator 에서 에러 메시지를 만들수 있는건 알고 있었는데 이를 규격화 할 수 있는 방법은 모르고 있었다.

       @Column()
       @IsString({
         message: stringValidationMessage, // <-- dto 또는 entity 에서 이렇게 message 에 method를 선언 
       })
       title: string;
      
      // #################################
       // 별도의 파일에서 이렇게 받은 데이터에 대한 에러 메시지를 다룰 수 있음.
       import { ValidationArguments } from 'class-validator';
      
       export const stringValidationMessage = (arg: ValidationArguments) => {
         return `${arg.property}에 string을 입력해주세요.`;
       };
      
  6. class-validator 의 옵션

    • 아니 pipe 등록에서 유용한 옵션이 있다는걸 처음 알았다.

       app.useGlobalPipes(
         new ValidationPipe({
           transform: true, // dto에서 우리가 기본값을 넣어서 형성하려면 해당 옵션이 필요하다.
           transformOptions: {
             enableImplicitConversion: true, // 임의로 변환하는 것을 허가한다. ==> dto 에 선언된 타입 annotation 을 보고 자동으로 변환해준다.
           },
           whitelist: true, // 우리가 정의한 DTO field 외에는 다른 field 를 가져오지 않는다.
           forbidNonWhitelisted: true, // 우리가 정의한 DTO 외에 다른 필드가 들어오면 에러를 밷는다.
         }),
       ); // global pipe