콘텐츠로 이동

DI / IoC

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

DI(Dependency Injection)는 객체가 필요한 의존성을 직접 만들지 않고 외부에서 주입받는 패턴이고, IoC(Inversion of Control)는 객체의 생성과 생명주기 관리를 프레임워크에 맡기는 원칙이다.

Nest.js는 DI/IoC를 핵심으로 동작한다. @Injectable(), @Controller(), constructor(private readonly service: SomeService) 같은 코드가 모두 DI/IoC 기반이다. 이 개념을 모르면 Nest.js 코드가 “왜 이렇게 생겼는지” 이해할 수 없고, 에러가 나도 원인을 찾기 어렵다.

비유로 시작 — “레스토랑 주방”

요리사(Service)가 재료(의존성)를 직접 시장에 가서 사오면 요리에 집중하기 어렵다. 대신 주방장(IoC Container)이 필요한 재료를 미리 준비해서 요리사에게 건네준다. 요리사는 “나는 채소가 필요해”라고 선언만 하면 된다. 이게 DI다. 주방장이 재료 조달의 제어권을 가져간 것이 IoC다.

React의 <Provider value={...}>가 하위 컴포넌트 트리 전체에 값을 전달하는 것처럼, NestJS의 IoC Container도 모듈에 등록된 Provider를 하위에서 사용하는 모든 클래스에 자동으로 주입한다. 차이는 React Context가 런타임에 동적으로 값을 전달하는 반면, NestJS IoC Container는 앱 시작 시점에 의존성 그래프를 분석해 정적으로 인스턴스를 연결한다는 점이다.

IoC (제어의 역전) — 원리

일반적으로 코드가 필요한 객체를 직접 만든다(제어권이 코드에 있음). IoC는 이 제어권을 프레임워크(IoC Container)에 넘기는 것.

  • 직접 제어: const service = new UserService(new UserRepository());
  • IoC: 프레임워크가 알아서 생성하고 주입해줌

‘제어의 역전’은 어디서나 나타난다 — 패턴 전이 모델

IoC는 NestJS/Angular에만 국한된 개념이 아니다. “프레임워크가 내 코드를 호출한다”는 원칙은 생태계를 가리지 않고 동일하게 적용된다. Martin Fowler는 이를 “Hollywood Principle — Don’t call us, we’ll call you”라고 표현했다.

기술IoC가 나타나는 지점내가 하는 일프레임워크가 하는 일
Spring IoC@Component + ApplicationContext빈(Bean) 클래스 정의객체 생성·주입·생명주기 관리
Express 미들웨어app.use((req, res, next) => {...})핸들러 함수 작성요청이 오면 미들웨어 체인을 순서대로 호출
DOM 이벤트 핸들러button.addEventListener('click', handler)핸들러 등록이벤트 발생 시 핸들러 호출
React HooksuseEffect(() => {...}, [deps])사이드이펙트 함수 작성렌더 사이클에서 적절한 시점에 호출

공통점: 개발자는 “무엇을 할지”만 정의하고, “언제, 어떤 순서로 실행할지”는 프레임워크가 결정한다. 새로운 프레임워크를 만나도 “제어 흐름의 주체가 누구인가?”를 먼저 파악하면 구조를 빠르게 이해할 수 있다.

📖 참고: Martin Fowler — Inversion of Control, Inversion of Control Containers and the Dependency Injection pattern

DI (의존성 주입) — 원리

IoC의 구현 방법 중 하나. 필요한 객체(의존성)를 생성자나 메서드를 통해 외부에서 받는다.

📖 더 보기: A Guide on Dependency Injection in NestJS - DigitalOcean — 실습 코드 중심의 DI 입문 가이드

왜 DI/IoC를 설계 원칙으로 채택했는가 — 철학적 배경

DI/IoC는 단순한 “편의 기능”이 아니다. 이 패턴이 등장한 배경에는 소프트웨어 설계의 핵심 원칙인 SOLID 중 두 가지가 있다.

  • 의존성 역전 원칙(DIP, Dependency Inversion Principle): “고수준 모듈(비즈니스 로직)이 저수준 모듈(DB 연결, HTTP 클라이언트)에 직접 의존하면 안 된다. 둘 다 추상(인터페이스)에 의존해야 한다.” DI는 이를 구현하는 가장 실용적인 방법이다.
  • 단일 책임 원칙(SRP): 객체가 자신의 의존성을 조달하는 책임까지 갖게 되면 한 객체가 너무 많은 일을 한다. IoC로 “의존성 조달” 책임을 Container에 분리하면 각 객체는 본래 역할에만 집중할 수 있다.

“왜 Nest.js는 반드시 클래스 기반 DI인가?” — 설계 결정의 이유

Nest.js는 함수형 스타일이 아닌 클래스 기반 DI를 선택했다. 이 선택의 이유는 TypeScript의 타입 시스템을 최대한 활용하기 위해서다. TypeScript는 클래스의 생성자 파라미터 타입을 emitDecoratorMetadata로 런타임에 보존할 수 있지만, 일반 함수의 파라미터 타입은 보존하지 않는다. 즉, NestJS가 “어떤 의존성을 주입해야 하는지” 자동으로 파악하려면 클래스 기반 구조가 필수다. Angular가 같은 이유로 클래스 기반인 것도 동일한 설계 철학에서 비롯되었다.

