콘텐츠로 이동

DI / IoC

분류: Layer 0 - 런타임 & 프레임워크 기초 | 작성일: 2026-03-22

DI(Dependency Injection, 의존성 주입)는 어떤 객체가 필요한 협력 객체를 직접 만들지 않고 외부에서 받는 패턴이다. IoC(Inversion of Control, 제어의 역전)는 객체 생성, 연결, 생명주기 같은 제어권을 애플리케이션 코드가 아니라 프레임워크나 컨테이너가 갖는 설계 원칙이다.

NestJS에서 constructor(private readonly users: UsersService)라고만 써도 인스턴스가 들어오는 이유가 여기에 있다. 클래스는 “나는 UsersService가 필요하다”만 선언하고, 실제 생성 순서와 주입은 IoC Container가 결정한다. React의 Provider가 하위 트리에 값을 공급한다는 점은 느슨한 비유가 될 수 있지만, NestJS DI는 앱 시작 시 Provider 그래프를 만들고 클래스 인스턴스를 연결한다는 점이 핵심이다.

이 문서는 앞선 TypeScript 컴파일 원리에서 본 emitDecoratorMetadata를 깊게 반복하지 않는다. 여기서는 그 메타데이터를 NestJS Container가 어떻게 읽어 Provider를 만들고, 모듈 경계 안에서 어떻게 주입하며, 실패하면 어떤 신호가 나타나는지를 학습한다. 다음 문서인 Nest.js Discovery Module은 이 Container 위에서 “이미 등록된 Provider를 탐색하는” 단계로 넘어간다.

2. 선행 기술의 한계 - 왜 DI가 등장했는가

섹션 제목: “2. 선행 기술의 한계 - 왜 DI가 등장했는가”

처음에는 필요한 객체를 그냥 new로 만들면 된다. 작은 코드에서는 가장 읽기 쉽고, 별도 프레임워크도 필요 없다. 문제는 코드가 커져 협력 객체가 늘고, 테스트에서 실제 DB나 외부 API 대신 Mock을 넣어야 하는 순간 시작된다.

class OrdersService {
private readonly users = new UsersService();
private readonly payment = new PaymentClient("prod-token");
async placeOrder(userId: string) {
const user = await this.users.findById(userId);
return this.payment.charge(user.cardId);
}
}

이 코드는 OrdersService가 주문 로직뿐 아니라 UsersServicePaymentClient를 어떻게 만드는지도 알고 있다. 결과적으로 세 가지 결합이 생긴다.

  • 구현체 결합: UsersServiceCachedUsersService로 바꾸려면 OrdersService 내부를 고친다.
  • 환경 결합: 테스트에서도 prod-token을 가진 실제 클라이언트가 만들어질 수 있다.
  • 생성 책임 결합: 주문 로직을 읽는 사람이 객체 조립 로직까지 같이 읽어야 한다.

Factory는 생성 책임을 한 곳에 모아 이 문제를 줄인다. 하지만 클라이언트가 여전히 Factory를 import하고 호출한다. Service Locator는 locator.get("UsersService")처럼 필요한 객체를 꺼내 쓰게 해 결합을 더 숨긴다. 하지만 숨겨진 의존성이 생긴다. 생성자를 봐도 이 클래스가 무엇을 필요로 하는지 알 수 없고, 테스트가 필요한 의존성을 놓치기 쉽다.

Martin Fowler는 2004년 “Inversion of Control Containers and the Dependency Injection pattern”에서 이 흐름을 정리하면서, 너무 넓게 쓰이던 IoC 대신 의존성 전달 방식에 대해 “Dependency Injection”이라는 이름을 붙였다. 핵심은 객체가 자신의 협력자를 직접 찾거나 만들지 않고, 외부 assembler 또는 Container가 밀어 넣는다는 점이다.

의존성 획득 방식의 진화

직접 new

클라이언트 클래스 안에 구현체 이름과 생성 옵션이 박힌다.

결합점: 구현체 이름

Factory

생성 로직은 모았지만 클라이언트가 Factory를 알고 호출해야 한다.

결합점: Factory 이름

Service Locator

필요한 객체를 내부에서 꺼내므로 생성자에 의존성이 드러나지 않는다.

결합점: Locator와 문자열 키

DI

클라이언트는 필요한 의존성만 선언하고, 조달 방식은 Container 설정으로 이동한다.

결합점: 모듈과 Provider 등록

이 흐름은 결합점을 없애는 것이 아니라, 더 관리하기 좋은 위치로 옮기는 과정이다. DI를 쓰면 OrdersServiceUsersService가 필요하다는 사실만 드러낸다. 어떤 구현을 넣을지, 싱글턴으로 공유할지, 테스트에서 Mock으로 바꿀지는 외부 Provider 등록에서 결정한다.

class OrdersService {
constructor(
private readonly users: UsersService,
private readonly payment: PaymentClient,
) {}
async placeOrder(userId: string) {
const user = await this.users.findById(userId);
return this.payment.charge(user.cardId);
}
}

이 작은 차이가 테스트 가능성을 바꾼다. OrdersService 안에는 더 이상 new PaymentClient("prod-token")이 없으므로, 테스트 모듈에서 같은 토큰에 Mock 객체를 등록해 외부 결제를 막을 수 있다. DI의 실용적 가치는 “객체 생성이 예뻐졌다”가 아니라 “교체 지점이 명시적으로 생겼다”에 있다.

2.5 이 위임이 에러 메시지로 이어지는 방식

섹션 제목: “2.5 이 위임이 에러 메시지로 이어지는 방식”

DI의 lineage는 “코드의 강한 결합·테스트 어려움 → 의존성 관리를 프레임워크에 위임”이다. 2절에서 본 것처럼 직접 생성, Factory, Service Locator를 거쳐 DI로 오면 객체 조립 책임은 클래스 내부가 아니라 Container로 이동한다. 이 위임 때문에 NestJS의 흔한 에러 메시지는 처음에 모호해 보인다. 클래스가 의존성을 직접 찾지 않도록 설계했으므로, 실패했을 때 클래스 내부에는 진단 정보가 거의 없다. 대신 Container가 자기 시야에서 말한다. “이 모듈 컨텍스트에서 이 토큰을 찾을 수 없다”가 대표적인 실패 신호다. 따라서 DI 문제를 볼 때는 개별 클래스보다 먼저 모듈 그래프, Provider 등록, 토큰 일치를 봐야 한다.

DI 문서를 읽을 때 가장 많이 헷갈리는 이유는 “의존성”, “Provider”, “Token”, “Container”, “Module”이 섞여 쓰이기 때문이다. 각 단어를 먼저 분리하면 NestJS 에러 메시지를 훨씬 빨리 읽을 수 있다.

NestJS DI 핵심 용어

Dependency

어떤 클래스가 일을 하기 위해 필요로 하는 협력 객체다. 예: OrdersService가 필요로 하는 UsersRepository.

