콘텐츠로 이동

Nest.js Discovery Module

분류: Layer 0 - 런타임 & 프레임워크 기초 | 작성일: 2026-03-22 | 선수지식: DI/IoC, TypeScript decorator metadata

Nest.js Discovery Module은 Nest IoC Container에 등록된 Provider와 Controller를 런타임에 훑고, 데코레이터 메타데이터가 붙은 클래스나 메서드를 찾아 자동 등록 로직으로 연결하게 해주는 탐색 도구다.

이 문서는 DI/IoC 문서를 읽었다는 전제로 시작한다. DI 문서에서 이미 TypeScript 컴파일 원리, design:paramtypes, token 개념을 연결했기 때문이다. 여기서는 decorator metadata를 새로 깊게 배우기보다, “런타임에 남은 metadata를 Container가 읽는다”는 감각을 스캔 문제로 확장한다.

조금 더 풀면, Discovery Module은 “어떤 클래스가 컨테이너 안에 존재하는가”를 알려준다. MetadataScanner, Reflect.getMetadata, Reflector는 그 클래스와 메서드에 “어떤 표식이 붙어 있는가”를 읽는다. 이 둘을 합치면 @SlackEvent("message"), @EventsHandler(UserCreatedEvent), @Cron("0 * * * *")처럼 마커만 붙인 코드를 부팅 시점에 자동 라우팅할 수 있다.

이 문서는 L0의 마지막 토픽이다. 앞에서 배운 DI/IoC는 객체 생성과 주입을 컨테이너가 맡는다는 뜻이고, TypeScript decorator metadata는 컴파일 시 사라질 정보를 런타임에서 읽을 수 있게 남기는 장치다. Discovery Module은 이 두 기반 위에서 “등록된 객체들을 다시 찾아 의미 있는 메서드로 묶는 단계”를 맡는다.

팀 슬랙봇, CQRS 핸들러, 스케줄러, 커스텀 메시지 리스너는 공통적으로 “새 핸들러가 생겼을 때 중앙 등록 파일을 계속 고쳐야 하는가?”라는 문제를 만난다. 핸들러가 2개일 때는 직접 등록이 단순하다. 하지만 20개, 50개로 늘고 여러 모듈에 흩어지면 중앙 등록 파일은 merge conflict와 누락의 중심이 된다.

Discovery 패턴의 핵심은 등록 책임의 위치를 바꾸는 것이다.

  • 직접 등록: 중앙 registry가 모든 핸들러를 알고 있어야 한다.
  • Discovery 등록: 각 핸들러가 자기 메서드에 마커를 붙이고, scanner가 마커를 모아 registry를 만든다.

이 차이는 OCP(Open-Closed Principle, 개방-폐쇄 원칙)와 연결된다. 새 이벤트 핸들러를 추가할 때 기존 등록 코드를 수정하지 않아도 되면, 시스템은 새 기능에는 열려 있고 기존 코드 변경에는 닫힌 구조가 된다. Discovery Module은 NestJS에서 이 구조를 구현할 때 가장 자주 쓰는 도구다.

2.5 선행 기술의 한계 — DI/IoC만으로는 라우팅이 완성되지 않는다

섹션 제목: “2.5 선행 기술의 한계 — DI/IoC만으로는 라우팅이 완성되지 않는다”

DI/IoC는 “인스턴스를 누가 만들고 누구에게 주입하는가”를 해결한다. Nest Container는 @Injectable() 클래스의 생성자 의존성을 읽고, Provider 그래프를 만들고, 필요한 곳에 인스턴스를 주입한다. 그러나 DI만으로는 “어떤 인스턴스의 어떤 메서드가 어떤 외부 이벤트에 반응해야 하는가”를 알 수 없다.

예를 들어 슬랙 이벤트를 직접 등록하면 아래처럼 중앙 파일이 모든 핸들러를 알아야 한다.

@Injectable()
export class SlackEventRegistry {
constructor(
private readonly message: MessageHandlers,
private readonly reaction: ReactionHandlers,
private readonly mention: MentionHandlers,
) {}
register(client: SlackClient) {
client.on("message", this.message.handle.bind(this.message));
client.on("reaction_added", this.reaction.handle.bind(this.reaction));
client.on("app_mention", this.mention.handle.bind(this.mention));
}
}

이 코드는 DI를 쓰고 있으므로 인스턴스 생성 자체는 깔끔하다. 문제는 라우팅 정보가 여전히 한 파일에 몰려 있다는 점이다. 새 이벤트가 생기면 생성자 파라미터를 추가하고, 등록 코드를 추가하고, 때로는 모듈 import도 고쳐야 한다. 핸들러 N개가 늘면 중앙 파일 변경도 N번 늘어난다.

Discovery Module은 이 빈칸을 채운다. DI가 만든 객체 그래프를 다시 순회해서, @SlackEvent("message") 같은 마커가 붙은 메서드를 찾아 이벤트 이름과 함수 참조를 자동으로 묶는다.

@Injectable()
export class MessageHandlers {
@SlackEvent("message")
handleMessage(payload: SlackMessage) {
// message 이벤트 처리
}
}

핸들러를 추가할 때 바뀌는 것은 핸들러 클래스뿐이다. 중앙 registry는 “앱 안의 모든 Provider를 훑고, SlackEvent 메타데이터가 있으면 등록한다”는 일반 규칙만 가진다.

DI/IoC 위에 Discovery가 얹히는 위치
flowchart LR
A["TypeScript decorator metadata"] --> B["Nest IoC Container"]
B --> C["Provider/Controller InstanceWrapper"]
C --> D["DiscoveryService"]
D --> E["MetadataScanner"]
E --> F["Reflector / Reflect.getMetadata"]
F --> G["이벤트 이름 -> 핸들러 registry"]

이 문서의 lineage는 여기서 끝난다. DI/IoC가 객체 그래프를 만든다. Discovery Module은 그 그래프를 탐색 가능한 데이터로 보고, 마커 기반 라우팅을 만든다. 그래서 Discovery가 빠지면 코드는 다시 중앙 등록 파일과 수동 registry로 회귀한다.

3. 핵심 사고 모델: 마커, 스캐너, registry

섹션 제목: “3. 핵심 사고 모델: 마커, 스캐너, registry”

Discovery 패턴은 세 단어로 기억하면 된다.

  1. 마커: 자동 등록할 대상에 붙이는 표식이다. NestJS에서는 보통 커스텀 데코레이터와 metadata key다.
  2. 스캐너: 컨테이너에 등록된 Provider/Controller와 그 메서드를 훑는 코드다.
  3. registry: 스캐너가 찾은 결과를 실제 실행 구조로 바꾼 저장소다. 예를 들어 eventName -> handler function Map이다.

Discovery 패턴의 구성요소

Marker

`@SlackEvent('message')`처럼 등록 대상을 표시한다. 값은 metadata key 아래 저장된다.

