[NestJS] DI 컨테이너

Onam Kwon·2026년 4월 3일

Node JS

목록 보기
26/27

NestJS DI 컨테이너 깊이 이해하기 — Symbol 토큰과 의존성 주입의 모든 것

들어가며

NestJS를 쓰다 보면 providers: [MyService] 한 줄이면 어디서든 서비스를 주입받을 수 있다. 마법처럼 느껴지지만, 내부적으로는 DI 컨테이너라는 구조가 이를 관리하고 있다.

이 글에서는 NestJS의 DI가 실제로 어떻게 동작하는지, 그리고 Symbol 토큰이라는 강력한 도구를 왜, 언제, 어떻게 쓰는지를 정리한다.


1. DI 컨테이너란?

DI(Dependency Injection) 컨테이너는 한마디로 "이름표 → 인스턴스" 저장소다.

앱이 시작되면 NestJS가 내부적으로 이런 구조를 만든다:

┌──────────────────────────────────────────────┐
│  NestJS DI 컨테이너 (메모리)                  │
│                                               │
│  이름표 (토큰)          →  인스턴스            │
│  ─────────────────────     ────────────────   │
│  TeamService (클래스)   →  TeamService { }     │
│  AuthService (클래스)   →  AuthService { }     │
│  ConfigService (클래스) →  ConfigService { }   │
└──────────────────────────────────────────────┘

서비스 A가 서비스 B를 필요로 하면, A가 직접 B를 만들지 않는다. 대신 NestJS에게 "B 좀 줘"라고 요청하면 컨테이너에서 찾아서 넣어준다.

// ❌ 직접 만드는 방식 (DI 아님)
class TeamService {
  private authService = new AuthService(); // 직접 생성
}

// ✅ NestJS DI 방식
@Injectable()
class TeamService {
  constructor(private readonly authService: AuthService) {}
  // NestJS가 AuthService 인스턴스를 찾아서 넣어줌
}

DI의 3가지 핵심 규칙

  1. 싱글톤: 같은 이름표로 요청하면 항상 같은 인스턴스를 받는다 (기본값)
  2. 자동 생성: 앱 시작 시 DI 컨테이너가 모든 provider를 한 번에 만든다
  3. 의존성 해결: A가 B를 필요로 하고 B가 C를 필요로 하면, C → B → A 순서로 생성한다

2. Provider 등록의 진짜 의미

평소 쓰는 이 한 줄에는 생략된 의미가 있다:

// 우리가 쓰는 코드
@Module({
  providers: [TeamService],
})

// 실제로는 이것과 같다
@Module({
  providers: [
    {
      provide: TeamService,   // ← 이름표 (토큰)
      useClass: TeamService,  // ← 실제 구현체
    }
  ],
})

provide이름표, useClass실제로 만들 클래스다. 이름표와 구현체가 같으니까 축약이 가능한 것이다.


3. 이름표(토큰)의 3가지 종류

이름표로 쓸 수 있는 것은 3종류다:

3-1. 클래스 토큰 (가장 흔한 방식)

// 등록
providers: [TeamService]  // = { provide: TeamService, useClass: TeamService }

// 주입
constructor(private readonly teamService: TeamService) {}
// TypeScript 타입 정보로 자동 매칭

3-2. 문자열 토큰

// 등록
providers: [
  { provide: 'DATABASE_URL', useValue: 'oracle://localhost:1521/mydb' }
]

// 주입 — @Inject() 필수
constructor(@Inject('DATABASE_URL') private readonly dbUrl: string) {}

⚠️ 문자열의 문제: 오타 위험. 'DATABASE_URL''DATABSE_URL'로 쓰면 컴파일 타임에 못 잡는다.

3-3. Symbol 토큰

// 정의 — 절대 겹치지 않는 고유 식별자
export const NOTIFICATION_PORT = Symbol('NOTIFICATION_PORT');

// 등록
providers: [
  { provide: NOTIFICATION_PORT, useClass: NotificationAdapter }
]

// 주입 — @Inject() 필수
constructor(
  @Inject(NOTIFICATION_PORT) private readonly notification: INotificationPort
) {}

왜 Symbol인가?

// 문자열은 겹칠 수 있다
'NOTIFICATION' === 'NOTIFICATION'  // true — 같은 문자열이면 충돌

// Symbol은 절대 겹치지 않는다
Symbol('NOTIFICATION') === Symbol('NOTIFICATION')  // false — 항상 고유

Symbol은 이름이 같아도 서로 다른 값이다. 대규모 프로젝트에서 모듈 간 토큰 충돌을 원천 차단한다.


4. Provider 등록 방법 4가지

NestJS는 이름표에 연결할 값을 4가지 방법으로 지정할 수 있다:

providers: [
  // 1. useClass: 클래스를 인스턴스로 만들어서 등록
  { provide: NOTIFICATION_PORT, useClass: NotificationAdapter },

  // 2. useValue: 이미 만들어진 값을 그대로 등록
  { provide: 'API_KEY', useValue: 'sk-1234567890' },

  // 3. useFactory: 함수를 실행한 결과를 등록 (다른 provider 주입 가능)
  {
    provide: 'REDIS_CLIENT',
    useFactory: (config: ConfigService) => {
      return new Redis({ host: config.get('REDIS_HOST') });
    },
    inject: [ConfigService],  // factory 함수에 주입할 의존성
  },

  // 4. useExisting: 이미 등록된 다른 provider의 별칭
  { provide: 'LOGGER', useExisting: LoggerService },
]

5. 실전 예시: Port/Adapter 패턴과 Symbol 토큰

