Middleware 함수는 어플리케이션의 request-response cycle에서 next 함수와 request object와 response object에 접근할 수 있다. next 함수는 Express의 router 함수로서, 호출되면 현재 미들웨어 다음에 실행될 미들웨어를 실행한다.
다음과 같은 역할을 담당할 수 있다.
특정한 code를 실행할 수 있다.
request, response object들을 바꿀 수 있다.
request-response object를 끝낼 수 있다.
stack에서 next middleware를 호출할 수 있다.
만일 현재 middleware function이 request-response cycle을 끝내지 못한다면, 반드시 next() 를 호출하여 제어권을 다음 middleware function에 넘겨줘야 한다.
import * as express from "express";
import { Cat, CatType } from "./Cat/CatType";
import { error } from "console";
const app: express.Application = express();
// 아래의 router들을 살펴보면 동일한 logging 작업이 반복된다.
// 이를 middleware에서 처리하도록 한다. middleware는 맨 위에 적어줘야 한다
// 만일 맨 끝에 작성하게 되면 동작을 하지 않는다.
// app.use: router 전체에 대한 middleware
app.use((req, res, next) => {
// next 함수: 다음 router로 이동할 수 있게 하는 함수.
console.log("========================================");
console.log("this is logging middleware");
console.log(req.rawHeaders[1]);
console.log("========================================");
next();
});
app.get("/cats/som", (req, res, next) => {
// next 함수: 다음 router로 이동할 수 있게 하는 함수.
// 이 경우 /cats/som 의 router로 넘어간다.
console.log("========================================");
console.log("this is logging middleware for cats/som");
console.log(req.rawHeaders[1]);
console.log("========================================");
next();
});
// 아래와 같은 구조를 router이라고 한다.
app.get("/", (req: express.Request, res: express.Response) => {
console.log("router");
res.send({ cats: Cat });
});
app.get("/cats/blue", (req: express.Request, res: express.Response) => {
console.log("router");
res.send({ blue: Cat[0] });
});
app.get("/cats/som", (req: express.Request, res: express.Response) => {
console.log("router");
res.send({ som: Cat[1] });
});
// middleware을 마지막에 배치하여 유효하지 않은 path에 대해 error를 처리할 수 있다.
app.use((req, res, next) => {
console.log("========================================");
console.log("middleware for handling error");
console.log({ error: "404 not found" });
console.log("========================================");
});
app.listen(8000, () => {
console.log("server is On");
});
때떄로, 한 두개의 asynchronous task가 완료된 이후 application을 실행할 필요가 있다. 예를 들어 DB와의 연결이 완료될 확릴될 때까지 요청을 수락하지 않도록 하고 싶을 수 있다. asynchronous provider를 통해 이를 달성할 수 있다.
이를 위한 문법은 useFacotry에서의 async/await이다. factory는 Promise를 return한다. 그리고 await asynchronous task를 수행한다. Nest는 그러한 provider에 의존하는(주입받는) 클래스를 instance화하기 전에 promise가 해결될 때 까지 기다릴 것 이다.
앞서, 우리는 Dependency Injection(DI) 의 다양한 측면을 살펴보았다. 그리고 어떻게 Nest에서 이용되는지를 살펴보았다. 예를 들어 instance들을 class에 주입하기 위해서 constructor based DI가 있다. Dependency Injection은 Nest core에 근본적으로 내장되어 있다는 사실은 놀라운 것은 아니다. 지금까지, 한 가지 패턴만을 살펴보았다. 애플리케이션이 좀더 복잡해질 수록 DI의 모든 기능들을 이해해야 한다.
DI fundamentals
Dependency Injection은 Inversion of Control 기술이다. 이는 의존성의 인스턴스화를 자신의 코드에서 명령형으로 수행하는 대신 IoC(NestJS 의 경우 런타임 시스템)에 의임하는 것이다. Providers Chapter에서 예들을 확인할 수 있다.
의존성 주입(Dependency Injection, DI)은 객체가 필요한 의존성을 직접 생성하지 않고 외부에서 주입받는 디자인 패턴이다.
이를 통해 코드의 결합도를 낮추고, 더 유연하고 테스트하기 쉬운 코드를 작성할 수 있다.
NestJS는 DI 컨테이너를 사용하여 의존성을 관리한다. 이 컨테이너는 애플리케이션은 시작될 때 모든 프로바이더를 생성하고, 이를 필요한 곳에 주입함.
첫 번째로, provider를 정의한다. @Injectable() decorator는 CatsService class가 provider임을 나타낸다.
import { Injectable } from '@nestjs/common';
import { Cat } from './interfaces/cat.interface';
@Injectable()
export class CatsService {
private readonly cats: Cat[] = [];
findAll(): Cat[] {
return this.cats;
}
}
그리고 우리는 Nest가 provider를 controller class에 주입하도록 요청한다.
import { Controller, Get } from '@nestjs/common';
import { CatsService } from './cats.service';
import { Cat } from './interfaces/cat.interface';
@Controller('cats')
export class CatsController {
constructor(private catsService: CatsService) {}
@Get()
async findAll(): Promise<Cat[]> {
return this.catsService.findAll();
}
}
마지막으로 provider를 Nest IoC container에 등록한다.
import { Module } from '@nestjs/common';
import { CatsController } from './cats/cats.controller';
import { CatsService } from './cats/cats.service';
@Module({
controllers: [CatsController],
providers: [CatsService],
})
export class AppModule {}
이 작업이 작동하게 하는 데는 세 가지 주요 단계가 있다.
cats.service.ts에서, @Injectable() decorator은 CatsService 클래스를 Nest IoC 컨테이너에 의해 관리될 수 있는 클래스로 선언한다.
cats.controller.ts에서, CatsController는 생성자 주입을 통해 CatsService 토큰에 대한 의존성을 선언한다.
constructor(private catsService: CatsService)
app.module.ts에서 CatsService token을 cats.service.ts파일의 CatsService 클래스와 연결한다.
Nest IoC container는 CatsController를 인스턴스화 할 때, 먼저 [모든 dependency]를 찾는다. 만일 CatsService라는 dependency를 발견했다면, 먼저 CatsService token에 대한 조회를 수행하며, 이는 위에서 설명한 등록 단계(#3)대로 CatsService 클래스를 반환한다. SIngleTon 범위(기본 동작)을 가정하면 Nest는 CatsService instance를 생성하고 이를 캐시한 후 반환하거나, 이미 캐시된 instance가 있는 경우 기존 instance를 반환한다.
위의 '모든 dependeny를 찾는다,'는 요점을 설명하기 위해 약간 단순화되었다. 우리가 대충 넘긴 중요한 부분 중 하나는 dependency을 분석하는 과정이 매우 정교하며, 애플리케이션의 bootstrapping 중에 발생한다는 것 이다. 주요 기능 중 하나는 의존성 분석이 전이적(transitive) 이라는 점이다. 위의 예에서 CatsService 자체가 의존성을 가지고 있다면, 그것들도 해결된다. 의존성 그래프는 의존성이 올바른 순서로 해결되도록 보장한다. - 기본적으로 "bottom up". 이 메키니즘은 개발자가 이러한 복잡한 의존성 그래프를 관리할 필요가 업도록 한다.
BootStrapping: 소프트웨어 시스템을 초기화하고 실행하기 위해 필요한 초기 단계를 설정하는 과정을 의미한다.
NestJS에서는 애플리케이션을 시작하고 모든 필수 모듈과 서비스를 초기화하는 과정을 의미한다. NestJS는 bootstrapping 과정에서 IoC 컨테이너를 설정하고, 모든 의존성을 해결하며, HTTP 서버를, 애플리케이션을 실행할 준비를 한다.
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
await app.listen(3000);
}
bootstrap();
NestJS 부투스트래핑 과정
1. NestFactory.create(AppModule):
- 애플리케이션의 루트 모듈인 AppModule을 기반으로 NestJS 애플리케이션 instance를 생성
- 이 과정에서 NestJS는 IoC 컨테이너를 설정하고, 모든 module과 provider를 스캔하여 의존성 그래프를 생성한다.
2. module 및 provider 초기화:
- NestJS는 AppModule과 그 하위 모듈들을 초기화한다.
- 각 module의 provider와 controller를 instance화하고, 필요한 의존성을 주입한다.
3. 애플리케이션 설정:
- NestJS 애플리케이션 instance가 생성되면, 필요한 설정(예, 미들웨어, 파이프, 필터 등)을 적용한다.
4. 서버 시작:
- app.listen(3000)을 호출하여 HTTP 서버를 시작한다.
- 애플리케이션이 지정된 포트에서 클라이언트 요청을 수신할 준비를 마친다.
NestJS는 IoC(제어의 역전) 컨테이너는 의존성 주입을 통해 객체의 생성과 관리를 담당한다. 다음은 CatsController가 instance가 생성될 때 벌어지는 일련의 과정이다.
1. 의존성 탐색:
- Nest IoC 컨테이너는 CatsController를 인스턴스화할 때, CatsController가 의존하는 모든 서비스를 찾는다.
- 이 과정에서 생성자 주입을 통해 선언된 CatsService 의존성을 확인한다.
2. 토큰 조회:
- CatsService 의존성을 찾으면, IoC 컨테이너는 CatsService 토큰에 대한 조회를 수행한다.
- 이 조회는 등록 단계에서 설정된 바와 같이 CatsService 클래스를 반환한다.
3. 싱글톤 범위 관리:
- 기본적으로 NestJS는 모든 provider를 SingleTon 범위로 관리한다.
- 이는 애플리케이션 전체에서 해당 provider의 단일 instace를 생성하고 이를 재사용함을 의미한다.
4. 인스턴스 생성 및 캐싱:
- IoC 컨테이너는 CatsService 클래스의 인스턴스가 이미 생성되어 있는지 확인한다.
- 만약 instace가 없다면, 새로운 instance를 생성하고 이를 cache에 저장한다.
- 이미 instance가 존재한다면, 기존의 cache된 instance를 반환한다.
5. 의존성 주입:
- CatsService 인스턴스가 준비된다면, IoC 컨테이너는 이를 CatsController의 생성자에 주입한다.
- 이렇게 주입된 CatsService instance는 CatsController내에서 사용된다.
예제 코드와 과정
// example.service.ts
import { Injectable } from '@nestjs/common';
@Injectable()
export class ExampleService {
getExample(): string {
return 'This is an example service';
}
}
// cats.service.ts
import { Injectable } from '@nestjs/common';
import { ExampleService } from './example.service';
@Injectable()
export class CatsService {
constructor(private readonly exampleService: ExampleService) {}
getCats(): string {
return `This action returns all cats and ${this.exampleService.getExample()}`;
}
}
// cats.module.ts
import { Module } from '@nestjs/common';
import { CatsController } from './cats.controller';
import { CatsService } from './cats.service';
import { ExampleService } from './example.service';
@Module({
controllers: [CatsController],
providers: [CatsService, ExampleService],
})
export class CatsModule {}
// app.module.ts
import { Module } from '@nestjs/common';
import { CatsModule } from './cats/cats.module';
@Module({
imports: [CatsModule],
})
export class AppModule {}
동작 과정 요약
NestJS의 DI System은 DI graph를 생성하고 이를 통해 모든 Dependency을 올바른 순서로 해결한다. 이 과정은 전이적(Transitive) 으로 작동하므로, 특정 서비스가 다른 서비스에 의존하는 경우에도 모든 의존성이 해결된다. 이러한 메커니즘은 개발자가 복잡한 의존성 그래프를 관리할 필요 없이, NestJS가 자동으로 이를 처리해주어 개발의 편의성과 유지보수성을 높여준다.
NestJS 애플리케이션 초기화
애플리케이션이 시작되면, AppModule이 초기화된다.
AppModule은 CatsModule을 import하여 사용
DI 컨테이너 구성
NestJS IoC 컨테이너는 CatsModule을 스캔하여 CatsController, CatsService, ExampleService를 등록한다.
의존성 그래프 생성
NestJS는 각 프로바이더의 의존성을 파악하여 의존성 그래프를 생성한다.
CatsService가 ExampleService에 의존하므로, ExampleService가 먼저 instance화 된다.
의존성 해결
IoC 컨테이너는 의존성 그래프에 따라 ExampleService를 먼저 instance화하고 캐시한다.
그런 다음 CatsService를 instance화하고 ExampleService의 instance를 주입한다.
마지막으로 CatsController를 instance화하고 CatsService의 instance를 주입한다.
요청 처리
클라이언트가 '/cats' 엔드포인트로 요청을 보내면, 'CatsController'의 'findAll' method가 호출된다.
// 기본 제공 방식
// 설명: 이 방식은 단순히 CatsService 클래스를 providers 배열에 포함시켜 등록하는 것이다.
// 기본 동작: NestJS는 CatsService를 토큰으로 사용하여 동일한 이름의 클래스를 Provider로 등록한다.
// 이는 가장 일반적이고 간단한 사용 사례이다.
// 사용 사례: 토큰과 클래스 이름이 동일할 때 가장 많이 사용된다.
@Module({
controllers: [CatsController],
providers: [CatsService],
})
providers 속성은 providers의 array를 받을 것 이다. 지금까지 우리는 이러한 provider들을 class name을 통해 공급받았다. 사실은 providers: [CatsService] 따위의 구조는 아래의 복잡한 코드의 약식 표기법이다..
// 명시적 제공 방식
// 설명: 이 방식은 Provider 객체를 명시적으로 정의하여 provide 속성과 useClass 속성을 사용해 등록한다.
// 동작 방식: provide 속성은 토큰으로 지정하고, useClass 속성은 해당 토큰에 대해 사용할 클래스를 지정한다.
// 사용 사례: 더 복잡한 시나리오에서 유용하다. 예를 들어, 동일한 토큰에 대해 다른 클래스를 사용하거나,
// 팩토리 함수나 값을 사용할 때 사용된다.
providers: [
{
provide: CatsService,
useClass: CatsService,
},
];
활용 예제
//==============================================================
// mock-cats.service.ts
import { Injectable } from '@nestjs/common';
@Injectable()
export class MockCatsService {
fetchCats(): string {
return 'This action returns mock cats';
}
}
//==============================================================
// cats.service.ts
import { Injectable } from '@nestjs/common';
@Injectable()
export class CatsService {
getCats(): string {
return 'This action returns all cats';
}
}
//==============================================================
// cats.controller.ts
import { Controller, Get } from '@nestjs/common';
import { CatsService } from './cats.service';
@Controller('cats')
export class CatsController {
constructor(private readonly catsService: CatsService) {}
@Get()
findAll(): string {
return this.catsService.getCats();
}
}
//==============================================================
// cats.module.ts
import { Module } from '@nestjs/common';
import { CatsController } from './cats.controller';
import { CatsService } from './cats.service';
@Module({
controllers: [CatsController],
providers: [
{
// provide 키는 provider의 token을 지정한다.
// 이 토큰은 의존성을 주입할 때 사용된다.
// 일반적으로 클래스 이름을 토큰으로 사용하지만, 문자열이나 심볼을 사용할 수 있다.
provide: CatsService, // CatsService라는 이름으로 주입
// useClass 키는 주입할 클래스(또는 provider)를 지정한다.
// 이 클래스는 지정된 토큰과 연결된다.
// useClass에 지정된 클래스의 인스턴스가 생성되어 해당 토큰에 할당된다.
useClass: MockCatsService, // 실제 클래스는 MockCatsService
// 간단하게 이름은 CatsService이고 본체는 MockCatsService이다.
// provide를 CatsService, useClass를 MockCatsService로 설정하면
// CatsService 토큰을 통해 MockCatsService의 인스턴스를 주입받게 된다.
// 이는 NestJS IoC 컨테이너가 CatsService 토큰을 요청할 때 실제로는 MockCatsService 클래스를
// 사용하여 instace를 생성하고 반환하기 때문이다.
},
],
})
export class CatsModule {}
//==============================================================
// app.module.ts
import { Module } from '@nestjs/common';
import { CatsModule } from './cats/cats.module';
@Module({
imports: [CatsModule],
})
export class AppModule {}
//==============================================================
// main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
await app.listen(3000);
}
bootstrap();
지금은 이러한 명확한 구조를 통해, registration process를 이해할 수 있다. 여기서 CatsService 토큰을 CatsService 클래스와 명확하게 연결하고 있다. 약식 표기법은 단지 가장 일반적인 사용사례를 단순화하기 위한 편의일 뿐이며, 이 경우 토큰은 동일한 이름의 클래스 인스턴스를 요청하는 데 사용된다.
Custom Providers
만일 요구 사항이 provider가 제공하는 범위를 넘어설 때는 어떻게 해야하는가?, 다음은 몇 가지 예이다.
Nest가 class를 instance화(또는 캐시된 인스턴스를 반환)하는 대신, 사용자 정의 instance를 생성하고 싶다.
기존의 class를 두 번째 depedency에서 재사용하고 싶다.
test를 위해 class를 모의(mock)버전으로 재정의하고 싶다.
Nest는 이러한 경우를 handler할 수 있도록 Custom provider를 정의하게 해준다. 이는 몇 가지 방법이 존재한다.
<팁!> 만일 dependency에 관련해 문제가 있다면, NEST_DEBUG 환경 변수를 설정할 수 있다. 그리고 startup하는 동안 추가적인 dependency resolution logs를 얻을 수 있다.
Value providers: useValue
useValue 문법은 constant value를 주입하거나, 외부 라이브러리를 Nest 컨테이너에 넣거나, 실제 구현을 모의(mock) 객체로 대체하는 데 유용하다. 예를 들어 Nest가 모의(mock) CatService를 사용하도록 강제하고 싶다고 가정해보자.
import { CatsService } from './cats.service';
const mockCatsService = {
/* mock implementation
...
*/
};
@Module({
imports: [CatsModule],
providers: [
{
provide: CatsService, // CatsService를 주입하다고 하면
useValue: mockCatsService, // 실제로 주입되는 것은 mockCatsService 이다.
},
],
})
export class AppModule {}
위의 예에서, CatsService token은 mockCatsService의 mock object로 해결된다. useValue는 값을 필요로 하는데, 이 경우 CatsService 클래스를 대체하는 동일한 인터페이스를 가진 리터럴 객체이다. TypeScript의 구조적 타이핑 덕분에, 리터럴 객체나 new 키워드로 instance화된 class instance를 포함하여 호환 가능한 interface를 가진 어떤 객체도 사용할 수 있다.
// 구조적 타이핑(Structural Typing): 객체의 형태(구조)에 따라 타입을 결정하는 방식이다. 이는 객체가 특정 인터페이스나 타입을 만족하려면, 그 객체가 필요한 속성들을 모두 가지고 있어야 한다는 의미이다. 객체의 실제 타입보다는 객체의 구조와 속성이 중요하다.
// 아래의 John 객체는 Person 인터페이스에 만족한다. name과 age 속성을 모두 가지고 있고, 타입도 일치하기 때문이다.
interface Person {
name: string;
age: number;
}
const john: Person = {
name: 'John Doe',
age: 30
};
//literal object: 중괄호({})를 사용하여 직접 생성된 객체를 말한다. TypeScript와 JavaScript에서는 객체를 간단하게 생성하기 위해 객체 리터럴을 사용한다. 객체 리터럴은 속성과 값을 한 번에 정의할 수 있어, 코드 작성이 간편하고 가독성이 좋다.
// 객체 리터럴을 사용하여 객체를 생성
const person = {
name: 'John Doe',
age: 30,
greet: function() {
console.log('Hello, my name is ' + this.name);
}
};
// 객체의 속성에 접근
console.log(person.name); // 'John Doe'
console.log(person.age); // 30
// 객체의 메서드를 호출
person.greet(); // 'Hello, my name is John Doe'
Non-class-based provider tokens
지금까지, class name을 provider token으로 사용해왔다.(providers array 안에서의 provide 속성의 값) 이러한 standard한 pattern은 constructor based injection에서 사용됬다. (토큰의 개념이 이해가 안된다면 DI Fundatmentals 참고 ) 때때로, DI 토큰으로 string이나 symbol을 사용하는 유연성이 필요할 수 있다.
===토큰의 개념 설명===
token은 NestJS에서 의존성 주입(Dependency Injection,DI) 시스템이 특정 provider를 식별하고 주입하기 위해 사용되는 Key 이다.
token은 provider를 고유하게 식별하는 역할을 하며, provider를 요청할 때 사용된다. 일반적으로 class의 이름을 token으로 사용하지만, string이나 symbol도 token으로 사용할 수 있다.
===왜 토큰을 사용하는가?===
토큰은 의존성을 관리 주입하는데 있어 중요한 역할을 한다. 이를 통해 DI container가 어떤 객체를 생성하고 주입해야 하는지 명확히 알 수 있다. 또한, 동일한 interface를 구현하는 여러 클래스가 있을 때, 어떤 구현체를 주입할지 지정하는 데 유용하다.
===토큰의 유형===
1. 클래스 기반 토큰
- 클래스 자체를 토큰으로 사용하는 가장 일반적인 방법
- 클래스 이름을 토큰으로 사용하여 의존성을 주입
// Service, Module
import { Injectable, Module } from '@nestjs/common';
@Injectable()
export class CatsService {
getCats(): string {
return 'This action returns all cats';
}
}
@Module({
// 클래스 자체가 토큰으로 사용
// 다른 클래스에서 CatsService를 의존성으로 주입받을 수 있다.
providers: [CatsService],
})
export class CatsModule {}
// Controller
import { Injectable } from '@nestjs/common';
import { CatsService } from './cats.service';
@Injectable()
export class CatsController {
constructor(private readonly catsService: CatsService) {}
getCats(): string {
return this.catsService.getCats();
}
}
2. 문자열 토큰
- 문자열을 토큰으로 사용하여 의존성을 주입할 수 있다.
- 주로 특정 상수나 외부 자원을 주입할 때 사용
// Module
import { Module } from '@nestjs/common';
import { connection } from './connection'; // 외부에서 connection 객체를 가져옴
@Module({
providers: [
{
provide: 'CONNECTION',
useValue: connection,
},
],
})
export class AppModule {}
// Service
import { Injectable, Inject } from '@nestjs/common';
@Injectable()
export class SomeService {
constructor(@Inject('CONNECTION') private readonly connection: any) {}
useConnection() {
console.log(this.connection);
}
}
3. 심볼 토큰
- 심볼을 사용하여 고유한 토큰을 생성할 수 있다.
- 주로 고유성이 필요한 경우나 복잡한 의존성 관리가 필요할 때 사용된다.
// Module
import { Module } from '@nestjs/common';
import { CatsService } from './cats.service';
const CATS_SERVICE_TOKEN = Symbol('CATS_SERVICE_TOKEN');
@Module({
providers: [
{
provide: CATS_SERVICE_TOKEN,
useClass: CatsService,
},
],
exports: [CATS_SERVICE_TOKEN],
})
export class CatsModule {}
// Controller
import { Injectable, Inject } from '@nestjs/common';
@Injectable()
export class CatsController {
constructor(@Inject(CATS_SERVICE_TOKEN) private readonly catsService: CatsService) {}
getCats(): string {
return this.catsService.getCats();
}
}
===요약===
- 토큰의 역할: 의존성 주입 시스템에서 provider를 식별하고 주입하는 데 사용된다.
- 토큰의 유형: 클래스, 문자열, 심볼 등이 있으며, 각 유형은 고유한 상황에 맞게 사용된다.
- 유연성 제공: 토큰을 사용함으로써 다양한 형태의 의존성을 주입할 수 있으며, 특히 테스트나 외부 자연 주입 시 유용
아래의 코드에서 string-valued token('CONNECTION')을 사용했다. 그리고 이미 만들어 둔 external file인 connection object를 사용했다.
import { connection } from './connection';
@Module({
providers: [
{
// 비 클래스 기반 provider token을 사용하는 예제이다.
// 이 예제에서는 문자열 CONNECTION을 토큰으로 사용하여 특정 값을 주입한다.
provide: 'CONNECTION',
useValue: connection,
},
],
})
export class AppModule {}
<주의사항> token value로써 string 뿐만 아니라, JS의 symbols랑 TS의 enums를 사용할 수 있다.
이전에 standard constructor based injection pattern을 사용하여 provider를 inject하는 방법을 보았다. 이 pattern은 dependency가 class name으로 선언되어야 한다. 'CONNECTION' custom provider는 string value을 가진 토큰을 사용한다. 이러한 provider를 주입하는 방법을 살펴보자. 이를 위해 @Inject() decorator를 사용해야 한다. 이 decorator은 하나의 인자 즉 token을 받는다.
@Injectable()
export class CatsRepository {
constructor(@Inject('CONNECTION') connection: Connection) {}
}
위의 코드에서는 바로 'CONNECTION' string을 사용했지만, clean code를 위해서는 다른 file(constants.ts)에 정의된 것을 import하는 것이 좋다. 필요할 때마다 자신만의 파일에 정의하고 필요한 곳에서 import하는 symbol이나 enums 처럼 다뤄야한다.
Class Providers: useClass
useClass 구문을 사용하면 토큰이 해결해야 하는 클래스를 동적으로 결정할 수 있다. 예를 들어, ConfigService class를 가지고 있다고 가정하자. 현재의 environment에 따라, Nest가 다른 configuration service를 실행할 수 있도록 할 수 있다.
위의 코드에서 몇 개의 디테일을 살펴 보자. 첫 번째로 literal object로 configServiceProvider를 정의했다. 이는 module decorator의 providers 속성에 전달된다. 이는 약간의 다른 형태지만, 기능적으로는 앞에서 살펴본 것들과 동일하다.
두 번째로 ConfigService class name을 토큰으로 사용했다. ConfigService에 의존하는 모든 class에 대해, Nest는 제공된 class('DevelopmentConfigService' 또는 'ProductionConfigService')의 instance를 inject하여, 다른 곳에서 선언된 기본 구현(예, @Injectable() decorator로 선언된 ConfigService)을 재정의 한다.
Factory providers: useFactory
useFactory 구문은 provider을 동적으로 만들게 해준다. 실제 provider은 factory function에 의해 반환된 value에 의해 제공될 것 이다. factory function은 필요에 따라 simple하거나 complex 할 것 이다. simple factory는 다른 provider에 의존하지 않을 수 있다. complex한 factory는 결과를 계산하는 데 필요한 다른 provider를 자체적으로 주입할 수 있다. 후자의 경우, factory provider 구문에는 관련된 두 가지 메커니즘이 있다.
factory function은 (optional) argument를 받을 수 있다.
(optional) inject property는 Nest가 해결하고 instance화 과정에서 factory 함수에 argument로 전달할 provider array을 받는다. 또한, 이 provider는 선택적으로 표시될 수 있다. 두 목록은 상호연관되어야 한다. Nest는 'inject' 목록의 instance를 factory function의 argument로 같은 순서로 전달한다.
const connectionProvider = {
provide: 'CONNECTION',
// useFactory: 동적으로 프로바이더를 생성하는 팩토리 함수를 정의한다.
// optionsProvider: 필수 의존성
// optionalProvider: 선택적 의존성으로, 문자열이 될 수 있으며, 주입되지 않으면 undefined가 될 수 있다.
useFactory: (optionsProvider: OptionsProvider, optionalProvider?: string) => {
const options = optionsProvider.get();
return new DatabaseConnection(options);
},
inject: [OptionsProvider, { token: 'SomeOptionalProvider', optional: true }],
// \_____________/ \__________________/
// This provider The provider with this
// is mandatory. token can resolve to `undefined`.
};
@Module({
providers: [
connectionProvider,
OptionsProvider,
// { provide: 'SomeOptionalProvider', useValue: 'anything' },
],
})
export class AppModule {}
// ===================================================
// 1. 의존성 서비스 정의
import { Injectable, Optional } from '@nestjs/common';
@Injectable()
export class DependencyService {
getDependencyValue() {
return 'Dependency Value';
}
}
@Injectable()
export class OptionalService {
getOptionalValue() {
return 'Optional Value';
}
}
// ===================================================
// 2. 복잡한 factory provider 정의
const complexFactory = {
// provide: provider의 token인 COMPLEX_FACTORY를 정의
provide: 'COMPLEX_FACTORY',
// dependencyService를 사용하여 dependencyValue를 가져오고, optionalService가 주어지면, optionalValue를 가져오고, 그렇지 않으면 No Optional Service를 사용한다. 최종적으로 두 값을 조합하여 문자열을 반환한다.
useFactory: (
dependencyService: DependencyService,
optionalService?: OptionalService
) => {
const dependencyValue = dependencyService.getDependencyValue();
const optionalValue = optionalService ? optionalService.getOptionalValue() : 'No Optional Service';
return `Complex Factory Value: ${dependencyValue}, ${optionalValue}`;
},
// 팩토리 함수에 주입할 provider 목록을 정의한다. 이 목록의 순서와 factory 함수의 인수 순서는 일치해야한다.
inject: [DependencyService, OptionalService], // 주입할 프로바이더 목록
};
// ===================================================
// 2-1. provide, useFactory, inject에 대한 설명
// NestJS에서는 의존성 주입(DI)을 통해 애플리케이션의 모듈 간에 객체를 주입하고 관리할 수 있다.
// provide, useFactory, inject는 provider를 설정할 때 사용하는 중요한 속성이다.
// 이 속성들을 사용하여 동적으로 provider를 생성하고 필요한 의존성을 주입할 수 있다.
// provide
// 역할: provide 속성은 provider의 token을 지정한다. 이 token은 provider를 식별하는데 사용됨
// 유형: 일반적으로 class 이름, string, symbol 등을 사용할 수 있다.
// 사용 예시
{
provide: 'SOME_TOKEN',
useValue: someValue,
}
// useFactory
// 역할: useFactory 속성은 provider의 값을 동적으로 생성하는 factory function을 지정한다. factory function은 필요한 Dependency를 주입받아 복잡한 로직을 수행하고 값을 반환할 수 있다.
// 반환 값: 팩토리 함수는 provider로 사용할 값을 반환해야 한다.
// inject
// 역할: inject 속성은 factory function에 주입할 provider의 목록을 지정한다. 이 목록의 순서와 factory function의 인수 순서는 일치해야 한다
// 유형: class, token, symbol 등을 사용할 수 있다.
// 사용 예시
{
provide: 'SOME_TOKEN',
useFactory: (dependency1, dependency2) => {
// 로직 수행
return someValue;
},
inject: [Dependency1, Dependency2],
}
// ===================================================
// 3. module에서 provider 등록
import { Module } from '@nestjs/common';
import { DependencyService } from './dependency.service';
import { OptionalService } from './optional.service';
@Module({
providers: [DependencyService, OptionalService, complexFactory],
})
export class AppModule {}
// ===================================================
// 4. service에서 factory provider 사용
import { Injectable, Inject } from '@nestjs/common';
@Injectable()
export class SomeService {
// COMPLEX_FACTORY 토큰을 사용하여 factory provider에서 생성된 값을 주입받는 서비스를 정의
constructor(@Inject('COMPLEX_FACTORY') private readonly value: string) {}
getValue() {
return this.value;
}
}
Alias Providers : useExisting
useExising 문법은 기존의 provider에 별칭(aliases) 를 붙여준다. 이는 같은 provider에 접근할 수 있게 하는 두 방법을 제공한다. 예를 들어 string 기반의 AliasedLoggerService는 class 기반의 LoggerService이다. 두 개의 다른 dependency가 있다고 가정하자.(AliasedLoggerService, LoggerService) 만일 두 개의 dependency가 SINGLETON scope로 명시되어 있다면, 둘다 같은 instance를 제공한다.
다른 provider 처럼, custom provider는 선언된 module의 scope로 되어 있다. 만일 다른 module에서 이를 사용하게끔 하고싶다면, 반드시 export 해야한다. custom provider를 export하기 위해서는, provider의 token혹은 전체를 사용해야 한다. 다음 예는 token을 사용한 것 이다.
RxJS는 Reactive Extensions For JavaScript 의 약자로 reactive programming을 JS에서 지원하기 위해 만들어진 라이브러리다.
reactive programming: 데이터의 흐름과 변화에 반응하는 프로그래밍 패러다임이다.
이 패러다임은 데이터의 변화를 이벤트로 간주하고, 이러한 이벤트에 대한 반응을 정의함으로써 시스템을 구축한다.
특히 비동기적인 데이터 스트림을 처리하는 데 유용하며, 주로 사용자 인터페이스, 실시간 데이터 처리, 비동기 IO 작업 등에 적용된다.
이 리액티브 프로그래밍은 Push 시나리오 방식으로 외부와 통신한다.
Push 시나리오: 외부에서 명령하면서 응답이 오면 그때 반응하여 처리한다.
데이터를 가지고 오기 위해서는 subscribe를 해야한다.
RxJS는 이러한 비동기 이벤트 기반의 프로그램 작성을 돕기 위해 함수형 프로그래밍을 이용해 이벤트 스트림을 Observable이라는 객체로 표현한다.
Observable은 event가 흐르는 stream이다. Observable은 누군가 구독(subscribe)을 해야 event를 발행(publish)한다. Observer가 Observable을 구독하면서 next, error, complete 키워드를 사용해 Observable에 흐르는 event를 처리한다.
const observable$ = interval(1000).pipe(take(4));
Observable 변수에 붙은 $(달러) 표시는 Observable을 나타내는 코드 컨벤션이다. interval()은 정의된 시간마다 증가하는 연속값을 stream에 발생시키고, pipe() operator를 사용하여 Observable stream 내부에서 적용할 operator를 처리하게 된다. take는 발생시킨 이벤트 중 처음부터 n개까지의 이벤트만 받는다.
ReactX는 크게 세 요소로 구성된다.
Observable: 일련의 값들을 발행한다. 관찰될 수 있는 것, 관찰되는 대상이란 뜻. 아래의 코드에서는 1에서 20까지의 정수를 반환한다. 이렇게 연속적으로 발행되어 나오는 값들을 흐름, stream이라고 부른다.
Operator: 순수 함수들의 모임
Observer: 파이프만 쳐다보며 값을 기다리다가 뭔가 나오는 대로 최종 작업을 시행한다. 옵저버가 파이프를 주시하며 발행물을 기다리는 것을 ReactiveX에서는 subscribe, 구독한다고 표현한다. 구독자가 발행물들에 '반응'하는 것 이다.
Operator Operator는 Observable에서 각 이벤트들에 대해 연산을 할 수 있는 pure function이다. 앞서 언급한 것처럼, RxJS는 함수형 프로그래밍에 영향을 많이 받아 이러한 pure function들이 많이 존재한다. 대표적으로 tap(), filter(), min(), max()와 같은 operator가 존재한다.
tab(): 데이터 스트림의 요소에 대해 부수적인 작업을 수행할 때 사용된다. 스트림의 요소를 변경하지 않고, 디버깅이나 로깅 등에 유용하다.
import { of } from 'rxjs';
import { tap } from 'rxjs/operators';
// of : RxJS에서 제공하는 함수로, 주어진 인수들을 순차적으로 방출하는 Observable을 생성한다.
of(1, 2, 3).pipe(
tap(x => console.log(`Tapped: ${x}`))
).subscribe();
filter(): 조건을 만족하는 요소만 스트림에 남겨둔다. 스트림의 각 요소에 대해 조건을 평가하여 true인 경우만 방출
import { of } from 'rxjs';
import { filter } from 'rxjs/operators';
of(1, 2, 3, 4).pipe(
filter(x => x % 2 === 0)
).subscribe(console.log); // 2, 4
min(): 스트림에서 최소값을 찾는다. 스트림의 모든 요소를 비교하여 가장 작은 값을 방출
import { of } from 'rxjs';
import { min } from 'rxjs/operators';
of(5, 3, 9, 1).pipe(
min()
).subscribe(console.log); // 1
max(): 스트림에서 최대값을 찾는다. 스트림의 모든 요소를 비교하여 가장 큰 값을 방출
import { of } from 'rxjs';
import { max } from 'rxjs/operators';
of(5, 3, 9, 1).pipe(
max()
).subscribe(console.log); // 9
Observer Observer는 Observable을 구독하는 대상이다. Observer를 정의하고 next, error, complete 세 가지를 정의해 주고 Observable에 구독을 하면 완성이다.
import {fromEvent} from 'rxjs'
// fromEvent를 이용해서 이벤트를 observable 객체로 만든다.
// dom 요소와 click과 같은 이벤트
const observable = fromEvent(document, 'click');
// subscriber(이벤트핸들러)를 정의
const subscriber = () => console.log('Clicked!');
// observable가 subscribe를 사용해 이벤트 핸들러인 subscriber을 구독하도록 한다.
observable.subscribe(subscriber)
Observable Observable 객체는 특정 객체를 관찰하는 이벤트 핸들러인 Subscriber에게 여러 이벤트나 값을 보내는 역할을 한다.
아래의 코드는 RxJS의 Observable을 직접 생성하고 구독하는 예제이다.
observable이 subscriber에게 연속으로 1,2,3을 방출하고 1초 후에 4를 방출하도록 되어있다.
```javascript
const observable = new Observable(subscriber => {
subscriber.next(1)
subscriber.next(2)
subscriber.next(3)
setTimeout(() => {
subscriber.next(4)
subscriber.complete() // 스트림을 완료한다.
}, 1000)
})
// 1.
console.log('just before subscribe')
observable.subscribe({
next(x) { // 새로운 값이 방출될 때 마다 호출된다.
// 2.
console.log('got value ' + x)
},
error(err) {
console.error('something wrong occurred: ' + err)
},
complete() { // 스트림이 완료될 때 호출된다.
// 4
console.log('done')
},
})
// 3
console.log('just after subscribe')
```
Nest decorator로 불리는 language feature로 build 됬다. Decorator는 programming language에서 흔히 사용되는 개념이다. 하지만 Javascript 세상에서는, 여전히 상대적으로 새로운 개념이다. 어떻게 decorator가 작동하는지 더 이해하기 위해서는, 다음을 기사를 추천한다.
ES2016 decorator는 함수를 반환하는 표현식이며, 대상, 이름, 속성 기술자를 인수로 받을 수 있다. 데코레이터를 적용하려면 데코레이터 앞에 '@' 문자를 붙이고 데코레이팅하려는 것의 맨 위에 배치한다. decorator는 class, method 또는 property를 위해 정의될 수 있다.
Param decorators
Nest는 HTTP route handler와 함께 사용할 수 있는 유용한 param decorator를 제공한다. 아래는 제공되는 Express, Fastify 객체를 쉽게 다를 수 있게 해주는 데코레이터 리스트이다.
@Decorator
object
description
@Reqeust(), @Req()
req
전체 요청 객체(req)를 주입한다.
@Response(), @Res()
res
전체 응답 객체(res)를 주입한다.
@Next()
next
next 함수를 주입한다. 미들웨어 함수에서 주로 사용된다.
@Session()
req.session
세션 객체를 주입한다.
@Param(param?: string)
req.params/ req.params[param]
URL 파라미터를 주입한다. param 인자를 제공하면 해당 파라미터의 값을 가져오고, 제공하지 않으면 모든 파라미터를 객체 형태로 가져온다
@Body(param?:string)
req.body/req.body[param]
요청 본문(body)을 주입한다. param 인자를 제공하면 해당 본문 필드의 값을 가져오고, 제공하지 않으면 본문을 객체 형태로 가져온다.
@Query(param?:string)
req.query/req.query[param]
쿼리 문자열을 주입한다. param 인자를 제공하면 해당 쿼리 파라미터의 값을 가져오고, 제공하지 않으면 모든 쿼리 파라미터를 객체 형태로 가져온다.
@Headers(param?:string)
req.headers/req.headers[param]
요청 헤더를 주입한다. param 인자를 제공하면 해당 헤더의 값을 가져오고, 제공하지 않으면 모든 헤드를 객체 형태로 가져온다.
@Ip()
req.ip
요청의 IP주소를 주입한다.
@Query
req.hosts
호스트 파라미터를 주입한다. 이는 요청의 호스트 정보를 가져온다.
추가적으로, custom decorators를 만들 수 있다.
node.js에서는 속성(properties)를 request object에 붙이는게 흔한 관행이다. 이런 속성을 route handler에서 extract할 수 있다.
const user = req.user;
code를 좀 더 readable하고 transparent하게 만들기 위해, @User() decorator를 만들고, 이를 controller 전반에 걸쳐 재사용할 수 있다.
만일 decorator의 기능이 조건적으로 이루어져야 한다면, 'data' 매개변수를 사용하여 데코레이터의 팩토리 함수에 인수를 전달할 수 있다. custom decorator를 사용하면 request object에서 특정 key에 해당하는 속성을 추출하여 route handler method에 주입할 수 있다. 이를 통해 코드의 가독성과 재사용성을 높일 수 있다. 예를 들어 authentication layer request의 유효성을 검사한다. 그리고 user entity에 request object를 붙인다. authenticated request의 user entity는 다음과 같을 것 이다.
name을 key로 갖는 decorator를 정의해보자. 이는 만일 존재한다면 관련된 value를 반환한다.(만일 존재하지 않거나 user object가 만들어져 있지 않다면 undefined를 반환한다.)
// user.decorator.ts
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export const User = createParamDecorator(
(data: string, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
const user = request.user;
// user에서 data에 해당하는 정보가 있다면 그것을 반환
// 그렇지 data가 지정되지 않았다면 user 객체를 반환
return data ? user?.[data] : user;
},
);
// 사용
import { Controller, Get } from '@nestjs/common';
import { User } from './user.decorator'; // 경로는 실제 파일 위치에 따라 다릅니다.
@Controller('profile')
export class ProfileController {
@Get()
getProfile(@User() user: any) {
// 전체 사용자 객체 반환
console.log(user);
return user;
}
@Get('email')
getUserEmail(@User('email') email: string) {
// 사용자 이름만 반환
console.log(email);
return email;
}
}
controller에서 @User() decorator를 통해 어떻게 특정한 property에 접근하는가?
위의 같은 decorator, 허나 다른 key로 다른 property에 접근할 수 있다. 사용자 객체가 깊거나 복잡한 경우, 이 방법을 통해 request handler 구현을 더 쉽게 만들고 가독성을 높일 수 있다.
<팁!> Typescript 사용자는 createParamDecorator()가 제네릭이라는 점에 주목해야 한다.이를 통해 타입 안전성을 명시적으로 보장할 수 있다. (e.g. createParamDecorator((data, ctx)=> ... )) 또는 factory function에서 매개변수 타입을 지정할 수도 있다. (e.g. createParamDecorator((data:string, ctx)=> ...)) 만일 둘다 생략하면 data의 타입은 any이다.
factory function: 객체를 생성하는 함수. 객체의 생성과 초기화를 담당하며, 동일한 함수로 다양한 형태의 객체를 만들 수 있는 유연성을 제공한다.
Working with pipes
Nest는 기본 내장된 @Body(), @Param(), @Query()에서 처리하는 것과 같은 방식으로 custom param decorator를 처리한다. 이는 pipe는 custom annotated parameter에서도 실행된다는 걸 의미한다. (e.g. 위의 user argument ) 게다가, pipe를 바로 custom decorator에 apply할 수 있다. 그냥 custom decorator에도 pipe가 적용된다는 걸 뜻한다.
<팁!> validateCustomDecorator 옵션은 반드시 true로 설정되어야 한다. ValidationPipe는 default로 custom decorator로 주석이 달린 인수를 검증하지 않는다.
Decorator composition
Nest는 mutiple decorator를 구성할 수 있도록 helper method를 제공한다. 예를 들어, authentication과 관련된 모든 decorator를 하나의 decorator로 combine하기를 원한다고 하자. 이는 다음과 같은 구성으로 완료된다.
Interceptor는 NestInterceptor interface를 구현하고 @Injectable() decorator가 주석으로 달린 class이다.
Interceptor는 들어오는 요청(reqeust)와 나가는 응답(response)을 가로채고, 이들 사이에서 추가적인 로직을 실행할 수 있도록 하는 강력한 기능을 제공한다. 인터셉터는 다음과 같은 여러 용도로 사용될 수 있다.
- 로깅(Logging): 요청 및 응답 정보를 기록
- 변환(Transformation): 응답 데이터를 특정 형식으로 변환
- 캐싱(Caching): 반복적인 요청에 대해 캐시된 응답을 제공한다.
- 에러 처리(Error Handling): 특정한 에러를 처리하거나 에러 메시지를 변환한다.
- 타이밍(Timing): 요청 처리 시간을 측정한다.
1. Middleware: 모든 미들웨어가 순차적으로 실행된다.
2. Guard: 요청을 처리할 수 있는지 확인하는 모든 가드가 실행된다.
3. Interceptor(전처리): 요청 전처리를 수행하는 모든 인터셉터가 실행된다
4. Pipe: 입력 데이터의 변환 및 유효성 검사를 수행하는 모든 파이프가 실행된다.
5. Controller Handler: 컨트롤러의 핸들러 메서드가 실행된다.
6. Pipe(응답 데이터 변환): 컨트롤러 핸들러가 반환한 데이터를 변환하는 파이프가 있을 경우 실행된다.
7. Interceptor(후처리): 요청 후처리를 수행하는 모든 인터셉터가 실행된다.
AOP: OOP와 같은 방식으로 소프트웨어 모듈화를 목표로 하는 프로그래밍 패러다임이다. 주로 관심사의 분리(Separation of Concerns)를 통해 코드의 재사용성과 유지보수성을 높이는 데 중점을 둔다.
method 실행의 전, 후 로직을 묶는다.
function으로 부터 반환된 result를 바꿈
function으로 부터 던져진 exception을 바꿈
basic fucntion들의 기능 추가
특정 조건에 따라 function을 override 하는 것
Basics
각 interceptor는 intercept() method를 구현한다. 이는 두 개의 argument를 받는다. (ExecutionContext, CallHandler) ExecutionContext의 instance이다.(Guards에서의 object과 동일하다.) ExecutionContext는 ArgumentsHost를 상속받는다. 이전의 exception filter chapter에서 ArgumentHost를 살펴보았다. 이것은 original handler에 전달된 arguments들의 wrapper이며, application의 type에 따라 다양한 arguments array를 포함하고 있음을 보았다. 자세한 사항은 exception filters 살펴봐라.
ArgumentHost: 원래 handler에 전달된 argument를 래핑하는 객체로, 어플리케이션의 타입에 따라 다양한 인수 배열을 포함한다.
이를 통해, request handler에 전달된 모든 인수를 쉽게 접근할 수 있다.
1. getArgs() : 원래의 핸들러에 전달된 모드 인수 배열을 반환
2. getArgByIndex(index:number): 특정 인덱스의 인수를 반환
3. switchToHttp(): Http 컨텍스트로 전환하여 HTTP 요청 및 응답 객체에 접근할 수 있다.
4. swtichToWs(): WebSocket 컨텍스트로 전환하여 WebSocket 요청 및 응답 객체에 접근할 수 있다.
ExecutionContext: 'ArgumentsHost'를 확장한 인터페이스로, 주로 가드와 인터셉터에서 사용된다.
1. getClass(): 현재 처리 중인 클래스(controller)를 반환한다.
2. getHandler(): 현재 처리 중인 핸들러(method)를 반환한다.
3. getType(): 현재 실행 컨텍스트의 유형을 반환한다.(HTTP, RPC, WebSocket)
ArgumentHost와 ExecutionContext의 주요 정보
1. HTTP 컨텍스트(Express 사용 시)
- Request: Express의 요청 객체로, 요청된 URL, 헤더, 본문, 쿼리 파라미터 등을 포함한다.
- Response: Express의 응답 객체로, 응답을 작성하고 반환하는데 사용된다.
- Next: (옵션) 미들웨어 체인의 다음 함수로, 일반적으로 사용은 안됨
2. 메서드 및 클래스 정보
- Handler: 현재 실행 중인 핸들러 메서드
- Class: 현재 실행 중인 클래스(컨트롤러)
Execution context
ArgumentsHost, ExecutionContext를 extending함으로써 현재 execution process에 대해 추가정보를 제공하는 새로운 helper method들을 추가할 수 있다. 이러한 정보들은 controller, method, execution context를 걸쳐 좀더 generic interceptor를 build하는데 도움된다. ExecutionContext
Call handler
NestJs에서의 interceptor는 request를 가로채어 특정 로직을 실행할 수 있다. 인터셉터의 intercept() method는 두 개의 인자를 받는다. ExecutionContext와 CallHandler. CallHandler의 'handle()' method는 rotue handler method를 호출하는 데 사용된다. 만약 handle() method를 호출하지 않으면, request가 route handler로 전달되지 않고, 해당 route handler method는 실행되지 않는다.
이러한 접근은 intercept() method는 효과적으로 request/response streamdmf 감싼다. 결과대로, 마지막 route handler의 실행 전/후 둘다 custom logic를 실행할 것 이다. intercept() method가 handle() 이 호출되기 전에 실행된다. 하지만 후에 어떻게 영향을 주는가? handle() method가 Observable를 return 하기 때문에, 우리는 강력한 RxJS operator를 사용하여 response를 조작할 수 있다. Aspect Oriented Programming 기술을 사용하면, route handler의 호출(즉, handle() 호출)은 Pointcut이라고 하며 이 지점에서 추가 로직이 삽입 된다.
RxJS: 이벤트나 비동기, 시간을 마치 Array 처럼 다룰 수 있게 만들어 주는 라이브러리
예를 들어, POST /cats request가 들어온다고 가정하자. request는 CatsController안에 정의된 create() handler method로 향할 것이다. interceptor가 handle() method를 호출하지 않으면, 그 경로상의 어느 곳에서든 create() method가 실행되지 않을 것 이다. 한번 handle()이 호출되면(이는 Observable을 반환할 것 이다.) create() handler가 트리거된다. 그리고 Observable를 통해 응답 스트림이 수신되면, 그 스트림에 추가 작업을 수행하고 최종 결과를 호출자에게 반환할 수 있다.
Aspect interception
NestJs에서 인터셉터를 사용하여 사용자 상호작용을 logging 할 수 있다. 예를 들어, 사용자의 호출을 저장하거나, 비동기적으로 이벤트를 디스패치하거나, 타임스탬프를 계산할 수 있다. 아래는 간단한 LoggingInterceptor이다.
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
// pipe
// RxJS의 주요 연산자 중 하나로, Observable의 데이터를 처리하기 위해 다양한 연산자를 연결
// 할 수 있는 메서드이다. pipe를 사용하면 데이터를 변환하거나, 필터링하거나, 부수 효과를 추가하는
// 등의 작업을 체인 방식으로 적용할 수 있다.
// tap
// RxJS의 연산자 중 하나로, Observable 데이터 스트림을 변경하지 않고, 부수 효과(side effect)를 추가
// 할 수 있게 해준다. 주로 디버깅, 로깅, 데이터 수집 등의 목적으로 사용된다.
// tap 연산자는 스트림에 있는 각 데이터 항목에 대해 지정된 함수를 호출한다.
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
// route handler 전
console.log('Before...');
const now = Date.now();
// next: CallHandler 인터페이스를 구현한 객체로, 실제 컨트롤러의 핸들러 메서드를 호출하는 handle method를 제공한다.
// next.handle(): 핸들러 메서드를 호출하고, Observable을 반환한다.
return next
.handle()
// route handler 후
.pipe(
tap(() => console.log(`After... ${Date.now() - now}ms`)),
);
}
}
<팁!>
NestInterceptor<T,R> 는 generic interface이다. T는 Observable<T>의 타입을 나타낸다. 요청 핸들러가 반환하는 데이터 스트림의 타입을 나타낸다. 즉, 라우트 핸들러 메서드가 반환하는 데이터 타입 이다. R는 Observable<R>에 의해 감싸진 값의 타입을 말한다. 인터셉터가 반환하는 최종 데이터 타입이다. 인터셉터가 응답 데이터를 어떻게 변형할지를 정의한다.
<주의사항!> interceptor는 controller, provider, guard와 동일하게 constructor를 통해 dependency를 inject한다.
handle() 은 RxJS Observable를 반환하기 때문에, 우리는 다양한 RxJS 연산자를 사용하여 stream을 조작할 수 있다. 이를 통해, response data에 추가적인 로직을 적용하거나, 데이터를 변환하고, 부수 효과를 추가할 수 있다. 위의 예에서 tab() 연산자를 사용했다. 이는 Observable 스트림이 정상적으로 완료되거나 예외가 발생할 때 익명 로깅 함수를 호출한다. 그러나 response cycle에는 영향을 미치지 않는다. 이는 주로 디버깅, 로깅, 또는 부수효과를 추가하는 용도로 사용된다.
Binding interceptor
interceptor를 설정하기 위해서는 @nestjs/common에서 @UseInterceptors() decorator를 사용한다. pipe, guards와 마찬가지로interceptor는 또한 controller, method, global scope로 설정할 수 있다.
@UseInterceptors(LoggingInterceptor)
export class CatsController {}
위의 구조로, CatsController안에 정의된 모든 route handler들은 LogginInterceptor를 사용할 수 있다. 만일 GET /cats endpoint가 호출된다면, 다음과 같은 output을 얻을 수 있다.
Before...
After... 1ms
instance가 아닌 LogginInterceptor class 를 전달했다. 이는 instantiation과 dependeny injection 책임을 framework에게 넘겨준다. pipe, guard, exception filter과 동일하게, 또한 instance를 넘겨줄 수 있다.
@UseInterceptors(new LoggingInterceptor())
export class CatsController {}
앞서 언급한 대로 위의 구조는 controller에 선언된 모든 handler에 interceptor를 붙인다. 만일 interceptor의 범위를 single method로 좁히고 싶다면, 간단히 method level로 적용하면 된다.
만일 global interceptor를 설정하고 싶다면, useGlobalInterceptors() method를 사용한다.
global interceptor는 모든 controller, route handler를 걸친 application에 사용된다. dependency injection면에서, 위의 예 처럼, useGlobalInterceptors()를 사용하여 module 바깥에서 등록된 global interceptor는 dependency를 inject할 수 없다. 왜냐하면 module의 외부에서 inject가 이루어 졌기 때문이다. 이러한 문제를 해결하기 위해서는 다음과 같은 구조로 interceptor를 module 에서 바로 설정하면 된다.
import { Module } from '@nestjs/common';
import { APP_INTERCEPTOR } from '@nestjs/core';
@Module({
providers: [
{
provide: APP_INTERCEPTOR,
useClass: LoggingInterceptor,
},
],
})
export class AppModule {}
<팁!> 이 접근 방식을 사용하여 interceptor에 대한 dependency inject를 수행할 때, 이 구성이 사용되는 모듈에 관계없이 interceptor는 실제로 전역적임을 유의해야 한다. 이는 intercepter(위의 경우 LogginInterceptor)가 정의된 모듈을 선택해야 한다. 또한 'useClass'는 custome provider 등록을 처리하는 유일한 방법이 아니다. 자세히
Response mapping
handle() method는 Observable를 반환한다. stream에는 route handler에서 반환된 value를 포함한다. 그리고 즉, 이는 RxJS의 map() operator를 통해 쉽게 변경할 수 있다. map() 연산자는 Observable의 각 값을 변환하여 새로운 값으로 반환한다. 이를 통해 응답 데이터를 변형하거나 추가적인 로직을 적용할 수 있다.
<주의사항!> response mapping feature은 라이브러리별 response strategy(즉, '@Res() obejct를 직접 사용하는 것')와 함께 작동하지 않는다.
TransformInterceptor를 만들자. 이는 process를 보여주기 위해 각 response를 간단하게 변형한 것 이다. 이는 response object를 새롭게 만들어진 object의 data 속성에 할당하기 위해 RxJS의 map() 연산자를 사용할 것 이다. 이렇게 만들어진 object는 client에게 반환될 것 이다.
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
// 응답 데이터의 구조를 정의
export interface Response<T> {
data: T;
}
@Injectable()
//<T, Response<T>>
// T: 인터셉터가 처리할 입력 데이터의 타입을 나타냄
// Response<T>: 인터셉터가 반환할 출력 데이터의 타입을 나타냄
export class TransformInterceptor<T> implements NestInterceptor<T, Response<T>> {
// context: 현재 실행 컨텍스트를 나타냄, 요청 및 응답 객체에 접근할 수 있다.
// next: CallHandler 인터페이스를 구현한 객체로, 실제 컨트롤러의 핸들러 메서드를 호출하는 handle 메서드를 제공
intercept(context: ExecutionContext, next: CallHandler): Observable<Response<T>> {
return next.handle().pipe(map(data => ({ data })));
}
}
// 사용
import { Controller, Get, UseInterceptors } from '@nestjs/common';
import { TransformInterceptor } from './transform.interceptor';
@Controller('cats')
@UseInterceptors(TransformInterceptor)
export class CatsController {
@Get()
findAll() {
return [{ name: 'Tom' }, { name: 'Jerry' }];
}
}
Nest 인터셉터는 동기와 비동기 모두에서 작동한다. 필요에 따라 메서드를 간단히 async로 변경할 수 있다.
위의 구조에서 만일 GET /cats endpoints로 호출했을 때, response는 다음과 같은 결과를 도출할 것 이다.
{
"data": []
}
인터셉터는 어플리케이션 전체에서 발생하는 요구 사항에 대한 재사용 가능한 솔루션을 만드는데 큰 가치를 제공한다.
예를 들어, null값을 빈 문자열("")로 변환해야 한다고 가정해보자. 한 줄의 코드로 이를 수행할 수 있으며, 인터셉터를 전역적으로 바인딩하여 등록된 각 핸들러에서 자동으로 사용되도록 할 수 있다.
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
@Injectable()
export class ExcludeNullInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
return next
.handle()
.pipe(map(value => value === null ? '' : value ));
}
}
Exception mapping
다른 RxJS의 catchError() 연산자는 thrown exception을 override할 수 있다.
import {
Injectable,
NestInterceptor,
ExecutionContext,
BadGatewayException,
CallHandler,
} from '@nestjs/common';
import { Observable, throwError } from 'rxjs';
import { catchError } from 'rxjs/operators';
@Injectable()
export class ErrorsInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
return next
.handle()
.pipe(
// 핸들러 메서드에서 발생한 에러를 가로채고, 이를 BadGatewayException으로 변환
catchError(err => throwError(() => new BadGatewayException())),
);
}
}
Stream overriding
때때로 완전히 handler의 호출을 막고, 대신에 다른 값을 반환하기를 원하는 이유가 몇 있다. 명백한 예는 response time을 항샹시키기 위해 cache를 구현하는 것 이다. 간단한 simple cache interceptor를 보자. 이는 cache로 부터 interceptor의 response를 반환한다. cache interceptor를 사용하면 response time을 개선하기 위해 cached data를 반환할 수 있다. cache interceptor는 request에 대한 response를 cache하고, 동일한 request가 들어올 때 cached된 데이터를 반환하여 서버의 부담을 줄일 수 있다. 현실적인 예로, 우리는 TTL, cache invalidation, cache size...와 같은 다른 요소들을 고려해야 하지만, 이는 이 논의의 범위를 벗어난다. 여기서 main concept에 를 보여주는 간단한 예시가 있다.
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { Observable, of } from 'rxjs';
@Injectable()
export class CacheInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const isCached = true;
if (isCached) {
return of([]);
}
return next.handle();
}
}
위에서 CacheInterceptor는 하드코딩된 isCached 변수와 하드코딩된 [ ]을 가진다. 여기서 핵심 포인트는 RxJS의 of() 연산자로 인해 생성된 new stream을 반환하는 것 이다. 그러므로, route handler는 호출되지 않는다. 누군가 CacheInterceptor를 사용하는 endpoint를 호출하면, response(하드코딩된 빈 배열)이 즉시 반환된다. 일반적인 해결책으로는, Reflector를 사용하여 새로운 custom decorator을 만드는 것 이다. reflector
More operators
RxJS operator를 사용하여 stream을 조작하는 것은 많은 가능성을 제공해 준다. 흔한 use case를 생각해보자. 만일 route requests에서 timeouts를 handle하고 싶다고 하자. 만일 특정 시간이 지난 후에도, endpoint가 아무것도 반환하지 않으면 error response로 종료하고 싶을 수 있다. 다음 코드는 이러한 기능을 구현하는 방법을 보여준다.
import { Injectable, NestInterceptor, ExecutionContext, CallHandler, RequestTimeoutException } from '@nestjs/common';
import { Observable, throwError, TimeoutError } from 'rxjs';
import { catchError, timeout } from 'rxjs/operators';
@Injectable()
export class TimeoutInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
return next.handle().pipe(
// 요청이 5초 내에 완료되지 않으면 TimeoutError를 발생
timeout(5000),
catchError(err => {
if (err instanceof TimeoutError) {
return throwError(() => new RequestTimeoutException());
}
return throwError(() => err);
}),
);
};
};
5초 후에, request processing은 취소될 것 이다. RequestTimeoutException이 던져지기 전에 custom logic또한 추가할 수 있다.
guard는 CanActivate interface를 implement하는 @Injectable() decorator이 붙은 class이다.
//Middleware
// 역할: 요청과 응답 사이의 모든 것을 처리. 주로 로깅, 인증, 요청 변환에 사용
// 적용: 모든 경로에 대해 전역적으로 적용되거나 특정 경로에 대해 적용될 수 있다.
// 순서: 요청 수신 직후 실행된다.
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
@Injectable()
export class LoggerMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction) {
console.log('Request...');
next();
}
}
//Guard
// 역할: 요청이 컨트롤러 핸들러로 들어가기 전에 특정 조건(주로 인증/권한)을 확인하고, 접근을 허용할지 결정한다.
// 적용: 전역, 컨트롤러, 또는 특정 핸들러에 대해 적용될 수 있다.
// 순서: 미들웨어 이후에 실행된다.
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
@Injectable()
export class AuthGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
return validateRequest(request);
}
}
//Pipe
// 역할: 요청 데이터를 변환하고 검증한다.
// 적용: 특정 매개변수, 컨트롤러 메서드, 또는 전역적으로 적용될 수 있다.
// 순서: 가드 이후, 컨트롤러 핸들러에 전달되기 전에 실행된다.
import { PipeTransform, Injectable, ArgumentMetadata, BadRequestException } from '@nestjs/common';
@Injectable()
export class ParseIntPipe implements PipeTransform<string, number> {
transform(value: string, metadata: ArgumentMetadata): number {
const val = parseInt(value, 10);
if (isNaN(val)) {
throw new BadRequestException('Validation failed');
}
return val;
}
}
Guard는 single responsibility를 가진다. 이들은 주어진 request가 route handler에 다뤄질 것인지 아닐 지를 결정한다. (permission, role, ACLs. etc에 의거하여) 이는 종종 authorization, authentication으로 사용된다. Authorization(authentication)은 전통적인 Express application에서는 middleware에 의해 다뤄진다. middleware는 authentication에서는 괜찮은 선택이다. token 검증이나 request object에 속성을 붙이는 작업은 특정 rotue context(및 그 메타데이터)와 강하게 연결되어 있지 않기 때문이다.
허나 middleware은 멍청하다. next() function 호출 이후에 어떤 handler가 실행됬는지 알지 모른다. 반면에 Guard는 ExecutionContext instance에 접근한다. 즉, 바로 다음에 무엇이 실행될지 정확하게 안다. exception filter, pipe, 그리고 interceptor과 같이 guard는 request/response cycle의 정확한 지점에서 처리 로직을 선언적으로 삽입할 수 있도록 설계되었다. 이것은 code를 DRY하고 선언적으로 만들어 준다.
ExecutionContext: API 요청이 서버에 도달하면, NestJS는 이 요청에 대한 정보를 ExecutionContext 객체에 캡슐화한다.
이 컨텍스트 객체는 요청의 세부 사항을 포함하며, 이를 통해 Guard, Interceptor, Pipe, Exeception Filter 등이 요청을 처리할 수 있다.
1. 현재 실행 컨텍스트 정보 제공
- 요청 객체, 응답 객체, 컨트롤러 및 핸들러 메서드에 대한 정보를 제공한다.
- Guard, Interceptor, Filter, Pipe 등이 이 정보를 활용하여 특정 로직을 실행할 수 있다.
2. 상황별 분기 처리
- HTTP, WebSocket, RPC(Remote Porcedure Call) 등 다양한 프로토콜에 대해 적절한 컨텍스트를 제공한다.
- 각 프로토콜에 맞는 요청 처리를 구현할 수 있다.
<팁!> Guards는 모든 middleware 이후에, 그리고 intercepter or pipe 이전에 실행된다.
Authorization guard
언급한대로, authorization은 Guard를 쓰는 대표적인 시스템이다. 왜냐하면 caller(보통 authenticated user)가 충분한 permission을 가질 때 특정한 route를 호출할 수 있어야 하기 때문이다. AuthGuard authenticated user를 짐작한다.(request header에 token이 붙어있는) Guard는 token을 추출하고 검증할 것이다. 그리고 추출된 정보를 사용하여 request가 처리될지 말지를 결정한다.
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Observable } from 'rxjs';
@Injectable()
// AuthGuard가 CanActivate를 구현하여 가드로써 동작할 수 있다.
export class AuthGuard implements CanActivate {
// canActivate 메서드는 요청이 특정 경로에 접근할 수 있는지 여부를 결정한다.
// context: 현재 요청의 실행 컨텍스트를 나타내는 ExecutionContext 객체
// boolean, Promise<boolean>, Observable<boolean> 중 하나를 반환하여 요청이 허용될지 여부를 결정한다.
canActivate(
// 현재 요청의 실행 컨텍스트를 나타내는 ExecutionContext 객체
context: ExecutionContext,
): boolean | Promise<boolean> | Observable<boolean> {
const request = context.switchToHttp().getRequest();
// 주어진 요청 객체의 유효성을 검증하는 역할을 한다.
// 이 함수는 사용자 정의 함수이며, 주로 요청이 적절한 인증 및 권한을 가지고 있는지 확인하는 데 사용된다.
// e.g. JWT 토큰의 유효성 검사. 사용자 권한 확인 등을 포함할 수 있다.
return validateRequest(request);
}
}
실행 컨텍스트
API 요청이 서버에 도달하면, NestJS는 이 요청에 대한 정보를 ExecutionContext 객체에 캡슐화한다. 이 Context 객체는 요청의 세부 사항을포함하며, 이를 통해 Uard, Interceptor, Pipe, Exception Filter 등이 요청을 처리할 수 있다.
<팁!> 만일 이러한 검증 시스템이 어떤 메커니즘으로 돌아가는지 알고 싶다면, 이 챕터를 찾아가라. 마찬가지로 정교한 authorization example를 확인하고 싶다면 이 챕터를 참고하라.
validateRequest() function의 내부 로직은 simple하거나 정교할 수 있다. 이번 예제의 핵심 포인트는 어떻게 guard가 reqeust/response cycle에서 동작하는가 이다.
모든 guard는 반드시 canActivate() 함수를 구현해야 한다. 이 함수는 현재 request가 허용할지 말지를 나타내는 boolean값을 return 해야한다. 그리고 synchronously 또는 asynchronously 한지에 대한 response를 반환할 수 있다.(Promise 또는 Observable을 통해) Nest는 next action을 제어하기 위해 return value를 사용해야 한다.
만일 true를 반환하면 request가 처리되야 함
만일 false를 반환하면 request를 거절해야함
Execution context
canActivate() function은 single argument, ExecutionContext instance를 받는다. ExecutionContext는 ArgumentsHost를 상속받는다. 우리는 ArgumentsHost는 이전 filter chapter에서 다루었다. 위의 sample에서, ArgumentsHost에 정의된 동일한 helper method를 사용하여 request 객체에 대한 참조를 얻고 있다. exception filters의 Arguments host section에서 더 자세히 알 수 있다.
ArgumentsHost를 확장함으로써, ExecutionContext 또한 몇 개의 새로운 helper methods를 추가할 수 있다. 이들은 현재 실행중인 process에 대한 추가 detail들을 제공한다. 이러한 detail들은 좀더 generic한 guard를 설계하는데 유용하다. guard들은 controllers, methods 그리고 execution context에 걸쳐서 사용된다. ExecutionContext에 대한 자세한 설명
ArgumentsHost
- 다양한 유형의 핸들러(HTTP, RPC, WebSocket 등)에 대해 인수를 가져오는 데 사용된다.
- 파이프, 인터셉터, 예외 필터 등에서 인수를 다루는 데 사용된다.
주요 메서드
1. getArgs(): 인수 배열을 반환한다.
2. getArgByIndex(index: number): 특정 인수를 반환한다.
3. switchToHttp(): HTTP 컨텍스트로 전환한다.
4. swtichToRpc(): RPC 컨텍스트로 전환한다.
5. switchToWs(): WebSocket 컨텍스트로 전환한다.
ExecutionContext
- ArgumentsHost를 확장하여 실행 컨텍스트에 대한 더 많은 정보를 제공한다.
- 현재 실행 중인 컨텍스트(컨트롤러, 핸들러 등)에 대한 추가 메타데이터를 제공한다.
- 주로 가드와 인터셉터에서 컨트롤러와 핸들러 수준의 정보를 다루는데 사용
주요 메서드
1. getClass(): 현재 실행 중인 클래스(컨트롤러)를 반환한다.
2. getHandler(): 현재 실행 중인 핸들러(메서드)를 반환한다.
3. getType(): 현재 실행 중인 컨텍스트 타입을 반환한다.
Role-based authentication
이제 좀더 기능적인 특정한 규칙을 통해 허락된 유저만 가능토록하는 기능적인 guard를 설계해보자. 일단 먼저 basic guard template로 시작한다. 아래의 코드는 all request가 처리되도록 허용한다.
pipe나 exception filter와 같이, guard는 controller-scoped, global-scoped가 될 수 있다. 아래에서는 @UseGuards() decorator를 사용하여 controller-scoped guard를 설정했다. 이 decorator는 single argument, 혹은 comma-separated list of arguments가 받는다. 이는 한번의 선언(declaration)으로 적절한 guard set이 적용되도록 한다.
@Controller('cats')
@UseGuards(RolesGuard)
export class CatsController {}
<팁!> @nestjs/common package를 통해 @UseGuards()를 import한다.
위에서 RolesGuard class를 전달한다.(instance가 아닌), 이는 framework에게 instance화의 책임을 넘기고 DI를 가능케 한다. pipe와 exception filter와 비슷하게, instance를 넘겨줄 수 있다.
@Controller('cats')
@UseGuards(new RolesGuard())
export class CatsController {}
위의 구조는 controller에 선언된 모든 handler는 guard가 붙여진다. 만일 single method에 guard가 붙여지길 원한다면, 우리는 @UseGuards() decorator를 method level에서 적용할 수 있다.
global guard를 설정하기 위해서, Nest application instance의 useGlobalGuards() method를 사용할 수 있다.
hybrid App의 경우 useGlobalGuards() method는 gateway나 micro service에 guard() 설정하지 못한다.(만일 바꾸고 싶다면 Hybird application 참고) "standard"(non-hybird) microservice app의 경우, useGlobalGuard()는 guard를 global하게 마운트된다.
Global guard의 경우 전체 application, 모든 controller, 모든 route handler에 사용된다. Dependency injection 관점에서, module(위의 코드 처럼 useGlobalGuards())의 바깥에서 global guards는 dependency를 inject하지 못한다. 왜냐하면 이는 모듈의 바깥에서 이루어졌기 때문이다. 이러한 문제를 해결하기 위해, 다음과 같은 방식으로 module에서 바로 guard를 설정하면 된다.
import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
@Module({
providers: [
{
provide: APP_GUARD,
useClass: RolesGuard,
},
],
})
export class AppModule {}
<팁!> guard에 대해 이러한 방식으로 dependency injection을 사용할 때, 이러한 구성이 사용되는 모듈에 관계없이 실제로 전역적이라는 점에서 유의해야한다. 어디서 이 작업을 수행할까? guard(위의 예제에서 정의된 RolesGuard)를 정의한 module을 선택해라. 또는 useClass는custom provider 등록을 처리하는 유일한 방법이 아니다. 자세히
Setting roles per handler
앞의 RolesGuard는 잘 동작한다. 하지만 그렇게 smart하지는 않다. 아직 guard의 기능 들중 가장 중요한 것을 보지 않았다.(execution context). 아직 역할이나 각 핸들러에 허용된 역할에 대해 알지 못한다. 예를 들어 CatsController의 경우 다양한 route에 대해 permisstion scheme가 다르다. 일부는 오직 admin user에게만 가능할 수도 있고, 다른 것들은 everyone에게 허용될 수 있다. 어떻게 다양한 route에 대해 각각에 role를 유연하고 재사용가능하게 맞출 수 있을까?
여기서 custom metadata가 중요한 역할을 한다. 자세히 Nest는 Reflector.createDecorator static method를 통해 만들어진 decorator나 built-in @SetMetadata() decorator를 통해 custom metadata를 route handler에 붙이는 능력을 제공한다.
예를 들어, metadata를 handler에 붙여주는 Reflector.createDecorator method를 통해 @Roles() decorator를 만들었다고 가정하자. Reflector는 @nestjs/core package에서 framework에 의해 제공된다.
Roles decorator metadata를 create() method에 붙인다. 결국 admin이 있는 user만 이 route에 접근할 수 있다.
대신 Reflector#createDecorator method를 붙이면, 내장된 @SetMetadata() decorator를 사용할 수 있다. 자세히
Putting it all together
앞으로 돌아가 RolesGuard를 다시 묶어준다. 현재, request에 대해 모두 true를 반환한다. 현재 사용자가 할당받은 역할을 현재 처리 중인 route에 필요한 실제 역할과 비교하여 반환 값을 조건부로 만들고자 한다. route의 역할(custom metadata)에 접근하기 위해서, Reflector helper class를 다시 사용해야 한다.
// roles.decorator.ts
import { SetMetadata } from '@nestjs/common';
// SetMetadata(메타데이터 키, 메타데이터 값)
export const Roles = (...roles: string[]) => SetMetadata('roles', roles);
// roles.guard.ts
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Roles } from './roles.decorator';
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
// 요청이 핸들러에 접근할 수 있는지 여부를 결정한다.
canActivate(context: ExecutionContext): boolean {
// context.getHandler()를 통해 현재 핸들러의 메타데이터를 가져온다.
// 메타데이터 키 'roles'에 설정된 역할들을 가져와서, 현재 요청을 보낸 사용자의 역할과 비교
const roles = this.reflector.get('roles', context.getHandler());
// 역할이 정의되어 있지않다면, 모두 허용한다는 의미
if (!roles) {
return true;
}
// api를 요청하는 유저의 roles과 현재 api에 지정된 roles를 비교
const request = context.switchToHttp().getRequest();
const user = request.user;
return matchRoles(roles, user.roles);
}
}
// admin.controller.ts
import { Controller, Get, UseGuards } from '@nestjs/common';
import { RolesGuard } from './roles.guard';
import { Roles } from './roles.decorator';
@Controller('admin')
@UseGuards(RolesGuard)
export class AdminController {
@Get()
@Roles('admin') // 'admin' 역할이 필요함을 나타내는 데코레이터
findAll() {
return 'This is an admin route';
}
}
<팁!> node.js에서, authorized user를 request object에 붙이는 것은 흔한 관행이다. 즉, 위의 코드에서, request.user는 user instance와 허용된 역할을 가지고 있다고 가정하고 가져온다. app 내에서, custom authentication guard(or middleware)에서 이러한 연관을 만들 가능성이 높다. 다음 챕터에서 자세히
<주의사항!> matchRoles() 함수 내의 logic은 simple하거나 sophisticated 할 수 있다. 이 example의 포인트는 어떻게 guard가 request/response cycle에서 보여지는가 이다.
Reflection and metadata을 참고해라. endpoint에서 만일 user가 불충분한 권한으로 request 한다면, nest는 자동으로 다음과 같은 response를 보낸다.
Pipe는 @Injectable() decorator로 명시된 class이다. 이는 PipeTransform interface를 받는다.
클라이언트가 특정 API를 요청한다면, 먼저 middleware가 요청을 처리하고, 이후 pipe가 요청 데이터를 변환 및 검증하여 컨트롤러 메서드에 전달하게 된다.
Pipe는 두 가지의 역할을 한다.
transformation: input data를 원하는 형태의 form으로 바꿔준다.(e.g. string to integer)
validation: input data를 검사한다. 그리고 valid하다면 그대로 넘기고 그렇지 않다면 error를 발생시킨다.
위의 두 경우에서, pipe는 controller route handler에 의해 처리된 arguments에 의해서 동작한다. Nest는 method가 실행되기 전 pipe를 배치 시킨다. 그리고 pipe는 method로 전달될 argument를 받아서 작업을 수행한다. pipe를 통한 transformation 그리고 validation의 작업을 마친 후에, route handler가 그 변환된 argument로 실행시킨다.
parameter(매개변수): 함수 안에서의 정의 및 사용에 나열되어 있는 변수들을 의미
argument(전달인자): 함수를 호출할 때 전달되는 실제 값들
Nest는 별도의 설치가 필요없는 몇 개의 built-in된 pipe를 지닌다. 또한 pipe를 사용자에 맞게 custom 할 수 있다. 이번 chapter에서는, build-in pipe를 소개하고, 어떻게 그들을 route-handler와 묶을 수 있는지를 소개한다. 그리고 여러 custom pipe를 살펴보면서 처음부터 하나를 만드는 방법을 소개한다.
<팁!> pipe는 exception zone 내부에서 동작한다. 이는 만일 Pipe가 exception을 발생시켜도, 이는 exception layer에 의해 처리된다. (global exceptions filter 그리고 어떤 exception filters) pipe에서 exception이 발생하면, 그 이후에 controller method가 실행되지 않는다는 점이 분명하다. 이는 application에서 들어오는 data의 validation을 검사하는데 있어 굉장히 유용하다.
Middleware, pipe, filter
위의 3개의 장치의 순서는 다음과 같다. Middleware -> pipe, 그리고 filter
Middleware는 reqeust와 response 객체를 변형하거나, 특정 조건에 따라 요청을 차단하거나 logging을 수행한다. 주로 HTTP 요청을 처리하는 초기 단계에서 작동한다.
요청 로깅: 요청 정보를 기록
인증(Authentication): 사용자의 인증을 처리한다.
권한 부여(Authorization): 사용자의 권한을 확인한다.
요청 변환(Request Transformation): 요청 데이터를 변환하거나 검증한다.
응답 변환(Response Transformation): 응답 데이터를 변환하거나 포맷한다.
Pipe는 요청 데이터를 변형하거나 유효성을 검사한다. 주로 데이터 변환과 유효성 검사를 수행한다.
변환(Transformation): 입력 데이터를 원하는 형식으로 변환한다. 예를 들어, 문자열을 정수로 변환할 수 있다.
유효성 검사(Validation): 입력 데이터가 특정 조건을 만족하는지 확인한다. 조건을 만족하지 않으면 예외를 발생시킨다.
Filter는 예외가 발생했을 때 이를 처리하여, 적절한 응답을 클라이언트에게 반환한다.
Built-in pipes
Nest는 9개의 pipe가 내장되어 있다. @nestjs/common에서 import 할 수 있다.
ValidationPipe: 입력 데이터를 유효성 검사를 통해 확인. class-validation library와 함께 사용하여 DTO를 기반으로 유효성 검사 수행
DefaultValuePipe: 특정 파라미터가 없을 때 기본값으로 설정한다. 주로 선택적 파라미터에 사용
ParseFilePipe: 파일 업로드 시 파일을 파싱하고 검증한다. 주로 파일 크기, 타입 등의 조건을 확인
먼저 ParseIntPipe를 살펴보자. transformation을 수행한다.. 여기서 pipe는 method handler parameter가 javascript integer를 바뀜을 보장해준다.(만일 변환에 실패하면 exception을 던진다.) 후의 chapter에서는 ParseIntPipe을 implementation 간단한 custom pipe를 만들 것 이다. 아래의 예제들은 ParseBoolPipe, ParseFloatPipe, ParseEnumPipe, ParseArrayPipe, ParseUUIDPipe와 같은 다른 내장 transformation pipe들에도 적용된다. 이 글에서 이러한 pipe를 'Parse*' 라고 서술할 것이다.
Binding pipes
pipe를 사용하기 위해, pipe class의 instace를 적절한 context에 bind해야한다. 아래의 ParseIntPipe 예시에서, 특정한 route handler method와 pipe를 연결할 것이다. 그리고 method가 호출되기 전에 pipe가 실행될 것이다. 다음과 같은 구조로 사용하여, method parameter 수준에서 pipe를 binding한다.
위에서는 ParseIntPipe의 class를 호출하는 것이지, instance가 아니다. 이는 framework에 instance화의 책임을 떠넘기고 dependency injection을 가능케 한다. pipe와 guard와 마찬가지로, in-place instance를 전달할 수 있다. in-place instance를 전달하는 것은 built-in pipe의 행동을 설정하는데 있어서 유용하다.
앞서 언급한 대로, pipe를 customize화 할 수 있다. Nest는 강력한 ParseIntPipe그리고 ValidationPipe를 제공하지만, custom pipe가 어떻게 동작하는지 살펴보기 위해 간단한 custom version을 만들어 보자.
간단하게 input value를 받아 그대로 return 하는 pipe를 만들어보자.
import { PipeTransform, Injectable, ArgumentMetadata } from '@nestjs/common';
// ValidationPipe 클래스를 NestJS의 의존성 주입 시스템에 등록한다. 이를 통해 다른 클래스에서 이 파이프를 주입하여
// 사용할 수 있다.
@Injectable()
// PipreTransform 인터페이스를 구현하여 파이프로 사용될 수 있다.
export class ValidationPipe implements PipeTransform {
// transform 파이프의 핵심 메서드로, 입력 값을 변환한다.
// value: 파이프에 전달된 입력 값
// metadata: 현재 인수에 대한 메타 데이터
transform(value: any, metadata: ArgumentMetadata) {
return value;
}
}
<팁!> PipeTransform(string, number)를 했을 때의 console.log(metadata)의 결과
<팁!> PipeTransform<T,R> 은 반드시 모든 pipe에서 implement해야하는 generic interface이다. T는 input value의 타입이며, R은 transform() method의 return 타입이다.
import { PipeTransform, Injectable, ArgumentMetadata, BadRequestException } from '@nestjs/common';
@Injectable()
export class ParseIntPipe implements PipeTransform<string, number> {
transform(value: string, metadata: ArgumentMetadata): number {
const val = parseInt(value, 10);
if (isNaN(val)) {
throw new BadRequestException('Validation failed');
}
return val;
}
}
모든 pipe는 PipeTransform interface에 의해 transform() method를 호출해야한다. 이 method는 다음과 같은 두 개의 parameter를 가진다.
value: transform 메서드에 전달되는 값으로, 변환 또는 검증이 필요한 입력 데이터이다.
출처: 이 값은 controller method의 parameter에서 가져온다.
용도: validation 검사나 data transform을 수행할 때 사용된다.
metadata: 현재 인수에 대한 메타데이터를 제공한다.
구성: ArgumentMetadata 인터페이스를 통해 제공된다.
type: body, query, param, custom 중 하나로, 인수의 위치를 나타낸다.
metatype: 변환 전 원시 타입(String, Number, Boolean)
data: parameter의 이름(e.g. 'id')이다.
value parameter는 현재 처리되고 있는 method argument(route handling method가 받기 전의 argument)이며, metadata는 현재 처리되는 method의 argument의 metadata이다. metadata 객체는 다음과 같은 속성들을 가진다.
create method에 대한 모든 request가 valid한 body를 포함하도록 보장하고 싶다. 그래서 createCatDto 객체의 3개의 member의 유효성 검사를 실시해야 한다. 물론 이러한 검사를 route handler method 내부에서 실시할 수 있다. 허나 이는 single responsibility principle(SRP) 을 위배 한다.
단일 책임 원칙(Single Responsibility Principle): 객체는 단 하나의 책임만 가져야 한다. 하나의 클래스는 하나의 기능을 담당 하여 하나의 책임을 수행하는데 집중되어야 있어야 한다는 의미한다. 한 객체에 책임이 많아질 수록 클래스 내부에서 서로 다른 역할을 수행하는 코드 끼리 강하게 결합될 가능성이 높아지게 되어 시스템이 복잡해질 수 있다.
다른 접근법으로는 validator class를 만들고 유효성검사를 위임하는 것이다. 이 방법의 단점은 각 method의 시작 부분에서 이 validator 검사기를 호출해야 한다는 점이다.
validation middleware를 만드는 것은 어떤가? 이 방법이 작동할 수는 있지만, 불행하게도 application의 전체 context에 사용되는 generic middleware를 만드는 것이 불가능하다. 이는 middleware가 호출될 handler와 그 parameter를 포함한 execution context를 인지하지 못하기 때문이다.
middleware는 execution context를 알지 못한다는 것의 의미:
미들웨어가 실행 컨텍스트를 알지 못한다는 것은, 미들웨어가 특정 요청을 처리하는 동안 어떤 컨트롤러나 메서드가 호출될 지, 그리고 그 메서드의 매개변수가 무엇인지 알지 못한다는 뜻이다. 미들웨어는 주로 요청과 응답 객체만을 처리하며, 어플리케이션의 특정 비즈니스 로직이나, 컨트롤러 메서드에 대한 구체적인 정보를 알지 못한다.
pipe는 nestjs에서 요청 데이터를 변환하고, 유효성 검사를 위해 설계된 도구이다. 따라서 유효성 검사를 위해 파이프를 사용하는 것이 적절하다. 이제 validation pipe를 정교하게 다듬어 보자.
Object schema validation
object의 validation을 위한 clean하고 DRY한 여러 방법이 있다. 첫 번째 방법은 schema-based validation을 사용하는 것이다.
DRY(Don't repeat yourself): 소프트웨어 개발 원칙 중 하나로, 동일한 코드나 로직을 반복하지 않도록 하는 중요한 개념이다.
schema-based: 데이터의 형식과 제약 조건을 정의하는 schema를 사용하여 데이터를 검증한다.
schema는 데이터 구조, 필수 필드, 데이터 타입, 혀옹되는 값의 범위 등을 명확히 정의한다.
Zodlibrary는 readable API와 함께 schema를 만드는데 사용된다. Zod-based schema를 만드는 validation pipe를 만들자.
//Zod는 TS 및 JS에서 사용되는 schema 선언 및 validation 검사 라이브러리다.
import { z } from 'zod';
// 스키마 정의
const userSchema = z.object({
name: z.string(),
age: z.number().min(18),
});
// 데이터 유효성 검사
const result = userSchema.safeParse({ name: "John", age: 25 });
if (!result.success) {
console.error(result.error);
} else {
console.log("Valid data:", result.data);
}
먼저 package를 설치해야 한다.
$ npm install --save zod
constructor argument로서 schema를 만드는 간단한 class를 만든다. 이 때 schema.parse() method를 적용하는데, 이는 incoming argument의 유효성을 검사한다.
앞서 언급한 대로, validation pipe는 값을 그대로 반환하거나 에러를 던진다.
다음 section에서는 어떻게 @UsePipes() 를 통해 controller method에 적절한 schema를 던지는가를 볼 것이다. 이렇게 하는 것은 reusable validation pipe를 전체 context를 걸쳐 적용하게 한다.
import { PipeTransform, ArgumentMetadata, BadRequestException } from '@nestjs/common';
import { ZodSchema } from 'zod';
export class ZodValidationPipe implements PipeTransform {
// 생성자에서 ZodSchema 타입의 스키마를 받아와 저장한다.
// 아래의 userSchema와 비교
constructor(private schema: ZodSchema) {}
// value: 검증할 입력 값, metadata: 매개변수에 대한 metadata를 제공
transform(value: unknown, metadata: ArgumentMetadata) {
try {
// schema.parse를 사용하여 값을 검증하고 변환한다.
const parsedValue = this.schema.parse(value);
return parsedValue;
} catch (error) {
throw new BadRequestException('Validation failed');
}
}
}
// 사용 예시
import { z } from 'zod';
const userSchema = z.object({
name: z.string().min(1),
age: z.number().int().min(0),
email: z.string().email(),
});
@Controller('users')
export class UsersController {
@Post()
create(@Body(new ZodValidationPipe(userSchema)) createUserDto: CreateUserDto) {
return this.usersService.create(createUserDto);
}
}
Binding validation pipes
앞서, 어떻게 transformation pipe(ParseIntpipe, 또는 Parse* )가 bind 되는지를 살펴보았다.
이번에는, method call level에서 pipe를 bind할 것이다. 현재 예시에서, ZodValidationPipe를 사용할 것이다.
ZodValidationPipe의 instance를 만든다.
Pipe의 class constructor에 context 별 Zod schema를 전달한다.
pipe랑 method랑 연결 한다.
import { z } from 'zod';
export const createCatSchema = z
.object({
name: z.string(),
age: z.number(),
breed: z.string(),
})
.required();
// createCatSchema의 스키마를 기반으로 Typescript 타입을 생성
export type CreateCatDto = z.infer<typeof createCatSchema>;
<주의사항!> zod 라이브러리는 tsconfig.json file에서 strictNullChecks 옵션이 필요하다.
Class validator
<주의사항!> 이번에 배우는 것은 Typescript에서 요구하는 것이다. 만일 vanilla JavaScript라면 필요없다.
validation technique에 대해 대체 구현을 살펴보자. Nest는 class-validator 라이브러리에 잘 작동한다. 이는 decorator기반의 validate를 가능케 한다. decorator 기반의 유효성 검사는 Nest의 파이프 기능과 결합할 때 매우 강력하다. 이는 처리되는 속성 metatype에 접근할 수 있기 때문이다.
$ npm i --save class-validator class-transformer
패키지를 이용하면 CreateCatDto class에 몇 개의 decorator를 추가할 수 있다. 여기에는 몇 가지 장점이 있다. 별도의 유효성 검사를 위한 Class를 만들 필요가 없이 데이터를 검증할 수 있다.
import { PipeTransform, Injectable, ArgumentMetadata, BadRequestException } from '@nestjs/common';
import { validate } from 'class-validator';
import { plainToInstance } from 'class-transformer';
@Injectable()
export class ValidationPipe implements PipeTransform<any> {
// value: 검증할 입력값, metadata: 입력 값의 메타데이터(type 정보 등...)
async transform(value: any, { metatype }: ArgumentMetadata) {
// metatype이 없거나 기본 타입인 경우 검증하지 않고 값을 그대로 반환, 검증을 수행할 필요가 없음을 의미
// - metatype이 없다는 것은, 요청된 데이터의 타입 정보가 제공되지 않았음을 의미한다. NestJS에서 파이프는
// 입력 값의 메타타입을 통해 어떤 타입을 변환해야 하는지, 그리고 어떤 검증을 수행해야 하는지를 결정한다.
// toValidate(metatype) : 기본 타입(String, Boolean, Number, Array, Object)이 아닌 경우에만 검증을 수행하도록 함
// - toValidate method는 메타타입이 기본 타입(String, Boolean, Number, Array, Object)인지 확인
if (!metatype || !this.toValidate(metatype)) {
return value;
}
// class-transformer의 plainToInstance 함수를 사용하여 일반 JS객체('value')를 특정 클래스('metatype')의 인스턴스로 변환
const object = plainToInstance(metatype, value);
// validate 함수를 사용하여 변환된 객체('object')의 유효성을 검사한다.
// 유형성 검사에 실패하면, 유효하지 않은 필드와 관련된 오류 정보가 배열 형태로 저장된다.
// 성공하면, errors 배열은 비어 있다.
const errors = await validate(object);
if (errors.length > 0) {
throw new BadRequestException('Validation failed');
}
return value;
}
private toValidate(metatype: Function): boolean {
const types: Function[] = [String, Boolean, Number, Array, Object];
// types 배열에 포함되어 있으면 false를 반환하여 검증을 건너뛴다.
// 포함되어 있지 않으면, true를 반환하여 검증을 수행
return !types.includes(metatype);
}
}
<팁!> 앞서 언급한 대로, ValidationPipe는 Nest에서 기본적으로 제공해주기에 별도의 generic validation pipe를 만들 필요가 없다. built-in ValidationPipe는 이번 쳅터에서 만든 sample보다 더 많은 option을 지닌다. 자세한 사항
우리는 위에서 class-transformer 라이브러리를 사용했는데, 이는 class-validator 라이브러리와 동일한 작성자가 만든 것이므로 이 두 라이브러리는 매우 잘 호환됩니다.
위의 코드에서 주목해야할 5개가 있다. 첫 번째로, transform() method는 async가 붙어있다. 이는 Nest에서는 synchronous와 asynchronous pipe 둘다 지원하기 때문이다. asynch method를 만들었는데, 이는 일부 class-validator validations는 Promises를 이용해서 async가 될 수 있기 때문이다.
두 번째로, destructuring을 통해 metatype를 ArgumentMetadata에서 추출했다. 이는 ArgumentMetadata의 전부를 뽑아내서 metatype 변수를 할당하는 추가 구문을 사용하는 것에 대한 간단한 표현이다.
세 번째로, helper function인 toValidate() 를 주목해야 한다. 현재 처리 중인 argument가 native JavaScript 타입일 경우, 유효성 검사 단계를 건너뛰는 역할을 한다. 이 타입들은 유효성 검사 decorator를 붙일 수 없으므로, 유효성 검사 단계를 거칠 필요가 없다.
네 번째로, class-transformer의 plainToInstance() 를 사용한다. 이는 일반 javascript argument object를 타입이 지정된 object로 바꿔준다. 그래서 validation을 검사할 수 있다. 반드시 이것을 해야하는 이유는 incoming post body object(network request에서 deserialized된)는 타입 정보를 가지고 있지 않기 때문이다.(이는 Express와 같은 기본 플랫폼이 작동하는 방식이다.) Class-validator은 앞서 DTO에서 정의 내린대로 validation decorator를 사용해야 하므로, 들어오는 본문을 단순한 일반 객체가 아닌 적절히 decorator가 적용된 object로 처리하기 위해 이 변환을 수행해야 한다.
마지막으로, 앞서 언급한 대로 이것은 validation pipe이기에, 바뀌지 않은 값, 혹은 에러를 던진다.
마지막 스탭은 ValidationPipe를 bind 하는 것이다. Pipe는 parameter-scoped, method-scoped, controller-scoped, global-scoped가 될 수 있다. 일찍이, Zod 기반의 validation pipe로, method 수준에서 binding 예를 봤다. 이번 아래의 예는, pipe instance를 route handler에 @Body() decorator 로 묶을 것이다. 그래서 pipe는 post body의 유효성 검사를 위해 호출될 것 이다.
hybrid apps의 경우에 useGlobalPipes() method는 gateway와 microservice에 대해 pipe를 설정하지 않는다. "표준"(non-hybrid) microservice 앱의 경우, useGlobalPipes()는 pipe를 전역적으로 mount한다.
Global pipe는 전체 application, 모든 controller, 모든 route handler에 사용된다.
Dependency Injection면에서는 (위의 예시 처럼 useGlobalPipes() 로) 어떤 module 밖에서 등록된 global pipes는 dependency를 주입하지 못한다. 왜냐하면 binding이 어떤 module의 밖에서 일어났기 때문이다. 이를 해결하기 위해서, module에서 바로 global pipe를 설정할 수 있다.
import { Module } from '@nestjs/common';
import { APP_PIPE } from '@nestjs/core';
@Module({
providers: [
{
provide: APP_PIPE,
useClass: ValidationPipe,
},
],
})
export class AppModule {}
<팁!> pipe에 대해 이러한 접근으로 dependency injection을 수행할 때, 이 구성을 사용하는 module에 관계없이 pipe는 global하다. 어디서 수행되는가? pipe(위의 예제의 경우 ValidationPipe)가 정의된 모듈을 선택한다. 또한 useClass는 custom provider registration을 처리하는 유일한 방법은 아니다. 자세히
The built-in ValidationPipe
앞서 언급한 대로, ValidationPipe가 이미 Nest에서 제공하기 때문에 따로 generic validation pipe를 build할 필요가 없다. 내장된 ValidationPipe는 이번 쳅터에서 다룬 것보다 더 많은 옵션을 제공한다. 자세히
Transformation use case
유효성 검사는 커스텀 파이프의 유일한 사용 사례가 아니다.이 쳅터의 처음에서 pipe는 inputdata를 원하는 format으로 바꿀 수 있다고 했다. 이것이 가능한 것은 transform 함수에서 반환된 값은 이전의 argument 값을 덮어 씌우기 때문이다.
언제 유용한가? 가끔 client으로부터 전달된 data를 change할 필요가 있다. 예를 들어 string to integer, route handler method에 의해 다뤄지기 전에. 게다가, 어떤 required data filed가 missing됬을 때 이를 default value로 바꿀 필요가 있다. Transformation pipes가 processing function과 client request 또는 request handler 사이에 위치에서 이를 수행한다.
ParseIntPipe는 string을 integer value로 바꿀 수 있다.
import { PipeTransform, Injectable, ArgumentMetadata, BadRequestException } from '@nestjs/common';
@Injectable()
export class ParseIntPipe implements PipeTransform<string, number> {
transform(value: string, metadata: ArgumentMetadata): number {
// 10진법으로 변환
const val = parseInt(value, 10);
if (isNaN(val)) {
throw new BadRequestException('Validation failed');
}
return val;
}
}
pipe랑 param을 묶을 수 있다.
@Get(':id')
async findOne(@Param('id', new ParseIntPipe()) id) {
return this.catsService.findOne(id);
}
다른 유용한 transformation case는 request에서 공급된 id를 이용하여 DB에서 user entity에서 select하는 것이다.
// userEntity는 UserByIdPipe에 의해 처리된 결과로, UserEntity 타입의 객체이다.
@Get(':id')
findOne(@Param('id', UserByIdPipe) userEntity: UserEntity) {
return userEntity;
}
다른 모든 transformation pipe와 마찬가지로, input value(an id)를 받아 출력 값(UserEntity object)을 반환한다는 점에 유의해야 한다. 이렇게 하면 반복되는 코드를 handler에서 공통 pipe로 추상화하여 선언적이고 DRY(중복 배제)하게 만들 수 있다.
Providing defaults
Parse* pipes 는 parameter의 값이 정의되어 있을 것으로 예상한다. pipe들은 null 혹은 undefined values를 받으면 exception을 던질 것 이다. endpoint에서 놓친 query string parameter value를 다루도록 하기 위해서, 이러한 값에 대해 작동하기 전에 default value를 제공해야 한다. DefaultValuePipe는 이러한 목적으로 제공된다. 간단히 @Query() decorator에서 DefaultValuePipe를 인스턴스화 해서 넣으면 된다.
Nest에는 exception layer이 내장되어 있다. 이는 application의 전반에 걸쳐 모든 unhandled exception의 처리에 책임이 있다. 만일 개발자의 application code에 의해 발생되는, not handled한 exception이 발생할 때, 이것을 이 layer가 처리한다. 그리고 바로 자동적으로 적절하게 user-friendly response를 보낸다.
별도의 설치나 설정이 필요없이, 이러한 action은 built-in된 global exception filter에 의해 수행된다. 이러한 filter는 HttpException(혹은 이것의 subclass) type의 exception(예외)를 다룬다. 만일 unrecognized한 exception이 발생한다면(HttpException, HttpException에 의한 class가 둘다 아님), built-in된 exception filter가 다음과 같은 default JSON response를 일으킨다.
{
"statusCode": 500,
"message": "Internal server error"
}
<팁!> global exception filter는 부분적으로 http-errors library를 지원한다. 기본적으로, statusCode 그리고 message 속성을 포함하는 모든 exception은 적절하게 채워서 response로 반환된다. (기본적으로 인식되지 못한 예외에 대한 InternalSererErrorException 대신)
Throwing standard exceptons
Nest는 @nestJs/common에서의 build-in HttpException class를 제공한다. 전형적인 HTTP REST/GraphQL API 기반 applications에 대해서, 특정한 error가 발생했을 때, standard한 HTTP response object를 보내는게 best이다.
예를 들어, CatsController에서의 findAll() method가 있다고 하자. 그리고 어떠한 reason으로 route handler가 exception을 throw했다고 하자. 다음과 같다.
@Get()
async findAll() {
throw new HttpException('Forbidden', HttpStatus.FORBIDDEN);
}
<팁!> @nestjs/common의 HttpStatus는 상태 정보를 담는 enum helper 이다.
만일 client가 endpoint에서 호출한다면, response는 다음과 같다.
{
"statusCode": 403,
"message": "Forbidden"
}
HttpException 생성자는 두 개의 argument를 담는데, 이는 response를 결정한다.
response argument는 JSON response body를 정의한다. 이는 string 혹은 object가 될 수 있다.
statusCode: status argument에 제공된 HTTP status code로 기본 설정된다.
message: status에 기반한 HTTP error의 짧은 설명이다.
JSON response body의 message를 덮어 쓰기 위해서는, string을 response argument에 공급하면 된다.
JSON response body를 전부 덮기 위해서는, response argument에 object를 전달하면 된다. Nest는 객체를 serialize하고 JSON response body 형태로 반환할 것 이다.
두 번째 argument인 status는 반드시 유효한 HTTP status code를 넣어야 하다. 가장 최고의 방법은 @nestjs/common의 HttpStatus enum을 넣는 것이다.
optional한 세 번째 argument, option은 error의 cause을 전달하는데 사용된다. 이 cause object는 response object로 직렬화 되지 않지만, HttpException에 의해 발생한 내부 error에 대한 정보를 보여주는 logging의 목적으로 유용하다.
{
"status": 403,
"error": "This is a custom message"
}
Custom Exceptions
많은 경우에, custom exception을 작성할 필요가없다. 단지 built-in 되있는 Nest HTTP exception을 쓰면된다. 만일 customized exception을 만들 필요가 있다면 제일 좋은 방법은 HttpException class를 상속받는 자신만의 exception을 만드는 것이다. Nest는 exception을 인식하고, 자동으로 error responses를 관리할 것이다.
base(built-in) exception filter는 자동적으로 많은 경우들을 다루지만, exception layer을 모두 제어하기를 원할 수 있다. 예를 들어 동적인 요소들에 근거한 다른 JSON schema를 사용하거나 log를 붙이기를 원할 수 있다. Exception filters는 이러한 목적을 위해 디자인되었다. 이 filter들은 제어의 흐름과, client로 반환되는 response의 내용을 정확하게 제어할 수 있다.
HttpException class의 instance인 exception을 잡고, 이를 위한 사용자 정의 응답 로직을 구현하는 exception filter를 만들 것이다. 이것을 위해, 기본 플랫폼의 Request와 Response 객체에 접근할 필요가 있다. Request 객체에 접근하고, 원래의 url을 빼내고, 이를 log 정보에 포함시킬 것이다. Response 객체를 이용하여 응답을 직접 제어하고 response.json() method를 사용하여 응답을 전송할 것이다.
import { ExceptionFilter, Catch, ArgumentsHost, HttpException } from '@nestjs/common';
import { Request, Response } from 'express';
// @Catch(HttpExeception): HttpException 타입의 예외를 잡아 처리하는 필터임을 지정한다.
@Catch(HttpException)
// HttpExceptionFilter는 ExceptionFilter interface를 구현한다. 이는 'catch' method를 포함한다.
export class HttpExceptionFilter implements ExceptionFilter {
// catch() : ExceptionFilter의 일부로, 예외가 발생했을 때 이를 처리하는 메서드. 예외를 잡아내고, 적절하게 처리하여 클라이언트에게 응답을 반환하는 역할
// exception: 발생한 HttpException 객체이다.
// host: ArgumentsHost 객체로, 여러 유형의 요청 컨텍스트를 캡슐화한다.
// 요청 컨텍스트: 특정 요청에 대한 정보를 포함하는 객체로, 요청의 전체 생명 주기 동안 관련 데이터를 캡슐화한다.
// 다음과 같은 정보를 갖는다.
// 1. HTTP 요청/응답 객체: Express나 Fastify의 요청 및 응답 객체
// 2. WebSocket 데이터: WebSocket 연결 및 메시지 데이터
// 3. RPC 요청: 원격 프로시저 호출에 대한 요청 데이터
catch(exception: HttpException, host: ArgumentsHost) {
// HTTP 컨텍스트로 전환하여 express의 request, response 객체를 가져온다.
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
// 예외 객체에서 HTTP 상태 코드를 가져온다.
const status = exception.getStatus();
// 상태 코드와 JSON 형식의 응답을 클라이언트에게 전송한다.
response
.status(status)
.json({
statusCode: status,
timestamp: new Date().toISOString(),
path: request.url,
});
}
}
<팁!> 모든 exception filter들은 모두 generic ExceptionFilter interface를 사용해야 한다. 이는 catch(exception: T, host: ArgumentHost) method에게 나타난 <T>를 제공해야한다. 여기서 T는 예외의 유형을 나타낸다.
<주의사항!> 만일 @nestjs/platform-fastify를 사용한다면, response.json() 대신 response.send()를 사용해야 한다.
@Catch(HttpException) decorator는 요구된 metadata와 exception filter를 묶어준다. exception filter는 Nest에게 이 특수한 filter는 HttpException 타입의 예외를 원한다고 말해줘야한다. @Catch() decorator는 한 개의 파라미터나, comma로 구분된 리스트를 받는다. 이는 한 번에 filter의 타입을 설정하도록 해준다.
Arguments Host
catch() 메서드의 parameter를 보자. exception parameter는 현재 처리되고 있는 exception object이다. host parameter는 ArgumentsHost 객체이다. ArgumentsHost는 강력한 utility object로, 나중에 execution context chapter*에서 더 자세히 설명할 것 이다. 이번 code sample에서는 원래의 요청 핸들러(예외가 발생한 controller)로 전달되는 Request 및 Response 객체에 대한 참조를 얻기 위해 이를 사용한다. 이 code sample에서는 _ArgumentsHost*_의 몇 가지 helper method를 사용하여 원하는 Reqeust 및 Response 객체를 가져왔다. ArgumentsHost
이 정도의 추상화(abstraction)를 사용하는 이유는 ArgumentsHost가 모든 context에서 작동하기 때문이다.(e.g. 지금 작업 중인 HTTP 서버 context뿐만 아니라, 마이크로서비스와 WebSockets도 포함된다.) execution context chapter에서는 ArgumentsHost와 그 helper 함수의 기능을 통해 모든 실행 컨텍스트에 대해 적절한 인자를 접근할 수 있는 방법을 살펴볼 것 이다. 이를 통해 모든 context에서 작동하는 일반적인 exception filter를 작성할 수 있다.
Binding filters
이제 우리의 HttpExceptionFilter를 CatsController의 create() method에 연결하자.
@UseFilter()는 하나의 filter instance를 받거나, comma로 구분된 filter instance를 받는다. 코드에는 위에 작성된 HttpExceptionFilter instance를 넣었다. 대신에 instance 대신 class를 전달하여 instance화 책임을 프레임워크에 맡기고, dependency injection을 가능케 할 수 있다.
<팁!> 가능하다면, instance 대신 class를 사용함으로써 filter를 적용하는 것을 선호하자. 이는 Nest가 전체 module에서 같은 class의 instance를 쉽게 재사용할 수 있기 때문이다. 곧, memory 사용에 있어 이점을 취한다.
위의 코드에서 method-scoped에서 HttpExceptionFilter는 오직 하나의 create() route handler가 적용됬다. exception filter들은 다른 수준에서 적용될 수 있다. controller, resolver, gateway, controller-scoped, global-scope 등등 예를 들어 controller에 적용하는 것은 아래와 같다.
@UseFilters(new HttpExceptionFilter())
export class CatsController {}
이 구성은 CatsController내에 정의된 모든 route handler에 대해 HttpExceptionFilter가 적용된다.
<주의사항!> useGlobalFilters() method는 gateway나 hybrid application에 대해 filter를 적용하지 못한다.
global-scopde filter는 모든 controller, route handler에 대해 application 전반적으로 사용된다. DI 측면에서 위의 useGlobalFilters() 와 같이, 어떤 모듈의 바깥에서 등록된 global filter은 dependency를 inject할 수 없다. 이는 any module의 context 바깥에서 완료됬기 때문이다. 이러한 문제를 해결하기 위해서는, any module에서 부터 바로 global-scope filter를 등록할 수 있다.
import { Module } from '@nestjs/common';
import { APP_FILTER } from '@nestjs/core';
@Module({
providers: [
{
provide: APP_FILTER,
useClass: HttpExceptionFilter,
},
],
})
export class AppModule {}
이러한 접근 방식을 사용하여 filter에 대해 dependency inject을 수행할 때, 이 구성이 사용되는 module에 상관없이 filter는 실제로 global이라는 점에 유의해야 한다. 이 작업은 어디에서 수행되는가? filter(위의 경우 HttpExceptionFilter)가 정의된 모듈을 선택해야 한다. 또한 useClass는 custom provider registration을 처리하는 유일한 방법은 아니다. 자세히
많은 filter들을 이 기술로 추가할 수 있다. 간다히 각 provider array에 추가하기만 하면 된다.
Catch everything
모든 unhandled exception을 catch하기 위해, @Catch() decorator의 parameter를 비워두면 된다.
아래의 코드는 플랫폼과 무관하다. 왜냐하면 response를 처리하기 위한 HTTP adapter를 사용하고 Request, Response 따위의 플랫폼 종속 객체를 쓰지 않기 때문이다.
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpException,
HttpStatus,
} from '@nestjs/common';
import { HttpAdapterHost } from '@nestjs/core';
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
// HttpAdapterHost를 주입받는다.
constructor(private readonly httpAdapterHost: HttpAdapterHost) {}
catch(exception: unknown, host: ArgumentsHost): void {
// In certain situations `httpAdapter` might not be available in the
// constructor method, thus we should resolve it here.
// httpAdapter는 현재 애플리케이션에서 사용 중인 HTTP 서버 어댑터이다.
// 1. 요청 URL 가져오기: httpAdapter.getReqeustUrl(req)
// 2. 응답 보내기: httpAdapter.replay(res, body, statusCode)
const { httpAdapter } = this.httpAdapterHost;
const ctx = host.switchToHttp();
const httpStatus =
exception instanceof HttpException
? exception.getStatus()
: HttpStatus.INTERNAL_SERVER_ERROR;
// 응답으로 반환될 JSON 객체를 구성
const responseBody = {
statusCode: httpStatus,
timestamp: new Date().toISOString(),
// 현재 요청의 URL 경로를 얻기 위해 사용된다.
path: httpAdapter.getRequestUrl(ctx.getRequest()),
};
httpAdapter.reply(ctx.getResponse(), responseBody, httpStatus);
}
}
<주의사항!> 다양한 예외 필터를 조합하여 사용할 때, 모든 예외를 잡는 필터와 특정 타입에 바인딩된 필터를 함께 사용하는 경우, "모든 예외를 잡는" 필터를 먼저 선언해야 한다. 이렇게 해야 특정 필터가 바인됭 타입의 예외를 올바르게 처리할 수 있다.
import { Module } from '@nestjs/common';
import { APP_FILTER } from '@nestjs/core';
import { AllExceptionsFilter } from './filters/all-exceptions.filter';
import { SpecificExceptionFilter } from './filters/specific-exception.filter';
@Module({
providers: [
{
provide: APP_FILTER,
useClass: AllExceptionsFilter,
},
{
provide: APP_FILTER,
useClass: SpecificExceptionFilter,
},
],
})
export class AppModule {}
Inheritance
일반적으로, application 요구사항을 충족시키기 위해 완전하 사용자 정의된 exception filter를 만든다. 하지만, 간단히 built-in default global exception fileter를 확장하고, 특정 요소에 따라 동작을 재정의하고자 할 때가 있을 수 있다.
예외 처리를 기본 필터에 위임하려면, BaseExceptionFilter를 확장하고, 상속받은 catch() method를 호출해야 한다. 아래와 같은 방식으로 기본 전역 예외 필터를 확장하고 커스터마이징하여 애플리케이션의 요구 사항에 맞게 사용할 수 있다.
import { Catch, ArgumentsHost } from '@nestjs/common';
import { BaseExceptionFilter } from '@nestjs/core';
@Catch()
export class AllExceptionsFilter extends BaseExceptionFilter {
catch(exception: unknown, host: ArgumentsHost) {
super.catch(exception, host);
}
}
<주의사항!> BaseExceptionFilter를 확장한 Method-scoped와 Controller-scoped filter는 new를 이용해 instance화 되면 안된다. 대신에 framework가 이들을 자동으로 instance화하도록 해야 한다.
Global filter는 base filter를 확장할 수 있다. 이는 두 가지 방법으로 된다.
첫 번째 방법은 custom global filter를 인스턴스화할 때 HttpAdapter를 주입하는 것이다.