프로바이더

프로바이더는 Nest의 기본 개념입니다. 많은 기본 Nest 클래스는 서비스(Service), 레파지토리, 팩토리, 헬퍼 등등의 프로바이더로 취급될 수 있습니다. 프로바이더의 주요 아이디어는 의존성을 주입할 수 있다는 점입니다. 이 뜻은 객체가 서로 다양한 관계를 만들 수 있다는 것을 의미합니다. 그리고 객체의 인스턴스를 연결해주는 기능은 Nest 런타입 시스템에 위임될 수 있습니다.

여기까지 공식 문서 번역입니다. 멘붕에 빠지신 분들이 계실텐데요, 무슨 소리인지 모르겠죠? Nest의 프로바이더를 쉽게 읽으려면 아래 3가지 선행 지식이 필요합니다.

  • 계층형 구조(Layered Architecture)
  • 제어 역전(IoC, Inversion of Control)
  • 의존성 주입(Dependency Injection)

계층형 구조(Layered Architecture)

Nest 공식 문서를 읽다가 뜬금 없이 서비스라는 개념이 나와서 멘붕에 빠진 초심자분들이 계실겁니다. 게다가 공식문서는 "너도 이미 서비스 알지?(찡긋)" 를 바탕으로 깔고 있어서 서비스가 무엇인지에 대해 일절 설명이 없기 때문에 더더욱 그러하죠.

서비스에 대해서 이해하려면 먼저 계층형 구조(Layered Architecture) 혹은 다층 구조(n-tier Architecture)라는 아키텍쳐 디자인을 이해해야 합니다.

제가 안드로이드 개발을 처음 접했을 때의 삽질을 예로 들겠습니다. 안드로이드는 화면 하나를 구성하기 위해선 Activity를 상속받아서 사용합니다. 이 클래스에서는 UI를 조작할 수 있는데 어떤 기능에 대한 UI와 관련된 로직을 넣었죠. 아! SQLite 도 다뤄야 했습니다. 이 앱은 하나의 화면만 가진 앱이었기에 하나의 클래스에 UI, 비즈니스로직, 저장소가 다 들어가 있었습니다.

어쨌든 앱은 정상적으로 동작했습니다. 연습삼아 만들던 앱이라서 그 후로 그냥 잊혀졌습니다. 이런 프로그램의 코드는 조금만 커져도 유지보수 난이도가 곱절로 뜁니다. 이 클래스에 UI관련 코드, 저장소 관련 코드 등이 섞여있어서 이 클래스가 무슨 목적으로 존재하는가? 에 대해서 추론하기가 힘들기 때문입니다. 이는 소프트웨어 개발 시 응집도를 높이고 결합도를 낮추라는 소프트웨어 설계법칙에 정면으로 위반합니다. 게다가 이 코드는 재활용을 하기 힘들며 특히 자동화 테스트를 하기가 힘들어집니다. 결국 더 크고 복잡한 프로그램을 만들기란 불가능해지죠.

이런 문제 해결을 위해 소프트웨어 업계에서는 계층형 구조라는 기법을 사용합니다. 복잡해 보이는 작업도 그 작업을 나누고 각 작업마다 역량을 집중하면 쉽게 해결할 수 있지요(이를 관심사 분리라고 합니다.). 한 사람이 풀스택 개발자는 이름 아래 큰 웹서비스 전체를 완벽하게 만들기란 힘들지만, 프론트엔드와 백엔드를 나눠서 작업하면 쉽게 작업할 수 있는 것 처럼 말이죠. 몇 개의 계층으로 구분하는냐에 따라서 다르지만 보통 3계층 구조를 많이 사용합니다. 영어로는 3-Tier Architecture 라고 하지요. 3계층은 아래와 같이 나눌 수 있습니다.

  • Presentation Tier: 사용자 인터페이스 혹은 외부와의 통신을 담당합니다.
  • Application Tier: Logic Tier라고 하기도 하고 Middle Tier라고 하기도 합니다. 주로 비즈니스 로직을 여기서 구현을 하며, Presentation TierData Tier사이를 연결해줍니다.
  • Data Tier: 데이터베이스에 데이터를 읽고 쓰는 역할을 담당합니다.

