NestJS를 쓰다 보면 providers: [MyService] 한 줄이면 어디서든 서비스를 주입받을 수 있다. 마법처럼 느껴지지만, 내부적으로는 DI 컨테이너라는 구조가 이를 관리하고 있다.
이 글에서는 NestJS의 DI가 실제로 어떻게 동작하는지, 그리고 Symbol 토큰이라는 강력한 도구를 왜, 언제, 어떻게 쓰는지를 정리한다.
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 인스턴스를 찾아서 넣어줌
}
평소 쓰는 이 한 줄에는 생략된 의미가 있다:
// 우리가 쓰는 코드
@Module({
providers: [TeamService],
})
// 실제로는 이것과 같다
@Module({
providers: [
{
provide: TeamService, // ← 이름표 (토큰)
useClass: TeamService, // ← 실제 구현체
}
],
})
provide가 이름표, useClass가 실제로 만들 클래스다. 이름표와 구현체가 같으니까 축약이 가능한 것이다.
이름표로 쓸 수 있는 것은 3종류다:
// 등록
providers: [TeamService] // = { provide: TeamService, useClass: TeamService }
// 주입
constructor(private readonly teamService: TeamService) {}
// TypeScript 타입 정보로 자동 매칭
// 등록
providers: [
{ provide: 'DATABASE_URL', useValue: 'oracle://localhost:1521/mydb' }
]
// 주입 — @Inject() 필수
constructor(@Inject('DATABASE_URL') private readonly dbUrl: string) {}
⚠️ 문자열의 문제: 오타 위험. 'DATABASE_URL'을 'DATABSE_URL'로 쓰면 컴파일 타임에 못 잡는다.
// 정의 — 절대 겹치지 않는 고유 식별자
export const NOTIFICATION_PORT = Symbol('NOTIFICATION_PORT');
// 등록
providers: [
{ provide: NOTIFICATION_PORT, useClass: NotificationAdapter }
]
// 주입 — @Inject() 필수
constructor(
@Inject(NOTIFICATION_PORT) private readonly notification: INotificationPort
) {}
// 문자열은 겹칠 수 있다
'NOTIFICATION' === 'NOTIFICATION' // true — 같은 문자열이면 충돌
// Symbol은 절대 겹치지 않는다
Symbol('NOTIFICATION') === Symbol('NOTIFICATION') // false — 항상 고유
Symbol은 이름이 같아도 서로 다른 값이다. 대규모 프로젝트에서 모듈 간 토큰 충돌을 원천 차단한다.
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 },
]
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 토큰 정의
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 코드 변경 없음!
NestJS 앱이 시작되면 DI 컨테이너는 이런 순서로 동작한다:
1. 모듈 스캔
AppModule → imports 배열의 모든 모듈을 재귀 탐색
2. Provider 수집
각 모듈의 providers 배열에서 모든 토큰-구현체 쌍 수집
3. 의존성 그래프 구축
A → B → C 같은 의존 관계 파악
4. 인스턴스 생성 (역순)
의존성이 없는 것부터 생성 (C → B → A)
5. 싱글톤 저장
생성된 인스턴스를 컨테이너에 캐싱
6. 앱 실행
이후 @Inject()로 요청하면 캐싱된 인스턴스 반환
기본은 싱글톤이지만, 변경할 수 있다:
// 싱글톤 (기본) — 앱 전체에서 인스턴스 1개
@Injectable()
// 요청 스코프 — HTTP 요청마다 새 인스턴스
@Injectable({ scope: Scope.REQUEST })
// 트랜지언트 — 주입할 때마다 새 인스턴스
@Injectable({ scope: Scope.TRANSIENT })
대부분의 경우 싱글톤이면 충분하다. REQUEST나 TRANSIENT는 요청별 상태가 필요한 특수한 경우에만 사용한다.
// ❌ 에러: Nest can't resolve dependencies of TeamService
@Module({
providers: [TeamService], // AuthService 등록 안 함
})
→ TeamService가 AuthService를 주입받는데, AuthService가 등록되지 않았거나 해당 모듈에서 접근 불가
// AuthModule
@Module({
providers: [AuthService],
// exports: [AuthService], ← 이거 빠지면
})
// TeamModule
@Module({
imports: [AuthModule], // import했지만 export 안 되어 있으면 접근 불가
})
// ❌ 에러 — TypeScript 타입만으로는 Symbol을 찾을 수 없음
constructor(private readonly notification: INotificationPort) {}
// ✅ @Inject()로 토큰 지정
constructor(@Inject(NOTIFICATION_PORT) private readonly notification: INotificationPort) {}
| 상황 | 권장 토큰 |
|---|---|
| 일반적인 서비스 주입 | 클래스 토큰 (가장 간단) |
| 설정값, 상수 | 문자열 토큰 또는 ConfigService |
| 외부 서비스 추상화 (Port/Adapter) | Symbol 토큰 |
| 같은 인터페이스의 여러 구현체 | Symbol 토큰 (각각 다른 Symbol) |
| 테스트에서 쉽게 교체하고 싶을 때 | Symbol 토큰 |
providers: [MyService]는 { provide: MyService, useClass: MyService }의 축약@Inject(TOKEN)으로 Symbol/문자열 토큰의 인스턴스를 주입받는다DI 컨테이너를 이해하면 NestJS의 모듈 시스템, 테스트 전략, 아키텍처 설계가 자연스럽게 따라온다.