NestJS

김동현·2023년 3월 4일
0

Server

목록 보기
2/2
post-thumbnail

NestJS Project

Nest CLI를 사용하면서 프로젝트를 설정하기 위해서 아래와 같은 명령어를 입력해줍니다.

NestJs 개발 환경 셋팅

$ npm i -g @nestjs/cli
$ nest new <project-name>

파일 구조

프로젝트 생성시 아래와 같은 프로젝트가 생성됩니다.

src

src 폴더 내 파일에 대한 역할은 다음과 같습니다.

  • app.controller.ts : 하나의 라우트가 있는 기본 컨트롤러입니다. Express에서의 라우트와 같은 역할을 합니다.
    Express에서는 get()와 같은 메서드를 사용하였다면 Nest에서는 @Get()이라는 Typescript의 데코레이터를 사용합니다.

  • app.constoller.spec.ts : 컨틀롤러를 위한 유닛 테스트

  • app.module.ts : 애플리케이션 루트 모듈

  • app.service.ts : 단일 메서드를 사용하는 기본 서비스입니다. 컨트롤러에 사용될 비즈니스 로직을 갖고 있습니다.

  • main.ts : 핵심 기능 NestFactory를 사용하여 Nest 애플리케이션 인스턴스를 생성하는 애플리케이션의 엔트리 파일입니다.
    생성된 인스턴스의 listen(port) 메서드를 통해 서버를 열 수 있습니다.

package.json

@nestjs/common, @nestjs/core, @nestjs/platform-express 패키지의 경우 NestJs 자체적으로 동작하는 패키지입니다. reflect-metadata는 데코레이터 문법을 위한 패키지, rimraf 패키지는 rm -rf 명령어를 윈도우에서 사용하기 위한 패키지, rxjs 패키지는 비동기 이벤트 기반 프로그래밍을 위한 패키지입니다.

Controllers

컨트롤러는 들어오는 요청(req)을 처리하고 클라이언트에게 응답(res)을 반환합니다. 일반적으로 *.controller.ts 확장자를 사용합니다.

위 그림처럼 클라이언트가 HTTP Request를 전달하면 Controller가 받아 Response를 반환합니다.

app.controller.ts 파일에서 @Controller()라는 데코레이터를 통해 해당 클래스가 Controller 역할을 하기 위한 추가적인 메타 데이터를 제공받습니다.

데코레이터란 실제로 함수입니다. 클래스, 메서드, 프로퍼티, 접근자 프로퍼티, 파라미터 등에 적용 가능하며 추가적인 메타 데이터를 적용하기 위한 Typescript 문법입니다.
@Expresstion 와 같은 형식으로 사용하며 함수 참조를 작성합니다.

클래스 데코레이터의 경우 클래스의 생성자 함수(constructor 메서드)의 동작을 변경하기 위해 사용합니다.

@Expression()와 같이 호출문이 작성된 경우 데코레이터 팩토리라고 하며, 데코레이터 함수를 반환하는 함수입니다.

컨트롤러의 목적은 애플리케이션에 대한 특정 요청(Request)를 수신하는 것입니다.

기본 컨트롤러를 생성하기 위해 클래스와 데코레이터(@Controller())를 사용합니다. 데코레이터는 클래스를 필수 메타 데이터와 연결하고 Nest가 라우팅 맵을 만들 수 있도록합니다.

Routing

@Contoller(prefix) 데코레이터의 인수로 매칭될 경로의 접두사를 전달할 수 있습니다.

즉, 경로의 접두사를 지정하여 각 경로에 대한 반복되는 경로를 작성할 필요가 없습니다.

import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';

@Controller('cats')
export class AppController {
    
    // GET https://localhost:8000/cats/hello
    @Get('hello')
    getHello() : string {
        return 'hellow, world';
    }
}

@Get(), @Post(), @Put(), @Patch(), @Delete() 메서드 데코레이터를 적용하여 매칭될 HTTP 요청 메서드를 지정할 수 있습니다. 인수로 매칭될 경로를 작성합니다.

위와 같은 컨트롤러의 경우 '/cats/hello'로 GET 요청을 보내는 경우 getHello 메서드가 실행됩니다.

참고로 동적 라우팅의 경우 Express와 동일하게 /:path와 같은 형식으로 지정하고 요청 객체의 params

메서드 데코레이터는 "메서드의 동작(Property Descriptor)"을 변경하기 위해 사용됩니다.

Request

import { Controller, Get, Req } from '@nestjs/common';
import { Request } from 'express';

import { AppService } from './app.service';

@Controller('cats')
export class AppController {
    