개발자가 새 핸들러를 만들 때

Container snapshot

Nest가 이미 만든 Provider/Controller 목록이다. Discovery는 파일시스템이 아니라 이 목록을 본다.

앱 부팅 중 컨테이너 구성이 끝난 뒤

Scanner

`DiscoveryService`와 `MetadataScanner`가 wrapper와 메서드 이름을 순회한다.

`onModuleInit` 또는 `onApplicationBootstrap`

Registry

찾은 metadata를 이벤트 버스, 커맨드 버스, 스케줄러 같은 실행 구조에 연결한다.

앱이 요청이나 이벤트를 받기 전

중요한 점은 Discovery가 소스 파일을 직접 읽는 기능이 아니라는 것이다. Nest Container에 등록되지 않은 클래스는 파일이 존재해도 탐색되지 않는다. @Injectable()을 붙였더라도 해당 모듈의 providers에 들어가지 않았거나, 그 모듈이 앱 그래프에 포함되지 않았다면 Discovery는 그 클래스를 모른다.

4. Nest 내부 구성요소를 작은 단위로 보기

섹션 제목: “4. Nest 내부 구성요소를 작은 단위로 보기”

Discovery 코드를 읽을 때 자주 만나는 이름을 먼저 분해한다.

ModulesContainer는 Nest가 부팅하면서 구성한 모듈 그래프의 저장소다. 각 모듈은 자기 Provider, Controller, import/export 경계를 가진다. DiscoveryService는 내부적으로 이 컨테이너를 순회해서 “현재 앱 그래프에 들어온 wrapper 목록”을 만든다.

이 말은 두 가지를 뜻한다.

  • Discovery 범위는 현재 앱의 모듈 imports 그래프에 묶인다.
  • 어떤 파일이 repo에 있어도 모듈 그래프 밖이면 Discovery 결과에 없다.

DiscoveryService.getProviders()getControllers()가 반환하는 것은 실제 인스턴스 배열이 아니라 InstanceWrapper 배열이다. wrapper는 컨테이너가 Provider를 관리하기 위해 들고 있는 메타 객체다.

{
name: "MessageHandlers",
metatype: MessageHandlers, // 클래스 자체
instance: messageHandlersInstance, // 실제 인스턴스
token: MessageHandlers,
// scope, host module 등 내부 정보가 더 붙을 수 있다
}

학습할 때는 세 필드만 잡으면 충분하다.

  • metatype: 클래스 레벨 데코레이터 metadata를 읽을 때 자주 쓴다.
  • instance: 메서드를 실제로 호출하거나 bind할 때 쓴다.
  • name: 로그와 진단에 쓴다.

wrapper.instance가 항상 있다고 가정하면 위험하다. 일부 wrapper는 아직 인스턴스가 없거나, 값 Provider처럼 prototype을 스캔할 수 없는 형태일 수 있다. 그래서 scanner는 항상 instanceObject.getPrototypeOf(instance)를 확인한다.

MetadataScanner는 클래스 prototype에서 메서드 이름 목록을 뽑아준다. 즉 “이 Provider 인스턴스에 어떤 메서드가 있는가?”에 답한다.

const prototype = Object.getPrototypeOf(instance);
const methodNames = this.metadataScanner.getAllMethodNames(prototype);

일부 오래된 예제는 scanFromPrototype()을 쓴다. 사용 중인 Nest 버전에서 해당 API가 deprecated로 표시된다면 getAllMethodNames()로 바꾸면 된다. 이 문서의 핵심은 특정 API 이름보다 “prototype의 메서드 목록을 얻고, 각 메서드의 metadata를 읽는다”는 흐름이다.

TypeScript의 타입과 데코레이터 정보는 JavaScript 런타임에 그냥 남지 않는다. 앞선 TypeScript compiler 문서에서 본 것처럼, reflect-metadata 계열 API는 특정 target에 key-value metadata를 붙이고 다시 읽는 통로를 제공한다.

NestJS에서는 두 층을 구분하면 헷갈림이 줄어든다.

도구대답하는 질문보통 쓰는 위치
DiscoveryService”앱 안에 어떤 Provider/Controller가 있나?“scanner 시작점
MetadataScanner”이 인스턴스에 어떤 메서드가 있나?”메서드 순회
Reflect.getMetadata”이 target에 특정 key metadata가 있나?“low-level metadata 읽기
Reflector”Nest 방식으로 metadata를 읽고 합칠까?”Guard, Interceptor, Discovery scanner 내부에서 사용

Reflector는 scanner가 아니다. 특정 target을 이미 알고 있을 때 metadata를 읽는 Nest 편의 도구다. 반대로 DiscoveryService는 metadata를 읽지 않는다. 전체 후보 목록을 가져오는 도구다.

Discovery 실패의 상당수는 metadata key가 아니라 target을 잘못 읽어서 생긴다. “어디에 저장했는가”와 “어디에서 읽는가”가 같아야 한다.

// 클래스 레벨 metadata
export const RateLimit = (limit: number) => SetMetadata("rate_limit", limit);
@RateLimit(100)
@Controller("users")
export class UsersController {}
// 클래스 자체에 붙은 metadata는 metatype 또는 constructor에서 읽는다.
const limit = this.reflector.get<number>("rate_limit", wrapper.metatype);
// 메서드 레벨 metadata
export const SlackEvent = (event: string) => SetMetadata("slack_event", event);
@Injectable()
export class MessageHandlers {
@SlackEvent("message")
handleMessage(payload: SlackMessage) {}
}
const handler = instance[methodName];
const event = this.reflector.get<string>("slack_event", handler);

Nest의 SetMetadata()를 메서드에 붙이면 metadata는 보통 메서드 함수 자체에 저장된다. 따라서 instance[methodName]을 target으로 읽는 방식이 자연스럽다. 직접 만든 데코레이터가 Reflect.defineMetadata(key, value, target, propertyKey) 형태로 저장했다면 읽을 때도 같은 target, propertyKey 조합을 써야 한다.

// 직접 propertyKey에 저장한 경우의 low-level 읽기 예시
const event = Reflect.getMetadata("slack_event", prototype, methodName);

핵심은 Reflect.getMetadata(key, something)Reflect.getMetadata(key, something, methodName) 중 무엇이 정답이냐가 아니다. 데코레이터가 어느 target에 metadata를 저장했는지와 읽는 target이 같은지가 정답이다.

5. Worked example: Slack 이벤트 자동 등록 만들기

섹션 제목: “5. Worked example: Slack 이벤트 자동 등록 만들기”

아래 예시는 긴 실습 프로젝트가 아니라 손으로 흐름을 따라가기 위한 최소 코드다. 목적은 “마커가 어떻게 registry로 바뀌는가”를 보는 것이다.

문자열 key를 여러 파일에 직접 쓰면 오타가 silent failure가 된다. 먼저 단일 상수로 고정한다.

