본문 바로가기

의존성 주입과 IoC 컨테이너

객체를 직접 생성할 때 생기는 결합을 살펴보고 Nest IoC 컨테이너의 프로바이더 등록과 생성자 주입으로 해결합니다.

1장에서는 NestJS 기본 구성 요소와 첫 애플리케이션 동작 방식을 살펴봤습니다.

이제 2장에서는 NestJS를 더 효과적으로 활용하기 위한 핵심 개념을 깊이 있게 다룹니다.

첫 번째 주제는 의존성 주입(Dependency Injection, DI)과 제어의 역전(Inversion of Control, IoC) 컨테이너입니다.

이 두 개념은 NestJS의 근간을 이루며, 견고하고 유지보수하기 쉬운 애플리케이션을 만드는 데 필수적인 역할을 합니다.

핵심 원리를 단계적으로 정리하면 실제 설계와 구현에서 왜 필요한지 명확하게 확인할 수 있습니다.

Nest DI · 등록과 해결

모듈이 런타임 토큰을 등록하면 컨테이너가 의존성을 해결해 주입합니다

본문은 class token의 생성자 주입 예제입니다. string·Symbol token도 사용할 수 있으며, 아래 useClass/useValue는 같은 소비자를 운영 구현이나 테스트 대역에 연결하는 선택 예시입니다.

컨테이너는 토큰으로 그래프를 조립하고 소비자에게 주입한다 모듈 등록이 런타임 토큰 그래프를 만들고 IoC 컨테이너가 운영 구현 또는 테스트 대역을 선택해 같은 소비자 생성자에 주입하는 흐름이다. @Module providers imports · exports 경계 런타임 토큰 provider 인터페이스는 실행 시 사라짐 IOC CONTAINER Resolve Create 의존성과 수명을 재귀 해결 PRODUCTION useClass 실제 어댑터 TEST useValue 테스트 대역 INJECT Consumer 코드 변경 없음 TOKEN · PROVIDER · SCOPE ARE SEPARATE CHOICES
등록과 구현 선택을 구분하는 의존성 주입 모듈은 런타임 토큰과 프로바이더를 등록한다. 컨테이너가 의존성을 해결하며 useClass 실제 구현 또는 useValue 대역을 같은 소비자에게 연결할 수 있다. 두 구현은 한 토큰의 대안이며 동시에 주입하는 의미가 아니다. @Module 등록providers ·imports · exports프로바이더 가시성 런타임 토큰클래스 · 문자열 ·Symbol인터페이스 타입은사라짐 IoC 컨테이너의존성 해결 · 생성· 수명구현 등록 선택은아래의 대안 useClass실제 구현예시선택한토큰에등록 useValue테스트대역 예시같은토큰의대안 Consumer같은 생성자 계약직접 생성 코드 없음
  1. 등록

    모듈이 런타임 토큰과 프로바이더를 연결하고 필요한 토큰을 내보냅니다.

  2. 해결

    IoC 컨테이너가 의존성 그래프와 스코프를 따라 인스턴스를 만듭니다.

  3. 구현 선택

    운영 구현을 useClass로, 테스트 대역을 같은 토큰의 useValue로 등록할 수 있습니다. 두 방식이 환경별로 강제되는 것은 아닙니다.

  4. 주입

    소비자는 구현 생성법을 몰라도 같은 생성자 계약으로 협력자를 받습니다.

기본 singleton은 해당 provider 등록의 인스턴스를 공유합니다. REQUEST는 이를 주입받는 소비자까지 요청 수명으로 바뀝니다. TRANSIENT는 소비자마다 전용 인스턴스를 제공하며 소비자의 scope를 자동으로 바꾸지 않습니다.


의존성 주입이란 무엇인가?

의존성 주입과 IoC 컨테이너를 다룰 때 provider 등록, 주입 범위, 생명주기, 테스트 대역 기준을 확인합니다.

의존성은 한 클래스가 다른 클래스의 기능이나 데이터를 사용해야 하는 관계를 뜻합니다.

예를 들어, UserController가 사용자 정보를 처리하기 위해 UserService의 메서드를 호출해야 한다면, UserController는 UserService에 의존한다고 말할 수 있습니다.

직접적인 의존성 생성의 문제점

만약 UserController가 UserService를 직접 생성하여 사용한다면 어떻게 될까요?

// 직접 UserService를 생성하는 방식 (DI를 사용하지 않음)
class UserService {
  getUsers() { /* ... */ }
}

class UserController {
  private userService: UserService;

  constructor() {
    this.userService = new UserService(); // 직접 의존성을 생성
  }