    // GET -> https://localhost:8000/cats/hello
    @Get('hello')
    getHello(
      @Req() req: Request,
      @Body() body,
      @Param() param
    ) : string {
        
        console.log(req);  // -> @Req() 적용한 파라미터
        console.log(body);  //  -> @Body() 적용한 파라미터
        console.log(param);  // -> @Param() 적용한 파라미터
        
        return 'hellow, world';
    }
}

클라이언트가 보낸 요청을 위와 같이 @Req() 데코레이터를 적용한 req 매개변수로 전달받을 수 있습니다.

이때 타입을 express가 제공하는 Request 타입으로 요청 객체에 대한 타입을 지정할 수 있습니다.

req.body와 같은 데이터를 @Body() 데코레이터를 적용한 파라미터로 받아올 수 있으며, req.param의 경우에도 @Param() 데코레이터를 적용한 파라미터로 받아올 수 있습니다.

파라미터 데코레이터는 클래스의 생성자 함수(Constructor) 또는 파라미터가 선언된 메서드의 구조(Property Descriptor)의 동작을 변경하기 위해 사용합니다.

Request lifecycle

Request를 받으면 아래와 같은 lifecycle을 거치게 됩니다.

  1. Incoming request

  2. Middleware(Global bound -> Module bound)

  3. Gaurds(Global -> Controller -> Route)

  4. Interceptor(pre-controller, Global -> Controller -> Route)

  5. Pipe(Global -> Controller -> Route -> Route Parameter)

  6. Controller(method handler)

  7. Service(if exists)

  8. Interceptor(post-request, Route -> Controller -> Global)

  9. Exception Filter(Route -> Controller -> Global)

  10. Server response

Providers

Nest의 경우 controller에서 실행될 비즈니스 로직을 service라는 인스턴스로 제공받아 사용합니다. 참고로 service 이외 @Injectable() 데코레이터가 적용된 클래스라면 모두 제공받을 수 있습니다.

내부 메서드에서는 service 인스턴스의 메서드를 호출하여 비즈니스 로직을 실행하도록 하는 방식으로 사용합니다.

import { Controller, Get, Req } from '@nestjs/common';
import { Request } from 'express';

import { AppService } from './app.service';

@Controller('cats')
export class AppController {
    constructor(private readonly appService: AppService) {}
    
    // GET -> https://localhost:8000/cats/hello
    @Get('hello')
    getHello(
      @Req() req: Request,
      @Body() body,
      @Param() param
    ) : string {
        
        return this.appService.getHello();
    }
}

constructor 메서드에서 appService라는 프로퍼티를 생성하는 로직을 작성하면 appService 인스턴스를 주입받아 사용할 수 있습니다.

하지만 실제 appService라는 프로퍼티를 초기화하는 로직은 작성되지 않은 것을 확인할 수 있습니다. appService 인스턴스를 생성하여 주입하는 동작은 module의 역할입니다.

DI(Dependency Injection)

provider는 Nest에서 service, repository, factory 등 기본 Nest 클래스의 대부분은 공급자로 취급될 수 있습니다.

DI가 될 클래스에는 @Injectable() 클래스 데코레이터를 적용해야 합니다.

위 그림처럼 controller는 다양한 provider를 주입받을 수 있습니다.

provider의 주요 아이디어는 종속성을 주입(DI)할 수 있다는 것입니다.

Nest는 모든 것을 모듈화합니다. 이렇게 모듈화를 해주는 파일이 *.module.ts입니다.

app.module.ts 파일의 내용은 아래와 같습니다. module은 Controller와 Porvider등 모두 하나의 모듈로 모듈화하는 역할을 합니다.

@Module() 데코레이터의 인수로 전달되는 객체에서 소비자 역할을 하는 controller 클래스의 경우 controllers 배열에 작성되어 있습니다. 이때 소비자가 주입받아야할 클래스를 providers 배열에 작성해야 합니다.

즉, providers 배열이 controller에 제공될 인스턴스를 생성하는 클래스를 작성하는 배열입니다.

위 코드에서 소비자인 AppController에서는 providers 배열에 작성된 AppService 클래스의 인스턴스를 주입받아 사용합니다.

Modules

위 그림처럼 하나의 루트 모듈(AppModule)은 여러 모듈과 연결되어 있습니다. 이렇게 Nest 애플리케이션은 여러 모듈들로 구성되어 있습니다. 일반적으로 *.module.ts 확장자를 사용합니다.

모듈 역할을 하는 클래스의 경우 @Module() 데코레이터를 적용합니다. 이때 인수로 객체를 전달할 수 있으며, 전달될 객체의 구조는 아래와 같습니다.

import { Module } from '@nestjs/common';

