NestJS DI 에러 디버깅 — Nest can't resolve dependencies 3가지 원인과 서버 기동 테스트
📚 NestJS 실전 트러블슈팅 시리즈 (12편)
NestJS에서 Nest can't resolve dependencies 에러를 만나면 당황스럽습니다. 모듈 imports 누락, @Injectable() 빠짐, 순환 참조까지 — DI 에러의 3대 원인을 실전 코드로 분석하고, 서버 기동 테스트로 사전에 잡는 방법을 정리합니다.
💡 Tip. 바쁜 현대인들을 위한 본문 요약
- NestJS DI 에러의 3대 원인:
@Injectable()누락, 모듈imports/providers미등록, 순환 참조- 에러 메시지의
?마크가 어떤 의존성이 실패했는지 알려주는 핵심 단서- TypeScript 컴파일은 통과해도 런타임 DI 컨테이너에서 터지므로, 빌드 성공 ≠ 서버 기동 성공
- **서버 기동 테스트(startup smoke test)**를 CI에 추가하면 DI 에러를 배포 전에 100% 잡을 수 있음
forwardRef()는 순환 참조의 응급처치일 뿐, 근본 해결은 모듈 구조 재설계
PR을 올렸다. 빌드 통과. 타입 체크 통과. 머지하고 배포했더니 서버가 안 뜬다.
NestJS DI 에러는 TypeScript 컴파일러가 잡아주지 않는다.
tsc가 통과했다고 안심했는데, 런타임에서 바로 터졌다 💀
🔍 증상: 빌드는 되는데 서버가 안 뜬다

새로운 서비스를 추가하고 PR을 올렸다.
CI 파이프라인에서 tsc --noEmit은 깔끔하게 통과.
코드 리뷰도 끝나고 머지.
스테이징에 배포하니 서버가 기동을 거부했다. 로그를 까보니 이런 에러가 찍혀 있었다.
❌ 발생한 에러 메시지
[Nest] 12345 - ERROR [ExceptionHandler]
Nest can't resolve dependencies of the NotificationService (?).
Please make sure that the argument EmailRepository at index [0]
is available in the NotificationModule context.
Potential solutions:
- Is NotificationModule a valid NestJS module?
- If EmailRepository is a provider, is it part of the current NotificationModule?
- If EmailRepository is exported from a separate @Module, is that module imported within NotificationModule?
@Module({
imports: [ /* the Module containing EmailRepository */ ]
})
처음엔 “이게 뭐지?” 했다.
EmailRepository는 분명히 만들었고, 타입도 맞았다.
tsc가 문제없다고 했으니까.
⚠️ 주의:
tsc --noEmit은 타입 레벨 검증만 한다. NestJS의 DI 컨테이너는 런타임에 데코레이터 메타데이터를 읽어서 의존성 그래프를 구성하기 때문에, 타입이 맞아도 DI 설정이 빠지면 서버가 안 뜬다.
🔁 재현 조건
NestJS DI 에러가 터지는 전형적인 시나리오는 이렇다.
- 새 서비스/리포지토리를 생성한다
- 다른 서비스에서 생성자 주입으로 가져다 쓴다
npm run build(또는tsc)는 통과한다npm run start하면 서버 기동 단계에서 에러
핵심은 3번에서 4번 사이의 갭이다. TypeScript는 “이 타입이 존재하나?”만 본다. NestJS는 “이 타입에 대응하는 프로바이더가 DI 컨테이너에 등록돼 있나?”를 본다.
완전히 다른 검증이다.
🕵️ 탐색: NestJS DI 에러의 3가지 원인