  getUsers() {
    return this.userService.getUsers();
  }
}

이 방식은 간단해 보이지만 몇 가지 문제점을 야기합니다.

  • 강한 결합(Tight Coupling): UserController가 UserService의 구체적인 구현에 직접적으로 묶여 있습니다. UserService의 생성 방식이 변경되거나 다른 UserService 구현으로 교체해야 할 때 UserController 코드도 함께 수정해야 합니다.
  • 테스트의 어려움: UserController를 테스트할 때 UserService까지 함께 인스턴스화됩니다. 이는 UserService가 데이터베이스 연결 등 복잡한 외부 의존성을 가지고 있을 경우, UserController의 단위 테스트를 어렵게 만듭니다. 우리는 컨트롤러 자체의 로직만 테스트하고 싶을 때가 많습니다.
  • 코드 재사용성 저하: UserService를 사용하는 다른 클래스들도 각각 new UserService()를 통해 인스턴스를 생성해야 합니다.
해결책: 의존성 주입(Dependency Injection)

의존성 주입은 이러한 문제점을 해결하기 위한 디자인 패턴입니다.

이는 객체가 자신이 필요로 하는 의존성을 직접 생성하는 대신, 외부(NestJS의 IoC 컨테이너)에서 해당 의존성을 주입받는 방식을 의미합니다.

쉽게 말해, 필요한 부품을 직접 만드는 대신, 외부에서 조립된 부품을 받아서 사용하는 것이죠.

NestJS에서는 주로 생성자 주입(Constructor Injection) 방식을 사용합니다.

// 의존성 주입 방식
import { Injectable } from '@nestjs/common';

@Injectable() // UserService는 주입 가능한 프로바이더임을 나타냅니다.
class UserService {
  getUsers() { /* ... */ }
}

@Controller()
class UserController {
  // NestJS가 자동으로 UserService 인스턴스를 생성하여 주입해줍니다.
  constructor(private readonly userService: UserService) {}

  getUsers() {
    return this.userService.getUsers();
  }
}

위 코드에서 UserController는 생성자를 통해 UserService 타입의 userService 인스턴스를 요청합니다.

위 조각을 실제 파일로 옮길 때는 Controller도 @nestjs/common에서 import하고, 해당 모듈의 controllers: [UserController], providers: [UserService]를 등록해야 합니다. 예시의 getUsers() { /* ... */ }는 구현을 생략한 부분이므로, 아직 사용자 목록을 반환하는 완성 앱은 아닙니다. 생성자 선언은 필요한 객체를 알리고, 모듈 등록은 컨테이너가 그 객체를 제공할 수 있게 합니다.

new UserService()처럼 직접 인스턴스를 생성하지 않습니다.

대신 NestJS가 이 의존성을 감지하고 적절한 UserService 인스턴스를 찾아 UserController 생성자로 주입(Inject)해 줍니다.

의존성 주입의 장점
  • 느슨한 결합(Loose Coupling): 의존 객체의 생성 책임을 분리하여 구현 교체와 테스트 대역 주입이 쉬워집니다. 위 예제는 UserService 클래스 자체를 런타임 토큰으로 사용합니다. TypeScript 인터페이스만으로는 주입 토큰이 생기지 않으므로 인터페이스 기반 설계에는 별도 문자열·Symbol 등의 토큰이 필요합니다.
  • 테스트 용이성: UserController를 테스트할 때, 실제 UserService 대신 가짜(mock 또는 stub) UserService를 주입하여 컨트롤러의 로직만을 순수하게 테스트할 수 있습니다.
  • 코드 재사용성 및 관리 용이성: 하나의 UserService 인스턴스를 여러 곳에 재사용할 수 있으며, 의존성 관리가 중앙집중적으로 이루어져 효율적입니다.

제어의 역전 컨테이너

이제 의존성 주입이라는 개념을 이해했다면, 이를 실제로 구현하고 관리하는 주체에 대해 알아볼 차례입니다.

그것이 바로 제어의 역전(Inversion of Control, IoC) 컨테이너입니다.

일반적인 프로그래밍 흐름에서는 개발자가 객체를 직접 생성하고, 객체 간의 관계를 직접 제어합니다.

하지만 NestJS와 같은 프레임워크에서는 이 제어의 권한이 프레임워크로 넘어갑니다.

이것을 제어의 역전(IoC)이라고 부릅니다.

IoC 컨테이너는 이 제어의 역전을 수행하는 핵심 메커니즘입니다.