생성자 파라미터를 읽을 때

Provider

Container가 생성하거나 보관할 수 있게 등록된 값이다. 클래스, 객체, Factory 결과 모두 Provider가 될 수 있다.

providers 배열을 읽을 때

Token

Provider를 찾기 위한 키다. 클래스 자체, 문자열, Symbol, 추상 클래스가 토큰으로 쓰인다.

@Inject()나 provide 값을 볼 때

Container

토큰을 Provider 정의와 인스턴스에 매핑하고, 의존성 그래프 순서대로 생성·주입하는 저장소다.

NestFactory.create() 이후의 앱 구조를 볼 때

Module

Provider 가시성의 경계다. exports로 공개한 Provider만 imports한 다른 모듈에서 쓸 수 있다.

providers/imports/exports 오류를 볼 때

의존성은 클래스가 필요로 하는 대상이고, Provider는 Container에 등록된 공급 방식이며, Token은 그 Provider를 찾는 이름표다. 클래스 기반 DI에서는 보통 클래스 자체가 토큰이므로 이 셋이 하나처럼 보인다. 커스텀 Provider를 쓰는 순간 이 셋이 분리된다.

// 클래스 자체가 토큰인 가장 단순한 Provider
providers: [UsersService];
// 문자열 토큰으로 값 Provider를 등록
providers: [{ provide: "APP_CONFIG", useValue: { timeoutMs: 3000 } }];

UsersService는 클래스이면서 토큰이고, Container가 만들 Provider 정의이기도 하다. 반면 "APP_CONFIG"는 토큰일 뿐이고, 실제 값은 useValue에 있다. 이 차이를 모르면 토큰 불일치 에러를 이해하기 어렵다.

IoC는 더 넓은 원칙이다. 내 코드가 프레임워크를 호출하는 대신, 프레임워크가 정해진 시점에 내 코드를 호출하면 제어가 역전된 것이다. 웹 프레임워크의 라우터, DOM 이벤트 핸들러, 테스트 러너의 beforeEach도 모두 넓은 의미의 IoC다.

DI는 IoC를 의존성 조립 문제에 적용한 구체 패턴이다. 객체가 필요한 협력자를 직접 만들거나 찾지 않고, 외부에서 생성자나 프로퍼티나 메서드로 받는다. NestJS에서 가장 기본은 생성자 주입이다.

@Injectable()
export class OrdersService {
constructor(private readonly users: UsersService) {}
}

이 코드에서 OrdersServiceUsersService를 언제 만들지 모른다. 싱글턴인지 요청 단위인지도 모른다. 알아야 하는 것은 “주문 로직에는 사용자 조회 협력자가 필요하다”뿐이다. 이 무지가 의도다. 클라이언트가 조달 방식을 모를수록 테스트와 교체 지점이 단순해진다.

질문IoC 관점DI 관점
누가 흐름을 시작하는가?프레임워크가 내 코드를 호출한다Container가 객체를 만들고 생성자를 호출한다
개발자가 선언하는 것은?실행될 핸들러, 라이프사이클 훅, 클래스필요한 의존성, Provider 등록
실패하면 어디를 보는가?프레임워크 호출 시점과 등록 위치토큰, Provider, 모듈 가시성, 메타데이터
대표 NestJS 예시onModuleInit(), Controller route 호출@Injectable(), providers, constructor

DI를 “자동 new” 정도로만 이해하면 Scope, Module, Token 문제가 모두 우연처럼 보인다. 반대로 IoC를 “제어권이 Container로 이동했다”로 이해하면, 왜 모듈 경계 밖 Provider가 보이지 않는지, 왜 REQUEST Scope가 상위 그래프까지 전파되는지, 왜 Discovery가 Container를 스캔할 수 있는지까지 이어진다.

NestJS의 기본 경로는 단순하게 보면 네 단계다.

  1. 모듈이 Provider 정의를 등록한다.
  2. Container가 Provider의 토큰과 생성 방식을 저장한다.
  3. 클래스 생성자 파라미터 타입이나 @Inject() 토큰을 읽어 의존성 그래프를 만든다.
  4. 의존성이 없는 말단부터 인스턴스를 만들고 생성자에 주입한다.
NestJS DI 생성 흐름
flowchart LR
A["Module metadata"] --> B["Provider token 등록"]
B --> C["constructor 의존성 읽기"]
C --> D["모듈 imports/exports로 가시성 확인"]
D --> E["의존성 그래프 구성"]
E --> F["말단 Provider부터 생성"]
F --> G["생성자 주입"]

TypeScript 메타데이터는 타입을 알려주고, NestJS 모듈 그래프는 그 타입이 현재 컨텍스트에서 보이는지 결정한다.

앞선 TypeScript 문서에서 본 emitDecoratorMetadata는 여기서 3번에 해당한다. TypeScript는 타입을 런타임에서 기본적으로 지우지만, 레거시 데코레이터와 emitDecoratorMetadata를 쓰면 데코레이터가 붙은 클래스의 생성자 파라미터 타입을 design:paramtypes 메타데이터로 남길 수 있다. NestJS는 이 정보를 읽어 “이 생성자에는 UsersService가 필요하다”를 추론한다.

NestJS가 class와 decorator 기반 DI를 기본값으로 삼은 이유도 여기에 있다. class는 TypeScript에서 타입이면서 동시에 런타임에 남는 값이므로 Container가 토큰으로 사용할 수 있다. 반대로 interface나 type alias는 컴파일 후 사라져 런타임 Container가 볼 수 없다. 그래서 흔한 서비스 의존성은 class token으로 편하게 주입하고, 설정 객체나 interface 경계는 명시 토큰을 붙이는 식으로 나뉜다.

중요한 경계가 있다. design:paramtypes는 “어떤 타입처럼 보였는가”만 알려준다. 그 타입이 현재 모듈에서 보이는 Provider인지, 어떤 Scope인지, 어떤 토큰으로 등록되었는지는 NestJS 모듈과 Provider 설정이 결정한다. 따라서 메타데이터가 있어도 providers 등록이 없으면 주입은 실패한다.

@Injectable()
export class UsersService {}
@Injectable()
export class OrdersService {
constructor(private readonly users: UsersService) {}
}
@Module({
providers: [OrdersService, UsersService],
})
export class OrdersModule {}

이 예시에서는 OrdersService의 생성자 타입으로 UsersService를 읽을 수 있고, 같은 모듈의 providersUsersService가 등록되어 있으므로 주입이 가능하다. 둘 중 하나만 있어도 충분하지 않다. 생성자 타입 정보는 “무엇이 필요한지”를 알려주고, Provider 등록은 “어디서 공급할지”를 알려준다.

생성자 주입을 기본으로 보는 이유

섹션 제목: “생성자 주입을 기본으로 보는 이유”