NestJS DI 에러는 거의 예외 없이 3가지 원인 중 하나다.
에러 메시지의 ? 마크가 힌트인데, 처음엔 그걸 몰랐다.
원인 1: @Injectable() 데코레이터 누락
가장 흔하고, 가장 허탈한 원인이다.
NestJS는 reflect-metadata를 사용해서 생성자 파라미터의 타입 정보를 읽는다.
@Injectable() 데코레이터가 없으면 TypeScript 컴파일러가 메타데이터를 emit하지 않는다.
결과: DI 컨테이너가 “이 클래스가 뭘 필요로 하는지 모르겠다”고 포기한다.
NestJS 공식 문서의 Custom providers 섹션에서도 이 부분을 강조한다.
@Injectable()은 단순한 마커가 아니라, TypeScript의 emitDecoratorMetadata 옵션을 트리거하는 핵심 장치다.
📌 핵심:
@Injectable()이 빠지면tsconfig.json의emitDecoratorMetadata: true설정이 있어도 해당 클래스에 대해서는 메타데이터가 생성되지 않는다. 데코레이터가 하나도 없는 클래스는 메타데이터 emit 대상에서 제외되기 때문이다.
원인 2: 모듈 imports / providers 미등록
두 번째로 흔한 원인이다.
서비스를 만들고, @Injectable()도 붙였는데, 정작 모듈에 등록을 안 한 경우.
NestJS의 모듈 시스템은 명시적이다. Angular에서 가져온 설계인데, “알아서 찾아주지 않는다.”
두 가지 패턴이 있다.
패턴 A: 같은 모듈 내 provider 등록 누락
NotificationModule 안에서 NotificationService를 쓰려면, NotificationService가 해당 모듈의 providers 배열에 있어야 한다.
패턴 B: 다른 모듈의 provider를 가져다 쓰려는데 imports 누락
NotificationService가 EmailModule의 EmailRepository를 주입받으려면:
EmailModule이EmailRepository를exports에 등록해야 하고NotificationModule이EmailModule을imports에 등록해야 한다
이 양방향 설정을 하나라도 빠뜨리면 DI 에러가 터진다.
💡 팁: NestJS v10+에서는 에러 메시지에
Potential solutions가 같이 출력된다. 이 힌트가 상당히 정확해서, 메시지를 꼼꼼히 읽으면 대부분 해결된다. 옛날 버전에서는?만 덩그러니 나와서 추적이 훨씬 어려웠다.
원인 3: 순환 참조 (Circular Dependency)
세 번째는 순환 참조다.
ServiceA → ServiceB → ServiceA 형태로 의존성이 꼬이는 경우.
이건 에러 메시지가 좀 다르다.
[Nest] ERROR [ExceptionHandler]
A circular dependency has been detected.
Please, make sure that each side of a bidirectional relationship
is decorated with "forwardRef".
NestJS 공식 문서의 Circular Dependency 섹션에서 forwardRef()를 해결책으로 제시한다.
하지만 forwardRef()는 응급 처치지 근본 해결이 아니다.
순환 참조가 생겼다는 건 모듈 설계에 문제가 있다는 신호다.
🛠️ 해결: 원인별 Before/After 코드