@Module({
    imports: [],
    controllers: [],
    providers: [],
    exports: []
})
  • imports : 해당 모듈에 필요한 모듈을 작성하며, 작성된 모듈이 exports한 provider를 해당 모듈에서 사용할 수 있습니다.

  • constrollers : 해당 모듈에서 controller 역할을 하는 클래스 작성

  • providers : Nest 인젝터에 의해 인스턴스화되고 해당 모듈에서 공유될 수 있는 provider 역할을 하는 클래스 작성

  • exports : 외부의 다른 모듈이 사용할 provider 클래스 작성, 기본적으로 모듈은 provider를 캡슐화하기 때문에 exports하지 않은 provider를 외부 모듈은 사용할 수 없습니다.

외부 모듈의 provider를 providers 배열에 직접 작성할 수는 있지만 해당 패턴은 좋지 않습니다. imports하는 모듈이 늘어날 때마다 providers 배열에 작성되는 provider가 많아지며 관리하기 어려울 뿐만 아니라 모듈에 대한 SRP(단일 책임 원칙)이 깨지게 됩니다.
즉, 다른 모듈의 provider를 사용하기 위해서는 exports를 사용하는 방식을 권장합니다.

Circular dependency

만약 두 모듈이 서로 참조하는 경우, 예를 들어 모듈 A가 모듈 B를 import하고, 모듈 B도 모듈 A를 import하는 경우 순환 참조가 발생합니다.

모듈간 불가피하게 순환 참조가 발생한다면 forwardRef()를 통해 해결할 수 있습니다.

// a.module.ts

@Module({
    imports: [
        forwardRef(() => BModule),
        ,,,
    ],
    ,,,
})
export calss AModule {
    ,,,
}
// b.module.ts

@Module({
    imports: [
        forwardRef(() => AModule),
        ,,,
    ],
    ,,,
})
export calss BModule {
    ,,,
}

이때 두 모듈 모두 forwardRed()를 사용해주어야 합니다.

Middleware

Nest 미들웨어는 기본적으로 Express의 미들웨어와 동일합니다. 일반적으로 *.middleware.ts 확장자를 사용합니다.

미들웨어는 요청과 응답 사이에 추가적인 동작을 할 수 있으며, 요청과 응답 객체에 접근할 수 있고, next()를 통해 다음 미들웨어를 실행할 수 있습니다.

import { Injectable, NestMiddleware } from '@nestjs/common';
import { NextFunction, Request, Response } from 'exporess';

@Injectable()
export class LoggerMiddleware implements NestMiddleware {
    private logger = new Logger('HTTP');
    
    use(req: Request, res: Response, next: NextFunction) {
        
        this.logger.log(req.id, req.originalUrl);
        next();
    }
}

Nest 미들웨어는 NestMiddleware 클래스를 implements해야 하며, 내부에서는 use 메서드를 통해 미들웨어 로직을 작성합니다.

@Injectable() 데코레이터를 적용하면 해당 클래스는 주입될 수 있는 클래스가 됩니다. 즉, DI될 대상이 됩니다.

참고로 Nest는 로깅을 위한 인스턴스를 제공합니다. Logger 클래스로 생성한 인스턴스의 logger.log() 메서드를 사용할 수 있습니다.

Middleware 적용

미들웨어를 적용하기 위해서는 module 역할을 하는 클래스를 아래와 같이 작성해주어야합니다.

미들웨어는 요청과 응답 사이 특정한 처리를 위해 사용하며, 요청을 전달받은 순간 시작되고 응답을 반환하면 종료됩니다. 미들웨어는 요청, 응답 객체에 접근할 수 있으며 다음 미들웨어를 실행할 수 있습니다.

import { Module, MiddlewareConsumer, NestModule } from '@nestjs/common';

import { AppController } from './app.controller.ts';
import { AppService } from './app.service.ts';
import { LoggerMiddleware } from './logger.middleware.ts';

@Module({
    imports: [],
    controllers: [AppController],
    providers: [AppService]
})
exports class AppModule implements NestModule {
    configure(consumer: MiddlewareConsumer) {
        consumer
          .apply(LoggerMiddleware)
          .forRoutes('cats');
    }
}

NestModule 클래스를 implements하고 configure 메서드가 전달받는 인수로 apply 메서드에 미들웨어를 등록합니다. forRoutes 메서드를 통해 미들웨어가 시작될 경로를 작성합니다.

Exception

요청에 대한 예외 처리로 Nest는 자동으로 아래와 같은 형식의 응답을 전달합니다.

{
    "statusCode": statusCode,
    "message": "message",
    "error": "error"
}

원하는 형식으로 정보를 제공하기 위해서는 HttpException을 사용합니다.

@Controller('cats')
export class CatsController {

    @Get()
    getAllCat() {
        throw new HttpException({
            success: false,
            status: HttpStatus.FORBIDDEN,
            message: 'API is Broken!!'
        }, HTTPStatus.FORBIDDEN);
        
        return 'all cats';
    }
    
