이번 포스팅은 NestJS 프로젝트를 구성하면 처음으로 볼 수 있는 modules, controllers, providers에 대해서 알아보려 한다.
NestJS의 기본적인 구성 요소
NestJS 프로젝트를 처음 구성하게 되면 우리는 아래와 같은 파일들을 발견할 수 있다.
- main.ts - 프로그램 엔트리 파일 - NestFactory를 통해 Nest Application 인스턴스 생성 - app.module.ts - Nest Application의 root 모듈 - main.ts에서 인스턴스 생성에 사용한다 - app.controller.ts - 최초에 생성해주는 컨트롤러 - Request를 받아주는 역할 - app.service.ts - 최초에 생성해주는 서비스 (Provider로 분류된다) - 비즈니스 로직을 컴포넌트화 하여 분리하는 역할 - app.controller.spec.ts - 유닛 테스트를 위한 파일
사실 상 Controller 개념은 너무 간단하기 때문에, 가볍게 말로만 설명하려 한다.
Controller는 특정 요청을 수신하는 것이다. 라우팅이라고 생각하면 매우 이해하기 쉬운데,
이 부분은 프로바이더를 알아보며 같이 보도록 하자.
전반적인 흐름을 파악하기 위해선, 프로바이더가 제일 중요하다고 생각되는데, 프로바이더는 무슨 의미일까?
프로바이더
의존성을 주입할 수 있다.
밑도 끝도 없이 의존성을 주입할 수 있다. 라고 하면 당연히도 이해가 가지 않을 것이다.
의존성 주입의 의미를 이해하기 위해
- 계층형 구조 ( Layered Architecture )
- 제어 역전 ( IoC, Inversion of Control )
- 의존성 주입 ( Dependency Injection )
에 대해 먼저 알아봐야 한다.
계층형 구조 ( Layered Architecture )
복잡한 작업을 나누고, 각 작업마다 역량을 집중하여 해결하는 방식 ( 관심사 분리 )
이 내용을 보고 있는 당신들이 개발자라는 가정 하에 설명을 해보려 한다.
OOP 기반 프론트 코드를 작성하고 있다고 생각해보자.
지금 개발하고 있는 화면은 한 화면만 가지고 있는 앱이기에 한 클래스에서 UI, 비즈니스 로직, 저장소와 같은 것들이 모두 모여있다고 생각해보겠다.
상상만 해도 유지보수하기 싫어지지 않는가? 사실상 계층형 구조를 간단하게 생각해보면 구조 분리 라고 생각하면 될 듯 하다.
3-Tier Architecture
- Presentation Tier: 사용자 인터페이스 혹은 외부와의 통신을 담당 ⇒
Controller
- Application Tier: Logic Tier라고 하기도 하고 Middle Tier라고 하기도 한다. 주로 비즈니스 로직을 여기서 구현을 하며,
Presentation Tier
와Data Tier
사이를 연결 ⇒Service
- Data Tier: 데이터베이스에 데이터를 읽고 쓰는 역할을 담당
⇒ Controller와 하위 계층의 Provider로 구분
제어 역전 ( IoC, Inversion of Control )
나 대신 프레임워크가 제어한다.
자바스크립트를 써본 당신이라면 이와 같은 코드를 보았을 것이다.
const person = new Person();
클래스의 인스턴스 화이다. Home 클래스에서 Person 클래스를 인스턴스화 해서 가지고 있는 상황인 것이다.
집에는 사람이 사니까 말이다.
그런데 예를 들어 사람이 아니라 동물도 집에 살 수 있게 되었다.
이 경우에는 Home 클래스 내부의 모든 곳에 Animal 클래스 를 사용해야 하는 상황이 생겨버린다.
이미 Home이 Person에 의존하게 되어 바람직하지 않은 상황이 생긴 것이다.
interface Thing { think(): void; } interface Livable { sleep(): void; } class Home implements Livable { private Thing thing; constructor Home(private readonly Thing _thing) { thing = _thing; } public void think() { thing.think(); } } class Human implements Thing { public void think() { console.log('haman think!'); } }
Home home = new Home(new Thing());
하지만 이 상황에서 직접 사람을 넘겨야 하든, 여러 집에 같은 사람이 살고 있다와 같은 상황에는 좋지 않다.
이 경우 제어 역전을 사용한다.
import "reflect-metadata"; import { Container, Service } from "typedi"; @Service() class Human implements Thing { public void think() { console.log('human think!'); } } @Service() class Home implements Livable { constructor(private readonly thing: Thing) {} public void sleep() { this.thing.think(); } } const homeInstance = Container.get<Home>(Home); homeInstance.think(); // "human think!"
new를 사용하지 않고 있다. 이는 typedi의 Container라는 친구가 알아서 인스턴스를 생성하고 있는 것이다.
의존성 주입 ( DI, Dependency Injection )
프레임워크가 주체가 되어 나에게 필요한 클래스 등을 대신 관리해준다
사실 여기에서 IoC와 DI를 혼동할 수 있다.
멀리 봤을 때, DI보다 IoC가 더 크고 추상적인 개념이다.
IoC: 프레임워크가 제어 할게 ( 추상적 )
DI: 프레임워크가 주체가 되어 내가 필요한 것들을 대신 관리해줄 게 ( IoC의 구현체 )
DI를 통해 IoC를 구현한다
자, 다시 Provider를 보도록 하자.
프로바이더는 의존성을 주입해주는 친구이다.
아까 예시로 들었던 Home의 Human이 바로 프로바이더 이다. 어떤 컴포넌트가 필요하며 의존성을 주입당하는 객체를
프로바이더
라고 생각하자. ( 프론트 개념이랑 좀 많이 다르다.. )그리고 Nest 프레임워크 내부에서 컨테이너를 만들어서 관리해준다고 생각하면 된다.
서비스
고양이와 관련된 캣츠 서비스를 만들어보자.
import { Injectable } from '@nestjs/common'; import { Cat } from './interfaces/cat.interface'; @Injectable() export class CatsService { private readonly cats: Cat[] = []; create(cat: Cat) { this.cats.push(cat); } findAll(): Cat[] { return this.cats; } }
여기에서 유일한 새로운 기능은
@Injectable()
데코레이터를 사용한다는 것 이다.@Injectable()
데코레이터는 메타 데이터를 첨부하여 CatsService
가 Nest IoC 컨테이너에서 관리할 수 있는 클래스임을 알려주는 것이다. 이 예제에서는
Cat
인터페이스도 사용하는데, 아마 다음과 같은 코드일겁니다.// interfaces/cat.interface.ts export interface Cat { name: string; age: number; breed: string; }
// cats.controller.ts import { Controller, Get, Post, Body } from '@nestjs/common'; import { CreateCatDto } from './dto/create-cat.dto'; import { CatsService } from './cats.service'; import { Cat } from './interfaces/cat.interface'; @Controller('cats') export class CatsController { constructor(private catsService: CatsService) {} @Post() async create(@Body() createCatDto: CreateCatDto) { this.catsService.create(createCatDto); } @Get() async findAll(): Promise<Cat[]> { return this.catsService.findAll(); } }
의존성을 주입하기 위한 방법은 크게 3가지 이다
생성자를 이용한 의존성 주입(Constructor Injection)권장
수정자를 이용한 의존성 주입(Setter Injection)
필드를 이용한 의존성 주입(Field Injection)
선택적 프로바이더 ( Optional providers )
때때로, 반드시 해결될 필요가 없는 종속성이 있을 수 있다.
예를 들어 클래스는 configuration 객체에 의존할 수 있지만 해당 인스턴스가 없는 경우 기본값을 사용하는 경우이다.
이러한 경우 에러가 발생하지 않으므로 종속성이 선택사항이 된다.
import { Injectable, Optional, Inject } from '@nestjs/common'; @Injectable() export class HttpService<T> { constructor(@Optional() @Inject('HTTP_OPTIONS') private httpClient: T) {} }
프로바이더가 선택적임을 나타내려면 constructor 서명에
@Optional()
데코레이터를 사용하면 된다.속성 기반 주입 ( Property-based injection )
필드를 이용한 의존성 주입이다.
지금까지 생성자를 이용한 의존성 주입을 보았는데, 매우 구체적인 경우
속성 기반 주입
이 유용할 수 있다.예를 들어 최상위 클래스가 하나 또는 여러 프로바이더에 종속되어 있는 경우 생성자에서 하위 클래스의
super()
를 호출하여 해당 클래스를 끝까지 전달하는 것은 매우 지루할 수 있다.이 문제를 방지하려면 속성 수준에서
@Inject()
데코레이터를 사용할 수 있다.import { Injectable, Inject } from '@nestjs/common'; @Injectable() export class HttpService<T> { @Inject('HTTP_OPTIONS') private readonly httpClient: T; }
클래스가 다른 프로바이더를 확장(extend)하지 않는 이상 반드시 생성자를 이용한 의존성 주입을 사용하자.
모듈
지금까지 모듈은 알아보지 않았다.
모듈은 무엇인가?
@Module() 데코레이터가 달린 클래스. Nest가 애플리케이션 구조를 만들 때 사용할 수 있는 메타데이터를 제공해주는 역할
@Module 데코레이터가 가지는 속성
- providers: Nest 인젝터 (Injector: 의존성을 주입하는 Nest 내부 모듈) 가 인스턴스화 시키고 적어도 이 모듈 안에서 공유하는 프로바이더.
- controllers: 이 모듈안에서 정의된, 인스턴스화 되어야하는 컨트롤러의 집합
- imports: 해당 모듈에서 필요한 모듈의 집합. 여기에 들어가는 모듈은 프로바이더를 노출하는 모듈
- exports: 해당 모듈에서 제공하는 프로바이더의 부분집합이며, 이 모듈을 가져오는 다른 모듈에서 사용할 수 있도록 노출할 프로바이더
제작했던 CatsController와 CatsService를 CatsModule에 적용해보자.
// cats/cats.module.ts import { Module } from '@nestjs/common'; import { CatsController } from './cats.controller'; import { CatsService } from './cats.service'; @Module({ controllers: [CatsController], providers: [CatsService], }) export class CatsModule {}
이 모듈을 루트 모듈에 적용하자.
// app.module.ts import { Module } from '@nestjs/common'; import { CatsModule } from './cats/cats.module'; @Module({ imports: [CatsModule], }) export class AppModule {}
Node.js의 모듈 개념과 같이 Nest의 모든 모듈은 싱글톤이다. 이는 하나의 동일한 인스턴스를 사용하고 있다고 생각할 수 있는데,
이는 즉 공유가 가능한 모듈들이라는 뜻이며, 생성되면 모든 모듈에서 재사용 할 수 있다.
CatsService 프로바이더를 노출해보자.
//cats.module.ts import { Module } from '@nestjs/common'; import { CatsController } from './cats.controller'; import { CatsService } from './cats.service'; @Module({ controllers: [CatsController], providers: [CatsService], exports: [CatsService], }) export class CatsModule {}
의존성 주입
모듈 클래스도 프로바이더를 주입할 수 있다.
예를 들어 설정 관련된 목적으로 말이다.
// cats.module.ts import { Module } from '@nestjs/common'; import { CatsController } from './cats.controller'; import { CatsService } from './cats.service'; @Module({ controllers: [CatsController], providers: [CatsService], }) export class CatsModule { constructor(private catsService: CatsService) {} }
하지만 모듈 클래스 자체는 프로바이더로 주입할 수 없는 것을 유의하자.
전역 모듈 ( Global modules )
만약 모듈을 전역적으로 사용하고 싶은 경우 @Module() 데코레이터와 함께 @Global() 데코레이터를 사용하면 된다.
이 경우 import 배열에 추가할 필요가 없지만, 모든 모듈을 전역으로 만드는 것 또한 좋은 디자인이 아님을 유의하자.