❌ Before: @Injectable() 누락
// email.repository.ts
// ❌ @Injectable() 데코레이터가 없다
// TypeScript는 이 코드에 문제가 없다고 판단한다
export class EmailRepository {
constructor(private readonly prisma: PrismaService) {}
async findByUserId(userId: number) {
return this.prisma.email.findMany({ where: { userId } });
}
}
이 코드는 TypeScript 관점에서 완벽하다.
타입 체크 통과, 빌드 성공.
하지만 NestJS가 이 클래스를 DI 컨테이너에 등록하려 할 때,
생성자에 PrismaService가 필요하다는 정보를 읽을 수 없다.
✅ After: @Injectable() 추가
// email.repository.ts
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
@Injectable() // ✅ 이 한 줄이 TypeScript에게 "메타데이터를 emit하라"고 지시한다
export class EmailRepository {
constructor(private readonly prisma: PrismaService) {}
async findByUserId(userId: number) {
return this.prisma.email.findMany({ where: { userId } });
}
}
📊 데이터: NestJS GitHub Issues에서
can't resolve dependencies키워드로 검색하면 2,000개 이상의 이슈가 나온다. 그 중 상당수가@Injectable()누락이 원인이다. 공식 CLI(nest generate service)를 쓰면 자동으로 붙여주지만, 수동으로 파일을 만들면 빠뜨리기 쉽다.
❌ Before: 모듈 등록 누락
// notification.module.ts
import { Module } from '@nestjs/common';
import { NotificationService } from './notification.service';
import { NotificationController } from './notification.controller';
@Module({
// ❌ EmailModule을 imports하지 않았다
// NotificationService가 EmailRepository를 주입받으려는데
// DI 컨테이너에 EmailRepository가 없다
controllers: [NotificationController],
providers: [NotificationService],
})
export class NotificationModule {}
// email.module.ts
import { Module } from '@nestjs/common';
import { EmailRepository } from './email.repository';
@Module({
providers: [EmailRepository],
// ❌ exports에 EmailRepository를 안 넣었다
// 다른 모듈에서 접근 불가
})
export class EmailModule {}
✅ After: 양방향 설정 완료
// email.module.ts
import { Module } from '@nestjs/common';
import { EmailRepository } from './email.repository';
import { PrismaModule } from '../prisma/prisma.module';
@Module({
imports: [PrismaModule],
providers: [EmailRepository],
exports: [EmailRepository], // ✅ 외부 모듈에서 사용할 수 있도록 export
})
export class EmailModule {}
// notification.module.ts
import { Module } from '@nestjs/common';
import { NotificationService } from './notification.service';
import { NotificationController } from './notification.controller';
import { EmailModule } from '../email/email.module';
@Module({
imports: [EmailModule], // ✅ EmailModule을 import해서 EmailRepository 사용 가능
controllers: [NotificationController],
providers: [NotificationService],
})
export class NotificationModule {}
⚠️ 주의:
exports와imports는 반드시 쌍으로 확인해야 한다.EmailModule에서exports를 빠뜨려도,NotificationModule에서imports를 빠뜨려도 동일한 DI 에러가 발생한다. 에러 메시지만으로는 어느 쪽이 빠졌는지 구분이 안 되기 때문에, 양쪽을 모두 확인하는 습관이 필요하다.
⚠️ 순환 참조 — forwardRef() 응급 처치
// order.service.ts
import { Injectable, Inject, forwardRef } from '@nestjs/common';
import { PaymentService } from '../payment/payment.service';
@Injectable()
export class OrderService {
constructor(
@Inject(forwardRef(() => PaymentService)) // ⚠️ 순환 참조 우회
private readonly paymentService: PaymentService,
) {}
}
// payment.service.ts
import { Injectable, Inject, forwardRef } from '@nestjs/common';
import { OrderService } from '../order/order.service';
@Injectable()
export class PaymentService {
constructor(
@Inject(forwardRef(() => OrderService)) // ⚠️ 양쪽 다 forwardRef 필요
private readonly orderService: OrderService,
) {}
}
이렇게 하면 당장은 돌아간다. 하지만 이건 기술 부채다.
📌 핵심:
forwardRef()를 2개 이상 쓰고 있다면 모듈 구조를 재설계해야 한다는 신호다. 공통 로직을 별도 모듈로 분리하거나, 이벤트 기반으로 의존성을 끊는 게 올바른 방향이다. NestJS 공식 문서에서도 “avoid circular dependencies when possible”라고 명시하고 있다.
✅ 근본 해결: 공통 모듈 분리
// order-payment-shared.module.ts
import { Module } from '@nestjs/common';
import { OrderPaymentCalculator } from './order-payment-calculator.service';
// ✅ 순환 참조 대신 공통 로직을 별도 모듈로 분리
// OrderModule과 PaymentModule 모두 이 모듈을 import
@Module({
providers: [OrderPaymentCalculator],
exports: [OrderPaymentCalculator],
})
export class OrderPaymentSharedModule {}
순환 참조가 발생했다는 건 두 모듈이 서로의 내부 구현에 의존하고 있다는 뜻이다. 공통 부분을 추출하면 의존 방향이 단방향으로 정리된다.
✅ 검증: DI 에러가 사라졌는지 확인

수정 후 반드시 서버를 기동해서 확인한다.
❌ Before: 검증 없이 PR
# 빌드만 확인하고 PR 올림
$ npm run build
✔ Successfully compiled
# "빌드 됐으니 괜찮겠지"
$ git push origin feature/notification
✅ After: 서버 기동까지 확인
# 1. 빌드 확인
$ npm run build
✔ Successfully compiled
# 2. 서버 기동 확인 (이게 핵심!)
$ npm run start
[Nest] 12345 - LOG [NestFactory] Starting Nest application...
[Nest] 12345 - LOG [InstanceLoader] NotificationModule dependencies initialized
[Nest] 12345 - LOG [RoutesResolver] NotificationController {/notifications}:
[Nest] 12345 - LOG [NestApplication] Nest application successfully started
# ✅ "successfully started"가 뜨면 DI 문제 없음
# 3. 그제서야 PR
$ git push origin feature/notification
💡 팁:
npm run start로 서버가 정상 기동되면 DI 에러는 100% 없다고 봐도 된다. NestJS는 서버 기동 시점에 전체 DI 그래프를 검증하기 때문이다. 부분적으로 검증하는 게 아니라, 등록된 모든 모듈·프로바이더·컨트롤러의 의존성을 한꺼번에 확인한다.
🛡️ 예방: 서버 기동 테스트를 CI에 넣자