    ,,,
}

위와 같이 HttpException 생성자 함수의 첫 번째 인수로 객체를 전달하여 응답 객체 전체를 오버라이딩할 수 있습니다. 두 번째 인수로는 Http 응답 상태를 나타내는 상태 코드를 작성합니다.

혹은 첫 번째 인수로 문자열을 전달하는 경우 message 프로퍼티 값만 오버라이딩할 수 있습니다.

만약 GET으로 '/cats' 요청을 받으면 아래와 같은 응답이 전달됩니다.

{
    "success": false,
    "status": 403,
    "message": "API is Broken!!"
}

Exception filters

매번 예외 처리에 대한 응답 정보를 작성하는 것은 불필요한 반복을 사용해야 합니다. 만약 동일한 형식의 응답 정보를 제공해야하는 경우 Exception filter을 생성하여 적용할 수 있습니다.

즉, Exception이 발생하면 Exception filter을 거쳐 Response를 클라이언트에게 전달하게 됩니다.

HttpExceptionFilter 생성

// http-exception.filter.ts

import {
  ExceptionFilter,
  Catch,
  ArgumentsHost,
  HttpException
} from '@nestjs/common';
import { Request, Response } from 'express';


@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
    catch(exception: HttpException, host: ArgumentsHost) {
    
        // 실행환경
        const ctx = host.switchToHttp();
        // 응답 객체
        const response = ctx.getResponse<Response>();
        // 요청 객체
        const request = ctx.getRequest<Request>();
        // 응답 상태 코드 (statusCode 프로퍼티)
        const status = exception.getStatus();
        // 에러 메세지 (message 프로퍼티)
        const message = exception.getResponse();
        
        // 응답 객체 전달
        response.status(status).json({
            success: false,
            status,
            message,
        });
    }
}

HttpExceptionFilter 적용

HttpExceoption Filter는 전역 혹은 특정 라우트나 컨트롤러에 적용할 수 있습니다.

특정 컨트롤러나 라우트에 적용할 때는 @UseFilters() 데코레이터 인수로 HttpException Filter를 전달해줍니다.

// cats.controller.ts

@Controller('cats')
@UseFilters(HttpExceptionFilter)
export class CatsController {
    
    @Get()
    getAllCat() {
        throw new HttpException('API is Broken', 401);
        ,,,
    }
    
    ,,,
}

만약 GET으로 '/cats' 요청을 전달받으면 아래와 같은 응답이 전달됩니다.

{
    "success": false,
    "status": 401,
    "message": "API is Broken",
}

즉, 에러가 발생하면 생성된 HttpException 인스턴스가 HttpExceptionFilter를 거쳐 응답을 전달하게 됩니다.


전역에 적용할 때는 useGlobalFilter() 메서드의 인수로 HttpException Filter 인스턴스를 전달합니다. 404 에러와 같은 경우 전역적으로 적용합니다.

// main.ts

async function bootstrap() {
    const app = await NestFactory.create(AppModule);
    app.useGlobalFilter(new HttpExceptionFilter());
    await app.listen(3000);
}
bootstrap();

Pipes

파이프에는 두 가지 일반적인 사용 사례가 있습니다.

  1. 변환 : 입력 데이터를 원하는 형식으로 변환합니다.

  2. 유효성 검사 : 입력 데이터를 평가하고 유효한 경우 변경하지 않지만, 유효하지 않은 경우 예외를 발생시킵니다.

Binding pipes

controller의 특정 라우트에서 @Param() 파라미터 데코레이터를 통해 요청 객체의 param 프로퍼티 값만을 추출할 수 있었습니다. 이때 데코레이터에 첫 번째 인수로 특정 프로퍼티 키를 전달하면 특정 프로퍼티의 값을 추출하게 됩니다.

@Param('id')로 적용하게 되면 param 객체의 id값만을 추출하게 됩니다.

두 번째 인수 이후부터 적용할 파이프를 전달합니다. 만약 ParseIntPipe를 전달하면 값의 타입을 숫자 타입으로 변환합니다. 만약 숫자 타입으로 변환이 불가능한 경우에는 예외를 발생시킵니다.

@Delete(':id')
deleteCat(@Param('id', ParseIntPipe,,,) id: number) {
    ,,,
}

parseIntPipe외에도 parseFloatPipe, parseBoolPipe, parseArrayPipe 등 여러 파이프가 존재합니다.

Interceptors

인터셉터는 @Injectable() 데코레이터가 적용된 클래스입니다. 인터센터는 NestInterceptor 인터페이스로 구현되어야 합니다.

인터셉터에는 AOP(Aspect Oriented Programing) 기술에서 영감을 받았습니다.