NestJS에서는 생성자 주입을 가장 먼저 익히는 것이 좋다. 필요한 협력자가 클래스 시그니처에 드러나고, 테스트에서 무엇을 바꿔야 하는지도 바로 보인다. 메서드 안에서 moduleRef.get()으로 의존성을 꺼내 쓰는 방식은 Service Locator에 가까워져 숨은 의존성을 만든다. 특별한 동적 조회가 필요한 경우가 아니라면 생성자 파라미터로 선언하는 편이 더 읽기 쉽다.

// 권장: 의존성이 생성자에 드러난다.
@Injectable()
export class InvoiceService {
constructor(private readonly payments: PaymentClient) {}
}
// 주의: 내부에서 꺼내면 필요한 의존성이 클래스 시그니처에 드러나지 않는다.
@Injectable()
export class InvoiceService {
constructor(private readonly moduleRef: ModuleRef) {}
async issue() {
const payments = this.moduleRef.get(PaymentClient);
return payments.charge();
}
}

동적 조회가 항상 나쁜 것은 아니다. 플러그인 로딩, 선택적 Provider, 순환 의존성 임시 완화처럼 필요한 경우가 있다. 하지만 L0 단계에서는 “기본은 생성자 주입, 예외적으로 동적 조회”로 기억하면 된다.

NestJS를 처음 배울 때 @Injectable()을 붙였는데도 주입이 실패하는 이유는 대부분 Module 경계 때문이다. @Injectable()은 “Container가 관리할 수 있는 클래스”라는 표시일 뿐이다. 실제로 관리하려면 어떤 모듈의 providers에 등록되어야 한다. 다른 모듈에서 쓰려면 그 Provider를 exports로 공개하고, 소비하는 모듈이 해당 모듈을 imports해야 한다.

@Module({
providers: [UsersService, UsersRepository],
exports: [UsersService],
})
export class UsersModule {}
@Module({
imports: [UsersModule],
providers: [OrdersService],
})
export class OrdersModule {}

위 구조에서 OrdersServiceUsersService를 주입받을 수 있다. 하지만 UsersRepository는 주입받을 수 없다. UsersRepositoryUsersModule 내부 구현으로 등록되었지만 외부에 export되지 않았기 때문이다. 이 캡슐화 덕분에 모듈은 내부 저장소 구현을 숨기고 서비스 API만 공개할 수 있다.

모듈 그래프를 손으로 따라가는 법

섹션 제목: “모듈 그래프를 손으로 따라가는 법”

의존성 에러를 만났을 때는 코드를 위에서 아래로 읽는 대신 토큰의 이동 경로를 따라간다.

  1. 실패한 생성자 파라미터가 어떤 토큰을 요구하는가?
  2. 그 토큰이 현재 모듈의 providers에 직접 등록되어 있는가?
  3. 아니라면 imports한 모듈 중 그 토큰을 exports한 모듈이 있는가?
  4. 커스텀 Provider라면 provide 토큰과 @Inject() 토큰이 같은가?
  5. 클래스 토큰이라면 런타임에 타입 정보가 남아 있는가?

이 질문은 긴 CLI 실습보다 더 중요하다. 실패한 코드를 고치는 명령은 프로젝트마다 다르지만, Container가 보는 가시성 규칙은 같다.

”Provider가 보이지 않는다”의 전형적 신호

섹션 제목: “”Provider가 보이지 않는다”의 전형적 신호”
Error: Nest can't resolve dependencies of the OrdersService (?).
Please make sure that the argument UsersService at index [0]
is available in the OrdersModule context.

이 메시지는 OrdersService가 잘못되었다기보다, OrdersModule 컨텍스트에서 UsersService 토큰을 찾을 수 없다는 뜻이다. 에러 메시지의 index [0]은 생성자 첫 번째 파라미터가 문제라는 힌트다. 먼저 providers, imports, exports를 본다. 클래스 구현 내부를 먼저 파고들면 원인에서 멀어지기 쉽다.

7. Provider와 Token - 클래스 밖의 값도 주입하기

섹션 제목: “7. Provider와 Token - 클래스 밖의 값도 주입하기”

모든 의존성이 클래스는 아니다. 설정 객체, 외부 SDK 인스턴스, DB 연결, 메시지 큐 클라이언트처럼 이미 만들어진 값이나 비동기로 만들어야 하는 값도 있다. 이때 커스텀 Provider를 쓴다.

useValue - 이미 있는 값을 주입한다

섹션 제목: “useValue - 이미 있는 값을 주입한다”

useValue는 정적 값이나 테스트 Mock을 넣을 때 가장 단순하다.

export const APP_CONFIG = Symbol("APP_CONFIG");
type AppConfig = {
apiUrl: string;
timeoutMs: number;
};
@Module({
providers: [
{
provide: APP_CONFIG,
useValue: { apiUrl: "https://api.example.com", timeoutMs: 3000 },
},
],
exports: [APP_CONFIG],
})
export class ConfigModule {}
@Injectable()
export class ApiService {
constructor(@Inject(APP_CONFIG) private readonly config: AppConfig) {}
}

여기서는 AppConfig 타입이 런타임 토큰이 될 수 없다. TypeScript 타입은 컴파일 후 사라지기 때문이다. 따라서 APP_CONFIG 같은 런타임 값이 토큰으로 필요하고, 생성자에는 @Inject(APP_CONFIG)를 붙여야 한다.

문자열 토큰도 가능하지만 오타를 컴파일러가 잡지 못한다. "APP_CONFIG""app_config"는 사람 눈에는 비슷해도 Container에게는 완전히 다른 토큰이다. 여러 모듈에서 공유할 토큰은 상수나 Symbol로 분리하는 편이 안전하다. 특히 Symbol("APP_CONFIG")는 같은 설명 문자열을 써도 서로 다른 값이므로 우연한 충돌을 줄인다. 다만 여러 파일이 같은 토큰을 써야 한다면 각 파일에서 새 Symbol을 만들지 말고, tokens.ts 같은 한 곳에서 export한 상수를 import해야 한다.

useFactory - 런타임 값으로 Provider를 만든다

섹션 제목: “useFactory - 런타임 값으로 Provider를 만든다”

useFactory는 다른 Provider를 이용해 동적으로 값을 만들 때 쓴다. DB 연결, 외부 SDK, 환경별 클라이언트가 대표적이다.

export const SLACK_CLIENT = Symbol("SLACK_CLIENT");
@Module({
providers: [
{
provide: SLACK_CLIENT,
useFactory: (config: ConfigService) => {
return new WebClient(config.get("SLACK_BOT_TOKEN"));
},
inject: [ConfigService],
},
],
exports: [SLACK_CLIENT],
})
export class SlackClientModule {}

inject 배열은 Factory 함수가 받을 의존성을 명시한다. 클래스 생성자처럼 파라미터 타입을 자동으로 추론하는 것이 아니라, Factory Provider는 필요한 토큰을 직접 써야 한다. 비동기 초기화가 필요하면 Factory가 Promise를 반환하게 만들 수 있다. NestJS는 그 Promise가 해결될 때까지 기다린 뒤, 해결된 값을 Provider로 등록하고 이 토큰에 의존하는 클래스를 생성한다. 따라서 정상적인 async provider에서 소비자가 미해결 Promise 자체를 주입받는 것은 실패 모드가 아니다.