// DI 없이 (직접 생성) — 강하게 결합, 테스트 어려움
class OrderService {
private userService = new UserService(); // UserService 구현 바꾸면 여기도 바꿔야 함
}
// DI 있이 (주입받음) — 느슨하게 결합, 테스트 쉬움
class OrderService {
constructor(private readonly userService: UserService) {} // 외부에서 어떤 구현이든 주입 가능
}

Nest.js IoC Container 내부 동작 원리 — 왜 이렇게 동작하는가

📖 더 보기: NestJS 공식 문서 - Providers — IoC Container, Provider 등록, Scope 공식 설명

Nest.js의 DI가 마법처럼 동작하는 데는 TypeScript + 데코레이터 + reflect-metadata 세 가지가 핵심 재료다. 각각의 역할을 이해해야 “왜 @Injectable()을 붙이면 자동으로 주입이 되는가”를 설명할 수 있다.

역할 1: tsconfig.jsonemitDecoratorMetadata: true

이 설정이 없으면 Nest.js DI 전체가 작동하지 않는다. TypeScript는 컴파일 시 타입 정보(예: userService: UserService)를 기본적으로 JavaScript로 변환하면서 버린다. emitDecoratorMetadata: true를 켜면, 데코레이터가 붙은 클래스의 생성자 파라미터 타입 목록이 런타임 메타데이터로 보존된다.

// tsconfig.json — 이 두 옵션이 없으면 NestJS DI 동작 안 함
{
"compilerOptions": {
"experimentalDecorators": true, // 데코레이터 문법 활성화
"emitDecoratorMetadata": true // 생성자 파라미터 타입 정보 보존
}
}

역할 2: @Injectable() 데코레이터

@Injectable()이 실행되는 순간 내부적으로 reflect-metadata 라이브러리를 통해 아래가 일어난다:

// @Injectable() 내부에서 일어나는 일 (개념 설명)
Reflect.metadata("design:paramtypes", [UserRepository])(UserService);
// → "UserService의 생성자는 UserRepository를 필요로 한다"는 정보가 저장됨

나중에 Nest가 이 정보를 읽어 어떤 의존성을 주입해야 하는지 파악한다:

// Nest IoC Container 내부 (개념 설명)
const paramTypes = Reflect.getMetadata("design:paramtypes", UserService);
// → [UserRepository] ← TypeScript가 저장해준 타입 정보

역할 3: 앱 시작 시 의존성 그래프 구성

NestFactory.create()를 호출하면 Nest는 다음 순서로 작동한다:

  1. 모든 모듈 스캔 → 등록된 Provider 목록 수집
  2. 각 Provider의 design:paramtypes 메타데이터를 읽어 의존성 그래프 구성
  3. 그래프를 역방향으로 순회 → 의존성 없는 것부터 인스턴스 생성
  4. 생성된 인스턴스를 필요한 곳에 주입
의존성 그래프 예시:
UserController → UserService → UserRepository → TypeORM Connection
생성 순서 (역방향):
1. TypeORM Connection (외부 의존성 없음)
2. UserRepository (Connection 필요 → 이미 생성됨)
3. UserService (Repository 필요 → 이미 생성됨)
4. UserController (Service 필요 → 이미 생성됨)
// 실제 코드에서 이 흐름이 어떻게 표현되는지
const paramTypes = Reflect.getMetadata("design:paramtypes", UserService);
// → [UserRepository]
const repo = container.get(UserRepository); // 이미 생성된 인스턴스
const service = new UserService(repo); // 자동 주입
container.set(UserService, service);

📖 더 보기: NestJS Metadata Deep Dive - Trilon Consulting — reflect-metadata와 design:paramtypes가 NestJS 내부에서 어떻게 동작하는지 심화 분석 (중급)

⚠️ 초보자 오류 포인트: @Injectable() 혼자만으로는 부족하다

@Injectable()을 달았어도 해당 모듈의 providers에 등록해야 컨테이너가 인스턴스를 생성한다. 다른 모듈에서 쓰려면 exports도 필요하다.

users.module.ts
@Module({
providers: [UsersService], // 등록 안 하면 "Nest can't resolve dependencies" 에러
exports: [UsersService], // 다른 모듈에서 쓰려면 exports 필수
})
export class UsersModule {}
// orders.module.ts — UsersService를 쓰려면 UsersModule을 imports해야 함
@Module({
imports: [UsersModule], // exports된 UsersService가 이 모듈에서 사용 가능해짐
providers: [OrdersService],
})
export class OrdersModule {}

모듈 컨텍스트 격리 — 왜 모듈마다 DI 범위가 다른가