AOP란 관점 지향 프로그래밍으로 횡단 관심사(Cross-cutting concern)를 분리하여 모듈성을 증가시키는 프로그래밍 패러다임입니다.

이는 여러 모듈들이 공통적으로 갖는 기능을 하나의 모듈로 갖도록 하는 것입니다.

Nest 관점에서는 여러 controller에 공통적으로 적용되는 기능을 interceptor로 분리할 수 있습니다.

interceptor의 경우 pre-controller와 post-request가 존재합니다. pre-controller의 경우 controller가 시적되기 전, post-request의 경우에는 controller와 service 로직이 실행된 이후에 실행됩니다.

@Injectable()
export class SuccessInterceptor implements NestInterceptor {
    intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
        console.log('pre-controller interceptor...);
    
        return next.handle().pipe(
            map((data) => {
                console.log('post-request interceptoer...');
                
                return {
                    success: true,
                    data
                };
            })),
        );
    }
}

SuccessInterceptor를 아래와 같이 Controller의 라우트에 적용하는 방법은 아래와 같습니다.

@Controller()
@UseInterceptor(SuccessInterceptor)
,,,

@useInterceptoer() 데코레이터를 사용하여 SuccessInterceptor를 주입하면 요청이 들어오는 경우 먼저 SuccessInterceptor의 intercept 메서드가 실행되어 "post-request interceptor..."가 먼저 출력되고, 이후 Route와 Service로직이 실행됩니다.
Route가 반환한 응답은 intercept 메서드의 반환값으로 작성한 next.handle().pipe.map 메서드의 콜백 함수 인수로 전달되며, 콜백 함수의 반환값이 최종 응답으로 전달됩니다.

Database

Connect Database

MySQL과 TypeORM을 사용하기 위해서 아래와 같은 패키지를 설치해야 합니다. TypeORM은 SQL문이 아닌 TypeScript를 통해 데이터베이스를 관리하기 위한 패키지입니다.

추가적으로 데이터베이스에 대한 정보는 외부에 노출되어서는 안되는 민감한 정보입니다. 이러한 정보를 은닉하기 위해서 환경 변수를 사용하는데 환경 변수를 사용하기 위해서는 @nestjs/config 패키지를 설치해야 합니다.

환경 변수를 위한 파일은 프로젝트 루트에 .env 파일에 작성합니다. 작성된 환경 변수들은 process.env.*으로 접근할 수 있습니다.

$ npm i @nestjs/typeorm typeorm mysql2
$ npm i @nestjs/config

그리고 루트 모듈인 app.module.ts 파일에서 아래와 같이 코드를 작성합니다.

@Module({
    imports: [
        ConfigModule.forRoot(),
        TypeOrmModule.forRoot({
            type: 'mysql',  // database 종류
            host: process.env.HOST  // host 이름
            port: process.env.port,  // port 번호
            username: process.env.USERNAME,  // 사용자 이름
            password: process.env.PASSWORD,  // 비밀번호
            name: process.env.NAME,  // 데이터베이스 이름
            synchronize: true  // 동기화 여부
        }),
        ,,,
    ],
    controllers: [,,,],
    providers: [,,,]
})
export class AppModule {
   ,,,
}

ConfigModule.forRoot()TypeOrmModule.forRoot()를 imports에 작성해주어야 합니다.

이때 TypeOrmModule.forRoot()의 인수로 객체를 전달하는데 객체에 연결될 DB의 정보들을 작성해줍니다.

Entity

엔티티란 DB의 테이블을 의미합니다. TypeORM을 사용하여 Entity를 아래처럼 생성할 수 있습니다.

@Entity('cat')
export class CatsEntity {
    @PrimaryColum({ type: 'string', unique: true })
    email: string;
    
    @Column({ type: 'string' })
    password: string;
    
    @Column({ type: 'string' })
    name: string;
    
    @Column({ type: 'string', nullable: true })
    imgUrl: string;
}

즉, @Entity(table_name) 클래스 데코레이터를 적용하고 인수로 테이블명을 전달할 수 있습니다.

PK같은 경우 @PrimayColumn({ ,,, }) 프로퍼티 데코레이터를 적용하고, 일반적인 Column은 @Column({ ,,, }) 프로퍼티 데코레이터를 적용합니다. 그리고 인수로 각 칼럼에 대한 옵션을 작성할 수 있습니다.

Entity connect

생성한 엔티티를 TypeORM에게 알리기 위해서는 루트 모듈에서

class-validator

class-validator 패키지는 DB에 저장될 때 데이터의 유효성 검사를 강제하기 위한 패키지입니다.

$ npm i class-validator class-transformer