{
provide: "DB_CONNECTION",
useFactory: async (config: ConfigService) => {
const connection = await createConnection(config.get("DB_URL"));
return connection;
},
inject: [ConfigService],
}

여기서 Container가 기다리는 경계는 Factory가 반환한 Promise다. return createConnection(...)처럼 Promise를 그대로 반환해도 JavaScript의 Promise 연결 규칙에 따라 NestJS는 그 해결을 기다린다. 반대로 Factory 내부에서 비동기 작업을 시작만 하고 반환하지 않으면, NestJS가 기다릴 Promise가 없다.

const fallbackConfig = { timeoutMs: 1000, source: "fallback" };
let currentConfig = fallbackConfig;
{
provide: APP_CONFIG,
useFactory: async () => {
// 잘못된 경계: 작업을 시작했지만 await도 return도 하지 않는다.
void loadRemoteConfig().then((remote) => {
currentConfig = remote;
});
return currentConfig;
},
}

위 Factory의 바깥 Promise는 fallbackConfig로 즉시 해결된다. 소비자는 Promise가 아니라 너무 일찍 확정된 fallbackConfig 객체 참조를 주입받는다. 나중에 currentConfig = remote로 변수를 재할당해도 이미 주입된 참조는 교체되지 않으므로, 이 소비자의 timeoutMs는 계속 1000으로 남는다. 이런 오류는 부팅 에러보다 원격 설정을 불러왔다는 로그가 있는데도 애플리케이션이 계속 fallback 설정으로 동작하는 증상으로 나타난다. const remote = await loadRemoteConfig(); return remote;처럼 실제 준비 작업을 Factory의 반환 Promise 안에 포함해야 한다.

또 다른 경계는 “Promise 해결”과 “객체의 모든 초기화 완료”가 같은 말은 아니라는 점이다. Factory가 클라이언트 객체를 만들기만 하고 별도 connect(), warmUp(), 구독 등록을 기다리지 않은 채 반환하면, NestJS는 반환된 객체를 준비된 Provider로 간주한다. 소비자 초기화가 onModuleInit() 또는 onApplicationBootstrap()에 있다면 그 훅이 Promise를 반환하거나 내부 작업을 await해야 애플리케이션 초기화가 지연된다.

@Injectable()
class SearchService implements OnModuleInit {
constructor(@Inject(SEARCH_CLIENT) private readonly client: SearchClient) {}
// 잘못된 예: void를 반환하므로 NestJS는 warm-up 완료를 기다릴 수 없다.
onModuleInit(): void {
void this.client.warmUp();
}
}

이 경우에는 async onModuleInit() { await this.client.warmUp(); }처럼 훅의 반환 Promise에 준비 작업을 연결한다. reject도 어느 Promise가 NestJS 초기화 경계에 연결되었는지에 따라 결과가 다르다.

  1. async Factory가 반환한 Promise가 reject되면 Provider를 만들 수 없으므로 부트스트랩이 실패한다.
  2. void this.client.warmUp()처럼 분리한 Promise가 reject되면 NestJS 초기화 경계 밖의 실패다. 별도 처리가 없으면 unhandled rejection이 되고, 내부에서 catch해 로그만 남기면 NestJS는 훅이 끝난 것으로 보므로 애플리케이션이 미준비 상태로 계속 실행될 수 있다.
  3. onModuleInit()이나 onApplicationBootstrap()이 반환한 Promise가 reject되면 lifecycle 초기화 실패로 전파되어 app.init() 또는 app.listen()이 완료되지 않는다.

반면 소비자의 생성자 호출 전부터 연결이 반드시 준비되어야 한다면, 그 작업은 async Factory 안에서 기다린 뒤 연결된 클라이언트를 반환하는 편이 경계를 더 정확히 표현한다.

이 패턴의 판단 기준은 “이 값이 환경, 설정, 외부 연결, 테스트 대체 가능성에 의해 달라지는가?”다. Yes라면 Provider로 빼는 이점이 크다. No라면 단순 상수나 직접 생성이 더 읽기 쉬울 수 있다.

Factory가 반환한 Promise가 reject되면 NestJS는 실패를 값으로 주입하지 않는다. 의존 Provider를 만들 수 없으므로 원래 오류가 Provider 생성과 부트스트랩 단계에서 전파되고, 애플리케이션은 정상적인 요청 수신 단계까지 가지 못한다. 따라서 async provider의 실패를 볼 때는 “Promise가 주입됐는가”가 아니라 “반환 Promise가 실제 준비 작업을 포함하는가, 너무 일찍 resolve되지는 않는가, reject 원인이 부팅 로그에 보이는가”를 확인한다.

다음 코드는 타입상 크게 이상해 보이지 않지만 런타임에서 실패한다.

providers: [{ provide: "APP_CONFIG", useValue: config }];
@Injectable()
export class ApiService {
constructor(@Inject("app_config") private readonly config: AppConfig) {}
}

"APP_CONFIG""app_config"는 다른 토큰이다. Container는 대소문자를 보정하지 않는다. 실패 신호는 “argument APP_CONFIG at index [0] is available…”처럼 나온다. 이때 해결책은 에러 문자열을 외우는 것이 아니라, provide@Inject()가 같은 런타임 값을 가리키는지 확인하는 것이다.

8. Scope - 인스턴스 생명주기 선택

섹션 제목: “8. Scope - 인스턴스 생명주기 선택”

Provider Scope는 “Container가 인스턴스를 얼마나 오래 보관하는가”를 정한다. NestJS의 기본값은 싱글턴이다. 대부분의 Service는 상태를 직접 들고 있지 않거나, 들더라도 앱 전체에서 공유해도 되는 협력 객체이므로 싱글턴이 비용과 예측 가능성 면에서 유리하다.

Provider Scope 선택 기준

DEFAULT

앱 전체에서 인스턴스 하나를 공유한다. 생성 비용과 메모리를 줄이고 그래프가 안정적이다.

대부분의 Service, Repository, Client

REQUEST

요청마다 새 인스턴스를 만든다. 요청별 상태가 필요할 때만 쓴다.

tenant/user/request context가 생성자 상태여야 할 때

TRANSIENT

주입될 때마다 새 인스턴스를 만든다. 각 소비자가 독립 상태를 가져야 할 때 쓴다.

상태ful helper를 공유하면 안 될 때

수치 감각: Scope는 객체 수를 바꾼다

섹션 제목: “수치 감각: Scope는 객체 수를 바꾼다”