export const SLACK_EVENT_KEY = "slack_event" as const;
export type SlackEventName = "message" | "reaction_added" | "app_mention";

수치 감각으로 보면, 이벤트 3개일 때는 문자열 오타가 눈에 잘 띈다. 이벤트가 30개가 되고 핸들러가 여러 팀에 흩어지면 slack_eventslack_events 같은 차이는 리뷰에서 거의 안 보인다. 그래서 key 상수화는 스타일 문제가 아니라 자동 등록 구조의 안전장치다.

5.2 커스텀 데코레이터는 마커만 남긴다

섹션 제목: “5.2 커스텀 데코레이터는 마커만 남긴다”
import { SetMetadata } from "@nestjs/common";
export const SlackEvent = (eventName: SlackEventName) =>
SetMetadata(SLACK_EVENT_KEY, eventName);

이 데코레이터는 이벤트를 등록하지 않는다. metadata만 남긴다. 실제 등록은 scanner가 나중에 한다. 이 분리가 중요하다. 데코레이터 실행 시점에 Slack client나 event bus를 만지면 클래스 정의 시점과 앱 부팅 시점이 섞여 테스트가 어려워진다.

5.3 핸들러는 자기 책임만 가진다

섹션 제목: “5.3 핸들러는 자기 책임만 가진다”
@Injectable()
export class MessageHandlers {
@SlackEvent("message")
handleMessage(payload: SlackMessage) {
// message 이벤트 처리
}
@SlackEvent("reaction_added")
handleReaction(payload: SlackReaction) {
// reaction_added 이벤트 처리
}
}

핸들러 클래스는 자신이 어떤 이벤트를 처리하는지만 선언한다. “어느 중앙 registry에 등록해야 하는가”는 알지 않는다. 이것이 OCP의 체감 지점이다. 새 이벤트가 생겨도 기존 scanner와 기존 registry 코드는 그대로 둔다.

5.4 scanner는 Provider를 훑고 metadata를 읽는다

섹션 제목: “5.4 scanner는 Provider를 훑고 metadata를 읽는다”
import { Injectable, Logger, OnModuleInit } from "@nestjs/common";
import { DiscoveryService, MetadataScanner, Reflector } from "@nestjs/core";
import { SlackClient } from "./slack.client";
@Injectable()
export class SlackEventExplorer implements OnModuleInit {
private readonly logger = new Logger(SlackEventExplorer.name);
constructor(
private readonly discovery: DiscoveryService,
private readonly metadataScanner: MetadataScanner,
private readonly reflector: Reflector,
private readonly slackClient: SlackClient,
) {}
onModuleInit() {
for (const wrapper of this.discovery.getProviders()) {
const { instance, name } = wrapper;
if (!instance) continue;
const prototype = Object.getPrototypeOf(instance);
if (!prototype) continue;
for (const methodName of this.metadataScanner.getAllMethodNames(
prototype,
)) {
const handler = instance[methodName];
if (typeof handler !== "function") continue;
const eventName = this.reflector.get<SlackEventName>(
SLACK_EVENT_KEY,
handler,
);
if (!eventName) continue;
this.slackClient.on(eventName, handler.bind(instance));
this.logger.debug(`${name}.${methodName} -> ${eventName}`);
}
}
}
}

이 코드는 네 단계만 한다.

  1. getProviders()로 컨테이너 안의 Provider wrapper를 가져온다.
  2. 각 wrapper에서 실제 instance와 prototype을 얻는다.
  3. getAllMethodNames()로 메서드 이름을 훑는다.
  4. ReflectorSLACK_EVENT_KEY metadata가 있는 메서드만 이벤트 client에 등록한다.

handler.bind(instance)도 학습 포인트다. 메서드 참조만 event client에 넘기면 this가 사라질 수 있다. 핸들러 내부에서 this.someService를 쓰는 경우, bind 누락은 “메서드는 호출되지만 내부 의존성이 undefined” 같은 런타임 실패로 드러난다.

5.5 모듈은 DiscoveryModule과 탐색 대상을 등록한다

섹션 제목: “5.5 모듈은 DiscoveryModule과 탐색 대상을 등록한다”
import { Module } from "@nestjs/common";
import { DiscoveryModule } from "@nestjs/core";
import { MessageHandlers } from "./message.handlers";
import { SlackClient } from "./slack.client";
import { SlackEventExplorer } from "./slack-event.explorer";
@Module({
imports: [DiscoveryModule],
providers: [SlackClient, MessageHandlers, SlackEventExplorer],
})
export class SlackModule {}

DiscoveryModule은 scanner가 컨테이너를 조회할 수 있게 해준다. SlackClient는 Explorer가 생성자에서 요구하는 클래스 토큰이므로 providers에 등록한다. MessageHandlers도 탐색 대상이므로 providers에 있어야 한다. @Injectable()만 붙이고 providers에 등록하지 않으면 컨테이너가 인스턴스를 만들지 않으므로 Discovery에도 나오지 않는다.

내장 LoggerSlackEventExplorer 인스턴스의 필드로 함께 생성되므로 별도 Provider 등록이 필요 없다. 반대로 의존성을 주입받는 커스텀 logger로 바꾸면 그 logger를 모듈의 providers에 등록하고 필요하면 exports/imports 경계까지 연결해야 한다.

scanner가 실제로 보는 데이터는 파일 목록이 아니라 wrapper와 method metadata다. 작은 예시로 따라가 보자.

Provider wrappers
- MessageHandlers(instance 있음)
- handleMessage metadata: slack_event = "message"
- handleReaction metadata: slack_event = "reaction_added"
- helper metadata: 없음
- HealthCheckService(instance 있음)
- ping metadata: 없음
- ConfigObject(useValue)
- prototype scan 불가

scanner가 이 입력을 보면 registry는 이렇게 만들어진다.

순회 대상scanner 판단registry 반영
MessageHandlers.handleMessagemetadata 있음"message" -> bound handleMessage
MessageHandlers.handleReactionmetadata 있음"reaction_added" -> bound handleReaction
MessageHandlers.helpermetadata 없음무시
HealthCheckService.pingmetadata 없음무시
ConfigObject스캔 가능한 prototype 없음무시

이 표에서 알 수 있는 것은 두 가지다. 첫째, metadata가 없으면 정상적으로 무시된다. 이것이 silent failure와 정상 무시를 구분하기 어렵게 만든다. 둘째, wrapper 수가 많아도 실제 registry에 들어가는 것은 marker가 붙은 극히 일부 메서드다. 그래서 scanner 로그는 전체 스캔 수와 등록 수를 같이 보여줘야 한다.

5.7 registry 모양을 먼저 정해야 한다

섹션 제목: “5.7 registry 모양을 먼저 정해야 한다”

Discovery 코드는 “찾기”만 담당하고 끝나지 않는다. 찾은 결과를 어떤 자료구조로 저장할지도 설계해야 한다.