위에서 설계한 엔티티에 class-validator를 적용하면 아래와 같이 적용할 수 있습니다.

@Entity('cat')
export class CatsEntity {
    @PrimaryColum({ type: 'string', unique: true })
    @IsEmail()
    @IsEmpty()
    email: string;
    
    @Column({ type: 'string' })
    @IsString()
    password: string;
    
    @Column({ type: 'string' })
    @IsString()
    name: string;
    
    @Column({ type: 'string', nullable: true })
    @IsString()
    imgUrl: string;
}

@IsEmail(), @IsEmpty() 이나 @IsString() 데코레이터처럼 필드에 저장될 데이터의 유효성을 검사할 수 있습니다.

그리고 main.ts에서 class-validator를 적용하기 위해서 useGlobalPipes() 메서드의 인수로 ValidationPipe 인스턴스를 전달해줍니다.

async function bootstrap() {
    const app = await NestFactory.create(AppModule);
    
    // 등록
    app.useGlobalPipes(new ValidationPipe());
    
    app.listen(process.env.PORT);
}
bootstrap();

DTO(Data Transfer Object)

DTO는 계층간 데이터 교환을 위한 객체입니다. 이는 교환되는 데이터의 유효성 검사를 강제하기 위해서 사용할 수 있습니다.

만약 유효하지 않은 데이터의 경우 다음 계층으로 전달되지 않으며 예외를 발생시킵니다.

// cats.request.dto.ts

export class CatReqeustDto {
    @IsEmail() // 이메일 형식 강제
    @IsNotEmpty()  // 빈 값 허용하지 않음 강제
    email: string;
    
    @IsString()  // 문자열 타입 강제
    @IsNotEmpty()
    password: string;
    
    @IsString()
    @IsNotEmpty()
    name: string;
}

CatRequestDto를 아래처럼 적용을 하는 경우

@Post()
async signUp(@Body body: CatRequestDto) {
    ,,,
}

요청 객체의 몸체(body)가 CatRequestDto에 유효한지에 대한 유효성 검사를 실시하게 됩니다. 만약 유효하지 않은 경우 곧장 예외를 발생시켜 다음 단계로 넘어가지 않게 됩니다.

API Document

설계한 API에 대한 명세를 나타내는 페이지를 생성하기 위해서 아래와 같은 패키지를 설치합니다.

$ npm i @nestjs/swagger swagger-ui-express

그리고 main.ts 파일에 아래와 같은 코드를 추가시켜 줍니다.

// main.ts

async function bootstrap() {
    ,,,
    const config = new DocumentBuilder()
        .setTitle('API Ttitle')
        .Description('API Desc')
        .setVerstion('version')
        .build();
        
    const document: OpenAPIObject = SwaggerModule.createDocument(app, config);
    SwaggerModule.setup('docs', app, document);
    const PORT = process.env.PORT;
    
    app.listen(PORT);
}
bootstrap();

이후 'hostname:port/docs'에 접근하면 설계한 API에 대한 명세 페이지로 접근하게 됩니다.


만약 각 컨트롤러에 대한 추가적인 설명을 작성하고자 한다면 라우트 핸들러에 아래와 같은 코드를 추가해주어야 합니다.

@ApiOperation({ summary: 'Sign Up API' })
@Post()
async signUp(@Body() body: CatRequestDto) {
    ,,,
}

@ApiOperation() 메서드 데코레이터를 추가하고 인수로 객체를 전달합니다. 객체의 summary 프로퍼티에 구체적인 설명을 작성할 수 있습니다.


요청 몸체에 넘겨주어야 할 데이터에 대한 예시도 추가할 수 있습니다. 이때 요청 몸체(@Body() body: CatRequestDto)에 지정한 DTO(CatRequestDto)에 아래와 같은 코드를 추가적으로 작성해줍니다.

// cats.reqeust.dto.ts

@ApiProperty({
    example: 'test@test.com',
    description: 'Email',
    require: true
})
@IsEmail()
@IsNotEpty()
email: string;
,,,

@ApiProperty() 데코레이터를 추가하고 인수로 객체를 전달합니다. 객체의 프로퍼티로 example에는 예시 데이터, description에는 데이터에 대한 설명, require에는 필수 데이터 여부를 작성해줍니다.


응답 몸체에 대한 예시도 추가할 수 있습니다. 응답 몸체에 대한 예시 코드는 controller의 특정 라우트 핸들러에 아래와 같은 코드를 추가해줍니다.

@ApiResponse({
    status: 200,
    description: 'Success',
    type: CatResponseDto
})
@ApiResponse({
    status: 400,
    description: 'Server Error'
})
@Post()
async signUp(@Body body: CatRequestDto) {
    ,,,
}

