Node.js에서 데코레이터 없이 9년째 의존성 주입… 여전히 올바른 선택이라 생각합니다

발행: (2026년 6월 18일 PM 09:55 GMT+9)
7 분 소요
원문: Dev.to

출처: Dev.to

자, 먼저 솔직하게 말하도록 하겠습니다.
이 글은 partly 제가 풀어보는 내용입니다.

node‑dependency‑injection를 약 9년째 유지하고 있습니다. GitHub에 300개 스타를 받았습니다. 정확히 말하면 바이럴은 아니었습니다. 그리고 InversifyJS나 tsyringe가 인기를 끌 때마다 “네, 하지만 비용이 어느 정도일까?”라고 생각합니다.

그래서 데코레이터에 대한 제 문제를 설명하겠습니다.

다음과 같이 작성할 때:

@Injectable()
export class UserService {
  constructor(@Inject(MAILER_TOKEN) private mailer: IMailer) {}
}

그 @Injectable()는 어디에 있나요? DI 프레임워크 안에 있습니다. 이는 UserService(도메인 로직·비즈니스 규칙·프레임워크 결정을 초월해야 할 요소)와 IoC 컨테이너 라이브러리 사이에 직접적인 의존 관계를 만든다는 뜻입니다.
도메인이 인프라를 알고 있다는 것은 올바른 방향이 아닙니다.

알고 있습니다, 알고 있습니다. “그건 단순히 데코레이터일 뿐, 아무 일도 안 한다고요.”
하지만 여전히 임포트이며, 결합을 의미합니다. 그리고 컨테이너를 교체하거나 서비스를 다른 곳에 옮기거나, 전체 컨테이너를 띄우지 않고 테스트하려 할 경우 agora 생각해야 합니다.

NDI를 사용하면 서비스는 단순히 클래스일 뿐입니다:

export class UserService {
  constructor(private mailer: IMailer) {}
}

그게 전부입니다. 라이브러리 임포트도 없고, 데코레이터도 없으며, 메타데이터도 없습니다. 서비스는 자신이 주입받고 있다는 것을 모릅니다. 와이어링은 YAML 파일이나 부트스트랩 파일에 완전히 외부에서 관리됩니다. 도메인이 깨끗하게 유지됩니다.

Symfony는 실제로 서비스는 데코레이터를 사용하지 않습니다. DI 설정은 YAML, XML, PHP 파일로 외부에서 관리되며, 서비스는 단순히 PHP 클래스일 뿐입니다. 이것이 NDI가 처음부터 영감을 얻은 원인입니다.

간단한 예시입니다. 두 결제 제공자를 가지고 컨텍스트에 맞는 올바른 것을 주입하고 싶습니다:

services:
  payment.stripe:
    class: 'payments/StripePayment'
    keyed:
      group: payment
      key: stripe
      default: true

  payment.paypal:
    class: 'payments/PaypalPayment'
    keyed:
      group: payment
      key: paypal

  checkout.service:
    class: 'CheckoutService'
    arguments: ['@keyed(payment, stripe)']

// CheckoutService.ts — 순수 클래스, 프레임워크 임포트 없음

export class CheckoutService {
  constructor(private payment: IPaymentService) {}

  async process(order: Order) {
    return this.payment.charge(order.total)
   }
}

전략 패턴은 클래스 외부에서 완전히 구현됩니다. config 한 줄만 바꾸면 Stripe를 PayPal로 교체할 수 있습니다. CheckoutService는 전혀 신경 쓰지 않습니다.

솔직히 말하면, 이것이 제가 가장 자랑스럽게 생각하는 것입니다. NDI는 TypeScript 생성자를 읽어 자동으로 모든 것을 연결해 주며 — 데코레이터 하나 없이요:

// Just normal TypeScript. No imports from NDI.
export default class OrderService {
  constructor(
    private readonly repo: OrderRepository,
    private readonly mailer: MailService
   ) {}
}
const container = new ContainerBuilder(false, '/src')
const autowire = new Autowire(container)
await autowire.process()
await container.compile()

const orders = container.get(OrderService) // fully wired

TypeScript AST를 파싱하여 생성자 매개변수를 찾고 타입으로 해결합니다. reflect‑metadata도 없고, 데코레이터도 없으며, 아무것도 없습니다. 단지 타입만 존재합니다.

다른 컨테이너에서는 볼 수 없는 기능 중 하나는 환경에 따라 서비스를 등록하는 것입니다:

services:
  cache.redis:
    class: 'services/RedisCache'
    when:
      env_exists: REDIS_URL

  cache.memory:
    class: 'services/InMemoryCache'
    when:
      missing: cache.redis

프로덕션에서는 Redis가 존재해 메모리 캐시가 인스턴스화되지 않습니다. 로컬에서는 존재하지 않아 기본값으로 fallback됩니다. 코드 변경 없이도 동작합니다.

다시 Symfony에서 차용했습니다. 컨테이너를 컴파일하기 전 빌드 단계에서 변형할 수 있습니다:

class AddLoggingPass implements CompilerPass {
  async process(container: ContainerBuilder) {
    for (const { id, definition } of container.findTaggedServiceIds('loggable')) {
       // wrap every tagged service with a logging decorator
      definition.setDecorator('logger.decorator')
     }
   }
}
container.addCompilerPass(new AddLoggingPass())

@Injectable 데코레이터만 사용해 청결하게 구현해 보세요.

진심으로, 생태계가 ‘TypeScript DI’를 데코레이터와 연결시키는 교육을 시켰기 때문입니다. NestJS가 기본값으로 만들었습니다.
NestJS는 많은 장점이 있는 좋은 프레임워크이지만, 프레임워크를 전부 활용한다면 DI도 자연스럽게 따라옵니다.

하지만 청정 아키텍처, 헥사고날 설계, 도메인 로직을 인프라에서 분리하려 한다면 서비스에 데코레이터를 두는 것은 피하고자 하는 precisely(정확히) 것입니다.

NDI는 DI 컨테이너가 인프라로서의 역할을 하고, 도메인에 스며들지 않기를 원하는 사람들에게 적합합니다. 와이어링은 비즈니스 로직에 대한 어노테이션이 아니라 평범한 설정으로 이루어져야 합니다.

저는 이걸 9년째 생산 환경에서 사용해 왔습니다. 잘 작동합니다. 그리고 제 UserService는 여전히 컨테이너가 무엇인지 모릅니다.

GitHub
npm
Wiki

0 조회
Back to Blog

관련 글

더 보기 »

코드 리뷰가 잘못됐다

!Cover image for Code Review Gone Wronghttps://media2.dev.to/dynamic/image/width=1000,height=420,fit=cover,gravity=auto,format=auto/https%3A%2F%2Flavkesh.com%2F...