NestJS Discovery Module로 자동 등록 이해하기
NestJS Discovery Module은 IoC Container에 등록된 Provider와 Controller를 런타임에 탐색해, 데코레이터 기반 자동 등록 패턴을 구현하게 해주는 도구다. 슬랙봇 이벤트 핸들러처럼 새 기능을 추가할 때 중앙 등록 코드를 고치지 않는 구조를 이해하는 데 초점을 둔다.
Script Companion
오디오와 함께 스크립트 보기
- 01
NestJS Discovery Module을 이해하는 출발점은 데코레이터 하나로 특정 메서드가 자동으로 이벤트 핸들러가 되는 흐름이다. 팀 내 슬랙봇에 적용된 패턴도 여기에 기대고 있다. 새 핸들러를 만들 때마다 중앙 등록 코드를 직접 수정하는 대신, 메서드나 클래스에 표시를 남기고 런타임에 그 표시를 찾아 등록하는 방식이다. 그래서 이 주제는 단순한 편의 기능이 아니라, 반복 등록 코드가 왜 줄어드는지 설명하는 핵심 개념이다.
- 02
원문의 비유를 그대로 가져오면, Discovery Module은 도서관 시스템에 가깝다. 새 책이 들어올 때마다 사서가 직접 카탈로그에 적는 대신, 책마다 스티커를 붙여두면 시스템이 그 스티커를 읽고 자동으로 카탈로그에 넣는다. 여기서 책은 핸들러, 스티커는 데코레이터, 도서관 시스템은 DiscoveryModule이다. React 앱에서 pages 폴더에 파일을 추가하면 Next.js가 라우트를 자동 등록하는 것과도 유사한 철학이 보인다.
- 03
이 패턴의 설계 철학은 개방-폐쇄 원칙, 즉 OCP와 연결된다. 새 이벤트 핸들러를 추가할 때 기존 등록 코드는 닫혀 있고, 새 클래스에 데코레이터를 붙이는 방식으로만 확장된다. 반대로 이 패턴이 없으면 핸들러가 늘어날 때마다 중앙 등록 코드도 계속 수정해야 하고, 원문은 이런 문제를 산탄총 수술이라고 부른다. Discovery Module은 별도의 레지스트리를 새로 만드는 대신, NestJS의 IoC Container가 이미 가진 정보를 쿼리한다는 점도 중요하다.
- 04
내부 동작은 크게 DiscoveryService, ModulesContainer, MetadataScanner로 나눠 볼 수 있다. ModulesContainer는 IoC Container가 관리하는 모든 모듈과 Provider의 저장소이고, DiscoveryService.getProviders()는 이 컨테이너를 순회해 InstanceWrapper 목록을 반환한다. NestFactory.create()가 호출될 때 컨테이너 구성이 완료되므로, onModuleInit() 시점에는 모든 Provider 인스턴스가 준비된 상태로 보는 것이 기본 흐름이다. 다만 뒤에서 보듯 null 체크는 여전히 필요하다.
- 05
메서드 탐색은 MetadataScanner가 맡는다. 이 도구는 클래스 프로토타입을 훑으며 메서드 이름을 모으고, Reflect.getMetadata()로 각 메서드에 붙은 커스텀 메타데이터를 읽는다. NestJS v10부터는 scanFromPrototype()이 deprecated 되었고, 권장 방식은 getAllMethodNames()를 사용하는 것이다. getAllMethodNames는 결과를 캐싱하므로 같은 프로토타입을 반복해서 읽을 때 성능 저하를 피할 수 있다는 점도 원문에서 강조된다.
- 06
메타데이터 탐색에서 자주 헷갈리는 지점은 클래스 레벨과 메서드 레벨의 차이다. Reflect.getMetadata(key, target)은 클래스, 정확히는 constructor에 붙은 메타데이터를 읽는 방식이고, Reflect.getMetadata(key, target, propertyKey)는 메서드에 붙은 메타데이터를 읽는 방식이다. 세 번째 인자의 유무가 핵심이다. 이 차이를 놓치면 데코레이터를 분명히 붙였는데도 값이 계속 undefined로 나오는 버그를 만날 수 있다.
- 07
DiscoveryService는 Provider만 보는 도구가 아니다. Controller도 탐색할 수 있지만, NestJS가 Provider와 Controller를 내부적으로 다르게 관리하므로 Controller를 찾을 때는 getControllers()를 따로 호출해야 한다. 이 방식은 모든 Controller에서 특정 데코레이터를 스캔해 API 목록을 동적으로 수집하거나, API 권한과 Rate Limit 데코레이터를 한 번에 모아 적용하는 흐름으로 이어질 수 있다. CQRS의 Command와 Event Handler, 커스텀 스케줄러, 메시지 리스너, 플러그인 시스템에도 같은 원리가 쓰인다.
- 08
실무에서 먼저 확인할 실패 모드는 세 가지다. 첫째, 데코레이터를 붙였는데 getProviders()에 클래스가 보이지 않는다면, Injectable만 붙이고 실제 모듈의 providers에 등록하지 않았을 수 있다. 둘째, wrapper.instance가 null이면 constructor를 읽는 순간 TypeError가 나므로 반드시 null 체크를 둬야 한다. 셋째, SetMetadata()의 키 문자열과 Reflect.getMetadata()의 키 문자열이 다르면 에러 없이 undefined가 반환되어 조용히 누락된다. 그래서 메타데이터 키는 constant나 Symbol로 한 곳에서 관리하는 편이 안전하다.
- 09
정리하면 Discovery Module은 런타임에 IoC Container에 등록된 전체 Provider와 Controller를 탐색하는 도구다. MetadataScanner는 클래스의 메서드 이름을 수집하고, Reflect.getMetadata()는 데코레이터가 남긴 값을 읽는다. 이 조합으로 데코레이터만 붙이면 자동 등록되는 패턴이 만들어진다. 슬랙봇에서 app_home_opened 같은 새 이벤트를 추가할 때 @SlackEvent('app_home_opened')만 붙이고 탐색·등록 코드를 고치지 않는 흐름이 바로 이 개념의 적용 사례다.
같은 레이어