NestJS의 DI Container

FeRo 페로·2023년 4월 11일
1

Nest로 백엔드를 구축하면서 초반에 가장 많이 만났던 에러가 Error: Nest can't resolve dependencies of ... 였다. Dependency를 해결을 못해서 에러가 발생한 것이다. nest를 잘 모르는 상태에서 express를 다루는 것처럼 작업을 하니까 이런 에러는 뭘 하든지 계속 발생했다. '의존성이 잘못된 거 같은데 이걸 어떻게 해결하는 거야..?'하는 생각에 검색에 검색을 해보았지만 Nest의 사용법을 몰라서 이 문제를 근본적으로는 해결할 수 없다. 무수한 삽질의 시간을 보내고 Nest의 기본적인 부분을 공부하고 나서야 이 문제를 이해하고 해결할 수 있었다. Nest 작업이 끝난지 꽤 지났지만 더 늦기 전에 기록을 해봐야겠다.

Dependency

dependency, 의존성은 뭘까? 이것 먼저 확인해 보고 가자.

class Engine {
  hp(input:number) {
    return `This vehicle has ${input} horsepower`;
  }
}

class Truck {
  truckInfo:Car
  constructor() {
    this.truckInfo = new Engine();
  }

  getHp(hp:number) {
    return this.truckInfo.hp(hp)
  }
}

const truck = new Truck();
console.log(truck.getHp(40)); // "This vehicle has 40 horsepower"

간단한 예시 코드를 작성해 봤다. Truck 클래스 안에 있는 truckInfo라는 프로퍼티는 생성자 함수 안에서 Engine 클래스의 인스턴스를 할당 받는다. Truck은 스스로 Engine에 대한 종속성을 생성하고 있다. 이런 구조는 Truck이 Engine에 의존하는 형태이다. 이런 것을 종속성이라고 한다. 의존하는 관계, 그것이 종속성이다.
이처럼 종속성을 가지고 있으면 몇 가지 단점이 발생한다. Engine 코드가 수정이 됐을 때 Truck의 코드도 수정해야 할 가능성이 높고, 테스트 코드를 작성한다고 했을 때도 종속성을 가지고 있기 때문에 unit test의 독립성을 떨어뜨린다.
테스트 속도 면에서, 그리고 편의성 면에서도 단점이 있는데 이는 실제 서비스 코드를 떠올려 보자. 보통 service 클래스에서 repository 클래스를 이용해서 DB에 CRUD를 한다. 위 예시 코드 처럼 service 클래스가 repository 클래스에 종속되어 있다면, 테스트 환경에서도 실제 DB에 CRUD를 할 것이고 메모리를 사용하는 것 보다는 상대적으로 속도가 느리다. 하지만 가짜 데이터와 가짜 repository 클래스를 만들어서 모킹을 하고 메모리에서 CRUD를 하면 속도도 상대적으로 더 빠르고, 실제 DB를 사용하는 것이 아니기 때문에 훨씬 더 편하기도 하다. 이렇게 하기 위해서는 IoC를 적용해야 한다.

IoC

Inversion of Control(IoC)은 우리말로 '제어 역전'이라고 한다. 말이 좀 생소하거나 어려울 수 있지만 말 뜻을 그대로 받아들이면 된다. Truck 클래스 입장에서는 Engine 클래스가 필요하지만 생성자 함수에서 직접 초기화 하지 않으면서 스스로 종속성을 생성하지 않는 것이다. 즉, 누군가가 외부에서 이를 제어하는 것이다. 위 예시 코드를 조금 수정하면서 알아보자.

class Engine {
  hp(input:number) {
    return `This vehicle has ${input} horsepower`;
  }
}

class Truck {
  constructor(public truckInfo:Engine) {}

  getHp(hp:number) {
    return this.truckInfo.hp(hp)
  }
}

const engine = new Engine()
const truck = new Truck(engine);
console.log(truck.getHp(40)); // "This vehicle has 40 horsepower"

