Nest.js Discovery Module
분류: Layer 0 - 런타임 & 프레임워크 기초 | 작성일: 2026-03-22 | 선수지식: DI/IoC
1. 한 줄 정의
섹션 제목: “1. 한 줄 정의”Nest.js Discovery Module은 런타임에 IoC Container에 등록된 Provider와 Controller를 탐색(scan)하고, 특정 메타데이터(데코레이터)가 붙은 클래스나 메서드를 동적으로 찾아내는 기능이다.
2. 왜 중요한가
섹션 제목: “2. 왜 중요한가”팀 내 슬랙봇에 적용되어 있는 패턴이다. “어떻게 데코레이터 하나로 특정 메서드가 자동으로 이벤트 핸들러로 등록되는가”의 답이 Discovery Module에 있다. 이 패턴을 이해하면 반복적인 보일러플레이트 없이 확장 가능한 코드 구조를 설계할 수 있다.
3. 핵심 개념
섹션 제목: “3. 핵심 개념”비유로 시작 — “도서관 사서”
도서관에 새 책(핸들러)이 들어올 때마다 사서(개발자)가 직접 카탈로그에 등록해야 한다면 번거롭다. 대신 책마다 스티커(데코레이터)를 붙여놓고, 도서관 시스템(DiscoveryModule)이 스티커를 인식해서 자동으로 카탈로그에 등록해주면 훨씬 편하다. 새 책이 생겨도 스티커만 붙이면 된다.
React 앱에서 pages/ 폴더에 파일을 추가하면 Next.js가 자동으로 라우트를 등록하는 것처럼, NestJS Discovery 패턴도 핸들러 클래스에 데코레이터만 붙이면 런타임에 자동으로 탐색·등록된다. 직접 등록 코드를 수정할 필요가 없다는 점이 동일한 철학이다.
왜 Discovery 패턴이 필요한가 — 설계 철학
“데코레이터 하나로 자동 등록”되는 패턴은 단순한 편의 기능이 아니다. 이 패턴의 핵심 가치는 개방-폐쇄 원칙(OCP) 구현이다. 새 핸들러를 추가할 때 기존 등록 코드를 수정하지 않아도 된다(폐쇄). 단지 새 클래스에 데코레이터만 붙이면 된다(개방). 이 패턴 없이 이벤트 핸들러를 직접 등록한다면, 핸들러가 추가될 때마다 중앙 등록 코드도 함께 수정해야 하는 “산탄총 수술(Shotgun Surgery)” 문제가 생긴다.
또한 Discovery Module은 NestJS의 IoC Container가 이미 모든 정보를 갖고 있다는 사실을 활용한다. 별도의 레지스트리를 만드는 것이 아니라, 컨테이너를 “쿼리”하는 방식이다. 이미 등록된 정보를 재활용하므로 성능 오버헤드가 없다.
내부 동작 원리 — 왜 이렇게 동작하는가
📖 더 보기: NestJS Discovery - DEV Community — DiscoveryService + MetadataScanner 패턴 실습 예제
DiscoveryService는 내부적으로 두 가지 핵심 컴포넌트로 동작한다:
-
ModulesContainer: IoC Container가 관리하는 모든 모듈과 Provider의 저장소.DiscoveryService.getProviders()는 이 컨테이너를 순회해InstanceWrapper객체 목록을 반환한다. 이 컨테이너는 Nest가NestFactory.create()를 호출할 때 이미 구성이 완료되므로,onModuleInit()시점에서는 모든 Provider 인스턴스가 준비된 상태다. -
MetadataScanner: 클래스 프로토타입을 순회하면서 각 메서드의 메타데이터를 읽는 도구. 내부적으로Object.getOwnPropertyNames(prototype)을 호출해 모든 메서드 이름을 수집한 뒤,Reflect.getMetadata()로 각 메서드에 붙은 메타데이터를 읽는다.
getProviders()가 반환하는 InstanceWrapper 구조:
{ metatype: UsersService, // 클래스 자체 (데코레이터 메타데이터 읽기에 사용) name: 'UsersService', // 클래스 이름 문자열 instance: <UsersService 인스턴스>, // 실제 인스턴스 (메서드 바인딩에 사용) // ...}⚠️ NestJS v10+ API 변경: scanFromPrototype → getAllMethodNames
NestJS v10부터 MetadataScanner의 scanFromPrototype() 메서드가 deprecated 처리되었다. 새로운 권장 방식은 getAllMethodNames()를 사용하는 것이다.
// ❌ 구버전 방식 (deprecated, v10+에서 경고 발생)this.metadataScanner.scanFromPrototype( instance, Object.getPrototypeOf(instance), (methodName) => { const metadata = Reflect.getMetadata("my_key", instance, methodName); if (metadata) { /* 처리 */ } },);
// ✅ 신버전 방식 (v10+ 권장)const methodNames = this.metadataScanner.getAllMethodNames( Object.getPrototypeOf(instance),);methodNames.forEach((methodName) => { const metadata = Reflect.getMetadata("my_key", instance, methodName); if (metadata) { /* 처리 */ }});getAllMethodNames는 결과를 캐싱하므로 같은 프로토타입에 대해 반복 호출해도 성능 저하가 없다.
동작 흐름 (상세)
앱 시작 (NestFactory.create()) → IoC Container가 모든 Provider 등록 완료 → onModuleInit() 실행 (이 시점에 모든 인스턴스가 준비됨) → DiscoveryService.getProviders() → InstanceWrapper[] 반환 → 각 wrapper.instance가 존재하는지 확인 (lazy 로딩 등으로 null일 수 있음) → MetadataScanner.getAllMethodNames()으로 각 메서드 이름 목록 획득 → Reflect.getMetadata(KEY, instance, methodName)으로 메타데이터 읽기 → 메타데이터가 있는 메서드를 자동 핸들러로 등록기본 사용 구조 (v10+ 기준)
import { DiscoveryModule, DiscoveryService, MetadataScanner,} from "@nestjs/core";
@Module({ imports: [DiscoveryModule] })export class EventModule implements OnModuleInit { constructor( private readonly discovery: DiscoveryService, private readonly metadataScanner: MetadataScanner, ) {}
onModuleInit() { const providers = this.discovery.getProviders(); providers.forEach((wrapper) => { const { instance } = wrapper; if (!instance || !Object.getPrototypeOf(instance)) return;
// v10+ 권장 방식 const methodNames = this.metadataScanner.getAllMethodNames( Object.getPrototypeOf(instance), ); methodNames.forEach((methodName) => { const metadata = Reflect.getMetadata("my_key", instance, methodName); if (metadata) { console.log( `Found handler: ${methodName} with metadata: ${metadata}`, ); // 예상 출력: // Found handler: handleMessage with metadata: message // Found handler: handleReaction with metadata: reaction_added } }); }); }}실제 활용 예 (슬랙봇 패턴, v10+ 방식)
📖 더 보기: NestJS 공식 문서 - Custom Decorators — SetMetadata, Reflector 공식 사용법 📖 더 보기: NestJS Custom Decorators & Discovery - Michael Guay — 커스텀 데코레이터 + Discovery 패턴 단계별 구현
// 1. 커스텀 데코레이터 정의 — SetMetadata로 'slack_event' 키에 이벤트 이름 저장export const SlackEvent = (event: string) => SetMetadata('slack_event', event);
// 2. 핸들러에 데코레이터 사용 — 개발자는 이것만 추가하면 됨@Injectable()export class MessageHandlers { @SlackEvent('message') handleMessage(payload: any) { console.log('메시지 수신:', payload.text); }
@SlackEvent('reaction_added') handleReaction(payload: any) { console.log('리액션 추가:', payload.reaction); }}
// 3. Discovery로 자동 탐색 후 등록 (v10+ getAllMethodNames 사용)onModuleInit() { const providers = this.discovery.getProviders(); providers.forEach(wrapper => { const { instance } = wrapper; if (!instance || !Object.getPrototypeOf(instance)) return;
const methodNames = this.metadataScanner.getAllMethodNames( Object.getPrototypeOf(instance), ); methodNames.forEach((methodName) => { const eventName = Reflect.getMetadata('slack_event', instance, methodName); if (eventName) { this.slackClient.on(eventName, instance[methodName].bind(instance)); // → 'message' → handleMessage, 'reaction_added' → handleReaction 자동 매핑 } }); });}// → 새 핸들러 추가 시 @SlackEvent('xxx')만 붙이면 자동 등록됨, 등록 코드 수정 불필요실행 후 예상 로그 출력 (디버깅 시):
[Nest] LOG [SlackEventScanner] Provider 탐색 시작...[Discovery] MessageHandlers.handleMessage → 이벤트: message[Discovery] MessageHandlers.handleReaction → 이벤트: reaction_added[Discovery] NotificationHandlers.handleMention → 이벤트: app_mention[Nest] LOG [SlackEventScanner] 총 3개 핸들러 자동 등록 완료메서드 레벨 vs 클래스 레벨 메타데이터 탐색 — 어떻게 다른가
메타데이터를 읽는 위치가 메서드냐 **클래스(constructor)**냐에 따라 Reflect.getMetadata() 호출 방법이 다르다. 이 차이를 모르면 메타데이터가 항상 undefined로 나오는 버그를 만나게 된다.
// 클래스 레벨 데코레이터 예시export const RateLimit = (limit: number) => SetMetadata("rate_limit", limit); // 클래스에 붙임
@RateLimit(100) // ← 클래스에 붙은 데코레이터@Controller("users")export class UsersController {}
// ✅ 클래스 레벨 메타데이터 읽기 — instance.constructor 또는 metatype 사용providers.forEach((wrapper) => { // 클래스 메타데이터는 instance가 아닌 metatype(클래스 자체)에서 읽음 const limit = Reflect.getMetadata("rate_limit", wrapper.metatype); if (limit) console.log(`${wrapper.name}: ${limit} req/min`);});
// 메서드 레벨 데코레이터 예시export const SlackEvent = (event: string) => SetMetadata("slack_event", event); // 메서드에 붙임
@Injectable()export class MessageHandlers { @SlackEvent("message") // ← 메서드에 붙은 데코레이터 handleMessage(payload: any) {}}
// ✅ 메서드 레벨 메타데이터 읽기 — instance + methodName 조합 사용methodNames.forEach((methodName) => { // 메서드 메타데이터는 instance(프로토타입)와 메서드 이름을 함께 전달 const event = Reflect.getMetadata("slack_event", instance, methodName); if (event) console.log(`${methodName} → 이벤트: ${event}`);});핵심 구분:
Reflect.getMetadata(key, target)→ 클래스 레벨,Reflect.getMetadata(key, target, propertyKey)→ 메서드 레벨. 세 번째 인자의 유무가 차이점이다.
Controller도 탐색하기 — getControllers() 활용
DiscoveryService는 Provider뿐 아니라 Controller도 탐색할 수 있다. NestJS는 Provider와 Controller를 내부적으로 다르게 관리하므로, Controller를 탐색할 때는 getControllers()를 따로 호출해야 한다. 예를 들어, 모든 Controller의 특정 데코레이터를 스캔하여 API 목록을 동적으로 수집할 수 있다.
onModuleInit() { // Provider 탐색 const providers = this.discovery.getProviders(); // Controller 탐색 (별도 호출 필요) const controllers = this.discovery.getControllers();
[...providers, ...controllers].forEach((wrapper) => { const { instance } = wrapper; if (!instance || !Object.getPrototypeOf(instance)) return;
// Controller에 붙은 @RateLimit() 데코레이터 탐색 예시 const rateLimit = Reflect.getMetadata('rate_limit', instance.constructor); if (rateLimit) { console.log(`[RateLimit] ${wrapper.name}: ${rateLimit} req/min`); // 예상 출력: // [RateLimit] UsersController: 100 req/min // [RateLimit] PaymentController: 20 req/min } });}3.5 Discovery 패턴 Before/After 비교
섹션 제목: “3.5 Discovery 패턴 Before/After 비교”Discovery 패턴이 왜 필요한지는 “없을 때 어떤 문제가 생기는가”를 보면 명확하다.
Before: 직접 등록 방식 — 핸들러가 늘어날수록 중앙 등록 코드도 함께 늘어남
// ❌ Before: 핸들러를 중앙 서비스에 수동으로 등록// slack-event.service.ts — 핸들러가 추가될 때마다 이 파일도 수정해야 함@Injectable()export class SlackEventService { private handlers = new Map<string, Function>();
constructor( private messageHandlers: MessageHandlers, private reactionHandlers: ReactionHandlers, private mentionHandlers: MentionHandlers, // 새 핸들러 추가 시 여기도 추가 ) { // 이벤트 이름 → 핸들러 함수 수동 매핑 (산탄총 수술) this.handlers.set( "message", this.messageHandlers.handle.bind(this.messageHandlers), ); this.handlers.set( "reaction_added", this.reactionHandlers.handle.bind(this.reactionHandlers), ); this.handlers.set( "app_mention", this.mentionHandlers.handle.bind(this.mentionHandlers), ); // 새 항목 // → 핸들러 10개 추가 시 이 파일에 10줄 추가, 생성자에 10개 주입 필요 }
dispatch(event: string, payload: any) { const handler = this.handlers.get(event); if (!handler) return; handler(payload); }}After: Discovery 패턴 — 핸들러 클래스에 데코레이터만 붙이면 자동 등록
// ✅ After: 핸들러 클래스에 @SlackEvent() 붙이기만 하면 끝@Injectable()export class MentionHandlers { @SlackEvent("app_mention") // ← 이것만 추가, 등록 코드 수정 불필요 handleMention(payload: any) { console.log("멘션 수신:", payload.text); }}
// slack-event.service.ts — 핸들러가 추가돼도 이 파일은 변경 없음@Injectable()export class SlackEventService implements OnModuleInit { constructor( private readonly discovery: DiscoveryService, private readonly metadataScanner: MetadataScanner, ) {}
onModuleInit() { // 앱 전체를 한 번 스캔해서 @SlackEvent() 붙은 메서드를 자동 등록 this.discovery.getProviders().forEach((wrapper) => { const { instance } = wrapper; if (!instance || !Object.getPrototypeOf(instance)) return; this.metadataScanner .getAllMethodNames(Object.getPrototypeOf(instance)) .forEach((methodName) => { const eventName = Reflect.getMetadata( "slack_event", instance, methodName, ); if (eventName) { this.slackClient.on(eventName, instance[methodName].bind(instance)); } }); }); // → 새 핸들러 클래스가 생겨도 이 코드는 전혀 변경하지 않아도 됨 }}핵심 차이: Before 방식은 새 핸들러가 생길 때마다 최소 2~3곳(생성자 파라미터, 등록 코드, 모듈 imports)을 수정해야 하지만, After 방식은 새 클래스에 @SlackEvent() 하나만 붙이면 된다.
3.6 패턴 전이 — 다른 프레임워크에서의 동일 원리
섹션 제목: “3.6 패턴 전이 — 다른 프레임워크에서의 동일 원리”Discovery 패턴은 NestJS만의 고유 기능이 아니다. “어노테이션/데코레이터를 기반으로 런타임에 컴포넌트를 자동 탐색·등록한다” 는 원리는 여러 프레임워크에서 동일하게 적용된다. 이 원리를 알면 새로운 프레임워크를 만났을 때 “이건 Discovery 패턴이구나”라고 즉시 인식할 수 있다.
| 프레임워크 | 탐색 메커니즘 | NestJS Discovery와의 공통 원리 |
|---|---|---|
| Spring | ClassPathScanningCandidateComponentProvider — @Component, @Service, @Repository 등 스테레오타입 어노테이션이 붙은 클래스를 클래스패스에서 자동 스캔 | 어노테이션 마커 → 자동 등록. @ComponentScan(basePackages)가 NestJS의 DiscoveryModule import에 해당 |
| Angular | ModuleInjector 계층 — @NgModule.providers와 @Injectable({ providedIn: 'root' })로 등록된 서비스를 Injector 트리에서 자동 해석 | 모듈 단위 Provider 등록 + 계층적 탐색. Angular의 @Injectable() 데코레이터가 NestJS의 @Injectable() + SetMetadata()에 대응 |
| Java (일반) | javax.annotation.processing — 컴파일 타임에 어노테이션을 스캔하여 코드를 생성하거나 등록 (예: Dagger2, MapStruct) | 마커 기반 자동 처리. 차이점은 Java는 컴파일 타임, NestJS는 런타임에 수행 |
전이 사고 모델: “마커(데코레이터/어노테이션)를 붙이면, 스캐너가 자동으로 찾아서 등록한다”는 패턴이 핵심이다. 새로운 프레임워크에서
@Component,@Injectable,@Bean,@Route같은 마커를 보면 “어딘가에 이것을 스캔하는 Discovery 로직이 있을 것”이라고 추론할 수 있다.
- 📖 Spring Classpath Scanning and Managed Components — Spring의
@ComponentScan동작 원리 - 📖 Angular Hierarchical Dependency Injection — Angular Injector 트리의 Provider 해석 과정
3.7 Discovery 패턴의 Trade-off — 언제 쓰지 말아야 하는가
섹션 제목: “3.7 Discovery 패턴의 Trade-off — 언제 쓰지 말아야 하는가”Discovery 패턴은 강력하지만, 모든 상황에 적합한 것은 아니다. 직접 등록이 더 나은 경우를 알아야 올바른 판단을 내릴 수 있다.
| 고려 사항 | Discovery 패턴 (자동 탐색) | 직접 등록 (수동 매핑) |
|---|---|---|
| 부팅 시간 | 전체 Provider를 순회하므로 Provider 수에 비례하여 부팅 시간 증가. 대규모 앱(수백 개 Provider)에서 체감됨 | 등록 대상만 명시하므로 부팅 시간 예측 가능 |
| 디버깅 투명성 | ”이 핸들러가 왜 등록됐는지/안 됐는지” 추적이 어려움. 메타데이터 키 오타 시 silent failure | 등록 코드를 보면 즉시 파악 가능 |
| 테스트 모킹 | 테스트에서 특정 핸들러만 격리하려면 Discovery 스캔 범위를 제한해야 함. OverrideProvider나 별도 테스트 모듈 구성 필요 | 테스트에서 원하는 핸들러만 직접 주입하면 됨 |
| 모듈 캡슐화 | DiscoveryService는 모듈 경계를 넘어 Provider를 탐색할 수 있어, 의도치 않은 내부 서비스 노출 위험 | 명시적 exports로 캡슐화 유지 |
직접 등록이 더 나은 경우:
- 핸들러가 3~5개 이하로 적고 변경 빈도가 낮을 때 — 자동 탐색의 이점보다 명시적 코드의 가독성이 중요
- 등록 순서가 중요한 경우 — Discovery 패턴은 탐색 순서를 보장하지 않음
- 엄격한 모듈 캡슐화가 필요한 라이브러리 설계 시 — 외부에서 내부 Provider를 스캔할 수 없어야 할 때
Discovery 패턴이 적합한 경우:
- 핸들러가 지속적으로 추가되는 플러그인 구조 (10개 이상, 여러 모듈에 분산)
- 팀 규모가 커서 중앙 등록 코드의 merge conflict가 빈번할 때
- 데코레이터 기반의 선언적 프로그래밍 스타일을 팀이 선호할 때
📖 NestJS GitHub Issue #17638 - Auto-discovery boot time — 대규모 앱에서 auto-discovery가 부팅 시간에 미치는 영향 사례
4. 실무에서 어디에 쓰이나
섹션 제목: “4. 실무에서 어디에 쓰이나”- 슬랙봇 이벤트 핸들러 자동 등록
- CQRS 패턴에서 Command/Event Handler 자동 탐색
- 커스텀 스케줄러, 메시지 리스너 자동 등록
- 플러그인 시스템 구현
- API 권한/Rate Limit 데코레이터를 전체 Controller에서 한 번에 수집해 적용
📦 더 보기: @golevelup/nestjs-discovery — Discovery 패턴을 더 편리하게 쓸 수 있는 헬퍼 라이브러리 (providersWithMetaAtKey 등 편의 메서드 제공)
BackOps 실무 시나리오
- 슬랙봇에 새 이벤트(예:
app_home_opened)를 추가할 때:@SlackEvent('app_home_opened')만 붙이면 탐색·등록 코드 수정 없이 자동 동작 - 내부 어드민 도구에서 특정 데코레이터(
@AdminOnly())가 붙은 엔드포인트 목록을 자동 수집해 권한 체계 구성 - 기존 코드베이스에서 Discovery 패턴이 사용된 곳을 찾을 때:
DiscoveryService,MetadataScanner키워드로 검색
5. 현재 내 업무와 연결점
섹션 제목: “5. 현재 내 업무와 연결점”- 팀 슬랙봇 코드에서 이 패턴이 어떻게 쓰이는지 파악
- 새 슬랙 이벤트 핸들러를 추가할 때 올바른 방법 이해
- 유사 패턴을 다른 내부 도구에 적용 가능한지 판단
6. 자주 헷갈리는 개념 비교
섹션 제목: “6. 자주 헷갈리는 개념 비교”| 개념 A | 개념 B | 차이점 |
|---|---|---|
| DiscoveryService | Reflector | Discovery는 전체 Provider 탐색, Reflector는 특정 클래스/메서드의 메타데이터 읽기 |
| onModuleInit | onApplicationBootstrap | onModuleInit은 모듈 초기화 시, onApplicationBootstrap은 전체 앱 초기화 후 |
| SetMetadata | 일반 데코레이터 | SetMetadata는 Reflect에 데이터를 저장, 나중에 읽을 수 있음 |
| scanFromPrototype | getAllMethodNames | scanFromPrototype은 v10+에서 deprecated, getAllMethodNames가 권장 방식 |
| getProviders | getControllers | getProviders는 Service 탐색, getControllers는 Controller 탐색 (별도 호출 필요) |
6.5 트러블슈팅
섹션 제목: “6.5 트러블슈팅”🔧 Provider를 탐색했는데 핸들러가 등록되지 않음
섹션 제목: “🔧 Provider를 탐색했는데 핸들러가 등록되지 않음”증상: 데코레이터를 붙였는데 이벤트 핸들러가 동작하지 않음. getProviders()에서 해당 클래스가 보이지 않음
원인: @Injectable()을 달았지만 해당 클래스가 어떤 모듈의 providers에도 등록되지 않음. DiscoveryService는 IoC Container에 등록된 Provider만 탐색하기 때문에, 등록되지 않은 클래스는 찾을 수 없음
해결:
- 핸들러 클래스가 모듈의
providers에 등록되어 있는지 확인 - DiscoveryModule이
imports에 포함되어 있는지 확인
@Module({ imports: [DiscoveryModule], // ← 필수 providers: [MessageHandlers], // ← 핸들러 클래스 등록 필수})export class SlackModule {}🔧 wrapper.instance가 null이어서 TypeError 발생
섹션 제목: “🔧 wrapper.instance가 null이어서 TypeError 발생”증상: Cannot read properties of null (reading 'constructor') 같은 런타임 에러
원인: getProviders()는 모든 Provider 래퍼를 반환하는데, 일부는 아직 인스턴스가 생성되지 않은 상태(instance: null)일 수 있음. 특히 동적 모듈이나 lazy-loaded 모듈에서 발생
해결: 반드시 instance null 체크를 추가
providers.forEach((wrapper) => { const { instance } = wrapper; // ✅ null 체크 필수 if (!instance || !Object.getPrototypeOf(instance)) return; // 이후 로직 진행});🔧 onModuleInit에서 탐색해도 Provider가 없음
섹션 제목: “🔧 onModuleInit에서 탐색해도 Provider가 없음”증상: getProviders()를 호출했는데 예상한 Provider가 목록에 없음
원인: DiscoveryModule을 imports에 추가했지만, 탐색 대상 Provider가 있는 모듈을 imports하지 않아서 해당 Provider가 현재 모듈 컨텍스트에 없음
해결:
- 탐색 대상 Provider가 있는 모듈을
imports에 추가 - 또는 탐색 모듈을
AppModule레벨에 위치시켜 전체 Provider에 접근 가능하게 함
// 전체 앱 레벨에서 탐색하려면 AppModule에 위치@Module({ imports: [DiscoveryModule, UsersModule, OrdersModule], // 탐색 대상 모듈 모두 포함 providers: [EventScannerService],})export class AppModule {}🔧 scanFromPrototype is deprecated 경고
섹션 제목: “🔧 scanFromPrototype is deprecated 경고”증상: NestJS v10+로 업그레이드 후 콘솔에 deprecation 경고 발생
DeprecationWarning: MetadataScanner#scanFromPrototype is deprecated.Please use MetadataScanner#getAllMethodNames instead.원인: NestJS v10부터 scanFromPrototype()이 deprecated됨
해결: getAllMethodNames()로 마이그레이션
// ❌ 구버전 방식this.metadataScanner.scanFromPrototype( instance, Object.getPrototypeOf(instance), (name) => { const meta = Reflect.getMetadata("key", instance, name); },);
// ✅ 신버전 방식 (v10+)const methodNames = this.metadataScanner.getAllMethodNames( Object.getPrototypeOf(instance),);methodNames.forEach((name) => { const meta = Reflect.getMetadata("key", instance, name);});🔧 getProviders(metadataKey) 호출 시 크래시 발생
섹션 제목: “🔧 getProviders(metadataKey) 호출 시 크래시 발생”증상: 메타데이터 키를 인자로 넘겨 getProviders('my_key') 형태로 호출했을 때 런타임 에러 또는 빈 배열 반환
TypeError: Cannot read properties of undefined (reading 'get')원인: NestJS v11 이전 버전의 버그로, getProviders(metadataKey) 또는 getControllers(metadataKey) 호출 시 해당 메타데이터가 단 한 번도 적용되지 않은 경우(즉, 데코레이터가 아무 클래스에도 붙어있지 않은 경우) 내부의 wrappersByMetaKey Map이 undefined가 되어 에러가 발생함
해결:
// ❌ 데코레이터가 아무 곳에도 없으면 크래시 가능const handlers = this.discovery.getProviders("slack_event");
// ✅ try-catch 또는 메타데이터 키 없이 전체 탐색 후 직접 필터링try { const handlers = this.discovery.getProviders("slack_event");} catch (e) { console.warn("slack_event 메타데이터가 적용된 Provider 없음");}
// 또는 전체 탐색 후 수동 필터 (더 안전)const providers = this.discovery.getProviders(); // 인자 없이 전체 가져오기providers.filter((wrapper) => { const meta = Reflect.getMetadata("slack_event", wrapper.metatype); return !!meta;});🔧 SetMetadata 키 오타로 핸들러가 silent하게 등록되지 않음
섹션 제목: “🔧 SetMetadata 키 오타로 핸들러가 silent하게 등록되지 않음”증상: 데코레이터를 붙이고 Provider도 모듈에 등록했는데, Discovery 스캔 시 해당 핸들러가 발견되지 않음. 에러 메시지도 없이 조용히 무시됨
원인: SetMetadata()에 사용한 키 문자열과 Reflect.getMetadata()에서 읽는 키 문자열이 다름 (오타). Reflect.getMetadata()는 키가 일치하지 않으면 에러를 던지지 않고 undefined를 반환하므로, if (metadata) 조건에서 조용히 걸러진다.
// ❌ 키 오타 — 'slack_event' vs 'slack_events' (복수형 오타)export const SlackEvent = (event: string) => SetMetadata("slack_events", event); // 's' 추가 오타
// Discovery 스캔 코드const eventName = Reflect.getMetadata("slack_event", instance, methodName); // 원래 키// → eventName = undefined (오타 때문에 매칭 실패)// → if (eventName) 조건에서 걸러짐 → 핸들러 등록 안 됨 → 에러 없음!해결: 메타데이터 키를 상수(constant) 또는 Symbol로 정의하여 한 곳에서 관리. IDE 자동완성과 타입 체크가 가능해져 오타를 원천 차단한다.
// ✅ 상수로 키 관리 — 오타 방지export const SLACK_EVENT_KEY = "slack_event"; // 단일 출처(Single Source of Truth)
// 데코레이터 정의export const SlackEvent = (event: string) => SetMetadata(SLACK_EVENT_KEY, event);
// Discovery 스캔 코드const eventName = Reflect.getMetadata(SLACK_EVENT_KEY, instance, methodName);// → 같은 상수를 참조하므로 오타 불가능
// ✅ Symbol 사용 — 더 강력한 충돌 방지export const SLACK_EVENT_KEY = Symbol("slack_event");디버깅 팁: 핸들러가 등록되지 않을 때, 스캔 루프에 Reflect.getMetadataKeys()를 추가하여 실제 등록된 키 목록을 확인한다.
methodNames.forEach((methodName) => { const allKeys = Reflect.getMetadataKeys(instance, methodName); console.log( `[Debug] ${wrapper.name}.${methodName} 메타데이터 키 목록:`, allKeys, ); // 예상 출력 (오타 시): ['slack_events'] ← 's'가 붙어있음을 즉시 발견});🔧 재사용 가능한 라이브러리 모듈에서 DiscoveryService 주입 실패
섹션 제목: “🔧 재사용 가능한 라이브러리 모듈에서 DiscoveryService 주입 실패”증상: 별도 npm 패키지로 만든 NestJS 모듈에서 DiscoveryService를 사용하려 하면 에러 발생
Nest can't resolve dependencies of the DiscoveryService (?).Please make sure that the argument ModulesContainer at index [0]is available in the DiscoveryModule context.원인: DiscoveryService는 내부적으로 ModulesContainer를 주입받는데, 외부 패키지에서 별도의 NestJS 인스턴스를 참조하면 ModulesContainer가 애플리케이션의 것과 달라질 수 있다. 주로 node_modules에 @nestjs/core가 중복 설치된 경우(버전 불일치) 발생한다.
해결:
# 1. @nestjs/core 중복 설치 확인npm ls @nestjs/core# 예상 출력 (문제 있을 때):# ├── @nestjs/core@10.3.0# └─┬ my-nestjs-library@1.0.0# └── @nestjs/core@10.2.0 ← 버전 불일치!
# 2. 라이브러리의 package.json에서 @nestjs/core를 peerDependencies로 설정# (dependencies가 아닌 peerDependencies에 넣어야 호스트 앱의 인스턴스를 공유)// 라이브러리 package.json — peerDependencies로 선언{ "peerDependencies": { "@nestjs/core": "^10.0.0 || ^11.0.0" }}7. 체크리스트
섹션 제목: “7. 체크리스트”- Discovery Module이 무엇을 하는지 한 문장으로 설명할 수 있다
- 슬랙봇 코드에서 Discovery Module이 어디에 쓰이는지 찾을 수 있다
- 커스텀 데코레이터 + Discovery 패턴의 흐름을 설명할 수 있다
- DiscoveryService와 Reflector의 차이를 설명할 수 있다
- NestJS v10+에서
getAllMethodNames를 써야 하는 이유를 설명할 수 있다
8. 추가 학습 키워드
섹션 제목: “8. 추가 학습 키워드”MetadataScanner, Reflector, SetMetadata, Reflect.getMetadata, CQRS module, Custom decorator, onModuleInit, getAllMethodNames, InstanceWrapper, getControllers
8.5 추천 리소스
섹션 제목: “8.5 추천 리소스”- 📖 NestJS 공식 문서 - Discovery Service — DiscoveryModule, getProviders, getControllers 공식 설명 (입문)
- 📖 NestJS 공식 문서 - Custom Decorators — SetMetadata, Reflector 공식 사용법 (입문)
- 📖 NestJS Discovery - DEV Community — DiscoveryService + MetadataScanner 패턴 실습 예제 (중급)
- 📖 NestJS Custom Decorators & Discovery - Michael Guay — 커스텀 데코레이터 + Discovery 패턴 단계별 구현 (중급)
- 📦 @golevelup/nestjs-discovery — Discovery 패턴을 더 편리하게 쓸 수 있는 헬퍼 라이브러리,
providersWithMetaAtKey등 편의 메서드 제공 (중급) - 📖 Spring Classpath Scanning and Managed Components — Spring의
@ComponentScan+ClassPathScanningCandidateComponentProvider동작 원리 (패턴 전이 참고) - 📖 Angular Hierarchical Dependency Injection — Angular Injector 트리의 Provider 해석 과정 (패턴 전이 참고)
- 📖 reflect-metadata - npm — Metadata Reflection API 폴리필,
Reflect.getMetadata/getMetadataKeys레퍼런스
9. 내가 직접 확인해볼 것
섹션 제목: “9. 내가 직접 확인해볼 것”- 슬랙봇 코드에서 Discovery Module 사용 부분 찾아서 흐름 파악
# 프로젝트에서 DiscoveryService 사용 위치 확인grep -rn "DiscoveryService\|DiscoveryModule" src/ --include="*.ts"# 예상 출력:# src/slack/slack.module.ts:3:import { DiscoveryModule } from '@nestjs/core';# src/slack/slack-event.service.ts:5:import { DiscoveryService, MetadataScanner } from '@nestjs/core';-
@nestjs/core에서 DiscoveryService import하는 곳 검색
grep -rn "SetMetadata\|Reflect.getMetadata" src/ --include="*.ts"# 예상 출력:# src/slack/decorators/slack-event.decorator.ts:2:export const SlackEvent = (event: string) => SetMetadata('slack_event', event);- MetadataScanner가 어떤 메서드를 탐색하는지 확인 (v10+ 방식)
// 간단한 디버깅 코드로 탐색 결과 확인onModuleInit() { const providers = this.discovery.getProviders(); console.log('전체 Provider 수:', providers.length);
providers.forEach(wrapper => { const { instance, name } = wrapper; if (!instance || !Object.getPrototypeOf(instance)) return;
// v10+ 방식 const methodNames = this.metadataScanner.getAllMethodNames( Object.getPrototypeOf(instance), ); methodNames.forEach((methodName) => { const meta = Reflect.getMetadata('slack_event', instance, methodName); if (meta) console.log(`[Discovery] ${name}.${methodName} → 이벤트: ${meta}`); }); });}// 예상 출력:// 전체 Provider 수: 12// [Discovery] MessageHandlers.handleMessage → 이벤트: message// [Discovery] MessageHandlers.handleReaction → 이벤트: reaction_added- Controller도 함께 탐색이 필요한지 확인
// Provider와 Controller를 함께 탐색할 때의 패턴onModuleInit() { const all = [ ...this.discovery.getProviders(), ...this.discovery.getControllers(), // ← Controller는 별도로 가져와야 함 ]; console.log(`탐색 대상 총 ${all.length}개 (Provider + Controller 합산)`); // 예상 출력: // 탐색 대상 총 18개 (Provider + Controller 합산)}10. 핵심 요약
섹션 제목: “10. 핵심 요약”| 항목 | 핵심 내용 |
|---|---|
| DiscoveryService | IoC Container에 등록된 전체 Provider/Controller를 반환 |
| MetadataScanner | 클래스의 모든 메서드 이름을 수집 (v10+: getAllMethodNames) |
| Reflect.getMetadata | 메서드에 붙은 커스텀 데코레이터 값을 읽음 |
| 탐색 시점 | onModuleInit() — 이 시점에 모든 인스턴스가 준비됨 |
| null 체크 | wrapper.instance가 null일 수 있으므로 반드시 체크 필요 |
5줄 핵심
- Discovery Module은 런타임에 전체 Provider를 탐색하는 도구다
- 커스텀 메타데이터(데코레이터)가 붙은 메서드를 자동으로 찾아낼 수 있다
- “데코레이터만 붙이면 자동 등록”되는 패턴이 이 모듈로 구현된다
- 슬랙봇의 이벤트 핸들러 자동 등록이 이 패턴의 실제 적용 사례다
- DI/IoC를 이해한 다음에 봐야 흐름이 이해된다 (선수지식:
@Injectable(), IoC Container)
11. 다음 학습 경로
섹션 제목: “11. 다음 학습 경로”Nest.js Discovery Module (지금 여기) ↓Reflector + ExecutionContext ← 요청 처리 중 메타데이터 읽기 (Guard/Interceptor에서 활용) ↓NestJS CQRS Module ← Discovery 패턴이 내부적으로 적용된 실제 사례 코드 분석 ↓커스텀 Decorator 심화 ← createParamDecorator, applyDecorators 패턴 ↓Dynamic Module 작성 ← Discovery + forRoot() 패턴으로 재사용 가능한 라이브러리 설계인터뷰 대비 핵심 질문
- “Discovery Module은 왜
onModuleInit()에서 사용하는가?” - “
DiscoveryService와Reflector의 차이는?” - “v10+에서
scanFromPrototype대신getAllMethodNames를 써야 하는 이유는?” - “이 패턴으로 어떤 실무 문제를 해결했는가?” (슬랙봇 핸들러 자동 등록 사례 답변)
- “Discovery 패턴은 SOLID의 어떤 원칙과 연결되는가?” (OCP — 새 핸들러 추가 시 기존 코드 수정 불필요)
[2차 심화] onModuleInit vs onApplicationBootstrap — 언제 어느 것을 써야 하는가
Discovery 코드를 어느 Lifecycle Hook에서 실행할지 혼동하는 경우가 많다.
| Hook | 실행 시점 | Discovery에 적합한가 |
|---|---|---|
onModuleInit() | 해당 모듈의 의존성이 모두 해결된 직후 | 권장 — 이 시점에 모든 Provider 인스턴스가 준비됨 |
onApplicationBootstrap() | 모든 모듈 초기화 완료 후, 연결 수신 시작 전 | 마이크로서비스 트랜스포트가 필요한 경우에만 |
실무에서는 onModuleInit()가 표준 선택이다. 단, NestJS의 마이크로서비스 패턴에서 트랜스포트 설정이 bootstrap 함수에 위치할 경우, onModuleInit()에서 메시지 패턴 등록이 완료되기 전에 연결이 열릴 수 있다. 이 경우에만 onApplicationBootstrap()으로 늦춰서 실행한다.
// ✅ 표준 패턴 — onModuleInit (대부분의 경우)@Module({ imports: [DiscoveryModule] })export class SlackModule implements OnModuleInit { onModuleInit() { // 이 시점에 SlackModule의 모든 Provider가 준비됨 this.scanAndRegisterHandlers(); }}
// 마이크로서비스 패턴처럼 bootstrap 이후 연결이 열리는 경우@Module({ imports: [DiscoveryModule] })export class MessageBusModule implements OnApplicationBootstrap { onApplicationBootstrap() { // 모든 모듈 초기화 완료 후에 핸들러 등록 this.scanAndRegisterHandlers(); }}[2차 심화] @golevelup/nestjs-discovery — 언제 직접 구현 대신 라이브러리를 쓰는가
직접 DiscoveryService + MetadataScanner를 구현하면 반복 코드가 많다. @golevelup/nestjs-discovery는 이를 한 줄로 줄여준다.
// 직접 구현 — 보일러플레이트 多const providers = this.discovery.getProviders();providers.forEach((wrapper) => { const { instance } = wrapper; if (!instance || !Object.getPrototypeOf(instance)) return; const methodNames = this.metadataScanner.getAllMethodNames( Object.getPrototypeOf(instance), ); methodNames.forEach((name) => { const meta = Reflect.getMetadata("slack_event", instance, name); if (meta) handlers.push({ instance, methodName: name, meta }); });});
// @golevelup/nestjs-discovery 사용 — 한 줄로 동일한 결과import { DiscoveryService } from "@golevelup/nestjs-discovery";
const handlers = await this.discovery.providersWithMetaAtKey<string>("slack_event");// → [{ meta: 'message', discoveredMethod: { handler, methodName, parentClass } }, ...]// 예상 출력:// [// { meta: 'message', discoveredMethod: { methodName: 'handleMessage', parentClass: { name: 'MessageHandlers' } } },// { meta: 'reaction_added', discoveredMethod: { methodName: 'handleReaction', ... } }// ]직접 구현이 적합한 경우: 커스텀 필터링 로직이 복잡하거나, 팀 내에서 동작을 완전히 제어해야 할 때. 라이브러리가 적합한 경우: 빠르게 적용하고 유지보수 부담을 줄이고 싶을 때, 프로덕션 검증이 중요한 경우.
최종 수정: 2026-04-13