Nest.js 공부하기 (2)

2024. 11. 27. 22:02BACKEND

728x90

Custom Pipe 만들기

커스텀 파이프를 만들기 위해서는 PipeTransForm을 반드시 implements 해야 하고 transform 메서드를 오버라이드 해서 구현해야 한다.

자바를 사용한 개발자라면 익숙한 방식 일 것 같다.

import { ArgumentMetadata, PipeTransform } from '@nestjs/common';

export class BoardStatusValidationPipe implements PipeTransform {
    transform(value: any, metadata: ArgumentMetadata) {
        throw new Error('Method not implemented.');
    }
}

기본적으로 PipeTransform을 implements하고 transform을 구현해야 한다.

import { ArgumentMetadata, BadRequestException, PipeTransform } from '@nestjs/common';
import { BoardStatus } from '../board.model';

export class BoardStatusValidationPipe implements PipeTransform {
    readonly allowedStatuses = [
        BoardStatus.PUBLIC,
        BoardStatus.PRIVATE,
    ];

    transform(value: any, metadata: ArgumentMetadata) {
        if (!this.isStatusValid(value)) {
            throw new BadRequestException(`${value} is not a valid status`);
        }

        return value;
    }

    private isStatusValid(status: BoardStatus): boolean {
        const idx = this.allowedStatuses.indexOf(status);
        return idx !== -1;
    }
}

위 코드처럼 컨트롤러에서 받은 요청 값을 원하는 방식으로 체크 하고 유효하다면 value를 return 하면 된다.

readonly로 상태를 체크 할 배열을 만들고 indexOf로 값이 존재하는지를 체크 한다.

readonly가 헷갈릴 수 있는데 allowedStatuses 배열은 결국 [’PUBLIC’, ‘PRIVATE’]가 들어있게 되고 [’PUBLIC’, ‘PRIVATE’].indexOf(value) 가 되는 것이다.

import { Body, Controller, Delete, Get, Param, Patch, Post, Put, UsePipes, ValidationPipe } from '@nestjs/common';
import { BoardsService } from './boards.service';
import { Board, BoardStatus } from './board.model';
import { CreateBoardDto } from './dto/CreateBoardDto';
import { BoardStatusValidationPipe } from './pipes/board-status-validation.pipe';

@Controller('boards')
export class BoardsController {
  constructor(private boardsService: BoardsService) {
    this.boardsService = boardsService;
  }

  // client로 부터 받은 값 중에 status에 대한 유효성만 custom pipe를 사용해 확인하면 된다.
  // @Body 데코레이터에 2번째 인자로 BoardStatusValidationPipe를 전달.
  @Patch("/:id")
  updateBoardStatus(@Param("id") id: string, @Body('status', BoardStatusValidationPipe) status: BoardStatus): Board{
    return this.boardsService.updateBoardStatus(id, status);
  }
}

위 코드 처럼 @Body(’satus’, BoardStatusValidationPipe) 형태로 2번째 인자에 커스텀 파이프를 전달하기만 하면 된다, 참 쉽다.

아직까지는 얕게 경험해본 Nest.js 이지만 Spring boot와 유사하면서도 더 쉽다는 느낌이 든다.


Type ORM 사용하기

npm i pg typeorm @nestjs/typeorm type orm을 사용하기 위한 라이브러리를 설치 한다.

Type ORM Config 파일 생성

typeorm.config.ts

import { TypeOrmModuleOptions } from '@nestjs/typeorm';

export const typeOrmConfig: TypeOrmModuleOptions = {
  type: 'postgres',
  host: 'localhost',
  port: 5432,
  username: 'postgres',
  password: 'postgres',
  database: 'board-app',
  entities: [__dirname + '/**/*.entity{.ts,.tsx}'],
  synchronize: true,
}

이렇게 config 파일을 생성 한다.

app.module.ts

import { Module } from '@nestjs/common';
import { BoardsModule } from './boards/boards.module';
import { TypeOrmModule } from '@nestjs/typeorm';
import {typeOrmConfig} from './configs/typeorm.config'

@Module({
  imports: [
    TypeOrmModule.forRoot(typeOrmConfig),
    BoardsModule
  ],
  controllers: [],
  providers: [],
})
export class AppModule {}

app.module.ts 파일에 위 코드처럼 typeOrmConfig 파일을 추가한다.

board.entity.ts

import { BaseEntity, Column, Entity, PrimaryGeneratedColumn } from 'typeorm';
import { BoardStatus } from './board.model';

@Entity()
export class Board extends BaseEntity {
  @PrimaryGeneratedColumn()
  id:number;

  @Column()
  title: string;

  @Column()
  description: string;

  @Column()
  status: BoardStatus;
}

엔티티 파일도 생성해준다.

board.repository.ts

import {Repository } from 'typeorm';
import { Board } from './board.entity';

export class BoardRepository extends Repository<Board>{

}

위 코드처럼 repository를 생성한다, JPA를 사용해봤다면 상당히 익숙한 코드일 것 같다.

