Nest.js의 Modules, Controllers, Providers 알아보기

Nest.js의 Modules, Controllers, Providers 알아보기

Tag
NestJS
이번 포스팅은 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를 보도록 하자.

프로바이더는 의존성을 주입해주는 친구이다.
notion image
아까 예시로 들었던 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 배열에 추가할 필요가 없지만, 모든 모듈을 전역으로 만드는 것 또한 좋은 디자인이 아님을 유의하자.
 

Refrences