Nest에서의 Presentation Tier는 외부의 입력을 받아들이는 컨트롤러이며 서비스는 Application Tier에 해당합니다. 서비스에는 주로 비즈니스 로직이 들어갑니다. Nest는 이렇게 컨트롤러와 그 하위 계층을 프로바이더라는 이름으로 구분하며, 이는 응집도는 높이고 결합도는 낮추는 소프트웨어 설계입니다.

제어 역전(IoC, Inversion of Control)

제어 역전을 한 마디로 표현한다면 나 대신 프레임워크가 제어한다 입니다. 제어 역전을 설명하기 위해서는 의존성이라는 개념을 알아야하는데요.

타입스크립트를 비롯한 많은 언어에서는 클래스를 사용하려면 new 같은 키워드로 인스턴스화를 시켜야 합니다. 모름지기 사람이라면 붕어빵(인스턴스화시킨 클래스) 붕어빵틀(클래스)을 먹지는 않는 것과 같습니다.

const sword = new Sword();

Warrior클래스에서 Sword클래스를 인스턴스화 했습니다. 여기까지는 별 문제 없습니다. 필요한 클래스를 생성해서 사용하는게 무슨 문제가 있겠습니까. 다만 기획팀에서 다음 업데이트때 이제 전사는 칼 뿐만 아니라 몽둥이도 사용할 수 있다고 지령이 내려옵니다. 이미 코드 백 만군데나 Sword()를 박아뒀는데 이럴 어찌하면 좋습니까.

좋은 객체지향 설계는 구체적인 개념에 의존하지 말고 추상적 개념에 의존해야 합니다. 위 코드에서 new를 사용하면 SwordSword를 생성하는 Warrior 사이에 의존성이 생깁니다. 정확히는 WarriorSword에 의존하게 되지요. 이렇게 직접적이고 구체적으로 클래스를 인스턴스화하는 행동은 바람직하지 않습니다.

이럴때 인터페이스가 혜성처럼 나타납니다. 프로그래밍에서 인터페이스는 규약입니다. 인터페이스를 구현하려면 인터페이스가 원하는 규약을 따라야 합니다. 반대 급부로 프로그래머는 내가 호출하는 클래스가 무엇인지 정확하게 알 필요가 없습니다. 다만 특정 기능이 동작 가능하다는 사실만 알고 개발하면 됩니다. 이제 구조를 좀 바꿔보죠. 하는 김에 전사 말고 궁사나 마법사 확장도 염두에 두고 지원하도록 해봅시다.

interface Weaponable {
  swing(): void;
}

interface Playable {
  attack(): void;
}

class Warrior implements Playable {
  private Weaponable weapon;

  constructor Warrior(private readonly Weaponable _weapon) {
    weapon = _weapon;
  }

  public void attack() {
    weapon.swing();
  }
}

class Mongdungee implements Weaponable {
  public void swing() {
    console.log('Mongdungee Swing!');
  }
}

몽둥이 쥔 전사 클래스를 인스턴스화 시켜보겠습니다.

Warrior warrior = new Warrior(new Mongdungee());

참 쉽죠? 하지만 클래스 계층 구조가 복잡한 프로그램에서 직접 저 몽둥이를 넘겨야 한다거나, 여러 전사에게 같은 몽둥이를 넘기거나 한다는 상황에서는 그닥 좋지 않습니다. 이 경우 제어 역전을 사용합니다. Nest는 제어 역전을 추상화해서 그 동작이 잘 보이지 않기 때문에 typedi를 사용해서 이를 구현해보겠습니다.

import "reflect-metadata";
import { Container, Service } from "typedi";

// Weaponable, Playble 은 위와 같음

@Service()
class Mongdungee implements Weaponable {
  public void swing() {
    console.log('Mongdungee Swing!');
  }
}

@Service()
class Warrior implements Playable {
  // 아래 코드 중요!
  constructor(private readonly weapon: Weaponable) {}

  public void attack() {
    this.weapon.swing();
  }
}