@EntityRepository 는 TypeORM v0.3 이후 Deprecated 되기 때문에 커스텀레포지터리를 사용하지 말고 일반 페로지터리를 확장해야 된다고 한다.

 

모르고 지나갈뻔 했는데 webstorm은 정말 좋은 것 같다. 개인적으로 jetbrains 제품을 좋아 하고 all products pack를 매년 결제하고 있다.

환율이 많이 올라서 마음이 아프다.

import { Module } from '@nestjs/common';
import { BoardsController } from './boards.controller';
import { BoardsService } from './boards.service';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BoardRepository } from './board.repository';

@Module({
  imports: [TypeOrmModule.forFeature([BoardRepository])], // 추가
  controllers: [BoardsController],
  providers: [BoardsService]
})
export class BoardsModule {}

생성한 repository를 board controller 모듈에 imports 해주어야 한다.

spring boot와 다르게 어노테이션만 붙여주면 되는 것이 아니라 module에 등록해주어야 한다는 부분이 조금 귀찮은 것 같다.

일반적으로 type orm을 많이 사용하는 듯 하지만 나는 편의적인 부분을 위해 prisma를 사용 했다.


Passport - JWT 인증하기

JWT에 대해서는 워낙 다른 블로그들이 많으니 참고하기 바란다.

어떠한 어플리케이션을 만들때 회원가입, 로그인, 인증을 어떻게 처리 할 것인지는 상당히 중요한 부분이다.

나는 node 환경이라면 passport를 사용하는 것을 거의 기본으로 선택하고 있다, social 로그인과 같은 연동도 편리하고 개발자가 직접 관리해야 하는 부분이 많이 줄어들기 때문에 이런 외부 라이브러리를 적극 활요하는 편이 좋지 않을까 싶다.

java 환경에서 개발을 한다면 spring security를 거의 기본으로 사용해야 하지 않을까 싶다.

모듈 설치

// 필요한 라이브러리를 설치
npm i passport passport-jwt @nestjs/jwt @nestjs/passport --save

// auth 패키지를 생성
nest g module auth
nest g controller auth
nest g service auth

라이브러리를 설치하고 패키지를 생성 한다.

JWT 만들기

@UsePipes(new ValidationPipe())
  @Post('/signin')
  signIn(@Body() authCredentialDto: AuthCredentialDto): Promise<{accessToken: string}> {
    return this.authService.signIn(authCredentialDto)
  }
async signIn(authCredentialsDto: AuthCredentialDto): Promise<{accessToken: string}> {
    const {username, password} = authCredentialsDto
    const user = await this.prisma.user.findUnique({where: {username}})

    if(user && (await bcrypt.compare(password, user.password))){
      // 유저 토큰 생성 (Secret + Paylod)
      const payload = {username}
      const accessToken = this.jwtService.sign(payload)

      return {accessToken}
    } else {
      throw new UnauthorizedException('login failure')
    }
  }

위 코드처럼 bcrypt를 이용해 입력받은 비밀번호를 비교하고 검색되는 user가 DB에 있다면 username을 paylod에 담아서 토큰을 생성하고 return 해 주었다.

passport, jwt로 인증 및 유저 정보 가져오기

import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt'; // IDE에서 자동 iport를 할 때 passport를 가져오지 않게 주의 (passport-jwt)
import * as process from 'node:process';
import { AuthCredentialDto } from './dto/auth-credential.dto';
import { User } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor(private prisma: PrismaService) {
    super({
      secretOrKey: process.env.SECRET_KEY,
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken()
    });
  }

  // jwt의 payload가 들어온다.
  async validate(payload: AuthCredentialDto): Promise<User>{
    const {username} = payload;
    const user: User = await this.prisma.user.findUnique({where: {username}})
    if(!user) throw new UnauthorizedException()

   return user
  }
}

passport를 사용할 때에는 전략이라고 하는 Strategy 라는 개념을 사용한다. 지금은 jwt만 사용하고 있지만 나중에 카카오 로그인이나 구글로그인을 만들어야 한다면 Strategy 만 별도로 구현해서 적용해주면 되므로 관리가 편해진다.

import { Module } from '@nestjs/common';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { PrismaModule } from '../../prisma/prisma.module';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import * as process from 'node:process';
import { JwtStrategy } from './jwt.streategy';

@Module({
  imports: [
    PrismaModule,
    PassportModule.register({defaultStrategy: 'jwt'}),
    JwtModule.register({
      secret: process.env.SECRET_KEY, // jwt의 시그니쳐 부분에 입력되는 시크릿 값
      signOptions: {expiresIn: process.env.jwtExpiresIn},
    })
  ],
  controllers: [AuthController],
  providers: [AuthService, JwtStrategy], // auth module에서 사용하기 위해 JwtModule를 프로바이더에 넣어주어야 한다.
  exports: [JwtStrategy, PassportModule], // 다른 module에서 사용하기 위해 exports에 넣어주어야 한다.
})
export class AuthModule {}