문제 상황

TeamService가 알림을 보내야 한다. 현재는 NotificationService를 직접 주입한다:

@Injectable()
export class TeamService {
  constructor(
    private readonly notificationService: NotificationService,
  ) {}

  createTask() {
    // ...
    this.notificationService.notifyTeam({ team, message });
  }
}

테스트할 때 NotificationService를 가짜로 바꾸고 싶다면?

// 가능은 하지만, NotificationService라는 구체적인 이름을 알아야 함
{ provide: NotificationService, useValue: { notifyTeam: jest.fn() } }

Symbol 토큰으로 개선

// ① 인터페이스 + Symbol 토큰 정의
export interface INotificationPort {
  notifyTeam(params: { team: Team; message: string }): void;
}
export const NOTIFICATION_PORT = Symbol('NOTIFICATION_PORT');

// ② 구현체 (Adapter)
@Injectable()
export class NotificationAdapter implements INotificationPort {
  constructor(
    private readonly telegramService: TelegramService,
    private readonly discordService: DiscordService,
  ) {}

  notifyTeam(params) {
    this.telegramService.sendTeamNotification(params);
    this.discordService.sendTeamNotification(params);
  }
}

// ③ TeamService — 인터페이스만 알면 됨
@Injectable()
export class TeamService {
  constructor(
    @Inject(NOTIFICATION_PORT)
    private readonly notification: INotificationPort,
  ) {}

  createTask() {
    this.notification.notifyTeam({ team, message });
    // Telegram인지 Discord인지 Slack인지 모름. 관심 없음.
  }
}

모듈에서 연결:

// 프로덕션
{ provide: NOTIFICATION_PORT, useClass: NotificationAdapter }

// 테스트
{ provide: NOTIFICATION_PORT, useValue: { notifyTeam: jest.fn() } }

// 나중에 Slack으로 변경
{ provide: NOTIFICATION_PORT, useClass: SlackNotificationAdapter }
// TeamService 코드 변경 없음!

6. DI 컨테이너의 생명주기

NestJS 앱이 시작되면 DI 컨테이너는 이런 순서로 동작한다:

1. 모듈 스캔
   AppModule → imports 배열의 모든 모듈을 재귀 탐색

2. Provider 수집
   각 모듈의 providers 배열에서 모든 토큰-구현체 쌍 수집

3. 의존성 그래프 구축
   A → B → C 같은 의존 관계 파악

4. 인스턴스 생성 (역순)
   의존성이 없는 것부터 생성 (C → B → A)

5. 싱글톤 저장
   생성된 인스턴스를 컨테이너에 캐싱

6. 앱 실행
   이후 @Inject()로 요청하면 캐싱된 인스턴스 반환

스코프(Scope)

기본은 싱글톤이지만, 변경할 수 있다:

// 싱글톤 (기본) — 앱 전체에서 인스턴스 1개
@Injectable()

// 요청 스코프 — HTTP 요청마다 새 인스턴스
@Injectable({ scope: Scope.REQUEST })

// 트랜지언트 — 주입할 때마다 새 인스턴스
@Injectable({ scope: Scope.TRANSIENT })

대부분의 경우 싱글톤이면 충분하다. REQUEST나 TRANSIENT는 요청별 상태가 필요한 특수한 경우에만 사용한다.


7. 자주 하는 실수와 해결

실수 1: Provider 등록 안 하고 주입

// ❌ 에러: Nest can't resolve dependencies of TeamService
@Module({
  providers: [TeamService],  // AuthService 등록 안 함
})

→ TeamService가 AuthService를 주입받는데, AuthService가 등록되지 않았거나 해당 모듈에서 접근 불가

실수 2: export 안 하고 다른 모듈에서 사용

// AuthModule
@Module({
  providers: [AuthService],
  // exports: [AuthService],  ← 이거 빠지면
})

// TeamModule
@Module({
  imports: [AuthModule],  // import했지만 export 안 되어 있으면 접근 불가
})

실수 3: Symbol 토큰인데 @Inject() 빠뜨림

// ❌ 에러 — TypeScript 타입만으로는 Symbol을 찾을 수 없음
constructor(private readonly notification: INotificationPort) {}

// ✅ @Inject()로 토큰 지정
constructor(@Inject(NOTIFICATION_PORT) private readonly notification: INotificationPort) {}

8. 언제 뭘 쓸까?

상황권장 토큰
일반적인 서비스 주입클래스 토큰 (가장 간단)
설정값, 상수문자열 토큰 또는 ConfigService
외부 서비스 추상화 (Port/Adapter)Symbol 토큰
같은 인터페이스의 여러 구현체Symbol 토큰 (각각 다른 Symbol)
테스트에서 쉽게 교체하고 싶을 때Symbol 토큰

정리

  • NestJS DI 컨테이너는 "이름표 → 인스턴스" Map이다
  • 이름표(토큰)는 클래스, 문자열, Symbol 3종류
  • providers: [MyService]{ provide: MyService, useClass: MyService }의 축약
  • Symbol 토큰은 절대 겹치지 않는 고유 식별자로, 인터페이스 기반 DI에 적합
  • @Inject(TOKEN)으로 Symbol/문자열 토큰의 인스턴스를 주입받는다
  • Port/Adapter 패턴에서 Symbol 토큰을 쓰면 구현체 교체가 모듈 설정 1줄로 가능

DI 컨테이너를 이해하면 NestJS의 모듈 시스템, 테스트 전략, 아키텍처 설계가 자연스럽게 따라온다.

profile
뜨거운 백엔드 개발자

0개의 댓글