이렇게 생성자 함수에서는 Engine 클래스로 타입만 해주면 된다. 이런 방식으로 사용하면 사용하는 부분에서 Truck 클래스를 제어하게 되고 이 부분에서 제어의 역전이 발생한 것이다. 또 Truck에는 Engine으로 된 타입만 들어오면 코드가 돌아가니까 테스트를 위해 가짜 클래스를 연결하는 것, 즉 모킹하는 것도 편리해진다. 이를 통해서 앞에서 말했던 문제들을 해결할 수 있다. 하지만 이것도 나름의 단점이 있다.

마지막에 Engine 인스턴스를 생성하고 Truck 인스턴스를 생성하는 부분이 있는데, 만약 규모가 큰 어플리케이션에서 종속성이 복잡할 때는 저 부분이 여러 줄로 많아질 것이다. 그런 부분에서 Nest는 DI Container를 사용하면 된다.

DI Container

DI(Dependency Injection)는 우리말로 '의존성 주입'이라고 한다. 의존성을 주입하는 것 역시 IoC처럼 뜻을 그대로 이해하면 된다. 외부에서 의존성을 주입해 주는 것이다. 어떻게 보면 IoC를 사용하는 것에 관한 이야기이다. Nest는 프레임워크에서 DI를 DI Container를 사용해서 해주고 있고, 이 컨테이너가 제어권을 가지고 있는 것이다(IoC). 위 예시처럼 사용자가 일일이 인스턴스를 만드는 것이 아니라 프레임워크가 어플리케이션이 시작될 때 해준다. DI 컨테이너는 다음과 같은 과정을 거쳐서 제어권을 가지고 의존성을 주입해 준다.

  1. Nest는 시작 단계에서 DI 컨테이너에 사용하는 모든 클래스를 등록한다.
  2. 컨테이너는 각각의 클래스가 의존하는 클래스도 함께 등록한다.
    예) Truck 👉 Engine
  3. 그리고 나서 우리가 특정 클래스의 인스턴스를 요청하면 컨테이너가 우리를 위해 인스턴스를 생성해 준다.
  4. 이 과정에서 컨테이너는 '2번'에서 미리 등록해 둔 의존성에 따라 관련된 모든 의존성이 해결된 인스턴스를 준다. 위 예시 코드에서 일일이 인스턴스를 생성하고 주입해주는 것을 컨테이너가 다 해주는 것이다.
  5. 한 번 만든 이런 인스턴스는 컨테이너에 저장되어 있다가 필요하면 재사용한다.

위 코드를 Nest 코드로 조금 바꿔서 이 부분을 확인해 보자.

// engine.module.ts
import { Module } from '@nestjs/common';
import { EngineService } from './engine.service';

@Module({
  providers: [EngineService],
  exports: [EngineService], // 다른 module에서 참조하기 위해서는 꼭 exports에 EngineService 클래스를 넣어주어야 한다.
})
export class EngineModule {}

// engine.service.ts
import { Injectable } from '@nestjs/common';

@Injectable() // Injectable 데코레이터를 사용해야 1에서 2번까지를 Nest가 진행해 준다.
export class EngineService {
  hp(input: number) {
    return `This vehicle has ${input} horsepower`;
  }
}

// truck.Module.ts
import { Module } from '@nestjs/common';
import { EngineModule } from 'src/engine/engine.module';
import { TruckService } from './truck.service';

@Module({
  imports: [EngineModule],  // exports한 EngineService를 사용하기 위해서는 EngineModule을 imports에 넣어줘야 한다.
  providers: [TruckService],
})
export class TruckModule {}

// truck.service.ts
import { Injectable } from '@nestjs/common';
import { EngineService } from 'src/engine/engine.service';

@Injectable()
export class TruckService {
  constructor(private engineService: EngineService) {}  // EngineModule을 imports 했기 때문에 그곳에서 exports한 EngineService를 사용할 수 있다.

