NestJS Enterprise Backend APIs · 강의

DiscoveryService를 활용한 동적 공급자 등록

플러그인 시스템을 위해 DiscoveryService와 MetadataScanner로 런타임에 공급자를 검색하고 연결합니다.

레슨 2/413개 단계

DiscoveryService를 활용한 동적 공급자 등록은(는) CoddyKit의 무료 NestJS Enterprise Backend APIs 강의입니다. 이것은 4개 중 2번째 강의입니다. 아래에서 전체 강의를 무료로 읽을 수 있으며, 내장 코드 에디터와 24/7 AI 튜터와 함께 브라우저에서 직접 실습할 수 있습니다. 이 강의는 NestJS Enterprise Backend APIs 학습 경로의 일부이며, 진행 상황이 웹과 CoddyKit 앱에 동기화됩니다. NestJS Enterprise Backend APIs 강의에는 총 4개의 강의가 포함되어 있습니다.

이 강의의 일부는 아직 번역되지 않았으며 영어로 표시됩니다.

The Plugin Wiring Problem

In a plugin architecture, you don't know at compile time which handlers, strategies, or adapters will exist. A hexagonal core defines ports; plugins supply adapters. The challenge: how does the framework find and wire those adapters without you hand-registering each one?

  • Hard-coded arrays in a module are brittle — every new plugin edits core code.
  • You want providers to self-declare their role via decorators, then be discovered at runtime.

NestJS ships @nestjs/core's DiscoveryService and MetadataScanner exactly for this. They let you scan the live DI container and react to metadata.

Marking Providers with a Decorator

The pattern starts with a custom decorator that stamps metadata onto a class. We use SetMetadata (or Reflector.createDecorator) so the scanner can later filter for it.

Here a @Plugin() decorator tags a class as a discoverable plugin and carries a name so the registry can key on it.

import { SetMetadata } from '@nestjs/common';

export const PLUGIN_KEY = 'app:plugin';

export interface PluginMeta {
  name: string;
}

export const Plugin = (meta: PluginMeta): ClassDecorator =>
  SetMetadata(PLUGIN_KEY, meta);

// A plugin author writes:
@Plugin({ name: 'csv-exporter' })
export class CsvExporter {
  export(rows: unknown[]): string {
    return rows.map((r) => JSON.stringify(r)).join('\n');
  }
}

What DiscoveryService Gives You

DiscoveryService is exported by the DiscoveryModule. Inject it and you get two key methods:

  • getProviders() — every provider instance wrapper in the application container.
  • getControllers() — every controller wrapper.

Each item is an InstanceWrapper with .instance (the live object), .metatype (the class), and .name. You filter these wrappers by reading metadata off the metatype with a Reflector.

import { Module } from '@nestjs/common';
import { DiscoveryModule } from '@nestjs/core';
import { PluginRegistry } from './plugin.registry';

@Module({
  imports: [DiscoveryModule], // exposes DiscoveryService + MetadataScanner
  providers: [PluginRegistry],
  exports: [PluginRegistry],
})
export class PluginCoreModule {}

Scanning Providers on Bootstrap

Run discovery after the container is fully built. Implement OnModuleInit (or OnApplicationBootstrap if you need every module ready). Filter wrappers whose metatype carries your PLUGIN_KEY metadata.

Guard against null wrappers: some entries (value providers, request-scoped placeholders) have no metatype or no instance.

import { Injectable, OnModuleInit } from '@nestjs/common';
import { DiscoveryService, Reflector } from '@nestjs/core';
import { PLUGIN_KEY, PluginMeta } from './plugin.decorator';

@Injectable()
export class PluginRegistry implements OnModuleInit {
  private readonly plugins = new Map<string, object>();

  constructor(
    private readonly discovery: DiscoveryService,
    private readonly reflector: Reflector,
  ) {}

  onModuleInit(): void {
    for (const wrapper of this.discovery.getProviders()) {
      const { instance, metatype } = wrapper;
      if (!instance || !metatype) continue;
      const meta = this.reflector.get<PluginMeta>(PLUGIN_KEY, metatype);
      if (!meta) continue;
      this.plugins.set(meta.name, instance);
    }
  }

  get(name: string): object | undefined {
    return this.plugins.get(name);
  }
}

MetadataScanner for Method-Level Hooks

Sometimes the plugin point isn't the class but a method — e.g. @EventHandler('order.created') on individual methods. MetadataScanner walks every method of an instance's prototype so you can read per-method metadata.

Use getAllMethodNames(prototype) (modern API) and inspect each handler with the Reflector.

import { Injectable, OnModuleInit } from '@nestjs/common';
import { DiscoveryService, MetadataScanner, Reflector } from '@nestjs/core';

export const EVENT_KEY = 'app:event';

@Injectable()
export class EventBinder implements OnModuleInit {
  constructor(
    private readonly discovery: DiscoveryService,
    private readonly scanner: MetadataScanner,
    private readonly reflector: Reflector,
  ) {}