type RegisteredSlackHandler = {
eventName: SlackEventName;
providerName: string;
methodName: string;
handler: (payload: unknown) => Promise<void> | void;
priority: number;
};
const registry = new Map<SlackEventName, RegisteredSlackHandler[]>();

단일 이벤트에 핸들러 하나만 허용할지, 여러 개를 허용할지에 따라 실패 정책이 달라진다.

정책적합한 경우실패 처리
1 event = 1 handlercommand handler처럼 처리자가 하나여야 함중복 발견 시 부팅 실패
1 event = N handlersnotification, audit log처럼 fan-out 가능priority로 정렬하고 중복 자체는 허용
event별 명시 order실행 순서가 비즈니스 의미를 가짐metadata에 priority를 두고 wrapper 순서 무시

이 선택을 하지 않으면 scanner는 찾은 순서대로 등록하게 된다. 그 순서는 모듈 구성이나 내부 wrapper 순회에 따라 달라질 수 있으므로 비즈니스 규칙으로 삼으면 안 된다.

커스텀 데코레이터에서 자동 등록까지
flowchart TD
A["@SlackEvent('message')"] --> B["SetMetadata(SLACK_EVENT_KEY, 'message')"]
B --> C["MessageHandlers가 providers에 등록됨"]
C --> D["NestFactory.create()가 Provider graph 생성"]
D --> E["SlackEventExplorer.onModuleInit()"]
E --> F["DiscoveryService.getProviders()"]
F --> G["MetadataScanner.getAllMethodNames(prototype)"]
G --> H["Reflector.get(SLACK_EVENT_KEY, handler)"]
H --> I{"eventName 있음?"}
I -->|"yes"| J["slackClient.on(eventName, bound handler)"]
I -->|"no"| K["무시"]

6. Before/After로 보는 중앙 registry 비용

섹션 제목: “6. Before/After로 보는 중앙 registry 비용”

Discovery의 가치는 코드를 더 “마법처럼” 만드는 데 있지 않다. 변경 비용의 위치를 바꾸는 데 있다.

@Injectable()
export class SlackEventService {
private readonly handlers = new Map<string, (payload: unknown) => void>();
constructor(
private readonly messageHandlers: MessageHandlers,
private readonly reactionHandlers: ReactionHandlers,
private readonly homeHandlers: HomeHandlers,
) {
this.handlers.set(
"message",
this.messageHandlers.handleMessage.bind(this.messageHandlers),
);
this.handlers.set(
"reaction_added",
this.reactionHandlers.handleReaction.bind(this.reactionHandlers),
);
this.handlers.set(
"app_home_opened",
this.homeHandlers.handleHomeOpened.bind(this.homeHandlers),
);
}
}

이 방식의 장점은 투명성이다. 파일을 열면 라우팅 표가 바로 보인다. 단점은 변경 비용이다. 핸들러가 늘 때마다 생성자와 registry 코드가 함께 늘고, 여러 사람이 같은 파일을 만질 가능성이 커진다.

@Injectable()
export class HomeHandlers {
@SlackEvent("app_home_opened")
handleHomeOpened(payload: AppHomeOpened) {
// 홈 탭 열림 처리
}
}

scanner는 그대로다. 새 핸들러 클래스와 메서드만 추가한다. 변경량은 이벤트 종류 수가 늘어도 중앙 파일에 비례하지 않는다.

정량적으로는 이렇게 볼 수 있다.

상황직접 등록 방식Discovery 방식
핸들러 3개중앙 파일 1개에서 파악 가능, 단순함scanner 보일러플레이트가 더 크게 느껴질 수 있음
핸들러 20개생성자, registry, 테스트 fixture가 계속 증가각 핸들러가 자기 metadata만 선언
여러 팀이 추가같은 registry 파일 충돌 가능성이 커짐충돌 범위가 각 핸들러 파일로 분산
누락 형태등록 코드를 빼먹으면 이벤트가 조용히 무시될 수 있음metadata/key/provider 누락도 조용히 무시될 수 있음

핸들러 추가 관점에서 보면 직접 등록은 중앙 registry 변경량이 핸들러 수에 비례해 커지는 O(N) 문제로 흐르기 쉽다. Discovery는 새 핸들러가 자기 metadata만 선언하면 되므로 중앙 파일 변경을 O(1)에 가깝게 낮춘다. 이것이 OCP를 코드 변경량으로 해석한 핵심 이득이다.

따라서 Discovery는 “silent failure가 없어지는 패턴”이 아니다. 수동 등록 누락을 줄이는 대신, metadata 누락과 스캔 범위 누락이라는 다른 실패 모드를 만든다. 좋은 구현은 scanner 로그, 중복 key 검출, 테스트 단언으로 이 실패 모드를 드러나게 한다.

DiscoveryService.getProviders()는 Provider wrapper를 가져온다. Controller는 별도 목록이다.

const providers = this.discovery.getProviders();
const controllers = this.discovery.getControllers();

둘을 섞어도 되는 경우와 분리해야 하는 경우를 구분해야 한다.

스캔 대상적합한 사용 예주의점
Provider이벤트 핸들러, 커맨드 핸들러, 메시지 리스너, 스케줄 작업모듈 providers에 등록되어야 한다
ControllerHTTP endpoint 정책 수집, 권한/RateLimit 메타데이터 카탈로그getProviders()만 보면 Controller는 빠진다

예를 들어 @SlackEvent()는 보통 Provider 메서드에 붙인다. Slack 이벤트는 HTTP request lifecycle과 직접 연결되지 않기 때문이다. 반대로 @AdminOnly()@RateLimit() 같은 HTTP 정책을 전체 API 목록으로 수집하려면 Controller도 스캔해야 한다.

for (const wrapper of this.discovery.getControllers()) {
const controllerRateLimit = this.reflector.get<number>(
"rate_limit",
wrapper.metatype,
);
// 메서드 레벨 metadata도 필요하면 prototype을 스캔한다.
}

이 경계를 놓치면 “데코레이터가 붙어 있는데 scanner가 못 찾는다”는 실패가 생긴다. 실제 원인은 metadata가 아니라 스캔 목록이다.

Discovery scanner를 어디서 실행할지는 lifecycle 문제다.

Hook실행 시점Discovery 사용 기준
onModuleInit()해당 모듈의 의존성이 초기화된 뒤대부분의 in-memory registry 구성에 적합
onApplicationBootstrap()모든 모듈의 초기화 hook이 끝나고 앱 부팅이 마무리될 때다른 모듈의 init side effect나 외부 transport 준비가 필요할 때

기본 선택은 onModuleInit()이다. scanner가 하는 일이 컨테이너에 있는 Provider를 훑고 in-memory registry를 만드는 것이라면 이 시점이 충분하다.