NestJS의 각 모듈은 자체적인 DI 컨텍스트를 가진다. 모듈 A에 등록된 Provider는 모듈 B에서 자동으로 사용할 수 없다. 이 격리가 있어야 대규모 앱에서 의존성 충돌을 방지하고, 각 모듈이 독립적으로 테스트·배포될 수 있다. exports로 명시적으로 공개한 Provider만 다른 모듈에서 접근 가능하다.

// ✅ 모듈 격리 예시 — UsersModule의 Provider는 UsersModule 내부에서만 사용 가능
@Module({
providers: [UsersService, UsersRepository], // 모듈 내부 DI 컨텍스트
exports: [UsersService], // UsersService만 외부에 공개, UsersRepository는 비공개
})
export class UsersModule {}
// OrdersModule에서 UsersRepository를 직접 주입하면 에러 발생
// → "Nest can't resolve dependencies" — UsersRepository는 exports에 없기 때문

📖 더 보기: Understanding Dependency Injection in NestJS - A Practical Guide — 모듈별 DI 컨텍스트 격리와 Provider 범위를 실습으로 설명 (입문)

왜 유용한가

  • 테스트 시 실제 DB 대신 Mock 객체를 주입할 수 있음
  • 동일한 인스턴스를 여러 곳에서 재사용(싱글턴 기본)
  • 의존성이 명시적으로 드러나서 코드 파악이 쉬움

Scope (인스턴스 생명주기)

📖 더 보기: NestJS 공식 문서 - Custom Providers — useValue, useFactory, useClass 등 고급 Provider 패턴

Scope설명언제 쓰는가
DEFAULT (싱글턴)앱 전체에서 인스턴스 하나 공유대부분의 경우 (기본값)
REQUESTHTTP 요청마다 새 인스턴스 생성요청별 상태가 필요할 때 (성능 주의)
TRANSIENT주입될 때마다 새 인스턴스 생성완전히 독립된 상태가 필요할 때

싱글턴이 기본인 이유: 인스턴스 생성 비용이 줄고, 메모리를 절약하며, 상태 공유가 예측 가능하다. REQUEST Scope는 모든 상위 의존성도 REQUEST로 바뀌는 연쇄 효과가 있어 성능에 영향을 준다.

REQUEST Scope의 성능 비용 — 실무 주의사항

REQUEST Scope는 편리해 보이지만 “Scope Bubbling” 문제가 있다. REQUEST 스코프 Provider를 의존하는 모든 상위 Provider도 자동으로 REQUEST 스코프로 바뀐다. 이 연쇄 효과로 인해 앱 응답 속도가 최대 5% 이상 느려질 수 있다.

[2차 심화] Scope Bubbling이 왜 발생하는가 — 내부 원리

NestJS IoC Container는 의존성 그래프에서 “가장 넓은 Scope”로 자동 승격(promotion)시킨다. 이유는 일관성 보장을 위해서다. 싱글턴 Service A가 REQUEST Scope Service B를 주입받는다면, A의 인스턴스는 여러 요청이 공유하는데 B는 요청마다 다르다 — 이 상태는 논리적으로 불일치하므로 NestJS는 A도 REQUEST로 강제 승격한다. 이 연쇄가 위로 계속 전파되어 결국 최상위 Controller까지 REQUEST로 바뀔 수 있다. 따라서 REQUEST Scope는 “꼭 필요한 가장 말단 Provider”에만 적용하고, 가능하면 Durable Provider로 대체하는 것이 좋다.

// ❌ 이 패턴은 위험 — REQUEST Scope가 전체 체인에 전파됨
@Injectable({ scope: Scope.REQUEST })
export class TenantService {
// 이 서비스를 주입받는 모든 서비스도 REQUEST 스코프가 됨
}
// ✅ 멀티테넌시가 필요하다면 Durable Provider 고려
// 같은 tenant ID를 가진 요청끼리는 인스턴스를 재사용하므로 성능 훨씬 좋음
@Injectable({ scope: Scope.REQUEST, durable: true })
export class TenantService {
// tenant별로 한 번만 인스턴스 생성, 이후 같은 tenant는 캐시된 인스턴스 사용
}

커스텀 Provider 패턴 — useValue, useFactory

클래스가 아닌 값이나 동적으로 생성된 객체를 주입할 때 커스텀 Provider를 사용한다. 이때 NestJS가 주입 토큰을 자동으로 알 수 없으므로 명시적인 토큰(문자열 또는 Symbol)을 지정해야 한다.

// useValue — 설정값, 외부 라이브러리 인스턴스 주입
const configProvider = {
provide: "APP_CONFIG", // 명시적 토큰 (클래스가 없으므로 필수)
useValue: { apiUrl: process.env.API_URL, timeout: 5000 },
};
// 주입 시 @Inject() 토큰 명시
@Injectable()
export class ApiService {
constructor(
@Inject("APP_CONFIG") private config: { apiUrl: string; timeout: number },
) {}
}
// useFactory — 비동기 초기화, 다른 서비스에 의존하는 값 생성
const dbProvider = {
provide: "DB_CONNECTION",
useFactory: async (configService: ConfigService) => {
const conn = await createConnection({ url: configService.get("DB_URL") });
return conn;
// → 앱 시작 시 비동기로 DB 연결 생성 후 주입
},
inject: [ConfigService], // useFactory에서 사용할 의존성 명시
};