예를 들어 TenantContext 하나를 REQUEST Scope로 만들고, 그것을 OrdersService, PaymentsService, AuditService가 의존한다고 하자. 각 서비스가 다시 Controller나 Guard에 연결되어 있으면 Scope Bubbling이 발생한다. 요청마다 TenantContext만 새로 생기는 것이 아니라, 그것을 주입받는 상위 Provider도 요청 단위로 승격될 수 있다.

대략적인 감각은 이렇다.

상황1,000 RPS에서 생성되는 인스턴스 감각
모두 DEFAULT Scope부팅 시 한 번 만들고 재사용
말단 Provider 1개 REQUEST최소 1,000개/초 추가 생성
상위 5개 Provider로 전파5,000개/초 이상 추가 생성 가능
Controller/Guard까지 전파요청 처리 경로의 주요 객체가 매 요청마다 재생성 가능

이 표는 특정 프레임워크 벤치마크가 아니라 메커니즘에서 나오는 계산이다. 공식 문서나 사례에서 “잘 설계된 request-scoped provider는 지연 증가가 작을 수 있다”는 식의 설명을 보더라도, 그것은 보장값이 아니다. 그래프 상위로 전파되면 메모리 할당, GC 빈도, 초기화 비용이 먼저 신호로 나타난다.

REQUEST Scope를 붙이기 전에는 다음 질문을 먼저 한다.

  • 이 상태가 정말 생성자 필드에 있어야 하는가?
  • 메서드 파라미터로 넘기거나 AsyncLocalStorage 같은 요청 컨텍스트로 읽을 수 없는가?
  • 이 Provider를 주입받는 상위 Provider가 몇 개인가?
  • HTTP 요청이 아닌 queue consumer, cron, CLI에서도 같은 Provider를 쓰는가?
  • 테스트에서 요청 컨텍스트 없이 만들 수 있어야 하는가?

특히 HTTP 요청 컨텍스트가 없는 진입점은 REQUEST Scope와 충돌하기 쉽다. 같은 서비스가 API Controller와 queue consumer 양쪽에서 쓰인다면, 요청별 상태를 생성자 의존성으로 들고 가는 설계가 맞는지 다시 봐야 한다.

Captive dependency - 조용히 잘못되는 Scope mismatch

섹션 제목: “Captive dependency - 조용히 잘못되는 Scope mismatch”

Scope 문제는 항상 부팅 에러로 드러나지 않는다. 싱글턴이 요청별 값을 가진 객체를 오래 붙잡으면 첫 요청의 tenantIdrequestId가 이후 요청 로그에 섞일 수 있다. 이런 문제를 captive dependency라고 부른다.

관측 신호는 다음과 같다.

  • 서로 다른 요청인데 같은 requestId가 로그에 반복된다.
  • tenant별 메트릭 분포가 실제 트래픽 분포와 맞지 않는다.
  • 테스트는 통과하지만 동시 요청에서 사용자 컨텍스트가 섞인다.
  • REQUEST Scope를 넣은 뒤 평균 지연보다 GC pause나 heap 사용량 변동이 먼저 커진다.

해결은 “REQUEST Scope를 더 많이 붙이기”가 아니라 상태의 위치를 바꾸는 것이다. 요청별 값은 생성자 필드보다 메서드 파라미터, 요청 컨텍스트 저장소, 명시적 command 객체에 두는 편이 더 안전할 때가 많다.

DI는 기본 도구지만 모든 코드를 Container에 넣는 것이 좋은 설계는 아니다. 의존성을 교체할 가능성이 없고, 객체 그래프가 얕고, 테스트가 외부 협력자를 바꿀 필요가 없다면 직접 생성이 더 단순하다.

DI 도입 판단 기준

DI가 이기는 경우

테스트에서 Mock으로 바꾸거나, 환경별 구현을 바꾸거나, 여러 소비자가 같은 인스턴스를 공유해야 한다.

외부 API, DB, Cache, Config, Logger

직접 생성이 나은 경우

순수 함수 유틸리티나 일회성 스크립트처럼 교체 지점이 거의 없다.

작은 CLI, 변환 함수, 로컬 helper

나중에 DI로 옮길 경우

프로토타입이 커지면서 테스트, 환경 분기, 생명주기 관리가 필요해진다.

PoC에서 서비스로 승격할 때

가장 쓸모 있는 질문은 하나다. “이 의존성을 테스트나 런타임에서 다른 구현으로 바꿀 일이 있는가?” Yes라면 DI의 이점이 커진다. No라면 DI가 오히려 인지 부하를 늘릴 수 있다. 또 다른 실패 신호는 생성자 파라미터가 계속 늘어나는 경우다. 서비스 하나가 8개, 10개 Provider를 주입받는다면 DI 자체보다 책임 분리가 무너졌을 가능성을 먼저 의심해야 한다.

슬랙봇 모듈에서 WebClient를 어디서 만들지 결정한다고 하자.

직접 생성 방식은 단순하다.

@Injectable()
export class SlackMessageService {
private readonly client = new WebClient(process.env.SLACK_BOT_TOKEN);
}

하지만 단위 테스트에서 실제 Slack API를 호출하면 안 된다. dev/prod 워크스페이스 토큰도 환경별로 달라진다. 이 경우 WebClient는 교체 가능성이 높으므로 Provider로 등록하는 편이 낫다.

export const SLACK_CLIENT = Symbol("SLACK_CLIENT");
@Injectable()
export class SlackMessageService {
constructor(@Inject(SLACK_CLIENT) private readonly client: WebClient) {}
}

테스트에서는 같은 토큰에 Mock을 넣는다.

const module = await Test.createTestingModule({
providers: [
SlackMessageService,
{
provide: SLACK_CLIENT,
useValue: {
chat: {
postMessage: jest.fn().mockResolvedValue({ ok: true }),
},
},
},
],
}).compile();

이 예시의 핵심은 코드 줄 수가 줄었다는 것이 아니다. 실제 외부 API와 테스트 대역이 같은 토큰 뒤에서 교체된다는 점이다. 테스트 케이스가 늘수록 이 차이는 누적된다.

작은 비용 계산으로 보면 더 분명하다. 외부 클라이언트를 서비스 안에서 직접 만들면 테스트 파일마다 모듈 단위 jest.mock()이나 환경변수 setup이 810줄씩 붙기 쉽다. 비슷한 spec 파일이 5개면 4050줄이 외부 호출 차단을 위해 반복된다. 반대로 Provider 토큰으로 분리하면 각 테스트 모듈에서 { provide: SLACK_CLIENT, useValue: mockClient } 1개만 바꾸면 되고, 테스트 케이스가 20개로 늘어도 실제 Slack 호출은 같은 Mock 뒤에 묶인다. 이 숫자는 벤치마크가 아니라 판단 감각이다. “한 번만 Mock하면 끝나는가, 테스트마다 외부 생성을 막아야 하는가”가 DI 도입 여부를 가르는 비용 신호다.

10. Circular dependency - forwardRef 전에 물어볼 것