  getHp(hp:number) {
    return this.engineService.hp(hp)
  }
}

위 예시 코드를 앞서 알아본 단계에 따라 분석해 보자. 가장 먼저 Injectable 데코레이터가 사용된 EngineService와 TruckService가 컨테이너에 초기화 된다. 이때 TruckService는 EngineService를 의존하고 있기 때문에 컨테이너에서도 이 부분을 확인해 둔다. 이까지가 2번 단계에 해당하는 작업이다.
그 다음에 우리가 TruckService를 예시 코드에는 없는 TruckControler 클래스에서 참조한다고 가정해 보자. 이후 우리가 TruckController를 사용하면 컨테이너는 이 세 단계에 걸친 의존성을 제어권을 가지고 의존성을 주입해서 우리에게 TruckController의 인스턴스를 준다.

이걸 왜 할까?

방법보다 중요한 것이 왜 하는지이다. 이 부분은 사실 앞에서도 설명을 했지만 여기서는 내 경험 상 어떤 부분이 편리했는지를 알려주고 싶다. 바로 테스트 코드이다. 정말로 테스트 코드를 작성하는 것에서 매우 편리해진다.

// truck.service.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { TruckService } from './truck.service';
import { EngineService } from '../engine/engine.service';

describe('TruckService', () => {
  let service: TruckService;
  let fakeEngineService: EngineService;

  beforeEach(async () => {
    fakeEngineService = {
    	hp(input: number) {
          return `This vehicle has ${input} horsepower`;
        }
    }
    
    const module: TestingModule = await Test.createTestingModule({
      providers: [TruckService],
      { provide: EngineService, useValue: fakeEngineService },
    }).compile();

    service = module.get<TruckService>(TruckService);
  });
  
  describe('getHp', () => {
	it('returns sentence with hp value', () => {
    	expect(service.getHp(40)).toBe('This vehicle has 40 horsepower')
    })
  })
});

위 테스트 코드를 보면 fakeEngineService라는 변수에 getHp 메소드를 구현했다. 이후 module에 EngineService가 호출될 땐 fakeEngineService를 사용하도록 했다. 즉, 모킹(mocking)을 한 것이다. EngineService의 생성자 함수에서 타입만 해주었기 때문에, 실제로 사용하는 테스트 파일에서 EngineService의 타입을 가지고 있는 가짜 EngineService를 모킹할 수 있었다.
나는 맨 처음 봤던 예시 코드처럼 코드를 작성하고 테스트 코드를 짜려고 했었다. 그때 에러가 엄청 떴다. 왜 뜨는지도 모른 채로 정말 삽질만 엄청 했었다. 이렇게 Nest가 어떻게 돌아가는지를 알게 된 후로는 그 규칙에 맞게 코드를 작성하였고 unit test에서 각 테스트 간 독립성을 유지하는 것이 굉장히 쉬워졌다. 의존성 주입과 제어 역전을 통해서 결합도가 느슨한 코드를 작성해서 얻은 큰 이점이다.

사실 테스트 코드를 쉽게 하기 위해서 IoC, DI를 사용한다면 주객이 전도된 것이지만, Nest에서 IoC, DI가 어떻게 사용되는지를 알고 난 후 제대로 사용했을 때 이 부분에서 가장 큰 차이를 느낄 수 있을 것이다. 적어도 나는 그랬다. 혹여나 나처럼 맨땅에 Nest로 헤딩하는 분들이 있다면 이 글을 통해서 Nest와 그 안에서 일어나는 IoC, DI가 어떻게 이루어 지는지, 그리고 그렇게 코드를 작성했을 때 어떤 이점이 있는지를 조금이나마 이해할 수 있었으면 좋겠다.

참고자료
https://develogs.tistory.com/19
https://velog.io/@server30sopt/IoC-Container
Udemy Nest 강좌 : NestJS: The Complete Developer's Guide

profile
주먹펴고 일어서서 코딩해

0개의 댓글