JwtStrategy를 auth.module.ts에 추가해주어야 한다. providers, exports 부분에 JwtStrategy, PassportModule를 추가한다.

Guards 사용하기

위 코드 만으로 사용자의 정보가 request값에 포함되지는 않는다.

app.module.ts에 의존성을 넣어줌으로써 passport-jwt strategy를 사용 할 수는 있지만 HTTP 요청에 클라이언트의 정보가 포함되어 있지 않게 된다.

@Post('/test')
test(@Req() req){
  console.log('req = ', req);
}

테스트 컨트롤러를 만들고 요청값이 어떻게 오는지 확인해보면 위 이미지와 같이 클라이언트 정보가 들어가 있지 않는다. 그래서 Guards 미들웨어를 사용해야 한다.

nest.js에서는 아래와 같은 미들웨어들이 제공된다. (직접 만들어 사용 할 수도 있다.)

  • pipes → 유효성 검사 타입 변환
  • filters → 오류 처리 미들웨어
  • guards → 인증과 관련된 처리를 위한 미들웨어
  • interceptors → 응답 맵핑, 캐시 관리, 로깅 등등 요청 전후에 실행되는 미들웨어

미들웨어 실행 순서

middleware → guard → interceptor(before) → pipe → controller → service → controller → interceptor(after) → filter (if applicable) → client

@Post('/test')
@UseGuards(AuthGuard())
  test(@Req() req){
  console.log('req = ', req);
}

@UseGuards(AuthGuard()) 를 추가함으로 인해 HTTP request 안에 인증 된 유저의 객체를 넣어주게 된다.


커스텀 데코레이터 사용하기

import { createParamDecorator } from '@nestjs/common';
import { User } from '@prisma/client';

export const GetUser = createParamDecorator((data, ctx): User => {
  const req = ctx.switchToHttp().getRequest();
  return req.user
})

커스텀 데코레이터를 생성 한다.

@Post('/test')
  @UseGuards(AuthGuard())
  test(@GetUser() user: User): User {
    console.log('user = ', user);
    return user
  }

위 코드처럼 @GetUser를 사용해서 request 값에 있는 User 객체를 가져 올 수 있다. 단 @UseGuards 데코레이터가 있어야 한다.

passport가 넣어주는 User 객체를 사용하기 때문이다.

boards.module.ts

@Module({
  imports: [PrismaModule, AuthModule], // AuthModule 추가
  controllers: [BoardsController],
  providers: [BoardsService]
})
export class BoardsModule {}

다른 컨트롤러에서 인증을 확인하려면 @UseGuards(AuthGuard()) 데코레이터를 붙여 주고 module파일의 imports 부분에 AuthModule

넣어주어야 한다.


로그 남기기

import { Body, Controller, Get, Logger, Post, Req, UseGuards, UsePipes, ValidationPipe } from '@nestjs/common';
import { AuthService } from './auth.service';
import { AuthCredentialDto } from './dto/auth-credential.dto';
import { User } from '@prisma/client';
import { AuthGuard } from '@nestjs/passport';
import { GetUser } from './get-user-decorator';

@Controller('/auth')
export class AuthController {
  private logger = new Logger('AuthController');
  constructor(private authService: AuthService) {
  }

  @UsePipes(new ValidationPipe())
  @Post('/signup')
  signUp(@Body() authCredentialDto: AuthCredentialDto): Promise<User> {
    return this.authService.signUp(authCredentialDto);
  }

  @UsePipes(new ValidationPipe())
  @Post('/signin')
  signIn(@Body() authCredentialDto: AuthCredentialDto): Promise<{accessToken: string}> {
    return this.authService.signIn(authCredentialDto)
  }

  @Post('/test')
  @UseGuards(AuthGuard()) // @UseGuards를 사용해야 request 객체에 User 객체가 추가 된다.
  test(@GetUser() user: User): User {
    this.logger.verbose('로그를 남기고 싶다면 logger 인스턴스를 만든 후 사용 합니다.');
    return user
  }
}

항상 개발 환경에서는 로그를 남기거나 오류를 확인 할 수 있도록 하는 것이 중요하다.

로컬에서 개발중에는 디버거 모드를 사용해 오류를 확인하겠지만 서비스 중인 상태에서 어느부분이 문제가 생겼는지를 보려면

로그를 잘 남기는 것은 매우 중요하다, 추후에는 sentry를 사용해 오류를 추적하는 것에 대해 학습해보자. nest.js에서 로그를 남기는 방법은

편리하다 Logger 인스턴스를 만들고 사용하기만 하면 된다.

 

'BACKEND' 카테고리의 다른 글

JSON_ARRAYAGG와 JSON_OBJECTAGG 차이 (Mysql 8.0)  (0) 2025.02.12
Nest, Adonis, Express 비교 하기  (1) 2024.12.09
Nest.js 공부하기 (1)  (3) 2024.11.04
Prisma 공부하기  (3) 2024.10.20
SOAP 프로토콜 사용하기  (0) 2024.05.05