TypeScript에서 데코레이터 기반 의존성 주입 컨테이너 구축하기
Takashi Yamamoto
Infrastructure Engineer · Leapcell

소개
Angular 또는 NestJS와 같은 프레임워크로 구축된 현대 웹 애플리케이션은 모듈성과 테스트 용이성에 크게 의존합니다. 이러한 특성을 가능하게 하는 중요한 패턴은 의존성 주입(DI)입니다. 프레임워크는 종종 정교한 DI 시스템을 기본적으로 제공하지만, 사용자 정의 시스템을 이해하고 구현하는 것, 특히 TypeScript 데코레이터의 강력함을 활용하는 것은 매우 통찰력 있을 수 있습니다. 이를 통해 이러한 시스템이 어떻게 작동하는지에 대한 더 깊은 이해를 얻고, 더 작고 특화된 애플리케이션이나 라이브러리를 구축할 때 세밀한 제어를 제공할 수 있습니다. 이 아티클에서는 TypeScript 데코레이터를 사용하여 간단하지만 강력한 자동 의존성 주입 컨테이너를 만드는 과정을 안내하고, 깔끔하고 유지 관리 가능하며 테스트 가능한 코드베이스를 달성하는 방법을 시연합니다.
핵심 개념 이해
구현에 들어가기 전에 관련 핵심 용어에 대한 공통된 이해를 확립해 봅시다:
- 의존성 주입(DI): 컴포넌트가 자체적으로 생성하는 대신 외부 소스에서 종속성을 받는 디자인 패턴입니다. 이는 느슨한 결합을 촉진하여 컴포넌트를 더 독립적이고 재사용 가능하며 테스트 가능하게 만듭니다.
- IoC 컨테이너(Inversion of Control Container): 종종 DI 컨테이너와 동의어로 사용되며, 애플리케이션 내 객체의 인스턴스화 및 수명 주기를 관리하는 프레임워크 또는 라이브러리입니다. 이는 컴포넌트 자체에서 객체 생성의 제어를 컨테이너로 '역전'시킵니다.
- 데코레이터: 클래스 선언, 메소드, 접근자, 속성 또는 매개변수에 첨부될 수 있는 특수한 종류의 선언입니다. 데코레이터는
@expression
형식을 사용하며, 여기서expression
은 장식된 선언에 대한 정보로 런타임에 호출될 함수로 평가되어야 합니다. TypeScript에서는 선언적으로 클래스 및 해당 멤버의 메타데이터를 추가하거나 동작을 수정하는 강력한 방법을 제공합니다. - 서비스/주입 가능(Service/Injectable): 특정 작업을 수행하고 다른 컴포넌트에 주입될 수 있는 클래스 또는 객체입니다. DI의 관점에서 볼 때, 이들은 컨테이너가 인스턴스를 관리할 컴포넌트입니다.
- 프로바이더(Provider): DI 컨테이너에 특정 종속성의 인스턴스를 생성하는 방법을 알려주는 메커니즘입니다. 이는 클래스, 값 또는 팩토리 함수가 될 수 있습니다.
데코레이터 기반 DI 컨테이너의 기본 원칙은 데코레이터를 사용하여 클래스를 주입 가능하다고 표시하고 생성자 종속성을 식별하는 것입니다. 그런 다음 컨테이너는 런타임에 이 메타데이터를 사용하여 필수 서비스를 자동으로 확인하고 인스턴스화합니다.
의존성 주입 컨테이너 구현
우리의 DI 컨테이너는 몇 가지 핵심 구성 요소로 구성됩니다. 주입 가능한 클래스를 표시하는 데코레이터, 종속성을 주입하는 방법을 지정하는 데코레이터, 그리고 인스턴스를 관리하는 책임을 가진 컨테이너 자체입니다.
1. @Injectable
데코레이터
이 데코레이터는 두 가지 역할을 수행합니다. 첫째, 클래스를 우리 컨테이너가 관리할 수 있는 서비스로 표시하고, 둘째, 종속성에 대한 메타데이터를 저장합니다.
// reflect-metadata는 데코레이터가 타입 정보와 함께 작동하기 위해 필요한 폴리필입니다. import 'reflect-metadata'; // 생성자 매개변수 타입을 저장하는 심볼 const INJECT_METADATA_KEY = Symbol('design:paramtypes'); /** * DI 컨테이너에서 주입 가능한 클래스로 표시합니다. * 이 데코레이터는 클래스의 생성자 매개변수에 대한 메타데이터를 저장하여 * 컨테이너가 종속성을 확인할 수 있도록 합니다. * @returns 클래스 데코레이터 */ function Injectable(): ClassDecorator { return (target: Function) => { // 타입 정보와 함께 데코레이터가 작동하기 위해 reflect-metadata가 필요한 폴리필입니다. // 즉시 실행할 필요는 없으며, TypeScript의 emitDecoratorMetadata 옵션이 활성화되어 있으면 // TypeScript 컴파일러가 자동으로 생성자 매개변수의 타입 정보를 design:paramtypes 키 아래에 // 메타데이터로 저장해 줍니다. 우리는 단지 이 데코레이터가 실행되었음을 보장하면 됩니다. }; }
설명: Injectable
데코레이터 자체는 본문에서 직접적으로 많은 작업을 수행하지 않습니다. 주요 역할은 TypeScript의 emitDecoratorMetadata
기능을 트리거하는 것입니다 (이는 tsconfig.json
에서 활성화되어야 합니다). emitDecoratorMetadata
가 true이면 TypeScript는 클래스 생성자 매개변수 타입에 대한 메타데이터를 자동으로 내보내고 reflect-metadata
폴리필을 통해 design:paramtypes
키를 사용하여 저장합니다. 우리 컨테이너는 나중에 이 메타데이터를 읽을 것입니다.
2. 컨테이너 코어
여기서 마법이 일어납니다. Container
클래스가 우리 서비스를 관리할 것입니다.
import 'reflect-metadata'; // 단 한 번 전역적으로 가져오도록 보장합니다. type Constructor<T> = new (...args: any[]) => T; /** * 서비스 인스턴스를 관리하는 간단한 의존성 주입 컨테이너입니다. */ class Container { private static instance: Container; private readonly providers = new Map<Constructor<any>, any>(); // 싱글톤 인스턴스 또는 팩토리 함수 저장 private constructor() {} /** * 컨테이너의 싱글톤 인스턴스를 가져옵니다. */ public static getInstance(): Container { if (!Container.instance) { Container.instance = new Container(); } return Container.instance; } /** * 클래스를 컨테이너에 프로바이더로 등록합니다. * 기본적으로 싱글톤으로 등록됩니다. * @param target 등록할 클래스 생성자입니다. */ public register<T>(target: Constructor<T>): void { if (this.providers.has(target)) { console.warn(`Service ${target.name} is already registered.`); return; } // 지금은 생성자 자체만 등록하면 됩니다. // 인스턴스는 첫 번째 해결 시점에 생성됩니다. this.providers.set(target, target); } /** * 컨테이너에서 주어진 클래스의 인스턴스를 확인합니다. * 종속성을 재귀적으로 확인하는 것을 처리합니다. * @param target 확인할 클래스 생성자입니다. * @returns 요청된 클래스의 인스턴스입니다. */ public resolve<T>(target: Constructor<T>): T { // 이미 인스턴스가 생성된 경우(싱글톤), 반환합니다. // 단순화를 위해 여기에서 기본 싱글톤 패턴을 직접 구현합니다. // 더 고급 컨테이너는 명시적인 수명 주기 관리 플래그를 가질 수 있습니다. if (this.providers.has(target) && (this.providers.get(target) instanceof target)) { return this.providers.get(target); } // reflect-metadata를 사용하여 생성자 매개변수 타입(종속성)을 가져옵니다. const paramTypes: Constructor<any>[] = Reflect.getMetadata('design:paramtypes', target) || []; const dependencies = paramTypes.map(paramType => { if (!paramType) { // 기본 타입이 주입되거나 emitDecoratorMetadata가 꺼진 경우 발생할 수 있습니다. throw new Error(`Cannot resolve dependency for ${target.name}. ` + `Ensure 'emitDecoratorMetadata' is true in tsconfig.json ` + `and all dependencies are also @Injectable.`); } // 종속성을 재귀적으로 확인합니다. return this.resolve(paramType); }); // 확인된 종속성과 함께 대상 클래스의 새 인스턴스를 생성합니다. const instance = new target(...dependencies); // 새로 생성된 싱글톤 인스턴스를 저장합니다. this.providers.set(target, instance); return instance; } } // 편리한 인스턴스 내보내기 const container = Container.getInstance(); export { container, Injectable };
설명:
Container.getInstance()
: 컨테이너에 대한 싱글톤 패턴을 구현합니다. 모든 서비스를 관리하기 위해 하나의 중앙 집중식 인스턴스만 필요합니다.register(target: Constructor<T>)
: 이 메소드를 사용하면 클래스를 컨테이너에 명시적으로 등록할 수 있습니다.resolve
메소드가 종속성을 암시적으로 찾을 수 있지만, 미리 등록하는 것은 명시적 구성에 유용할 수 있습니다. 이 기본 예제에서는register
가 생성자 자체를 저장하며, 실제 인스턴스는 첫 번째resolve
시점에 생성됩니다.resolve(target: Constructor<T>): T
: 이것이 DI 컨테이너의 핵심입니다.- 먼저
target
의 인스턴스가 이미 존재하는지 확인합니다 (기본적인 싱글톤 동작 구현). - 그런 다음
Reflect.getMetadata('design:paramtypes', target)
를 사용하여 생성자 매개변수의 타입을 검색합니다. 여기서reflect-metadata
폴리필과emitDecoratorMetadata
가 사용됩니다. - 종속성 인스턴스를 얻기 위해 각 매개변수 타입에 대해
this.resolve()
를 재귀적으로 호출합니다. - 마지막으로 확인된 종속성을 생성자 인수로 사용하여
target
클래스를new
연산자로 인스턴스화합니다. - 새로 생성된 인스턴스는 후속 요청에 대해
providers
에 저장되어 싱글톤 역할을 합니다.
- 먼저
3. 사용 예제
몇 가지 예제 서비스를 사용하여 컨테이너를 사용하는 방법을 설명해 보겠습니다.
// services.ts import { container, Injectable } from './container'; // container.ts가 같은 디렉토리에 있다고 가정 @Injectable() class LoggerService { log(message: string): void { console.log(`[Logger]: ${message}`); } } @Injectable() class DataService { constructor(private logger: LoggerService) {} // LoggerService는 종속성 getData(): string { this.logger.log('Fetching data...'); return 'Hello from DataService!'; } } @Injectable() class ApplicationService { constructor(private dataService: DataService, private logger: LoggerService) {} // DataService 및 LoggerService는 종속성 run(): void { this.logger.log('Application starting...'); const data = this.dataService.getData(); this.logger.log(`Received data: ${data}`); this.logger.log('Application finished.'); } } // main.ts import { container } from './container'; import { ApplicationService } from './services'; // 모든 서비스를 컨테이너에 등록합니다 (선택 사항이지만 명확성을 위해 좋은 습관). // 더 큰 앱에서는 모듈 시스템 또는 자동 검색이 있을 수 있습니다. container.register(LoggerService); container.register(DataService); container.register(ApplicationService); // 최상위 애플리케이션 서비스를 확인합니다. const app = container.resolve(ApplicationService); app.run(); // 싱글톤 동작을 확인합니다. const anotherLogger = container.resolve(LoggerService); const firstLogger = container.resolve(LoggerService); console.log('Are loggers the same instance?', anotherLogger === firstLogger); // true여야 합니다.
이 코드를 실행하려면 다음 사항을 확인해야 합니다.
reflect-metadata
설치:npm install reflect-metadata
tsconfig.json
에서emitDecoratorMetadata
및experimentalDecorators
활성화:{ "compilerOptions": { "target": "es2016", "module": "commonjs", "emitDecoratorMetadata": true, "experimentalDecorators": true, "outDir": "./dist", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true } }
- TypeScript 컴파일:
tsc
- 컴파일된 JavaScript 실행:
node dist/main.js
다음과 유사한 출력이 표시되어야 합니다.
[Logger]: Application starting...
[Logger]: Fetching data...
[Logger]: Received data: Hello from DataService!
[Logger]: Application finished.
Are loggers the same instance? true
이는 ApplicationService
가 명시적으로 생성하지 않고도 생성자에 DataService
및 LoggerService
의 인스턴스를 자동으로 받는 방법을 보여줍니다. 더 나아가, DataService
도 LoggerService
를 받습니다. 모든 서비스는 컨테이너에 의해 싱글톤으로 관리됩니다.
애플리케이션 시나리오
- 마이크로서비스 및 API 게이트웨이: 서비스 클라이언트, 인증 제공자 및 공통 유틸리티 서비스를 다양한 마이크로서비스에 걸쳐 쉽게 관리하고 주입할 수 있습니다.
- 명령줄 도구: 특정 명령 또는 모듈에 특정 구성 또는 도우미 클래스가 필요한 복잡한 CLI 애플리케이션을 구축합니다.
- 사용자 정의 프레임워크/라이브러리 구축: 자체 라이브러리에 대한 경량 DI 솔루션을 제공하여 소비자가 컴포넌트를 더 쉽게 확장하고 통합할 수 있도록 합니다.
- 테스트 용이성: 가장 중요한 이점입니다. 컴포넌트는 종속성을 받기 때문에 단위 테스트 중에 이러한 종속성을 쉽게 모의(mock)하거나 교체할 수 있어 격리되고 효율적인 테스트를 할 수 있습니다.
결론
데코레이터의 강력함을 활용하여 TypeScript에서 기본적이지만 기능적인 의존성 주입 컨테이너를 성공적으로 구축했습니다. 클래스를 @Injectable
로 표시하고 컨테이너가 매개변수 확인을 처리하도록 함으로써 매우 모듈화되고 느슨하게 결합되며 테스트 가능한 코드베이스를 달성합니다. 이 접근 방식은 코드 구성 및 유지 관리성을 크게 향상시켜 더 깔끔한 애플리케이션 아키텍처를 가능하게 합니다. 종속성을 자동으로 관리하는 기능은 현대 JavaScript 개발에서 필수적인 자산입니다.