섹션 제목: “10. Circular dependency - forwardRef 전에 물어볼 것”

순환 의존성은 A가 B를 필요로 하고, B도 다시 A를 필요로 하는 구조다.

@Injectable()
export class UsersService {
constructor(private readonly orders: OrdersService) {}
}
@Injectable()
export class OrdersService {
constructor(private readonly users: UsersService) {}
}

NestJS는 이런 그래프를 만들 때 어느 쪽을 먼저 생성해야 할지 결정하기 어렵다. forwardRef()는 토큰 해석을 늦춰 일부 순환을 풀 수 있게 해준다. 하지만 순환 자체를 없애는 것이 아니라 “일단 서로를 참조하도록 허용”하는 장치다. 그래서 최후의 수단으로 봐야 한다.

순환 의존성이 보이면 다음 순서로 생각한다.

  1. 두 서비스가 서로의 내부 행동을 너무 많이 알고 있는가?
  2. 공통 책임을 별도 서비스로 추출하면 방향이 단방향이 되는가?
  3. 한쪽은 명령을 보내고 다른 쪽은 반응만 하면 되는 이벤트 구조인가?
  4. 필요한 값만 메서드 파라미터로 넘기면 생성자 의존성이 사라지는가?
  5. 정말 두 모듈이 서로 import해야 하는가, 아니면 공개 API를 한쪽으로 좁힐 수 있는가?

대부분의 순환은 도메인 경계가 흐려졌다는 신호다. 예를 들어 “유저 삭제 시 주문 정리”가 필요하다고 해서 UsersServiceOrdersService를 직접 알고, OrdersService가 다시 UsersService를 알 필요는 없다. UserDeletedEvent를 발행하고 주문 모듈이 반응하면 의존성 방향이 끊긴다.

// UsersService는 주문 서비스를 모른다.
this.eventEmitter.emit("user.deleted", { userId });
// OrdersService는 이벤트만 구독한다.
@OnEvent("user.deleted")
handleUserDeleted(payload: { userId: string }) {
return this.cancelOpenOrders(payload.userId);
}

모듈 분리가 이미 배포 계약이 되었고, 단기간에 구조를 바꾸기 어렵고, 양쪽 참조가 실제로 런타임에 필요하다면 forwardRef()를 쓸 수 있다. 하지만 사용한 위치에는 기술 부채 표시가 남아야 한다.

@Injectable()
export class UsersService {
constructor(
@Inject(forwardRef(() => OrdersService))
private readonly orders: OrdersService,
) {}
}

REQUEST Scope와 순환 의존성이 같이 있으면 더 위험하다. 요청마다 인스턴스를 새로 만들고, 생성 타이밍도 얽히므로 forwardRef()가 있어도 의존성이 아직 준비되지 않은 상태를 만날 수 있다. 이 경우에는 순환을 허용하기보다 이벤트, 중간 서비스, 명시적 파라미터 전달로 구조를 끊는 편이 안전하다.

11. 테스트에서 DI가 빛나는 지점

섹션 제목: “11. 테스트에서 DI가 빛나는 지점”

DI의 장점은 테스트에서 가장 빨리 확인된다. 실제 DB, 실제 HTTP 클라이언트, 실제 Slack API를 호출하지 않고도 서비스 로직만 검증할 수 있다.

describe("OrdersService", () => {
let service: OrdersService;
let users: { findById: jest.Mock };
let payment: { charge: jest.Mock };
beforeEach(async () => {
users = { findById: jest.fn() };
payment = { charge: jest.fn() };
const module = await Test.createTestingModule({
providers: [
OrdersService,
{ provide: UsersService, useValue: users },
{ provide: PaymentClient, useValue: payment },
],
}).compile();
service = module.get(OrdersService);
});
it("사용자의 카드로 결제한다", async () => {
users.findById.mockResolvedValue({ id: "u1", cardId: "card_1" });
payment.charge.mockResolvedValue({ ok: true });
await service.placeOrder("u1");
expect(payment.charge).toHaveBeenCalledWith("card_1");
});
});

여기서 OrdersService 코드는 바뀌지 않았다. 바뀐 것은 테스트 모듈의 Provider 등록뿐이다. 이것이 DI의 핵심 학습 포인트다. 클래스는 토큰을 요구하고, 테스트는 같은 토큰에 다른 Provider를 꽂는다.

Mock Provider를 만들 때도 토큰 규칙은 동일하다. 프로덕션에서 SLACK_CLIENT Symbol 토큰으로 주입했다면 테스트에서도 같은 SLACK_CLIENTuseValue를 등록해야 한다. 클래스 토큰을 쓰는 서비스는 { provide: UsersService, useValue: mockUsers }처럼 클래스 자체를 토큰으로 쓴다.

긴 트러블슈팅 절차보다 먼저 실패 신호를 개념에 연결하는 것이 중요하다. DI 실패는 대개 토큰 해석, 그래프 구성, 생명주기 경계 중 하나에서 발생한다.

실패 신호의미먼저 볼 곳
Nest can't resolve dependencies현재 모듈 컨텍스트에서 토큰을 찾지 못했다providers, imports, exports, 토큰
Object 또는 ?로 표시됨런타임 타입 메타데이터가 없거나 모호하다emitDecoratorMetadata, import type, @Inject()
토큰 이름은 맞아 보이는데 실패문자열/Symbol 토큰이 실제로 다르다provide@Inject() 상수 일치 여부
circular dependency 에러Provider 또는 Module 그래프가 순환한다책임 분리, 이벤트, 중간 서비스, forwardRef
async Factory reject로 부트스트랩 실패Provider 준비 Promise가 실패했다Factory가 반환한 Promise의 원래 에러와 외부 의존성
분리 Promise의 unhandled rejection 또는 미준비 실행reject가 NestJS 초기화 경계에 연결되지 않았다void 호출과 해당 Promise의 catch 처리
lifecycle Promise reject로 init/listen 실패초기화 훅이 반환한 Promise가 실패했다onModuleInit(), onApplicationBootstrap() 오류
원격 로드 후에도 fallback 설정 유지먼저 주입된 객체는 이후 변수 재할당으로 안 바뀜Factory의 조기 반환과 주입된 객체 참조
REQUEST Scope 이후 지연/GC 증가Scope Bubbling으로 객체 생성량이 늘었다REQUEST Provider의 상위 의존 그래프

Provider가 현재 모듈에서 보이지 않는다

섹션 제목: “Provider가 현재 모듈에서 보이지 않는다”

@Injectable()을 붙인 것과 현재 모듈에서 보이는 것은 다르다. Provider가 같은 모듈에 있으면 providers에 있어야 한다. 다른 모듈에 있으면 그 모듈이 Provider를 exports하고, 현재 모듈이 그 모듈을 imports해야 한다. Discovery Module에서도 같은 원리가 이어진다. 다음 문서에서 DiscoveryService.getProviders()가 찾을 수 있는 대상은 결국 Container에 등록된 Provider다.

