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에 있다. 이 패턴을 이해하면 반복적인 보일러플레이트 없이 확장 가능한 코드 구조를 설계할 수 있다.
2.5 Lineage — DI/IoC만 있을 때 어디가 비어 있었나
섹션 제목: “2.5 Lineage — DI/IoC만 있을 때 어디가 비어 있었나”선수지식 DI/IoC는 “인스턴스를 누가 만들고 누구에게 주입하는가” 까지 해결한다 — 컨테이너가 @Injectable() 클래스를 생성·캐시하고 생성자에 주입한다. 그러나 “어떤 인스턴스의 어떤 메서드가 어떤 외부 이벤트(슬랙 메시지·CQRS Event·Cron)에 반응할 것인가” 라는 라우팅 문제는 DI 단독으로 풀리지 않는다. DI만 있는 상태에서는 다음 코드가 굳어진다.
// DI는 주입까지만 해준다 — 라우팅은 사람이 매번 직접 적는다constructor( private msg: MessageHandlers, // ← 새 핸들러마다 생성자에 줄 추가 private react: ReactionHandlers, private mention: MentionHandlers,) { this.bus.on("message", this.msg.handle.bind(this.msg)); // ← 그리고 여기도 this.bus.on("reaction_added", this.react.handle.bind(this.react)); this.bus.on("app_mention", this.mention.handle.bind(this.mention));}핸들러 N개를 더하면 같은 중앙 파일에 ~2N줄을 손대야 하고, 누락은 컴파일 에러 없이 런타임에 침묵(silent failure)으로 나타난다. 즉 DI/IoC는 “객체 그래프”까지만 만든다 — 그 그래프 위에 “마커 기반 라우팅” 층을 얹는 것이 Discovery Module의 역할이다.
선례 — Spring이 같은 한계를 먼저 풀었다. Spring 초기에는 모든 bean을 <bean id="..." class="..."/> XML로 일일이 등록해야 했고, 새 컴포넌트마다 XML이 한 줄씩 늘었다(위 NestJS 생성자 코드와 동형 문제). Spring 2.5에서 @ComponentScan + 스테레오타입(@Component/@Service/@Repository)이 도입되면서 클래스에 마커만 붙이면 classpath 스캐너가 자동 등록하는 패턴으로 전환되었다 — 공식 문서는 그 의도를 “This removes the need to use XML to perform bean registration” 으로 명시한다(Spring Reference — Classpath Scanning). NestJS Discovery Module은 이 원리를 데코레이터 메타데이터와 IoC Container 위로 옮긴 것이다 — 마커는 @Injectable() + 커스텀 데코레이터, 스캐너는 DiscoveryService.getProviders() + Reflect.getMetadata() 조합이다(NestJS 공식 — Discovery Service).
해결 메커니즘이 줄이는 비용: 핸들러 추가 시 중앙 등록 코드 변경량을 O(N)에서 O(1) 로 떨어뜨린다(개방-폐쇄 원칙의 정량적 표현). 단 비용 분포가 바뀐 것이지 사라진 것은 아니다 — Discovery 스캔과 그 동반 객체 그래프 비용이 부팅 시간에 누적되며, 실측으로는 Provider 그래프 복잡도가 50ms → 12s를 만든 사례까지 보고됐다(아래 3.7 참조).
이 토픽이 사라지면 깨지는 것: 슬랙봇 이벤트 라우팅(현 BackOps 코드), @nestjs/cqrs의 @EventsHandler·@CommandHandler 자동 등록, @nestjs/schedule의 @Cron/@Interval 등록 — 모두 마커 기반 자동 탐색에 의존한다. Discovery가 빠지면 위 생성자 코드 패턴으로 회귀하고, 머지 충돌 빈도와 silent failure 표면적이 핸들러 수에 비례해 늘어난다.
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 로직이 있을 것”이라고 추론할 수 있다.
낯선 프레임워크에서 Discovery 메커니즘을 분해하는 4가지 질문
마커 기반 자동 등록을 지원하는 프레임워크는 모두 아래 4가지에 답할 수 있다. 4개 모두에 답하지 못한다면 그 프레임워크의 Discovery 메커니즘을 아직 다 파악하지 못한 것이다.
- 마커는 무엇인가? — 어떤 어노테이션/데코레이터가 자동 등록 대상을 표시하는가?
- 스캐너는 누구인가? — 마커를 읽어 등록을 수행하는 클래스/함수는 무엇인가?
- 스캔 시점은 언제인가? — 컴파일 타임인가 런타임인가, 런타임이면 어느 Lifecycle Hook인가?
- 스캔 범위는 어디까지인가? — 모듈 단위인가 전체 클래스패스인가, 외부 의존성도 포함되는가?
적용 사례 1 — Spring @ComponentScan (이 4질문으로 분해)
Spring 공식 문서에 4질문을 적용하면: (1) 마커는 @Component/@Service/@Repository/@Controller 스테레오타입, (2) 스캐너는 ClassPathScanningCandidateComponentProvider, (3) 시점은 ApplicationContext refresh 단계의 런타임, (4) 범위는 @ComponentScan(basePackages=...)로 지정된 패키지 트리. NestJS와 결정적으로 다른 지점은 (4)다 — Spring은 “패키지 경로 문자열”, NestJS는 “모듈 imports 그래프”. 이 차이를 인지하면 Spring → NestJS 마이그레이션의 핵심 결정이 “패키지 경로를 어떻게 모듈로 분할할까”임이 즉시 보인다.
적용 사례 2 — NestJS CQRS 모듈 (같은 질문, 다른 답)
NestJS 안에서도 패턴이 중첩된다. CQRS 모듈(@nestjs/cqrs/event-bus.ts)에 4질문을 적용하면: (1) 마커는 @EventsHandler(EventClass), (2) 스캐너는 모듈 익스플로러가 사전 탐색한 InstanceWrapper[]를 받아 EventBus.register()가 Reflect.getMetadata(EVENTS_HANDLER_METADATA, handler)로 이벤트→핸들러 라우팅을 구성, (3) 시점은 onApplicationBootstrap() (모든 모듈 초기화 후), (4) 범위는 CQRS 모듈을 imports한 모듈 트리. 즉 CQRS는 본 문서의 패턴을 한 단계 더 추상화한 사례다 — Discovery + 메타데이터 라우팅으로 Command/Event 분기까지 자동화한 것.
- 📖 Spring Classpath Scanning and Managed Components — Spring의
@ComponentScan동작 원리 - 📖 Angular Hierarchical Dependency Injection — Angular Injector 트리의 Provider 해석 과정
- 📖
@nestjs/cqrsEventBus 소스 —EventBus.register(InstanceWrapper[])+EVENTS_HANDLER_METADATA라우팅 패턴
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 공식 이슈 #12738의 실측: 동일 Provider 구성에서 Static Module + useValue는 부팅 50ms였지만, Dynamic Module + useValue로 바꾸면 ~12초(약 200배 감속)였다. 사용하지 않는 Mongoose 스키마 1개를 제거했더니 12s → 2s(6배 개선)로 떨어졌다. 즉 Discovery 스캔 자체가 아니라 Provider 트리의 객체 그래프 복잡도가 부팅 시간의 지배 변수다.
이 측정에서 도출되는 결정 규칙: 부팅이 느려졌을 때 “Discovery 패턴을 버려야 하는가”보다 먼저 다음을 확인한다.
useValue로 큰 객체(스키마/설정/캐시 prefill)를 주입하는 곳이 있는가 →useFactory로 전환 (위 이슈에서 50ms 회복)- Dynamic Module을 Static으로 평탄화 가능한가 — 환경별 분기가 정말 런타임에 필요한지 재검토
- 이 두 가지로 해결되지 않을 때만 Discovery 범위 축소(특정 모듈에서만 스캔) 또는 lazy module loading 도입
Discovery 자체를 걷어내는 것은 비용 대비 효과가 가장 낮은 옵션이다.
📖 NestJS Issue #12738 — useValue Dynamic Module 부팅 지연 (50ms → 12s) — 위 측정의 1차 출처 📖 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/[email protected]# └─┬ [email protected]# └── @nestjs/[email protected] ← 버전 불일치!
# 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 합산)}- grep만으로 끝내지 말고 실제로 앱을 띄워 Discovery 동작을 검증 (NestJS 공식 starter scripts 기준)
# 1. 디버그 모드로 앱 기동 — onModuleInit 로그를 콘솔에서 직접 확인npm run start:debug# 내부적으로: nest start --debug --watch# 예상 출력 (앞부분):# [Nest] LOG [NestFactory] Starting Nest application...# [Nest] LOG [InstanceLoader] DiscoveryModule dependencies initialized# [Discovery] MessageHandlers.handleMessage → 이벤트: message# [Discovery] MessageHandlers.handleReaction → 이벤트: reaction_added# [Nest] LOG [NestApplication] Nest application successfully started
# 2. 단위 테스트로 Discovery 결과를 단언 (콘솔 로그 의존 금지)npm run test -- slack-event.service.spec.ts# 내부적으로: jest slack-event.service.spec.ts# 예상 출력:# PASS src/slack/slack-event.service.spec.ts# SlackEventService# ✓ @SlackEvent 메서드를 자동 등록한다 (45ms)# ✓ 메타데이터 키 오타 시 등록되지 않는다 (12ms)
# 3. e2e로 핸들러까지 도달하는지 확인npm run test:e2e# 내부적으로: jest --config ./test/jest-e2e.json# 예상 출력:# PASS test/slack.e2e-spec.ts (8.4 s)검증이 실패할 때 의심 순서: (a) 기동은 되는데 핸들러 로그가 안 찍힘 → 6.5의 “Provider를 탐색했는데 핸들러가 등록되지 않음” (DiscoveryModule 미import 또는 providers 누락), (b) Cannot read properties of null → “wrapper.instance가 null”, (c) 로그 없이 조용히 실패 → “SetMetadata 키 오타”의 Reflect.getMetadataKeys() 디버깅 팁을 적용.
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