하지만 다음 조건이면 onApplicationBootstrap()을 고려한다.

  • scanner가 다른 모듈의 onModuleInit()에서 채운 registry나 설정을 읽어야 한다.
  • 메시지 브로커, 마이크로서비스 transport, 외부 event bus 연결이 앱 전체 bootstrap 이후에 안정적으로 열린다.
  • 여러 모듈의 scanner 결과를 합친 뒤 마지막 단계에서 한 번만 publish해야 한다.

반례도 중요하다. “늦게 실행하면 항상 안전하다”는 직관은 절반만 맞다. 너무 늦게 등록하면 앱이 이미 이벤트를 받을 수 있는 상태인데 handler registry가 비어 있는 순간이 생길 수 있다. 그래서 hook 선택은 “필요한 의존성이 준비되는 가장 이른 시점”을 기준으로 잡는다.

9. DiscoveryService와 Reflector는 역할이 다르다

섹션 제목: “9. DiscoveryService와 Reflector는 역할이 다르다”

헷갈리는 질문은 보통 이렇다.

“metadata를 읽을 거면 Reflector만 쓰면 되지, 왜 DiscoveryService가 필요한가?”

Reflector는 이미 알고 있는 target의 metadata를 읽는다. 예를 들어 Guard 안에서는 context.getHandler()context.getClass()가 있으므로 Reflector만으로 충분하다.

const requiredRoles = this.reflector.get<string[]>(
"roles",
context.getHandler(),
);

반면 Slack scanner는 “어떤 handler가 있는지”를 모른다. 먼저 앱 안의 모든 후보를 찾아야 한다. 이때 DiscoveryService가 필요하다.

for (const wrapper of this.discovery.getProviders()) {
// 여기서야 후보 instance를 얻는다.
}

정리하면 다음과 같다.

  • Guard/Interceptor처럼 요청 처리 중 이미 Controller method를 알고 있다면 Reflector가 중심이다.
  • 부팅 중 “앱 전체에서 특정 metadata가 붙은 메서드들을 찾아야” 한다면 DiscoveryService가 중심이고, metadata 읽기에 ReflectorReflect.getMetadata를 곁들인다.

10. 패턴 전이: Spring, CQRS, 스케줄러에서도 같은 질문을 던진다

섹션 제목: “10. 패턴 전이: Spring, CQRS, 스케줄러에서도 같은 질문을 던진다”

Discovery 패턴은 NestJS 전용 마법이 아니다. 많은 프레임워크가 “마커를 붙이면 스캐너가 찾아 등록한다”는 구조를 쓴다. 낯선 프레임워크를 만나면 네 가지 질문으로 분해하면 된다.

마커 기반 자동 등록을 분해하는 4가지 질문

  • 마커는 무엇인가? 어떤 데코레이터나 어노테이션이 등록 대상을 표시하는가?
  • 스캐너는 누구인가? 전체 후보 목록을 어디서 얻고 metadata를 누가 읽는가?
  • 스캔 시점은 언제인가? 컴파일 타임인가, 런타임 부팅 시점인가, 요청 처리 시점인가?
  • 스캔 범위는 어디까지인가? 파일시스템, package path, module imports graph, injector tree 중 무엇인가?

다른 생태계로 옮겨 보는 Discovery 사고 모델

Spring

`@Component`/`@Service`를 classpath scanner가 찾아 `ApplicationContext`의 bean으로 등록한다.

범위: base package

NestJS CQRS

`@EventsHandler`와 `@CommandHandler` metadata를 읽어 EventBus/CommandBus에 핸들러를 등록한다.

범위: Nest module graph

NestJS Schedule

`@Cron`/`@Interval` metadata가 붙은 메서드를 찾아 scheduler registry에 연결한다.

시점: app bootstrap

Compile-time annotation processing

런타임 스캔 대신 컴파일 시점에 metadata를 읽어 코드를 생성한다.

시점: compile time

Spring과 NestJS의 차이는 특히 중요하다. Spring의 @ComponentScan은 package path를 범위로 삼고, NestJS Discovery는 module imports graph 안의 wrapper를 범위로 삼는다. Spring에서 “패키지 아래에 있으면 스캔된다”는 감각으로 NestJS를 보면 실패한다. NestJS에서는 파일 위치보다 모듈 등록과 imports가 더 중요하다.

@nestjs/cqrs도 같은 모델로 읽을 수 있다. 마커는 @EventsHandler(EventClass)이고, scanner는 Nest module graph에서 handler wrapper를 모은 뒤, metadata를 읽어 EventBus에 등록한다. 즉 CQRS는 이 문서의 Discovery 패턴을 도메인 이벤트와 커맨드 라우팅으로 포장한 사례다.

11. Trade-off: 자동 탐색은 공짜가 아니다

섹션 제목: “11. Trade-off: 자동 탐색은 공짜가 아니다”

Discovery는 중앙 등록 파일을 줄여주지만 비용을 없애지는 않는다. 비용은 부팅 시점과 진단 시점으로 이동한다.

scanner 비용은 대략 다음처럼 볼 수 있다.

스캔 작업량 ~= Provider/Controller wrapper 수 + 스캔한 메서드 수 + metadata 조회 수

예를 들어 Provider 400개, Provider당 평균 메서드 8개면 약 3,200개 메서드를 확인한다. 단순 metadata 조회만 보면 큰 비용이 아닐 수 있다. 하지만 실제 부팅 시간은 scanner 자체보다 Provider 그래프 구성, Dynamic Module 초기화, 큰 useValue 객체, DB/스키마 초기화 같은 비용에 더 크게 흔들린다.

기존 문서에 있던 공개 이슈 사례도 이 관점으로 읽어야 한다. 특정 구성에서 static module과 작은 useValue는 수십 ms 수준으로 시작했지만, dynamic module과 큰 provider graph 조합에서는 초 단위 지연이 보고됐다. 이 숫자는 보편 법칙이 아니라 “Discovery를 의심하기 전에 Provider graph 비용을 먼저 보라”는 신호다. 예를 들어 50ms -> 12s, 구조 조정 후 12s -> 2s처럼 큰 차이가 관찰된 사례가 있어도, 원인을 “Discovery가 느리다” 하나로 단정하면 안 된다. 큰 객체 생성, dynamic provider 구성, 모듈 초기화가 함께 섞이면 scanner는 증상을 드러내는 위치일 뿐 병목의 전부가 아닐 수 있다.

부팅이 느릴 때의 판단 순서는 다음이 안전하다.

  1. scanner가 몇 개 wrapper와 몇 개 메서드를 훑는지 로그로 센다.
  2. useValue로 큰 객체나 스키마를 통째로 주입하는지 확인한다.
  3. Dynamic Module이 꼭 필요한지, static module로 평탄화할 수 있는지 본다.
  4. 그래도 scanner 자체가 크면 스캔 범위를 특정 모듈이나 특정 marker로 좁힌다.

Discovery가 항상 더 좋은 선택은 아니다.