Symbol 토큰 베스트 프랙티스 — 문자열 토큰의 문제를 피하는 방법

문자열 토큰("APP_CONFIG")은 타입 안전성이 없고 오타가 있어도 컴파일 시 잡히지 않는다. 실무에서는 Symbol을 사용하는 것이 더 안전하다.

// ❌ 문자열 토큰 — 오타 발생 시 런타임에야 에러 발견
@Module({
providers: [{ provide: "APP_CONFIG", useValue: config }],
})
export class AppModule {}
@Injectable()
export class ApiService {
constructor(@Inject("app_config") private config) {} // 소문자 오타 → 런타임 에러!
}
// ✅ Symbol 토큰 — 상수 파일로 중앙 관리, 오타 방지
// tokens/injection-tokens.ts
export const APP_CONFIG = Symbol("APP_CONFIG");
export const DB_CONNECTION = Symbol("DB_CONNECTION");
export const SLACK_CLIENT = Symbol("SLACK_CLIENT");
// app.module.ts
@Module({
providers: [{ provide: APP_CONFIG, useValue: { apiUrl: "..." } }],
exports: [APP_CONFIG],
})
export class AppModule {}
// api.service.ts — 같은 Symbol 상수를 import해서 사용 → 오타 불가능
@Injectable()
export class ApiService {
constructor(@Inject(APP_CONFIG) private config: AppConfig) {}
// APP_CONFIG 상수를 import하면 IDE가 자동완성 + 오타 방지
}

왜 Symbol인가: Symbol("APP_CONFIG")은 매번 고유한 값을 생성하므로 문자열처럼 서로 다른 모듈에서 같은 이름이 충돌할 위험이 없다. 별도 tokens 파일로 중앙 관리하면 리팩터링 시 한 곳만 수정하면 된다.

📖 더 보기: Mastering Providers, Tokens, and Scopes in NestJS DI - Medium — Symbol 토큰, InjectionToken 패턴 실무 가이드 (중급)

DI를 쓰지 말아야 하는 경우 — over-engineering 경계

DI는 강력하지만 모든 상황에 필요한 것은 아니다. 아래 조건에 해당하면 DI Container 없이 직접 인스턴스를 생성하는 편이 코드 복잡도와 인지 부하 측면에서 낫다.

상황이유
일회성 스크립트·CLI 도구의존성 그래프가 1~2단계로 단순. Container 셋업 비용이 로직보다 클 수 있음
순수 함수 위주의 유틸리티 라이브러리상태가 없으므로 주입할 대상 자체가 없음
런타임에 구현체를 교체할 일이 없는 경우DI의 핵심 이점(교체 용이성)이 불필요
프로토타입·PoC 단계인터페이스 분리·Provider 등록 오버헤드가 속도를 늦춤

판단 기준: “이 의존성을 테스트나 런타임에 다른 구현으로 교체할 일이 한 번이라도 있는가?” → Yes면 DI 도입, No면 직접 생성이 더 단순하다.

📖 참고: When does Dependency Injection become an anti-pattern? — David’s Code, Cataloging dependency injection anti-patterns in software systems — ScienceDirect

  • Nest.js의 모든 Service, Repository, Controller
  • 테스트 시 의존성 목킹 (jest.mock, Test.createTestingModule)
  • 공통 유틸(Logger, Config 등)을 싱글턴으로 전체에서 재사용
  • 멀티테넌시 환경에서 테넌트별 컨텍스트 분리 (Durable Provider 활용)
  • 외부 라이브러리(axios 인스턴스, Redis 클라이언트 등)를 useValue로 주입해 테스트 시 Mock 교체

테스트에서의 DI 활용 — Mock 주입 패턴

DI의 가장 큰 실질적 장점 중 하나는 테스트 시 실제 의존성을 Mock으로 교체할 수 있다는 점이다. NestJS는 Test.createTestingModule()로 격리된 테스트 모듈을 만들고, 원하는 Provider만 Mock으로 바꿔 주입할 수 있다.

// users.service.spec.ts — 실제 DB 없이 UserService 단위 테스트
import { Test, TestingModule } from "@nestjs/testing";
import { UsersService } from "./users.service";
import { UserRepository } from "./user.repository";
describe("UsersService", () => {
let service: UsersService;
let mockRepo: jest.Mocked<UserRepository>;
beforeEach(async () => {
// 실제 UserRepository 대신 Mock 객체를 주입
mockRepo = {
findById: jest.fn(),
save: jest.fn(),
} as any;
const module: TestingModule = await Test.createTestingModule({
providers: [
UsersService,
{
provide: UserRepository, // 이 토큰으로 들어오는 의존성을 교체
useValue: mockRepo, // 실제 DB 없이 Mock 사용
},
],
}).compile();
service = module.get<UsersService>(UsersService);
});
it("findById가 올바른 유저를 반환해야 한다", async () => {
mockRepo.findById.mockResolvedValue({ id: 1, name: "Alice" });
const user = await service.findById(1);
expect(user.name).toBe("Alice");
expect(mockRepo.findById).toHaveBeenCalledWith(1);
// → 실제 DB 연결 없이, UsersService의 비즈니스 로직만 순수하게 검증
});
});