const playerInstance = Container.get<Warrior>(Warrior);
playerInstance.attack();  // "Mongdungee Swing!"

코드 어디에서도 new가 없습니다. 하지만 잘 동작함을 확인할 수 있습니다. 이는 typediContainer라는 친구가 알아서 클래스의 인스턴스를 생성했기 때문입니다. 이처럼 제어권을 내가 아닌 프레임워크에게 넘기는 것이 제어 역전입니다.

여기서 라이브러리와 프레임워크의 결정적인 차이가 발생합니다. 라이브러리는 내가 짠 코드에서 필요할 때 라이브러리를 실행시킵니다. 즉 라이브러리는 제 코드의 소비자가 될 수 없습니다. 반면 프레임워크는 제 코드의 소비자가 될 수 있습니다. 프레임워크는 내가 짠 코드가 필요할 때 알아서 실행시키게 되지요.

의존성 주입(DI, Dependency Injection)

제어 역전(이하 IoC)은 나 대신 프레임워크가 제어한다라면 의존성 주입(이하 DI)은 프레임워크가 주체가 되어 네가 필요한 클래스 등을 너 대신 내가 관리해준다는 개념이라고 생각하시면 됩니다.

앗? DI는 이미 제어 역전 설명할때 나왔죠. Warrior 클래스의 생성자에서 new없이 선언만 했는데도 마치 인스턴스처럼 사용할 수 있었던 코드 말이죠! 이쯤되면 많은 분들이 제어 역전(IoC)과 의존성 주입(DI)이 헷갈리곤 합니다. 멀리서 보면 DI보다 IoC가 더 크고 추상적인 개념입니다. IoC는 추상적이기 때문에 이를 구현한게 바로 DI이며 이는 제어 역전의 구현체 중 하나입니다. 그래서 DI를 통해 IoC를 구현했다는 말이 나옵니다. Nest는 DI를 통해 IoC를 구현한 프레임워크입니다.

자, 그럼 다시 공식 문서를 읽어볼까요?

프로바이더는 Nest의 기본 개념입니다. 많은 기본 Nest 클래스는 서비스(Service), 레파지토리, 팩토리, 헬퍼 등등의 프로바이더로 취급될 수 있습니다. 프로바이더의 주요 아이디어는 의존성을 주입할 수 있다는 점입니다. 이 뜻은 객체가 서로 다양한 관계를 만들 수 있다는 것을 의미합니다. 그리고 객체의 인스턴스를 연결해주는 기능은 Nest 런타입 시스템에 위임될 수 있습니다.

프로바이더

약간 이해될듯 말듯 하시죠? 한 마디로 하자면 WarriorMongdungee가 바로 프로바이더입니다. 어떤 컴포넌트가 필요하며 의존성을 주입당하는 객체를 프로바이더라고 생각하시기 바랍니다. 그리고 Nest 프레임워크 내부에서 알아서 컨테이너를 만들어서 관리해준다는 말입니다. 이해 가시나요? 그럼 계속 진행하겠습니다 :)

이전 챕터에서 저희는 간단한 CatsController를 만들었습니다. 컨트롤러는 HTTP 요청을 처리하고 보다 복잡한 일을 프로바이더에 위임합니다. 프로바이더는 모듈에서 프로바이더로 선언된 평범한 자바스크립트 클래스입니다. 모듈은 또 추후 설명하지만 그냥 이런게 있다고만 알아두시면 됩니다.

서비스

간단한 캣츠 서비스부터 만들어 봅시다. 이 서비스는 데이터 저장 및 검색을 담당하며, CatsController가 사용하도록 설계되었으므로 프로바이더로 정의하기 좋습니다.

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;
  }
}

CLI로 서비스를 생성하려면 쉘에서 $ nest g service cats 명령어를 실행하시면 됩니다.

CatsService는 하나의 속성과 두 개의 메소드를 가진 기본 클래스입니다. 유일한 새로운 기능은 @Injectable() 데코레이터를 사용한다는 것 뿐입니다. @Injectable() 데코레이터는 메타 데이터를 첨부하여 CatsService가 Nest IoC 컨테이너에서 관리할 수 있는 클래스임을 선언합니다. 이 예제에서는 Cat 인터페이스도 사용하는데, 아마 다음과 같은 코드일겁니다.