조건더 나은 선택이유
핸들러가 3~5개이고 거의 늘지 않음직접 등록scanner 보일러플레이트가 이점보다 클 수 있다
등록 순서가 비즈니스 의미를 가짐직접 등록 또는 명시 order metadataDiscovery 순회 순서에 의존하면 읽기 어렵다
라이브러리 모듈 내부를 숨겨야 함명시 exports/API외부 scanner가 내부 Provider를 전제하면 캡슐화가 깨진다
팀이 metadata 패턴에 익숙하지 않음작은 직접 등록부터 시작silent failure 진단 비용이 더 클 수 있다
핸들러가 계속 추가되고 여러 모듈에 분산Discovery중앙 registry 변경과 conflict를 줄인다
흔한 metadata scan 반복 코드가 많음검증된 helper library 검토boilerplate를 줄이되 버전/API 가정은 확인한다

핵심 기준은 “등록 대상의 수”만이 아니다. 변경 빈도, 팀 수, 모듈 분산 정도, 실패를 테스트로 잡을 수 있는지까지 함께 봐야 한다. 직접 구현은 스캔 순서, 중복 정책, registry shape을 프로젝트가 통제해야 할 때 적합하다. @golevelup/nestjs-discovery 같은 helper는 metadata key 기반 조회가 반복되고 정책이 단순할 때 더 낫다.

11.3 자동 탐색의 실패는 조용한 편이다

섹션 제목: “11.3 자동 탐색의 실패는 조용한 편이다”

직접 등록 방식은 컴파일 에러가 잘 난다. 생성자 파라미터를 빼먹거나 import를 안 하면 TypeScript나 Nest DI가 알려준다. Discovery 방식은 metadata가 없으면 그냥 “찾을 것이 없음”으로 처리되기 쉽다.

그래서 Discovery 구현에는 최소한 아래 관찰 지점이 필요하다.

  • 스캔한 wrapper 수
  • metadata가 붙은 handler 수
  • event key별 handler 수
  • 중복 event key가 있을 때의 정책
  • 찾은 handler 목록을 테스트에서 단언하는 방법

이 로그는 운영 잡음이 아니라 silent failure를 드러내는 계측이다. 다만 매 요청마다 찍으면 안 되고, 부팅 시점에 한 번 또는 debug 모드에서만 찍는다.

12. 실패 신호와 먼저 의심할 지점

섹션 제목: “12. 실패 신호와 먼저 의심할 지점”

아래는 긴 트러블슈팅 런북 대신 학습용으로 남겨야 할 실패 신호다. 증상을 보면 어떤 개념이 깨졌는지 연결하는 데 초점을 둔다.

증상의미하는 개념 실패먼저 볼 것
핸들러 클래스가 getProviders() 결과에 없다컨테이너 등록 실패providers 등록, 모듈 imports graph
Controller 데코레이터가 안 잡힌다스캔 목록 경계 착각getProviders()만 쓰지 않았는지, getControllers()
metadata가 항상 undefinedkey 또는 target 불일치key 상수, handler vs prototype, methodName
아무 에러 없이 이벤트가 무시된다silent failure부팅 로그의 handler count, Reflect.getMetadataKeys()
Cannot read properties of null가 난다wrapper 형태를 가정함wrapper.instance null check
핸들러 내부 this.service가 undefined다method binding 누락handler.bind(instance)
이벤트 처리 순서가 흔들린다Discovery 순서에 비즈니스 의미를 부여함명시적 order metadata 또는 직접 등록
라이브러리 모듈에서 DiscoveryService 주입 실패Nest core 인스턴스 중복 가능성@nestjs/core를 dependency가 아니라 peer로 두는지
부팅이 갑자기 느려진다scanner보다 Provider graph 비용일 수 있음wrapper/method count, Dynamic Module, 큰 useValue

짧은 관찰 snippet은 충분하다.

const keys = Reflect.getMetadataKeys(handler);
this.logger.debug({
provider: wrapper.name,
methodName,
metadataKeys: keys,
});

이 snippet은 “정말 metadata가 붙었는가”와 “내가 읽는 key가 맞는가”를 보여준다. 전체 grep, npm run, e2e 절차를 본문에 길게 둘 필요는 없다.

13. 버전별 API와 패키지 문제는 어떻게 다룰까

섹션 제목: “13. 버전별 API와 패키지 문제는 어떻게 다룰까”

Discovery 문서는 특정 Nest 버전의 migration guide가 아니다. 그래도 실무에서 자주 밟는 버전/패키지 문제는 실패 신호로 남겨야 한다.

13.1 scanFromPrototype 예제가 보일 때

섹션 제목: “13.1 scanFromPrototype 예제가 보일 때”

오래된 예제는 MetadataScanner.scanFromPrototype()을 사용할 수 있다. 현재 프로젝트의 Nest 버전에서 deprecated 경고가 보이면 getAllMethodNames()로 바꾸고, 각 method name에 대해 metadata를 읽으면 된다.

const methodNames = this.metadataScanner.getAllMethodNames(
Object.getPrototypeOf(instance),
);
for (const methodName of methodNames) {
const handler = instance[methodName];
const eventName = this.reflector.get(SLACK_EVENT_KEY, handler);
}

여기서 배워야 할 것은 “API 이름”이 아니라 “prototype에서 메서드 이름을 얻고, handler target에서 metadata를 읽는다”는 순서다.

DiscoveryService.getProviders(metadataKey)처럼 metadata key를 인자로 받는 예제도 버전과 패키지에 따라 동작 가정이 달라질 수 있다. 가장 보수적인 기본값은 getProviders()로 wrapper 전체를 받은 뒤, 내가 소유한 metadata key를 직접 읽어 필터링하는 방식이다. 그러면 Nest 버전의 helper 시그니처보다 “wrapper를 훑고 target metadata를 확인한다”는 원리가 코드에 남는다.

13.2 재사용 라이브러리에서 @nestjs/core가 중복될 때

섹션 제목: “13.2 재사용 라이브러리에서 @nestjs/core가 중복될 때”

별도 패키지로 만든 NestJS 모듈에서 DiscoveryService 주입이 실패한다면, 애플리케이션과 라이브러리가 서로 다른 @nestjs/core 인스턴스를 들고 있을 수 있다. 이 경우 같은 이름의 DiscoveryService라도 바라보는 ModulesContainer가 달라진다.

학습 관점에서는 이렇게 기억하면 된다.

  • DiscoveryService는 앱의 ModulesContainer를 읽어야 한다.
  • 라이브러리가 자기 @nestjs/core를 dependencies로 들고 있으면 컨테이너가 둘로 갈라질 수 있다.
  • 재사용 Nest 라이브러리는 보통 @nestjs/core를 peer dependency로 두어 호스트 앱의 인스턴스를 공유한다.

이것은 npm 명령을 외우는 문제가 아니라 “Discovery는 현재 앱 컨테이너를 보는 도구”라는 원리가 패키지 그래프에서도 유지되어야 한다는 문제다.

