03 [NestJS] 의존성주입(DI)

03 [NestJS] 의존성주입(DI)

개발을 하다 보면 여러 클래스나 모듈이 서로를 필요로 하는 상황은 필연적으로 발생한다. 의존성 주입은 바로 이 '의존' 관계를 똑똑하게 관리하여 코드를 유연하고, 테스트하기 쉽게 만들어주는 핵심 디자인 패턴이다.


의존성 주입(DI)

의존성 주입(DI)이라는 용어만 들으면 뭔가 거창해 보이지만, 핵심 아이디어는 아주 단순하다.

클래스가 사용할 의존 객체를 내부에서 직접 만들지 않고, 외부에서 생성해서 넣어주는 것.

이게 전부다. 말이 되는가? 코드를 보며 이해해 보자. 의존성 주입을 사용하지 않는다면 보통 이렇게 코드를 짠다.

// 의존성을 직접 생성하는 방식 (나쁜 예)
class CatsService {
  // ... 비즈니스 로직
}

class CatsController {
  private readonly catsService: CatsService;

  constructor() {
    this.catsService = new CatsService(); // 컨트롤러가 서비스 객체를 직접 생성
  }
}

위 코드에서 CatsControllerCatsService가 있어야만 동작한다. 즉, CatsService의존한다. 문제는 CatsControllernew CatsService()를 호출하며 직접 의존성을 생성하고 있다는 점이다. 이렇게 되면 둘은 서로에게 꽉 묶인 강한 결합(Tight Coupling) 상태가 된다. CatsService를 수정하거나 테스트를 위해 가짜(mock) 객체로 바꾸고 싶을 때 CatsController의 코드까지 건드려야 하는 불상사가 생긴다.

하지만 의존성 주입을 적용하면 이야기가 달라진다.

// 의존성을 주입받는 방식 (좋은 예)
class CatsController {
  constructor(private readonly catsService: CatsService) {} // 외부에서 만들어서 넣어줌
}

CatsController는 더 이상 CatsService를 어떻게 만드는지 알지도, 신경 쓰지도 않는다. 그저 생성자를 통해 CatsService 타입의 객체를 전달받아 사용할 뿐이다. 의존성을 만들고 주입하는 책임은 CatsController 외부의 누군가에게 넘어갔다. NestJS에서는 그 '누군가'가 바로 IoC 컨테이너다.


NestJS의 DI 동작 원리: IoC 컨테이너와 프로바이더

NestJS는 IoC(Inversion of Control, 제어의 역전) 컨테이너를 통해 DI를 구현한다. IoC 컨테이너는 일종의 거대한 '객체 보관함'이라고 생각하면 쉽다. 우리가 필요하다고 정의한 객체(Provider)들을 미리 생성해서 이 보관함에 넣어두고, 누군가 "나 이 객체 필요해!"라고 요청하면 해당 객체를 꺼내서 주입해주는 역할을 한다.

1. 프로바이더 정의: @Injectable()

NestJS에서 IoC 컨테이너가 관리할 수 있는 클래스를 프로바이더(Provider) 라고 한다. 서비스, 리포지토리, 팩토리 등이 모두 프로바이더가 될 수 있다. 특정 클래스를 프로바이더로 만들려면 클래스 선언부 위에 @Injectable() 데코레이터를 붙여주기만 하면 된다.

src/cats/cats.service.ts

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

@Injectable() // 이 클래스는 NestJS IoC 컨테이너가 관리하는 프로바이더임을 선언!
export class CatsService {
  private readonly cats: string[] = ['냥이', '나비', '고등어'];

  findAll(): string[] {
    return this.cats;
  }
}

이제 CatsService의 인스턴스화와 생명주기 관리는 전적으로 NestJS에게 위임되었다.

2. 프로바이더 등록: @Module()

프로바이더를 정의했다면, 이 프로바이더를 어떤 범위에서 사용할지 NestJS에 알려줘야 한다. 이 역할은 모듈(@Module) 이 담당한다. @Module 데코레이터의 providers 배열에 우리가 만든 프로바이더를 등록하면 된다.

src/cats/cats.module.ts

import { Module } from '@nestjs/common';
import { CatsController } from './cats.controller';
import { CatsService } from './cats.service';

@Module({
  controllers: [CatsController],
  providers: [CatsService], // CatsService를 이 모듈의 프로바이더로 등록!
})
export class CatsModule {}

이제 CatsModule의 스코프 안에서는 CatsService를 주입받을 준비가 끝났다.

3. 프로바이더 주입 및 사용: 생성자 주입

이제 마지막 단계다. CatsController에서 CatsService를 주입받아 사용해 보자. NestJS에서는 생성자 기반 주입(Constructor-based injection) 을 사용한다.

src/cats/cats.controller.ts

import { Controller, Get } from '@nestjs/common';
import { CatsService } from './cats.service';

@Controller('cats')
export class CatsController {
  // TypeScript의 접근 제한자(private, public 등)를 생성자 파라미터에 사용하면
  // 해당 파라미터가 즉시 클래스의 프로퍼티로 선언 및 초기화된다.
  constructor(private readonly catsService: CatsService) {}

  @Get()
  findAll(): string[] {
    // 주입받은 catsService의 메서드를 호출한다.
    return this.catsService.findAll();
  }
}

constructor(private readonly catsService: CatsService) 이 한 줄이 전부다. NestJS는 CatsController의 생성자를 보고 CatsService 타입의 의존성이 필요함을 인지한다. 그리고 IoC 컨테이너에 등록된 CatsService 프로바이더의 인스턴스를 자동으로 찾아 이곳에 주입해준다. 정말 직관적이고 깔끔하다.


의존성 주입, 왜 써야만 하는가?

처음에는 new로 직접 만드는 게 편해 보일 수 있다. 하지만 프로젝트가 복잡해질수록 DI의 진가는 드러난다.

  • 느슨한 결합 (Loose Coupling): 클래스들이 서로의 구체적인 구현을 몰라도 되므로, 한 부분의 변경이 다른 부분에 미치는 영향을 최소화한다. 코드 수정이 자유로워진다.

  • 향상된 테스트 용이성: 실제 CatsService 대신 테스트용 가짜(mock) 서비스를 컨트롤러에 쉽게 주입할 수 있다. 이는 의존성으로부터 분리된 순수한 단위 테스트를 가능하게 한다.

  • 코드 재사용성 증가: 잘 분리된 서비스는 CatsController 뿐만 아니라 다른 어떤 곳에서도 필요하다면 쉽게 가져다 재사용할 수 있다.

  • 중앙 관리의 용이성: 의존성 객체의 생성과 생명주기를 모두 NestJS IoC 컨테이너가 전담하므로 개발자는 비즈니스 로직에만 집중하면 된다.