// interfaces/cat.interface.ts
export interface Cat {
  name: string;
  age: number;
  breed: string;
}

고양이 정보를 받아오는 서비스가 생겼으니 이를 CatsController 안에서 사용해보도록 하지요.

// 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();
  }
}

CatsService는 컨트롤러 클래스 생성자를 통해 주입됩니다. 생성자의 파라미터에 주목해 주세요. 보통 Java에서 객체의 생성자는 아래와 같을겁니다.

public class User {
  private String name;
  constructor(String name) {
    this.name = name;
  }
}

타입스크립트는 자바보다는 훨씬 간단하게 생성자를 생성할 수 있습니다. 아래 코드는 위의 코드와 동일한 동작을 합니다.

class User {
  constructor(private name: string) {}
}

의존성을 주입하기 위한 방법은 크게 3가지가 있습니다.

  • 생성자를 이용한 의존성 주입(Constructor Injection)
  • 수정자를 이용한 의존성 주입(Setter Injection)
  • 필드를 이용한 의존성 주입(Field Injection)

Nest에서는 주로 생성자를 이용한 의존성 주입을 권장합니다. 필드를 이용한 의존성 주입은 한 두 스크롤 아래 속성 기반 주입(Property-based injection)이라는 항목으로 소개합니다.

범위(Scopes)

프로바이더는 일반적으로 Nest 프로그램의 수명 주기와 동기화 된 수명(범위)을 갖습니다. Nest 프로그램이 부트 스트랩 될 때 모든 종속성을 해결해야 하기 때문에 모든 프로바이더가 인스턴스화 됩니다. 마찬가지로 Nest 프로그램이 종료되면 각 프로바이더가 메모리에서 삭제됩니다. 그러나 프로바이더의 수명을 요청 단위로 제한하는 방법도 있습니다. 다만 성능에 문제가 될 수 있기 때문에 특수한 상황이 아니라면 기본 설정된 수명 주기를 사용하도록 하시기 바랍니다.

선택적 프로바이더(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)

앞서 설명한 의존성 주입의 3가지 방법 중 3번째 방법인 "필드를 이용한 의존성 주입"입니다. 지금까지 예제는 의존성이 생성자 방법을 통해 주입되기 때문에 생성자를 이용한 의존성 주입이라고 불린다. 매우 구체적인 경우 속성 기반 주입이 유용할 수 있습니다. 예를 들어 최상위 클래스가 하나 또는 여러 프로바이더에 종속되어 있는 경우 생성자에서 하위 클래스의 super()를 호출하여 해당 클래스를 끝까지 전달하는 것은 매우 지루할 수 있습니다. 이 문제를 방지하려면 속성 수준에서 @Inject() 데코레이터를 사용할 수 있습니다.

import { Injectable, Inject } from '@nestjs/common';

@Injectable()
export class HttpService<T> {
  @Inject('HTTP_OPTIONS')
  private readonly httpClient: T;
}

만약 여러분의 클래스가 다른 프로바이더를 확장(extend)하지 않는 이상 반드시 생성자를 이용한 의존성 주입을 사용하기 바랍니다.

프로바이더 등록

이제 우리는 프로바이더(CatsService)를 정의했고, 그 서비스의 소비자(CatsController)를 가지고 있으므로, 주입을 수행할 수 있도록 Nest에 서비스를 등록해야 합니다. 모듈 파일 (app.module.ts)을 편집하고 서비스를 @Module() 데코레이터의 providers 배열에 추가하면 됩니다.

Nest는 이제 CatsController 클래스의 의존성을 해결할 수 있습니다. 디렉토리 구조는 다음과 같습니다.

  • src
    • cats
      • dto
        • create-cat.dto.ts
      • interfaces
        • cat.interface.ts
      • cats.service.ts
      • cats.controller.ts
    • app.module.ts
    • main.ts