@ApiResponse() 데코레이터를 추가하고 인수로 객체를 전달합니다. 객체의 status 프로퍼티에는 응답 코드, description에는 설명, type에는 응답 몸체에 대한 DTO를 작성해줍니다.


요청, 응답 몸체에 대한 각 데이터마다 위와 같이 반복해서 작성하는 것보다는 엔티티에 @ApiProperty() 데코레이터를 적용하고, 엔티티 클래스를 상속받아 DTO를 생성하도록 합니다.

// cats.entity.ts

export class CatsEntity {
    @ApiProperty({
        example: 'test@test.com',
        description: 'Eamil',
        require: true
    })
    @PrimaryColumn({
        type: 'varchar',
        length: '255',
        unique: true
    })
    @IsEmail()
    @IsNotEmpty()
    email: string;
    
    ,,,
}

그리고 DTO에서는 엔티티 클래스를 상속받아 필요한 필드만을 추출하도록 작성해줍니다. 엔티티의 특정 컬럼만 추출하기 위해 PickType 혹은 OmitType을 사용할 수 있습니다.

// cats.request.dto.ts

export class CatRequestDto extends PickType(CatsEntity, ['id', 'password', 'name'] as const) {}
// cats.response.dto.ts

exports class CatResponseDto extends PickType(CatsEntity, ['id', 'name']) {}

위 코드처럼 요청 몸체에 대한 DTO를 엔티티 상속을 받아 생성하도록 하고, PickType을 통해 원하는 컬럼만을 선택할 수 있습니다.

CORS

CORS를 해제하려면 main.ts 파일에 아래처럼 코드를 추가해줍니다.

// main.ts

async function bootstrap() {
    ,,,
    app.enableCors({
        origin: 'https://domain.com',
        credential: true
    });
    ,,,
}
bootstrap();

enableCors() 메서드를 호춣하고 인수로 옵션 객체를 전달합니다. 객체에 origin에는 접근을 허용할 URL을 작성합니다.

Repository Design Pattern

service에서 실제 서비스의 비즈니스 로직을 작성했습니다. 이때 DB에 접근하는 로직도 포함되어 있는데 만약 DB에 접근하는 로직이 복잡해진다면 비즈니스 로직에 집중을 할 수 없게 되어 테스트나 코드 중복, 가독성이 좋지 않아 집니다.

위 그림처럼 Repository Pattern을 적용한다면 service에서 직접 DB에 접근하지 않고 service는 Repository로 접근하고, Repository가 DB로 접근하는 로직을 갖도록 하는 패턴입니다.

만약 어떤 service에서 DB에 접근하는 로직을 다른 service가 사용해야 하는 경우 service가 다른 service를 참조해야하는 경우가 발생할 수도 있습니다.

하지만 Repository에 DB 접근하는 로직을 분리하여 갖도록 한다면 여러 service들은 하나의 Repository에 접근함으로써 참조되는 방향이 일방향으로 이루어지며 모듈간 책임 분리가 확실하게 됩니다.

또한 Repository Pattern의 핵심은 service 레이어에서 DB 관계없이 동일한 방식으로 데이터에 접근할 수 있습니다.

예를 들어, 서비스에서 사용되는 DB가 MongoDB, Mysql 등 여러 DB를 사용하는 경우에 각각 데이터에 접근하는 쿼리 방식이 다르지만 Repository에서 데이터에 접근하는 방식을 통일화한다면 각 service는 Repository에 정의된 통일된 방식으로 데이터에 접근할 수 있게됩니다.

// cats.repository.ts

@Injectable()
export class CatsRepository {
    constructor(private readonly catEntity: CatEntity) {}

    async isExistEmail(email: string): Promise<boolean> {
        const result = await this.catEntity.exist({ where: { email }});
        
        return result;
    }
    
    async create(cat: CatReqeustDto): Promise<CatResponse> {
        const newCat = this.catEntity.save(cat);
        
        return { eamil: cat.email, name: cat.name };
    }
}

위 코드처럼 직접적으로 DB에 접근하는 로직을 Repository로 분리하여 관리하고, Service에서는 Repository를 DI 받아 사용하도록 합니다.

// cats.service.ts

@Post()
async signUp(@Body body: CatReqeust) {
    const { email, password, name } = body;
    
    const isExist = await this.catsRepository.isExistEamil(eamil);
    
    if(isExist) {
        throw HttpException('Eamil is already Exist');
    }
    
    const result = await this.catsRepsotiry.create(body);
    
    return result;
}

Authentication (JWT)