  onModuleInit(): void {
    for (const w of this.discovery.getProviders()) {
      if (!w.instance || !w.metatype) continue;
      const proto = Object.getPrototypeOf(w.instance);
      for (const method of this.scanner.getAllMethodNames(proto)) {
        const event = this.reflector.get<string>(EVENT_KEY, proto[method]);
        if (event) this.bind(event, w.instance, method);
      }
    }
  }

  private bind(event: string, target: object, method: string): void {
    // register target[method] as a listener for `event`
  }
}

The Method-Level Decorator

Pair the binder with a method decorator. Note it's a MethodDecorator — SetMetadata attaches the value to the method's descriptor.value, which is exactly what reflector.get(EVENT_KEY, proto[method]) reads.

This keeps the wiring declarative: a plugin author adds an annotation and the core binds it — no manual emitter.on(...) calls.

import { SetMetadata } from '@nestjs/common';
import { EVENT_KEY } from './event.binder';

export const OnEvent = (event: string): MethodDecorator =>
  SetMetadata(EVENT_KEY, event);

@Injectable()
export class InventoryPlugin {
  @OnEvent('order.created')
  reserveStock(payload: { orderId: string }): void {
    // adjust stock for payload.orderId
  }

  @OnEvent('order.cancelled')
  releaseStock(payload: { orderId: string }): void {
    // restore stock
  }
}

Modeling Discovery in Plain TypeScript

Strip away NestJS and the core idea is simple: a registry maps a key to an instance discovered from a list, then routes calls by key. This standalone model captures the registry semantics you'll wire to DiscoveryService.

The exact same Map-based lookup powers the real registry — only the source of instances differs.

interface Exporter {
  readonly name: string;
  export(rows: object[]): string;
}

class CsvExporter implements Exporter {
  name = 'csv';
  export(rows: object[]): string {
    return rows.map((r) => Object.values(r).join(',')).join('\n');
  }
}

class JsonExporter implements Exporter {
  name = 'json';
  export(rows: object[]): string {
    return JSON.stringify(rows);
  }
}

class Registry {
  private map = new Map<string, Exporter>();
  register(...plugins: Exporter[]): void {
    for (const p of plugins) this.map.set(p.name, p);
  }
  run(name: string, rows: object[]): string {
    const p = this.map.get(name);
    if (!p) throw new Error('Unknown exporter: ' + name);
    return p.export(rows);
  }
}

const reg = new Registry();
reg.register(new CsvExporter(), new JsonExporter());
const data = [{ id: 1, sku: 'A' }, { id: 2, sku: 'B' }];
console.log(reg.run('csv', data));
console.log(reg.run('json', data));

Discovery Timing and Lifecycle

Timing matters. The DI container is only complete at certain lifecycle phases:

  • onModuleInit — fires per module after its providers resolve. Fine if all plugins live in one module.
  • onApplicationBootstrap — fires once, after all modules initialized. Safest for cross-module plugin scanning.

Scanning too early yields an empty or partial provider list. Prefer onApplicationBootstrap when plugins can ship in feature modules loaded later.

import { Injectable, OnApplicationBootstrap } from '@nestjs/common';
import { DiscoveryService, Reflector } from '@nestjs/core';
import { PLUGIN_KEY, PluginMeta } from './plugin.decorator';

@Injectable()
export class PluginRegistry implements OnApplicationBootstrap {
  private readonly plugins = new Map<string, object>();

  constructor(
    private readonly discovery: DiscoveryService,
    private readonly reflector: Reflector,
  ) {}

  onApplicationBootstrap(): void {
    const found = this.discovery
      .getProviders()
      .filter((w) => w.instance && w.metatype)
      .map((w) => ({
        meta: this.reflector.get<PluginMeta>(PLUGIN_KEY, w.metatype!),
        instance: w.instance,
      }))
      .filter((x) => x.meta);
    for (const { meta, instance } of found) {
      this.plugins.set(meta!.name, instance);
    }
  }
}

Scope Pitfalls: REQUEST and TRANSIENT

Discovery sees singletons cleanly. Beware non-default scopes:

  • Scope.REQUEST / Scope.TRANSIENT providers may have wrapper.instance === null at bootstrap — there's no single instance to cache.
  • A discovered request-scoped instance would be stale and leak per-request state if you cache it.

Rule of thumb: keep plugins singleton-scoped. If a plugin truly needs request data, discover the class and resolve a fresh instance per request via ModuleRef.resolve() instead of caching the instance.

import { Injectable } from '@nestjs/common';
import { ModuleRef } from '@nestjs/core';

@Injectable()
export class ScopedPluginInvoker {
  constructor(private readonly moduleRef: ModuleRef) {}

  // metatype was discovered earlier; resolve fresh per request
  async invoke<T>(metatype: new (...a: any[]) => T): Promise<T> {
    return this.moduleRef.resolve(metatype, undefined, { strict: false });
  }
}

Validating and Guarding the Registry