14.1 마커만 붙이면 자동 등록된다는 오해

섹션 제목: “14.1 마커만 붙이면 자동 등록된다는 오해”
@Injectable()
export class HiddenHandlers {
@SlackEvent("message")
handleMessage() {}
}

이 클래스가 어떤 모듈의 providers에도 없으면 Discovery는 찾지 못한다. 마커는 “찾혔을 때 읽을 정보”이지, 파일을 컨테이너에 등록하는 기능이 아니다.

14.2 Reflector만 있으면 전체 탐색이 된다는 오해

섹션 제목: “14.2 Reflector만 있으면 전체 탐색이 된다는 오해”
const event = this.reflector.get(SLACK_EVENT_KEY, unknownTarget);

Reflector는 target을 알아야 읽을 수 있다. target 후보를 찾는 일은 DiscoveryServiceMetadataScanner의 몫이다.

14.3 메서드 이름만 저장하면 충분하다는 오해

섹션 제목: “14.3 메서드 이름만 저장하면 충분하다는 오해”
this.slackClient.on(eventName, instance[methodName]);

이 코드는 handler 내부에서 this를 쓰지 않을 때는 우연히 동작할 수 있다. 하지만 대부분의 Nest Provider 메서드는 주입받은 서비스를 this.someService로 사용한다. 따라서 event client에 넘길 때는 bind(instance) 또는 arrow wrapper로 호출 context를 보존한다.

14.4 Discovery 순서가 안정적이라는 오해

섹션 제목: “14.4 Discovery 순서가 안정적이라는 오해”

Provider 순회 순서에 비즈니스 의미를 두면 안 된다. 여러 핸들러가 같은 이벤트를 처리하고 순서가 중요하다면 metadata에 priority를 추가하고 scanner가 명시적으로 정렬해야 한다.

export const SlackEvent = (eventName: SlackEventName, priority = 0) =>
SetMetadata(SLACK_EVENT_KEY, { eventName, priority });

이때도 “자동 탐색”은 유지된다. 다만 실행 순서는 우연한 wrapper 순서가 아니라 명시 metadata로 결정한다.

Discovery를 도입할지 결정할 때 아래 질문에 답한다.

Discovery 도입 전 판단 기준

  • 새 등록 대상이 계속 늘어나는가, 아니면 몇 개로 고정되는가?
  • 등록 대상이 여러 모듈과 여러 팀에 흩어지는가?
  • 중앙 registry 변경 때문에 conflict나 누락이 실제로 발생하는가?
  • metadata key, handler count, 중복 등록을 테스트와 로그로 확인할 수 있는가?
  • 등록 순서나 캡슐화가 중요해 명시 코드가 더 안전한 구간은 없는가?

도입한다면 scanner는 작게 시작한다. 처음부터 범용 plugin framework를 만들기보다, 한 marker key와 한 registry를 대상으로 구현한다. 예를 들어 Slack event scanner면 SLACK_EVENT_KEY 하나만 읽고, 찾은 결과를 eventName -> handlers[] 구조로 등록한다. 권한, rate limit, scheduler까지 한 scanner에 섞으면 실패 신호가 흐려진다.

16. 테스트 관점: 로그보다 registry 계약을 단언한다

섹션 제목: “16. 테스트 관점: 로그보다 registry 계약을 단언한다”

Discovery 코드는 부팅 중 실행되므로 콘솔 로그로만 확인하기 쉽다. 하지만 로그는 사람이 보는 신호이고, 회귀를 막는 계약은 테스트에서 registry 결과를 단언해야 한다.

학습용 테스트는 다음 세 가지를 잡으면 충분하다.

  1. marker가 붙은 메서드는 registry에 들어간다.
  2. marker가 없는 메서드는 registry에 들어가지 않는다.
  3. key 오타나 target 불일치가 있으면 등록 수가 0이 되며, 이 상태를 테스트가 드러낸다.
import { DiscoveryModule } from "@nestjs/core";
import { Test } from "@nestjs/testing";
import { FakeSlackClient } from "./fake-slack.client";
import { MessageHandlers } from "./message.handlers";
import { SlackClient } from "./slack.client";
import { SlackEventExplorer } from "./slack-event.explorer";
it("SlackEvent metadata가 붙은 메서드만 등록한다", async () => {
const moduleRef = await Test.createTestingModule({
imports: [DiscoveryModule],
providers: [
MessageHandlers,
SlackEventExplorer,
FakeSlackClient,
{ provide: SlackClient, useExisting: FakeSlackClient },
],
}).compile();
const explorer = moduleRef.get(SlackEventExplorer);
explorer.onModuleInit();
const client = moduleRef.get(FakeSlackClient);
expect(client.handlersFor("message")).toHaveLength(1);
expect(client.handlersFor("reaction_added")).toHaveLength(1);
expect(client.handlersFor("unknown")).toHaveLength(0);
});

useExistingSlackClient 토큰을 이미 생성된 FakeSlackClient Provider의 별칭으로 연결한다. 따라서 Explorer가 주입받은 객체와 테스트가 moduleRef.get(FakeSlackClient)로 조회한 객체가 같고, 등록 결과를 같은 registry에서 단언할 수 있다.

이 테스트의 목적은 Nest testing API를 외우는 것이 아니다. Discovery scanner의 산출물이 “로그 몇 줄”이 아니라 “검증 가능한 registry 상태”여야 한다는 점을 익히는 것이다. 실무에서는 FakeSlackClient 대신 event bus, scheduler registry, command bus 같은 대상의 등록 결과를 단언하면 된다.

Discovery scanner는 보통 부팅 시 한 번 돈다. 그래서 매 요청 latency보다 startup time, handler count, registry size가 더 직접적인 관찰값이다.

관찰값정상적으로 기대하는 모습위험 신호
scanned provider 수배포 사이에 앱 규모만큼 완만히 증가특정 모듈 추가 후 갑자기 수천 단위 증가
scanned method 수provider 수와 평균 메서드 수에 비례getter, generated method까지 과도하게 포함
registered handler 수실제 marker 수와 일치0 또는 이전 배포 대비 급감
duplicate event 수정책에 따라 0이거나 의도된 fan-out 수command성 이벤트에서 2개 이상 발견
startup timeprovider graph 변화와 함께 완만히 변동scanner 추가 뒤 초 단위로 급증

부팅 시간이 늘었을 때 바로 “Discovery가 느리다”고 결론 내리면 진단이 빗나갈 수 있다. scanner는 Provider graph가 만들어진 뒤 그 결과를 훑는다. 따라서 startup time이 커졌다면 scanner count와 함께 Dynamic Module, 큰 useValue, request-scoped provider, 외부 연결 초기화를 같이 본다.

18. 선택 부록: 관찰용 최소 코드

섹션 제목: “18. 선택 부록: 관찰용 최소 코드”

본문 이해에는 필요 없지만, 실제 코드에서 silent failure를 줄일 때 유용한 작은 관찰 코드만 남긴다.