생성자 타입이 Object로 나오거나 ?로 보이면 메타데이터 문제를 의심한다. 앞선 TypeScript 문서의 내용을 짧게 되살리면, interfacetype은 런타임에 사라지고, import type도 런타임 값을 남기지 않는다. 클래스 토큰 자동 추론이 안 되는 경우에는 @Inject(TOKEN)으로 런타임 토큰을 명시해야 한다.

// 런타임 값이 없는 타입만 import하면 DI 토큰으로 쓸 수 없다.
import type { EmailService } from "./email.service";
// 클래스 토큰 자동 추론이 필요하면 런타임 import가 필요하다.
import { EmailService } from "./email.service";

단, 인터페이스에 의존하고 싶다면 클래스 자동 추론에 기대면 안 된다. 명시 토큰을 만든다.

export const EMAIL_SENDER = Symbol("EMAIL_SENDER");
constructor(@Inject(EMAIL_SENDER) private readonly email: EmailSender) {}

커스텀 Provider의 절반 이상은 토큰 불일치에서 실패한다. 문자열을 직접 여러 번 쓰면 오타가 런타임까지 숨어 있다. 상수 파일에서 export const APP_CONFIG = Symbol("APP_CONFIG")처럼 한 번 만들고, 등록과 주입이 같은 상수를 import하게 하는 습관이 좋다.

async provider의 준비 경계가 끊겼다

섹션 제목: “async provider의 준비 경계가 끊겼다”

async Factory가 반환한 Promise는 Container의 부팅 경계다. 그 Promise가 pending이면 의존 소비자 생성도 기다리고, reject되면 부팅이나 테스트 모듈 컴파일이 실패한다. 반면 Factory가 비동기 작업을 fire-and-forget으로 시작한 뒤 구성값이나 클라이언트를 먼저 반환하면 Container 관점에서는 정상 resolve다. 앞의 구성값 예제에서는 이미 주입된 fallbackConfig 참조가 계속 유지된다. 이와 별개로 클라이언트 준비 작업을 기다리지 않은 경우에는 첫 DB 쿼리에서 “연결되지 않음”이 나거나 초기 구독 이벤트가 유실될 수 있다.

같은 기준을 lifecycle hook에도 적용한다. onModuleInit()onApplicationBootstrap()이 반환한 Promise는 NestJS가 기다리므로 reject 시 app.init() 또는 app.listen()이 실패한다. 하지만 훅 안에서 void initialize()로 작업을 떼어 놓으면 그 reject는 NestJS가 처리하는 초기화 실패가 아니다. 로컬 catch가 없으면 unhandled rejection이 되고, 오류를 잡기만 하면 앱은 미준비 상태로 계속 실행될 수 있다. 먼저 “소비자 생성 전에 반드시 준비되어야 하는가”를 묻는다. 그렇다면 async Factory의 반환 Promise에 포함하고, 모든 Provider가 생성된 뒤 요청 수신 전에 끝나면 되는 작업이라면 Promise를 반환하는 초기화 훅에 둔다.

forwardRef()를 바로 붙이면 에러는 사라질 수 있다. 하지만 설계 문제도 같이 사라진 것은 아니다. 두 서비스가 서로를 알아야 하는 이유를 먼저 문장으로 써본다. “A가 끝나면 B가 반응해야 한다”라면 이벤트가 더 맞을 수 있다. “A와 B가 같은 계산을 공유한다”라면 공통 서비스를 추출하는 편이 맞을 수 있다. “A가 B의 상태 일부만 필요하다”라면 메서드 파라미터가 충분할 수 있다.

DI/IoC는 객체 그래프를 만든다. 어떤 클래스가 어떤 Provider를 필요로 하는지, 어떤 Scope로 만들어지는지, 어떤 모듈 경계에서 보이는지를 정한다. 하지만 DI만으로는 “어떤 Provider의 어떤 메서드가 특정 이벤트를 처리해야 하는가”를 자동으로 찾지 않는다.

다음 문서의 Discovery Module은 이 빈칸을 채운다. Container에 이미 등록된 Provider 목록을 순회하고, 각 클래스나 메서드에 붙은 메타데이터를 읽어 핸들러를 자동 등록한다. 따라서 Discovery를 이해하려면 이 문서에서 다음 네 가지를 확실히 잡아야 한다.

  1. Provider는 Container에 등록되어야 탐색 대상이 된다.
  2. Module imports/exports는 어떤 Provider가 보이는지 결정한다.
  3. 메타데이터는 TypeScript 컴파일 결과와 데코레이터가 남긴 런타임 정보다.
  4. 토큰과 Scope는 자동 탐색 이후에도 그대로 비용과 실패 신호를 만든다.

DI는 “객체를 만들고 연결하는 층”이고, Discovery는 “만들어진 객체 그래프를 탐색해 의미 있는 메서드를 찾는 층”이다. 이 구분이 잡히면 @Injectable()@SlackEvent() 같은 데코레이터가 왜 서로 다른 일을 하는지 자연스럽게 보인다.

14. 선택 부록 - 직접 확인할 때의 최소 실험

섹션 제목: “14. 선택 부록 - 직접 확인할 때의 최소 실험”

아래 부록은 본문 이해에 필수는 아니다. 실제 NestJS 프로젝트에서 DI가 어떻게 실패하고 복구되는지 손으로 확인하고 싶을 때만 사용한다. 긴 CLI 절차 대신 관찰 포인트만 남긴다.

Provider 등록 누락 관찰
  1. ItemsServiceItemsController 생성자에 주입한다.
  2. ItemsModuleproviders에서 ItemsService를 잠시 제거한다.
  3. 앱을 부팅하면 ItemsController (?) 또는 ItemsService at index [0] 형태의 에러가 난다.
  4. providers: [ItemsService]를 복원하면 해결된다.

학습 포인트: @Injectable()은 표시이고, providers 등록이 Container의 실제 공급 목록이다.

Module exports 누락 관찰
  1. UsersModuleUsersService를 등록하되 exports에서 빼둔다.
  2. OrdersModuleUsersModule을 imports하고 OrdersServiceUsersService를 주입받게 한다.
  3. 앱을 부팅하면 OrdersModule context에서 UsersService를 찾지 못한다.
  4. UsersModuleexports: [UsersService]를 추가하면 해결된다.

학습 포인트: imports는 모듈을 연결하지만, 외부에서 쓸 수 있는 Provider는 exports가 결정한다.

커스텀 토큰 mismatch 관찰
providers: [{ provide: "APP_CONFIG", useValue: { timeoutMs: 3000 } }];
@Injectable()
class ApiService {
constructor(@Inject("app_config") private readonly config: AppConfig) {}
}

이 코드는 대소문자 하나 때문에 실패한다. 문자열 토큰을 상수나 Symbol로 바꾸면 같은 실수를 줄일 수 있다.

export const APP_CONFIG = Symbol("APP_CONFIG");