이 패턴의 핵심은 UsersServiceUserRepository의 구체 구현에 의존하는 것이 아니라 DI 토큰에 의존한다는 점이다. 테스트 환경에서 같은 토큰에 Mock을 등록하면 UsersService 코드를 한 줄도 바꾸지 않고 의존성을 교체할 수 있다. 슬랙 API, DB, 외부 HTTP 클라이언트 등 실제 연결이 필요한 의존성을 모두 Mock으로 대체하면 빠르고 안정적인 단위 테스트를 만들 수 있다.

BackOps 실무 시나리오

  • 슬랙봇 연동 서비스를 새로 만들 때: @Injectable() + providers 등록 → exports → 사용 모듈에서 imports
  • 테넌트별 DB 연결이 필요한 경우 useFactory로 동적으로 연결 생성
  • “새 서비스를 만들었는데 의존성이 안 잡힌다” → 가장 먼저 providers 등록 여부, exports 여부 확인
  • 새 슬랙봇 기능 구현 후 단위 테스트 작성 시 → Test.createTestingModule로 슬랙 API 클라이언트를 Mock으로 교체해 실제 API 호출 없이 로직만 검증
  • Nest.js 코드에서 constructor 파라미터의 의미 이해
  • “Cannot find module / Circular dependency” 에러 원인 파악
  • 새 Service를 만들 때 어디에 @Injectable()을 달고 어디에 등록해야 하는지 이해
개념 A개념 B차이점
IoCDIIoC는 원칙, DI는 IoC를 구현하는 방법 중 하나
DIService LocatorDI는 외부에서 밀어넣음, Service Locator는 내부에서 당겨옴
싱글턴 ScopeRequest Scope싱글턴은 앱 전체 공유, Request는 요청마다 새 인스턴스
@Injectable()@Module() providersInjectable은 클래스 표시, providers는 모듈에 등록
useValueuseFactoryuseValue는 정적 값, useFactory는 런타임 동적 생성

🔧 “Nest can’t resolve dependencies of the XXService (?)” 에러

섹션 제목: “🔧 “Nest can’t resolve dependencies of the XXService (?)” 에러”

증상: 서버 시작 시 아래와 같은 에러로 앱이 죽음

Error: Nest can't resolve dependencies of the OrdersService (?).
Please make sure that the argument UserService at index [0]
is available in the OrdersModule context.

원인: @Injectable()은 달았지만 UserServiceOrdersModuleproviders에 없거나, UsersModuleimports하지 않음 해결:

  1. 에러 메시지의 index [0] → 첫 번째 생성자 파라미터 확인
  2. 해당 Service가 같은 모듈에 있다면 providers 배열에 추가
  3. 다른 모듈에 있다면 그 모듈을 imports에 추가 (+ 그 모듈이 exports하는지 확인)

🔧 REQUEST Scope 순환 의존성 — forwardRef로도 undefined 발생

섹션 제목: “🔧 REQUEST Scope 순환 의존성 — forwardRef로도 undefined 발생”

증상: REQUEST Scope Provider 간에 순환 참조가 있고, forwardRef()를 적용했는데도 의존성이 undefined로 들어옴

TypeError: Cannot read properties of undefined (reading 'someMethod')
// → forwardRef()가 있는데도 주입된 값이 undefined

원인: NestJS가 REQUEST Scope Provider의 순환 참조를 해결하는 타이밍 문제. 단순 싱글턴 순환과 달리, REQUEST Scope는 process.nextTick()을 거쳐야 상호 의존 Provider가 모두 생성된다. 이 비동기 타이밍 차이로 인해 forwardRef()만으로는 해결되지 않는 경우가 있다.

해결: REQUEST Scope 간 순환 참조 자체를 제거하거나, EventEmitter2로 완전 디커플링


🔧 “A circular dependency has been detected” 에러

섹션 제목: “🔧 “A circular dependency has been detected” 에러”

📖 더 보기: NestJS Circular Dependency 공식 문서 — forwardRef() 공식 사용법

증상: 서버 시작 시 순환 의존성 에러

Error: A circular dependency has been detected (UserService -> OrdersService -> UserService).

원인: UserServiceOrdersService를 주입받고, OrdersServiceUserService를 주입받는 순환 참조 발생 해결:

  1. 먼저 아키텍처 재검토 — 순환 참조 자체가 설계 문제일 가능성이 높음. 공통 로직을 별도 SharedService로 추출하면 순환이 없어진다
  2. 불가피하다면 forwardRef()로 해결:
user.service.ts
constructor(
@Inject(forwardRef(() => OrdersService))
private ordersService: OrdersService,
) {}
// orders.module.ts
@Module({
imports: [forwardRef(() => UsersModule)],
})
  1. forwardRef()는 최후의 수단 — 가능하면 공통 로직을 별도 서비스로 추출하여 순환 참조 자체를 없애는 것이 좋음

🛠 Madge로 순환 의존성 시각화 — 어디서 꼬였는지 한눈에 파악

섹션 제목: “🛠 Madge로 순환 의존성 시각화 — 어디서 꼬였는지 한눈에 파악”

순환 참조가 여러 모듈에 걸쳐 있다면 에러 메시지만으로 원인을 찾기 어렵다. madge는 TypeScript 소스 파일을 분석해 의존성 그래프를 이미지로 출력해주는 도구다. 빨간색으로 표시된 노드가 순환 참조 지점이다.

Terminal window
# madge 설치
npm install -g madge
# 순환 의존성만 출력
npx madge --circular src/main.ts --ts-config tsconfig.json
# 예상 출력 (순환 있을 때):
# Circular dependency found!
# src/users/users.service.ts -> src/orders/orders.service.ts -> src/users/users.service.ts
# 의존성 그래프를 이미지로 저장 (graphviz 필요)
npx madge --image graph.png --extensions ts --circular src
# → graph.png에 파란색(의존성 있음), 초록(의존성 없음), 빨간색(순환 참조) 노드 출력

📖 더 보기: Identify Circular Dependencies in NestJS using Madge - Medium — Madge 설치부터 시각화까지 단계별 가이드 (입문)

🔧 순환 의존성 근본 해결 — EventEmitter2로 완전 디커플링

섹션 제목: “🔧 순환 의존성 근본 해결 — EventEmitter2로 완전 디커플링”

forwardRef()는 순환 참조를 허용하는 것이지 없애는 게 아니다. 두 서비스가 서로를 알아야만 하는 경우, 이벤트 기반으로 분리하면 의존성 방향이 단방향이 된다.

user.service.ts
// ❌ 순환 참조 구조 — UserService ↔ OrdersService
constructor(private ordersService: OrdersService) {} // 상호 의존
// ✅ EventEmitter2로 디커플링
// 1. app.module.ts에 EventEmitterModule 등록
import { EventEmitterModule } from '@nestjs/event-emitter';
@Module({ imports: [EventEmitterModule.forRoot()] })
export class AppModule {}
// 2. user.service.ts — 이벤트 emit만 (OrdersService 모름)
import { EventEmitter2 } from '@nestjs/event-emitter';
@Injectable()
export class UserService {
constructor(private eventEmitter: EventEmitter2) {}
deleteUser(userId: number) {
// OrdersService를 직접 주입하지 않고 이벤트 발행
this.eventEmitter.emit('user.deleted', { userId });
}
}
// 3. orders.service.ts — 이벤트 수신만 (UserService 모름)
import { OnEvent } from '@nestjs/event-emitter';
@Injectable()
export class OrdersService {
@OnEvent('user.deleted')
handleUserDeleted(payload: { userId: number }) {
// userId에 해당하는 주문 정리
console.log(`유저 ${payload.userId} 삭제됨 → 관련 주문 처리`);
}
}
// → 두 서비스가 서로를 전혀 모름. 순환 참조 구조적으로 불가능

증상: 코드는 문제없어 보이는데 에러 발생

Error: Nest can't resolve dependencies of the ResourceController (ResourceService, ?).
Please make sure that the argument Object at index [1] is available...

원인: TypeScript의 import type을 사용하면 런타임에 타입 정보가 사라짐. Nest가 Reflect.getMetadata로 읽어야 하는 타입 정보가 없어서 Object로 표시됨 해결:

// ❌ 잘못된 방법 — 런타임에 타입 정보 사라짐
import type { EmailService } from "./email.service";
// ✅ 올바른 방법 — 런타임에도 타입 정보 유지
import { EmailService } from "./email.service";

🔧 emitDecoratorMetadata 미설정으로 인한 DI 전체 실패

섹션 제목: “🔧 emitDecoratorMetadata 미설정으로 인한 DI 전체 실패”

증상: DI 자체가 전혀 작동하지 않음. 모든 의존성이 undefined로 주입되거나, 다음과 같은 에러 발생

TypeError: Cannot read properties of undefined (reading 'someMethod')

원인: tsconfig.jsonemitDecoratorMetadata: true가 없어서 TypeScript가 생성자 파라미터의 타입 정보를 런타임에 버림. Nest는 design:paramtypes 메타데이터를 읽지 못해 의존성을 파악할 수 없음 해결:

// tsconfig.json 확인 — 이 두 옵션이 반드시 있어야 함
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
}

🔧 커스텀 Provider 토큰 불일치로 주입 실패

섹션 제목: “🔧 커스텀 Provider 토큰 불일치로 주입 실패”

증상: useValue / useFactory로 등록한 Provider가 주입되지 않고 undefined 반환 또는 에러 발생

Error: Nest can't resolve dependencies of the ApiService (?).
Please make sure that the argument APP_CONFIG at index [0] is available...