JWT는 JSON 포맷을 사용한 인증 토큰입니다. JWT는 아래와 같은 형식으로 구성되어 있습니다.

  • Header : base64 인코딩 토큰의 타입과 알고리즘

  • Payload : 사용자 정보 base64 인코딩 데이터 (key-value)

  • Signature : Header + Payload를 조합하고 비밀키로 서명한 후, base64로 인코딩

  1. Client측에서 인증을 위한 정보(eamil, password,,,)를 요청에 실어 전달하면 Server에서는 정보가 유효한지 검사한 뒤 JWT을 생성하여 응답으로 전달해줍니다.

  2. 이후 Client측에서는 응답으로 전달받은 JWT을 요청 헤더에 실어 보내게 됩니다.

  3. Server 측에서는 JWT Guard를 통해 JWT Strategy를 실행하고, JWT Strategy에서 secret key를 통해 JWT을 디코딩합니다.
    디코딩된 정보를 갖고 비즈니스 로직을 실행하고 응답을 전달합니다.


JWT를 사용하기 위해서는 아래와 같은 패키지를 설치해주어야 합니다.

$ npm i @nestjs/passport passport @nestjs/jwt passport-jwt

$ npm i -D @types/passport-jwt

그리고 auth module과 service를 생성합니다.

$ nest g mo auth
$ nest g service auth

JWT Guard

생성된 auth 폴더에 jwt 폴더를 생성하고 내부에 jwt.guard.ts 파일을 생성합니다. 그리고 아래와 같이 작성합니다.

// jwt.guard.ts

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}

AuthGuard를 상속하여 정의해야하며, AuthGuard는 Stractegy를 자동으로 실행해주는 기능을 포함하고 있습니다.

jwt.strategry.ts

jwt 폴더에 jwt.strategy.ts 파일도 생성하여 아래와 같이 작성합니다. strategry의 경우 인증을 위해 사용합니다.

// jwt.strategy.ts

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
    constructor() {
        super({
            jwtFromRequest: Extract.fromAuthHeaderAdBearerToken(),
            secretOrKey: 'secret-key',
            ignoreExpiration: false
        })
    }
    
    async validate(payload) {
        const cat = await this.catSRepository.findCatByIdWithoutPassword(
            payload.sub
        );
        
        if(cat) {
            return cat;
        } else {
            throw new UnthorizedException();
        }
    }
}

JwtStrategyPassportStrategy를 상속받아 정의합니다. JwtStrategy에서 JWT 전략 설정과 인증 메서드를 갖고 있습니다.

super 호출을 통해 JWT 전략을 설정할 수 있습니다. jwtFromRequest 프로퍼티에는 JWT 추출 경로, secretOrKey 프로퍼티에는 사용될 JWT 생성할 때 사용될 비밀 키, ignoreExpiration 프로퍼티에는 JWT 만료 무시 여부를 설정합니다.

그리고 validate 메서드가 유효성 검사를 진행하는 메서드입니다.

auth.module.ts

이전에 생성한 auth.module.ts 파일에는 아래와 같이 작성해줍니다.

// auth.module.ts

@Module({
    imports: [
        PassportModule.register({ defaultStrategy: 'jwt', session: false }),
        JwtModule.register({
            secret: 'secret-key',
            signOptions: { expiresIn: '1y' }
        })
    ],
    providers: [ AuthService, JwtStrategy ]
})
export class AuthModule {}

PassportModule.register()은 Strategy에 대한 기본 설정을 할 수 있습니다. defaultStrategry 프로퍼티는 인증 전략, session프로퍼티는 세션 쿠기 사용 여부를 작성합니다.

JwtModule.register()은 JWT 토큰 생성에 대한 설정을 할 수 있습니다. signOptions.expiresIn 프로퍼티에는 만료 기간을 설정하고, secret 프로퍼티에는 jwt.strategy.ts에서 작성한 secretOrKey 값을 동일하게 작성해줍니다.

auth.service.ts

// auth.service.ts

@Injectable()
export class AuthService {
    constructor(
        private readonly catsRepository: CatsRepository,
        private jwtService: JwtService,
    ) {}
    
    asyn jwtLogIn(data: LoginRequestDto) {
        const { email, password } = data;
        
        const cat = await this.catsRepository.findCatByEmail(email);
        
        if(!cat) {
            throw new UnauthorizedException('Check Email and Password');
        }
        
        const isPasswordValid: boolean = await bcrypt.compare(password, cat.password);
        
        if(!isPasswordValid) {
            throw new UnauthorizedException('Check Email and Password');
        }
        
        const payload = { email, sub: '토큰제목' };
        
        return {
            token: this.jwtService.sign(payload);
        };
    }

}

JwtService를 DI 받아 최종적으로 JWT 토큰 생성하기 위해서는 this.jwtService.sign(payload); 메서드를 사용해야 합니다. 인수로 payload에 들어갈 데이터를 전달합니다.

profile
Frontend Dev

0개의 댓글