A plugin system that silently swallows duplicates or missing contracts is a debugging nightmare. Add guards during discovery:

  • Duplicate names — throw, don't overwrite, so two plugins can't collide on one key.
  • Contract check — verify the instance implements the expected method shape before trusting it.

Failing fast at bootstrap turns a runtime plugin bug into a clear startup error.

private register(name: string, instance: object): void {
  if (this.plugins.has(name)) {
    throw new Error(`Duplicate plugin name: ${name}`);
  }
  if (typeof (instance as { export?: unknown }).export !== 'function') {
    throw new Error(`Plugin ${name} missing export()`);
  }
  this.plugins.set(name, instance);
}

Why This Fits Hexagonal Design

Discovery-based registration is the runtime glue of ports and adapters:

  • The core defines a port (an interface) and a registry keyed by capability.
  • Each adapter/plugin declares itself with a decorator — it depends on the core's contract, never the reverse.
  • Adding a capability means dropping in a new annotated provider; no edits to core wiring.

This inverts the dependency direction (the Dependency Inversion Principle) and keeps the core closed for modification but open for extension — the heart of plugin architecture.

Quick Check

You build a plugin registry that caches each discovered provider's .instance in a Map during onApplicationBootstrap. One plugin is declared with Scope.REQUEST. What goes wrong and what's the right fix?

Recap

You built a runtime plugin system on NestJS discovery primitives:

  • Tag plugins with a metadata decorator (SetMetadata + a PLUGIN_KEY), class-level for whole plugins, method-level for handlers.
  • Scan the container with DiscoveryService.getProviders(), filtering wrappers via Reflector; use MetadataScanner.getAllMethodNames() for per-method hooks.
  • Time it with onApplicationBootstrap for cross-module safety, and skip wrappers lacking instance/metatype.
  • Guard against duplicate keys and contract violations; keep plugins singleton-scoped, resolving via ModuleRef only when request scope is genuinely needed.

The payoff: a hexagonal core that's closed for modification yet open to new annotated adapters — zero core edits per plugin.

무료로 시작

AI 튜터와 함께 TypeScript을(를) 배우세요 — 무료

브라우저에서 실제 코드를 작성하고 실행하며, 24/7 AI 튜터로부터 즉각적인 도움을 받고, 웹이나 앱에서 중단한 부분부터 계속 학습하세요.

코스
20
레슨
76

자주 묻는 질문

“DiscoveryService를 활용한 동적 공급자 등록” 강의는 무료인가요?

네 — “DiscoveryService를 활용한 동적 공급자 등록” 전체 내용을 이 웹사이트에서 무료로 읽을 수 있습니다. 인터랙티브하게 실습하려면(내장 코드 에디터와 24/7 AI 튜터), CoddyKit PRO로 업그레이드하면 NestJS Enterprise Backend APIs 강의 전체를 잠금 해제할 수 있습니다. NestJS Enterprise Backend APIs 강의에는 총 4개의 강의가 포함되어 있습니다.

“DiscoveryService를 활용한 동적 공급자 등록”에서 뭘 배우나요?

플러그인 시스템을 위해 DiscoveryService와 MetadataScanner로 런타임에 공급자를 검색하고 연결합니다. 브라우저에서 직접 실행하는 실습 코드로 NestJS Enterprise Backend APIs을(를) 배우며, 24/7 AI 튜터가 강의를 진행하면서 질문에 답변해줍니다.

NestJS Enterprise Backend APIs을(를) 시작하는 데 경험이 필요한가요?

사전 경험은 필요하지 않습니다. CoddyKit의 NestJS Enterprise Backend APIs은(는) 초급자부터 고급 학습자까지를 위해 구성되어 있으므로, 여기서 시작하거나 처음부터 시작할 수 있으며 자신의 속도대로 진행할 수 있습니다. 이것은 4개 중 2번째 강의입니다.

“DiscoveryService를 활용한 동적 공급자 등록” 강의는 얼마나 걸리나요?

대부분의 CoddyKit 강의는 약 5~10분이 소요됩니다. 각 강의는 간결하고 인터랙티브하여 꾸준한 진행이 가능하며, 웹과 앱에서 중단한 부분부터 바로 시작할 수 있습니다.

이 NestJS Enterprise Backend APIs 강의에서 코드를 작성하고 실행할 수 있나요?

네. 모든 NestJS Enterprise Backend APIs 강의에는 내장 코드 에디터가 포함되어 있으므로, 브라우저에서 바로 실제 코드를 작성하고 실행한 후 즉시 AI 피드백을 받을 수 있습니다 — 로컬 설정이 필요 없습니다.

이 강의의 모든 강의

  1. 도메인 격리를 위한 포트와 어댑터
  2. DiscoveryService를 활용한 동적 공급자 등록
  3. 지연 로딩 모듈과 기능 토글
  4. 모듈 참조 API를 활용한 확장 지점
← NestJS Enterprise Backend APIs(으)로 돌아가기