원인: provide 토큰과 @Inject() 토큰이 다름. 문자열 토큰은 대소문자까지 정확히 일치해야 함. 또는 @Inject()를 누락한 경우

// ❌ 토큰 불일치 — 'APP_CONFIG' ≠ 'app_config'
providers: [{ provide: 'APP_CONFIG', useValue: config }]
constructor(@Inject('app_config') private config) {} // 소문자 → 실패
// ❌ @Inject() 누락 — 클래스 기반 토큰이 아니면 @Inject()가 반드시 필요
constructor(private config: AppConfig) {} // AppConfig가 클래스면 OK, 문자열 토큰이면 NG
// ✅ 토큰 일치 + @Inject() 명시
export const APP_CONFIG = 'APP_CONFIG'; // 상수로 추출하면 오타 방지
providers: [{ provide: APP_CONFIG, useValue: config }]
constructor(@Inject(APP_CONFIG) private config) {} // 상수 재사용

해결: 문자열 토큰은 별도 상수 파일로 추출하여 오타를 방지하고, @Inject()에는 반드시 같은 상수를 사용한다.

  • DI와 IoC의 차이를 설명할 수 있다
  • Nest.js에서 Service를 새로 만들 때 필요한 데코레이터와 등록 위치를 말할 수 있다
  • 싱글턴 Scope가 기본값인 이유를 설명할 수 있다
  • “Circular dependency” 에러가 왜 발생하는지 설명할 수 있다 (A가 B를 주입받고 B가 A를 주입받는 순환 참조)
  • emitDecoratorMetadata가 왜 필요한지 설명할 수 있다

Nest.js Module system, forwardRef(), Custom Provider, useFactory, useValue, Abstract class injection, reflect-metadata, design:paramtypes, Durable Provider, Scope Bubbling

🧪 단계별 실습: NestJS CLI로 DI 흐름 체험하기

섹션 제목: “🧪 단계별 실습: NestJS CLI로 DI 흐름 체험하기”

아래 실습은 서비스를 생성하고, 의도적으로 Provider 등록을 누락시켜 에러를 재현한 뒤 해결하는 과정이다. 에러 메시지를 직접 보고 고쳐야 DI 동작 원리가 체감된다.

Terminal window
# 1. 프로젝트 생성
npx @nestjs/cli new di-lab --package-manager npm
cd di-lab
# 2. 모듈과 서비스 생성
npx nest g module items
npx nest g service items
npx nest g controller items
# → items.module.ts의 providers에 ItemsService가 자동 등록됨을 확인
// 3. ItemsService에 간단한 메서드 추가 (src/items/items.service.ts)
@Injectable()
export class ItemsService {
getAll() {
return [{ id: 1, name: "Widget" }];
}
}
// 4. ItemsController에서 주입받아 사용 (src/items/items.controller.ts)
@Controller("items")
export class ItemsController {
constructor(private readonly itemsService: ItemsService) {}
@Get()
findAll() {
return this.itemsService.getAll();
}
}
Terminal window
# 5. 정상 동작 확인
npm run start:dev
curl http://localhost:3000/items
# 예상 출력: [{"id":1,"name":"Widget"}]
src/items/items.module.ts
// 6. 의도적으로 에러 재현 — providers에서 ItemsService 제거
@Module({
controllers: [ItemsController],
providers: [], // ← ItemsService를 빼본다
})
export class ItemsModule {}
Terminal window
# 7. 서버 재시작 → 에러 확인
npm run start:dev
# 예상 에러:
# Error: Nest can't resolve dependencies of the ItemsController (?).
# Please make sure that the argument ItemsService at index [0]
# is available in the ItemsModule context.
// 8. 해결 — providers에 다시 추가
@Module({
controllers: [ItemsController],
providers: [ItemsService], // ← 복원
})
export class ItemsModule {}
// → 서버 재시작하면 정상 동작

핵심 체크포인트: @Injectable()을 달았어도 providers 등록이 빠지면 Container가 인스턴스를 만들 수 없다. 에러 메시지의 index [0]은 생성자의 몇 번째 파라미터가 문제인지 알려준다.

📖 참고: NestJS 공식 문서 - Providers, NestJS CLI - Generating Components

  • 현재 프로젝트 코드에서 @Injectable() 사용 패턴 확인
Terminal window
# 프로젝트에서 @Injectable() 사용 위치 확인
grep -rn "@Injectable()" src/ --include="*.ts"
# 예상 출력:
# src/users/users.service.ts:5:@Injectable()
# src/orders/orders.service.ts:4:@Injectable()
  • Module의 providers / exports 구조 파악
Terminal window
# providers / exports 패턴 확인
grep -rn "providers\|exports\|imports" src/ --include="*.module.ts"
# 예상 출력:
# src/users/users.module.ts:7: providers: [UsersService],
# src/users/users.module.ts:8: exports: [UsersService],
  • tsconfig.jsonemitDecoratorMetadata가 설정되어 있는지 확인