NestJS의 IoC 컨테이너는 다음과 같은 역할을 수행합니다.

  • 프로바이더 관리: 모듈의 providers에 등록된 클래스나 값 등의 프로바이더를 관리합니다. @Injectable()은 클래스의 주입 관련 메타데이터를 제공하지만, 데코레이터만 붙인 클래스가 모든 모듈에 자동 등록되지는 않습니다.
  • 의존성 해결: 어떤 클래스가 특정 프로바이더를 필요로 할 때, 컨테이너가 해당 프로바이더의 인스턴스를 찾아 주입해 줍니다. 이때 필요하다면 해당 프로바이더의 의존성까지 재귀적으로 해결합니다.
  • 인스턴스 생명주기 관리: 프로바이더의 인스턴스를 언제 생성하고, 언제 파괴할지(싱글톤, 스코프 등) 결정합니다. NestJS에서 대부분의 프로바이더는 기본적으로 싱글톤(Singleton) 스코프를 가집니다. 이는 같은 컨테이너의 해당 프로바이더 등록을 공유하는 소비자들이 인스턴스를 재사용한다는 의미입니다. 서로 다른 모듈에 중복 등록하면 별도 인스턴스가 생길 수 있습니다.

IoC 컨테이너는 등록된 프로바이더를 보관하고, 클래스 생성 시 필요한 의존성을 찾아 주입합니다.

클래스는 직접 의존 객체를 만들지 않고 필요한 타입을 선언하며, 컨테이너가 생성 순서와 인스턴스 전달을 관리합니다.

NestJS에서 @Module()의 providers 배열에 클래스를 등록하고 @Injectable()을 붙이는 행위는, IoC 컨테이너에 이 클래스가 주입 가능한 프로바이더임을 알리는 과정으로 볼 수 있습니다.

컨테이너는 이 정보를 바탕으로 내부 의존성 그래프를 구성하고, 필요할 때 인스턴스를 생성해 주입합니다.

앞의 그림에서 런타임 토큰은 위 예제의 UserService 클래스처럼 사용할 수도 있습니다. Symbol과 useClass/useValue는 구현을 선택하는 확장 예시입니다.

토큰, 모듈 공개 범위, 인스턴스 범위를 나누어 생각하기

토큰은 컨테이너에서 무엇을 찾을지 정하는 식별자이고, 모듈의 imports·exports는 그 등록을 어디에서 사용할 수 있는지 정합니다. 예를 들어 UsersModule의 providers: [UserService]와 exports: [UserService]가 있다면, ReportsModule은 imports: [UsersModule]로 그 서비스를 사용할 수 있습니다. 이때 같은 기본 범위 등록을 공유하려면 ReportsModule.providers에 UserService를 또 등록하지 않습니다. 등록이 여러 개이면 클래스 이름이 같아도 상태가 별도로 생길 수 있습니다.

TypeScript 인터페이스는 실행할 때 사라지므로 인터페이스 이름만으로 조회할 수 없습니다. Symbol 토큰을 쓴다면 하나의 상수를 내보내어 등록 쪽과 @Inject(TOKEN) 쪽에서 함께 가져와야 합니다. 아래는 Nest 등록 코드가 아니라 Symbol의 동일성을 확인하는 JavaScript 예제입니다.

const USER_REPOSITORY = Symbol('USER_REPOSITORY');
const sameDescription = Symbol('USER_REPOSITORY');
console.log(USER_REPOSITORY === sameDescription); // false
const registrations = new Map([[USER_REPOSITORY, { name: 'memory' }]]);
console.log(registrations.get(USER_REPOSITORY).name); // memory
console.log(registrations.has(sameDescription)); // false
범위인스턴스를 구분하는 기준사례
DEFAULT해당 프로바이더 등록을 공유하는 소비자가 재사용공통 설정이나 상태 없는 계산 서비스
REQUEST요청마다 새 인스턴스; 이를 주입받는 소비자에도 요청 범위가 전파됨한 요청에서만 필요한 처리 상태
TRANSIENT주입받는 소비자마다 별도 인스턴스소비자별 독립 도우미; 메서드 호출마다 새로 생성되는 뜻은 아님

기본 범위 서비스의 currentUser 필드에 요청 사용자를 저장하면, 비동기 작업 중 다른 요청이 그 값을 덮어쓸 수 있습니다. 사용자별 값은 메서드 인수로 전달하는 등 요청 경계를 지켜야 합니다. DI로 객체를 공유할 수 있다는 사실이 모든 가변 상태를 공유해도 된다는 뜻은 아닙니다. 공식 프로바이더 문서와 주입 범위 문서는 등록과 생명주기를 따로 설명합니다.