부팅 시 handler count 로그
let scannedProviders = 0;
let scannedMethods = 0;
let registeredHandlers = 0;
for (const wrapper of this.discovery.getProviders()) {
scannedProviders += 1;
const { instance } = wrapper;
if (!instance) continue;
for (const methodName of this.metadataScanner.getAllMethodNames(
Object.getPrototypeOf(instance),
)) {
scannedMethods += 1;
const handler = instance[methodName];
const eventName = this.reflector.get(SLACK_EVENT_KEY, handler);
if (!eventName) continue;
registeredHandlers += 1;
}
}
this.logger.log({
scannedProviders,
scannedMethods,
registeredHandlers,
});

이 로그의 목적은 “0개 등록” 같은 silent failure를 부팅 시점에 바로 보는 것이다. 수치 자체의 정답은 없다. 이전 정상 배포와 비교해 갑자기 0이 되거나 크게 줄면 위험 신호다.

metadata key 오타 확인
const keys = Reflect.getMetadataKeys(handler);
if (keys.length > 0 && !keys.includes(SLACK_EVENT_KEY)) {
this.logger.debug({
method: `${wrapper.name}.${methodName}`,
keys,
});
}

slack_event를 기대했는데 slack_events가 보이면 key 불일치다. 이 문제는 에러를 던지지 않고 undefined로 사라지기 쉬우므로 key 상수화와 함께 확인한다.

코드베이스에서 Discovery 사용 위치 찾기
Terminal window
rg "DiscoveryService|DiscoveryModule|MetadataScanner|SetMetadata|Reflect.getMetadata" src

검색은 출발점일 뿐이다. 검색 결과를 보면 “scanner가 어디서 후보를 모으는지”, “metadata key가 어디서 정의되는지”, “registry가 무엇으로 만들어지는지”를 따라가야 한다.

개념 A개념 B구분 기준
DiscoveryServiceReflectorDiscovery는 후보 목록을 찾고, Reflector는 이미 알고 있는 target의 metadata를 읽는다
ModulesContainerInstanceWrapperModulesContainer는 모듈 그래프 저장소이고, InstanceWrapper는 개별 Provider/Controller 관리 객체다
Provider scanController scanProvider는 이벤트/메시지 핸들러에, Controller는 HTTP endpoint 정책 수집에 주로 쓴다
onModuleInitonApplicationBootstrap필요한 의존성이 준비되는 가장 이른 시점이 기준이다
SetMetadata일반 데코레이터SetMetadata는 나중에 읽을 key-value metadata를 target에 저장한다
직접 등록Discovery 등록직접 등록은 투명하고 작을 때 좋고, Discovery는 변경 대상이 많고 분산될 때 좋다

Discovery Module 복습 체크리스트

  • Discovery Module이 DI/IoC 이후에 어떤 빈칸을 채우는지 설명할 수 있다.
  • ModulesContainer, InstanceWrapper, MetadataScanner의 역할을 각각 한 문장으로 말할 수 있다.
  • 커스텀 데코레이터가 metadata를 남기고 scanner가 registry를 만드는 흐름을 코드 없이 설명할 수 있다.
  • Provider scan과 Controller scan을 언제 나눠야 하는지 판단할 수 있다.
  • DiscoveryService와 Reflector의 차이를 설명할 수 있다.
  • silent failure가 생기는 대표 원인 세 가지를 말할 수 있다.

퀴즈

`@SlackEvent('message')`를 붙였는데 핸들러가 조용히 등록되지 않는다면 무엇을 먼저 확인해야 할까?

힌트: Discovery는 파일이 아니라 컨테이너 wrapper를 보고, metadata는 key와 target이 맞아야 읽힌다.

정답 보기

먼저 핸들러 클래스가 현재 앱 모듈 그래프의 `providers`에 등록되어 `DiscoveryService.getProviders()` 결과에 들어오는지 확인한다. 그 다음 `SLACK_EVENT_KEY`가 데코레이터와 scanner에서 같은 상수인지, `SetMetadata`가 저장한 target과 scanner가 읽는 target이 같은지 `Reflect.getMetadataKeys()`나 `Reflector.get()` 로그로 확인한다.

ModulesContainer, InstanceWrapper, MetadataScanner, Reflector, Reflect.getMetadata, SetMetadata, DiscoveryService.getProviders(), DiscoveryService.getControllers(), onModuleInit, onApplicationBootstrap, @nestjs/cqrs, @nestjs/schedule, custom decorator, metadata key, OCP

항목핵심 내용
Discovery ModuleNest Container에 등록된 Provider/Controller wrapper를 런타임에 탐색한다
해결하는 한계DI가 만든 객체 그래프 위에서 “어떤 메서드가 어떤 이벤트를 처리하는가”를 연결한다
핵심 패턴마커 데코레이터를 붙이고, scanner가 metadata를 읽어 registry를 만든다
OCP 효과새 핸들러 추가 시 중앙 등록 파일 변경을 줄인다
주요 실패provider 미등록, scan boundary 착각, metadata key/target 불일치, bind 누락
비용부팅 시 wrapper/method 스캔과 Provider graph 초기화 비용을 만든다

5줄로 다시 쓰면 다음과 같다.

  1. DI/IoC는 객체를 만들고 주입하지만, 이벤트 라우팅까지 자동으로 알지는 못한다.
  2. Discovery Module은 컨테이너에 등록된 Provider/Controller를 탐색해 라우팅 후보를 찾는다.
  3. 커스텀 데코레이터는 metadata marker를 남기고, scanner는 그 marker를 읽어 registry를 만든다.
  4. 이 패턴은 중앙 등록 파일 변경을 줄이지만, metadata 누락과 스캔 범위 누락이라는 silent failure를 만든다.
  5. 작은 고정 목록은 직접 등록이 낫고, 계속 늘어나는 분산 핸들러는 Discovery가 유리하다.
Nest.js Discovery Module (지금 여기)
Reflector + ExecutionContext ← 요청 처리 중 metadata 읽기
NestJS CQRS Module ← Discovery 패턴이 Command/Event 라우팅으로 포장된 사례
Custom Decorator 심화 ← createParamDecorator, applyDecorators, metadata target 설계
Dynamic Module 작성 ← Discovery + forRoot()로 재사용 가능한 모듈 만들기

인터뷰나 코드 리뷰에서 자주 나오는 질문은 다음이다.

  • Discovery Module은 DI/IoC와 무엇이 다른가?
  • DiscoveryServiceReflector는 각각 어떤 질문에 답하는가?
  • Provider와 Controller를 왜 따로 스캔해야 하는가?
  • onModuleInit()onApplicationBootstrap() 중 어디서 scanner를 실행할 것인가?
  • 자동 등록 패턴의 silent failure를 어떤 로그와 테스트로 드러낼 것인가?

최종 수정: 2026-04-13