학습 포인트: DI Container는 타입 이름을 추측하지 않고, 런타임 토큰이 정확히 같은지 비교한다.

순환 의존성 판단 연습

순환 의존성이 보이면 도구로 그래프를 그리기 전에 먼저 아래 문장을 채운다.

UsersService가 OrdersService를 필요로 하는 이유는 ______ 때문이다.
OrdersService가 UsersService를 필요로 하는 이유는 ______ 때문이다.
두 이유 중 하나는 이벤트, 공통 서비스, 메서드 파라미터로 바꿀 수 있는가?

학습 포인트: forwardRef()는 순환을 허용하는 장치이지 책임 경계를 정리하는 설계가 아니다.

개념 A개념 B차이점
IoCDIIoC는 제어권 역전 원칙, DI는 의존성 전달 패턴
DIService LocatorDI는 외부에서 밀어 넣고, Service Locator는 내부에서 꺼낸다
ProviderTokenProvider는 공급 방식, Token은 그 Provider를 찾는 키
@Injectable()providersInjectable은 관리 가능 표시, providers는 실제 등록 목록
importsexportsimports는 모듈 연결, exports는 외부 공개 Provider 목록
DEFAULT ScopeREQUEST ScopeDEFAULT는 앱 단위 공유, REQUEST는 요청마다 생성
useValueuseFactoryuseValue는 이미 있는 값, useFactory는 런타임 생성 함수
forwardRef()책임 분리forwardRef는 지연 참조, 책임 분리는 순환 자체 제거

DI/IoC 복습 체크리스트

  • DI와 IoC의 차이를 한 문장씩 설명할 수 있다.
  • `new` 직접 생성, Factory, Service Locator, DI의 결합점이 어떻게 달라지는지 설명할 수 있다.
  • Provider, Token, Container, Module의 역할을 구분할 수 있다.
  • `@Injectable()`만으로는 부족하고 `providers` 등록이 필요하다는 점을 설명할 수 있다.
  • 다른 모듈의 Provider를 쓰려면 `exports`와 `imports`가 모두 필요하다는 점을 말할 수 있다.
  • 싱글턴, REQUEST, TRANSIENT Scope의 비용 차이를 설명할 수 있다.
  • Scope Bubbling이 왜 상위 Provider 생성 비용을 늘리는지 설명할 수 있다.
  • 커스텀 Provider에서 `useValue`, `useFactory`, 명시 토큰을 언제 쓰는지 구분할 수 있다.
  • async Factory가 반환한 Promise를 NestJS가 기다리며, 조기 반환 뒤 변수를 재할당해도 이미 주입된 객체 참조는 교체되지 않는다는 점을 설명할 수 있다.
  • Factory 반환 Promise reject는 부트스트랩 실패, 분리한 Promise reject는 unhandled rejection 또는 미준비 실행, lifecycle 반환 Promise reject는 app.init/listen 실패로 이어짐을 구분할 수 있다.
  • 순환 의존성에서 `forwardRef()`를 쓰기 전에 검토할 설계 대안을 말할 수 있다.
  • 테스트에서 같은 토큰에 Mock Provider를 등록해 실제 의존성을 교체할 수 있다.

퀴즈

`@Injectable()`을 붙였는데도 Nest가 의존성을 못 찾는다면 가장 먼저 어디를 봐야 할까?

힌트: 데코레이터는 표시이고, 컨테이너의 시야는 모듈 그래프다.

정답 보기

현재 모듈의 `providers`에 해당 Provider가 있는지 확인한다. 다른 모듈의 Provider라면 그 모듈이 `exports`하고 현재 모듈이 `imports`하는지도 확인한다. 커스텀 Provider라면 `provide` 토큰과 `@Inject()` 토큰이 같은지도 봐야 한다.

퀴즈

문자열 토큰으로 등록한 `APP_CONFIG`가 주입되지 않을 때 가장 가능성이 높은 원인은?

힌트: TypeScript 타입 문제가 아니라 런타임 키 비교 문제일 수 있다.

정답 보기

`provide`에 쓴 토큰과 `@Inject()`에 쓴 토큰이 정확히 다를 가능성이 높다. 문자열은 대소문자와 공백까지 그대로 비교되므로 상수나 Symbol로 토큰을 분리해 같은 값을 재사용하는 편이 안전하다.

항목핵심 내용
IoC객체 생성·호출·생명주기 제어권을 프레임워크나 Container에 넘기는 원칙
DI객체가 필요한 의존성을 직접 만들지 않고 외부에서 받는 패턴
ProviderContainer에 등록된 공급 방식. 클래스, 값, Factory 결과 모두 가능
TokenProvider를 찾는 런타임 키. 클래스, 문자열, Symbol 등이 가능
ModuleProvider 가시성 경계. exports한 Provider만 imports한 모듈에서 사용
Scope인스턴스 생명주기. DEFAULT가 기본이고 REQUEST는 비용 전파를 조심
메타데이터design:paramtypes는 자동 주입 힌트지만 Provider 등록을 대체하지 않음
async 경계Factory가 반환한 Promise는 기다리며, 경계 밖 초기화 작업은 기다리지 않음

5줄 핵심

  1. IoC는 제어권을 프레임워크에 넘기는 원칙이고, DI는 의존성을 외부에서 받는 구체 패턴이다.
  2. DI의 가치는 new를 숨기는 것이 아니라 구현 교체와 테스트 대역 주입 지점을 명시하는 데 있다.
  3. NestJS는 생성자 메타데이터와 @Inject() 토큰을 읽고, 모듈의 Provider 그래프 안에서 의존성을 찾는다.
  4. “Nest can’t resolve dependencies”는 대부분 Provider가 현재 모듈에서 보이지 않거나 토큰이 맞지 않는다는 신호다.
  5. Scope와 순환 의존성은 런타임 비용과 설계 경계를 드러내는 신호이므로, REQUESTforwardRef()는 먼저 판단 기준을 통과해야 한다.
TypeScript 컴파일 원리
DI / IoC (지금 여기)
Nest.js Discovery Module
Reflector + ExecutionContext
Dynamic Module / CQRS / Schedule 같은 자동 등록 패턴

다음 문서에서는 Container가 이미 만든 Provider 목록을 어떻게 탐색하는지 본다. DI가 “인스턴스를 만들고 연결하는 문제”를 풀었다면, Discovery는 “등록된 인스턴스 중 어떤 메서드가 어떤 역할을 하는지 찾아내는 문제”를 푼다.

  • @Injectable()
  • @Module({ providers, imports, exports })
  • Provider token
  • useValue
  • useFactory
  • useClass
  • useExisting
  • design:paramtypes
  • emitDecoratorMetadata
  • Scope.DEFAULT
  • Scope.REQUEST
  • Scope.TRANSIENT
  • Scope Bubbling
  • forwardRef()
  • ModuleRef
  • DiscoveryService

최종 수정: 2026-04-13