예시: 컨테이너 없이도 주입의 효과 확인하기

DI라는 설계 방식과 Nest 컨테이너의 자동 조립은 구분할 수 있습니다. 아래 코드는 JavaScript로 생성자 주입만 확인하는 작은 예제입니다. 서버나 데이터베이스는 사용하지 않습니다.

class UserController {
  constructor(userService) {
    this.userService = userService;
  }
  getUsers() {
    return this.userService.getUsers();
  }
}

const productionService = { getUsers: () => ['Alice', 'Bob'] };
const testService = { getUsers: () => ['Test User'] };

console.log(new UserController(productionService).getUsers());
console.log(new UserController(testService).getUsers());

두 결과는 각각 ['Alice', 'Bob']와 ['Test User']입니다. 컨트롤러 구현을 바꾸지 않고 외부에서 전달하는 협력자만 교체했습니다. 이 예제에서는 개발자가 직접 조립했고, Nest에서는 모듈의 프로바이더 등록과 컨테이너가 그 조립을 맡습니다. 주입 자체가 모든 결합을 없애는 것은 아닙니다. 소비자는 여전히 getUsers()라는 계약을 알아야 합니다.

등록과 주입을 구분해 보기

UserController의 생성자는 그대로인데 UserService를 모듈의 providers에서 제거하고, 가져온 모듈에서도 제공하지 않는다고 가정합니다. 앱이 정상 부팅하여 첫 요청 때만 실패할까요? @Injectable()을 다시 붙이면 해결될지도 판단해 보세요.

컨테이너가 필요한 정보

이 기본 범위 예제에서는 부트스트랩 중 컨트롤러의 의존성을 해결할 수 없어 시작에 실패합니다. @Injectable() 메타데이터만으로 모듈의 등록이 생기지는 않습니다. 프로바이더를 다시 등록하거나, 그 프로바이더를 내보내는 모듈을 가져와 현재 모듈에서 사용할 수 있게 해야 합니다.


의존성 주입과 IoC 컨테이너는 NestJS의 모듈성, 테스트 용이성, 유지보수성을 구성하는 핵심 원리입니다.

서비스, 컨트롤러, 모듈의 책임을 분리할 때 이 구조를 기준으로 삼습니다.

다음 절에서는 NestJS 애플리케이션에서 요청을 처리하고 응답을 보내는 과정에서 핵심적인 역할을 하는 요청 파이프라인에 대해 자세히 알아보겠습니다.

연습: 같은 서비스를 찾고 공유할 수 있는가

  1. UsersModule만 UserService를 기본 범위로 등록하고 내보냅니다. ReportsModule은 이 모듈을 가져오며 서비스를 다시 등록하지 않습니다. 두 모듈의 소비자는 같은 등록을 공유할까요? exports만 제거하면 어떻게 될까요? 두 모듈 외에 이 서비스를 제공하는 등록은 없다고 합니다.
  2. 등록 쪽과 주입 쪽에서 각각 Symbol('USER_REPOSITORY')을 새로 만들었습니다. 설명 문자열이 같으면 의존성을 찾을 수 있을까요? 최소 수정 방법을 적어 보세요.
  3. DEFAULT 컨트롤러가 REQUEST 서비스를 주입받습니다. 컨트롤러의 유효 범위는 어떻게 되나요? 별도로 DEFAULT 서비스 A와 B가 TRANSIENT 도우미를 주입받으면, 도우미와 부모의 범위를 각각 설명해 보세요.
등록·동일성·범위 풀이
  1. 첫 조건에서는 하나의 기본 범위 등록을 공유합니다. exports를 제거하면 ReportsModule에서 이 프로바이더를 사용할 수 없어 의존성 해결이 실패합니다. 가져온 모듈 내부에 등록이 있다는 것과 외부 모듈에 공개했다는 것은 별개입니다.
  2. 두 Symbol은 서로 다른 값이므로 조회 토큰이 일치하지 않습니다. 하나의 토큰 상수를 내보내고 등록과 주입 양쪽에서 같은 상수를 가져옵니다. 설명 문자열을 바꾸는 것만으로는 해결되지 않습니다.
  3. 요청 범위 의존성 때문에 컨트롤러도 요청 범위로 동작합니다. 반면 A와 B는 각각 별도 transient 도우미를 받지만, 그 사실만으로 부모 A와 B가 요청 범위가 되지는 않습니다. 각 부모가 유지되는 동안 같은 도우미 메서드를 두 번 호출한다고 새 도우미 두 개가 생기는 것도 아닙니다.