Terminal window
grep "emitDecoratorMetadata\|experimentalDecorators" tsconfig.json
# 예상 출력:
# "experimentalDecorators": true,
# "emitDecoratorMetadata": true,
# → 둘 다 없거나 false이면 NestJS DI가 정상 작동하지 않음
  • 테스트 파일에서 DI가 어떻게 목킹되는지 확인
// 테스트에서 DI 목킹 패턴 예시
const module = await Test.createTestingModule({
providers: [
OrdersService,
{
provide: UserService,
useValue: { findOne: jest.fn().mockResolvedValue({ id: 1 }) }, // Mock 주입
},
],
}).compile();
// → 실제 UserService 대신 Mock이 주입되어 DB 없이 테스트 가능
  • REQUEST Scope가 사용된 Provider가 있다면 Scope Bubbling 영향 범위 파악
Terminal window
# REQUEST Scope 사용 위치 확인
grep -rn "Scope.REQUEST\|scope: Scope" src/ --include="*.ts"
# 예상 출력:
# src/tenant/tenant.service.ts:3:@Injectable({ scope: Scope.REQUEST })
# → 이 서비스를 주입받는 모든 서비스도 REQUEST 스코프로 바뀜 (성능 영향 있음)
# → 멀티테넌시가 목적이라면 durable: true 옵션 검토
항목핵심 내용
IoC객체 생성·관리 제어권을 프레임워크에 넘기는 원칙
DI필요한 객체를 외부에서 주입받는 IoC 구현 방법
@Injectable()IoC Container가 관리할 클래스임을 표시 (+ providers 등록 필수)
기본 Scope싱글턴 — 앱 전체에서 인스턴스 하나 공유 (비용·메모리 효율)
동작 원리emitDecoratorMetadatadesign:paramtypes → 의존성 그래프 → 역방향 생성

5줄 핵심

  1. IoC는 객체 생성/관리 제어권을 프레임워크에 넘기는 원칙이다
  2. DI는 필요한 객체를 직접 만들지 않고 외부에서 주입받는 패턴이다
  3. Nest.js는 IoC Container가 @Injectable() 클래스를 자동으로 생성·주입한다 (tsconfig.jsonemitDecoratorMetadata: true 필수)
  4. 기본값은 싱글턴 — 같은 인스턴스를 앱 전체에서 공유한다 (REQUEST Scope는 Scope Bubbling 주의)
  5. “Nest can’t resolve dependencies” / “Circular dependency” 에러가 나면 providers 등록·exports 여부를 먼저 확인한다

이 문서를 이해했다면 다음 순서로 학습을 이어간다:

DI / IoC (지금 여기)
Nest.js Discovery Module ← IoC Container 위에서 Provider를 동적으로 탐색하는 패턴
Nest.js Module System 심화 ← Dynamic Module, forwardRef(), Global Module
Custom Provider 패턴 ← useClass, useValue, useFactory, useExisting 전략
NestJS Testing (Unit / E2E) ← DI를 활용한 Mock 주입 패턴

인터뷰 대비 핵심 질문 (실제 자주 출제)

  • “DI와 Service Locator 패턴의 차이는?”
  • “싱글턴 Scope가 기본인 이유는? REQUEST Scope의 성능 비용은?”
  • “순환 의존성이 발생하면 어떻게 해결하는가? forwardRef()만이 답인가?”
  • emitDecoratorMetadata가 없으면 어떤 일이 발생하는가?”
  • “DI/IoC의 설계 철학은 SOLID의 어떤 원칙과 연결되는가?”

[2차 심화] NestJS DI가 병렬 처리되는 방식 — 실무 주의점

NestJS는 의존성 그래프에서 서로 독립적인 Provider들을 병렬로 초기화한다. 즉, NestFactory.create()가 호출될 때 순서가 보장되지 않는 초기화가 동시에 일어날 수 있다. 이 때문에 onModuleInit()에서 다른 모듈의 특정 Provider가 이미 완전히 준비됐을 것이라고 가정하면 안 된다. 특히 비동기 useFactory를 사용하는 경우, 그 완료를 기다리지 않고 다른 코드가 실행될 수 있다. NestJS는 이를 해결하기 위해 비동기 Provider의 경우 해당 Provider에 의존하는 모든 Provider의 초기화를 자동으로 대기시킨다 — 단, useFactoryasync가 명시된 경우에 한해서다.

// ✅ 비동기 초기화가 완료된 후에야 이 Provider가 주입됨
const dbProvider = {
provide: "DB_CONNECTION",
useFactory: async (config: ConfigService) => {
// ← async가 있어야 NestJS가 완료를 기다림
return await createConnection(config.get("DB_URL"));
},
inject: [ConfigService],
};
// ❌ async 없이 Promise를 반환하면 NestJS가 기다리지 않음 (버그 원인)
const badProvider = {
provide: "BAD_DB",
useFactory: (config: ConfigService) => {
// async 없음 → Promise 객체가 그대로 주입됨 (await 되지 않음!)
return createConnection(config.get("DB_URL"));
},
inject: [ConfigService],
};

최종 수정: 2026-04-13