DI 에러는 한 번 경험하면 두 번 다시 당하고 싶지 않다. 근데 사람은 잊는다. 그래서 CI가 잡아줘야 한다.
서버 기동 스모크 테스트 작성법
// test/app.startup.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import { AppModule } from '../src/app.module';
describe('Application Startup (Smoke Test)', () => {
let app: INestApplication;
it('should bootstrap without DI errors', async () => {
// ✅ 전체 모듈을 로드해서 DI 그래프 검증
// 이 테스트가 통과하면 모든 의존성이 올바르게 연결된 것
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
await app.init(); // DI 에러가 있으면 여기서 터진다
expect(app).toBeDefined();
});
afterAll(async () => {
if (app) await app.close();
});
});
이 테스트는 딱 한 가지만 검증한다. “서버가 뜨는가?”
그게 전부인데, 이게 DI 에러를 100% 잡아준다.
📊 데이터: NestJS 프로젝트에서
app.init()기반 스모크 테스트를 도입한 후, 배포 후 DI 관련 장애가 0건으로 줄었다. 테스트 실행 시간은 약 2~5초. 투자 대비 효과가 압도적이다.
CI 파이프라인에 추가
# .github/workflows/ci.yml
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- run: npx prisma generate # ORM 클라이언트 생성
- run: npm run test -- --testPathPattern=startup
# ✅ startup 스모크 테스트만 별도 실행
# DB 연결 없이도 DI 그래프 검증은 가능
⚠️ 주의: DB 연결이 필요한 프로바이더(예:
PrismaService)가 있으면 스모크 테스트에서 DB 연결 에러가 날 수 있다. 이 경우.env.test에 더미 DB URL을 넣거나,PrismaService를 모킹하는 방법이 있다. DI 그래프 검증이 목적이므로 실제 DB 연결은 필요 없다.
서버 기동 테스트 체크리스트
새 서비스/모듈을 추가할 때마다 이 순서를 따른다.
@Injectable()데코레이터 확인- 해당 모듈의
providers배열에 등록 확인 - 외부 모듈에서 사용한다면
exports+imports양방향 확인 npm run start또는npm run test -- --testPathPattern=startup으로 기동 확인- PR 올리기
💡 팁: NestJS CLI의
nest generate(nest g service notification,nest g module email)를 사용하면 2번까지 자동으로 처리된다. 수동으로 파일을 만드는 습관이 있다면, CLI를 쓰는 게 NestJS DI 에러 예방의 가장 확실한 방법이다.
📋 정리
NestJS DI 에러는 TypeScript 컴파일러가 잡아주지 않는다. “빌드 성공”과 “서버 기동 성공”은 완전히 다른 검증이다.
| 상황 | 안티패턴 | 권장 패턴 |
|---|---|---|
| 새 서비스 추가 | @Injectable() 빠뜨리고 빌드만 확인 | CLI(nest g) 사용 또는 데코레이터 필수 체크 |
| 외부 모듈 의존성 | providers에만 등록하고 exports/imports 누락 | 양방향 등록 확인 후 서버 기동 테스트 |
| 순환 참조 | forwardRef()로 땜질하고 넘어감 | 공통 모듈 분리로 의존 방향 정리 |
| PR 전 검증 | tsc --noEmit만 돌리고 PR | startup smoke test 포함한 CI 파이프라인 |
| 빌드 성공 후 배포 | 빌드 통과 = 배포 가능이라고 판단 | 서버 기동 테스트 통과 = 배포 가능 |
서버 기동 테스트는 2~5초밖에 안 걸린다. 이 2~5초가 프로덕션 장애 30분을 막아준다 ✨
📚 NestJS 실전 트러블슈팅 시리즈 (12편)
- 1. NestJS + Prisma에서 N+1 쿼리 문제 해결하기
- 2. NestJS CORS 삽질 총정리 — PATCH만 안 되는 이유
- 3. Prisma 마이그레이션 실수 방지 — 컬럼 누락 해결기
- 4. NestJS DTO 클래스 필수인 이유 — interface로 만들면 터지는 두 가지
- 5. NestJS FK 제약 위반 디버깅 — Level ID 검증으로 500 에러 잡기
- 6. Prisma enum vs 도메인 타입 캐스팅 함정 — TypeScript 타입 불일치 해결기
- 7. Seed 데이터 FK 삭제 순서 삽질 — Prisma deleteMany가 터지는 이유
- 8. NestJS DI 에러 디버깅 — Nest can't resolve dependencies 3가지 원인과 서버 기동 테스트
- 9. Docker 빌드에서 pnpm 모노레포 삽질 — 데코레이터 에러 3132개의 정체
- 10. NestJS 재귀 호출 무한루프 — API 504 타임아웃의 숨겨진 원인 찾기
- 11. Soft Delete 필터가 빠진 곳 찾기 — 삭제한 데이터가 되살아나는 미스터리
- 12. prisma generate 누락 — 빌드는 되는데 런타임 에러가 나는 이유