Angular의 의존성 주입(DI) 시스템은 @Injectable 데코레이터를 사용하는 것 이상으로 훨씬 깊이 있는 기능을 제공합니다. 특히 InjectionToken을 활용하면, 클래스의 구현과 소비를 완전히 분리하여 최고 수준의 디커플링을 달성할 수 있습니다.
이 글에서는 InjectionToken을 사용하여 @Injectable 없는 클래스를 주입 가능하게 만드는 두 가지 강력한 패턴을 비교 분석합니다. 하나는 소비자가 명시적으로 Provider를 등록하는 방식이고, 다른 하나는 토큰 자체가 자신을 제공하여 소비자가 아무 설정도 할 필요 없는 방식입니다.
두 패턴을 비교하기 전에, 우리가 주입할 대상인 순수 TypeScript 클래스를 먼저 정의하겠습니다. 이 클래스는 내부적으로 inject()를 사용하여 HttpClient에 의존합니다.
data-processor.ts
import { HttpClient } from '@angular/common/http';
import { inject } from '@angular/core';
import { Observable } from 'rxjs';
// @Injectable이 없는 순수 클래스. 두 패턴에서 공통으로 사용됩니다.
export class DataProcessor {
private http = inject(HttpClient);
private endpoint = 'https://jsonplaceholder.typicode.com/todos/1';
fetch(): Observable<any> {
console.log('Fetching data from DataProcessor...');
return this.http.get(this.endpoint);
}
}
이 패턴은 "책임의 분리"를 강조합니다. 클래스 파일은 클래스와 함께 주입 "레시피"(Provider)를 제공하고, 애플리케이션 설정 파일(app.config.ts)은 그 레시피를 사용할지 여부를 명시적으로 결정하고 등록합니다.
import { InjectionToken, Provider } from '@angular/core';
import { DataProcessor } from './data-processor'; // 공통 클래스 import
// 1. DI 시스템에서 사용할 고유 키(Token)
export const DATA_PROCESSOR_TOKEN = new InjectionToken<DataProcessor>('DataProcessor');
// 2. 주입 방법을 정의하는 레시피(Provider)
export const DATA_PROCESSOR_PROVIDER: Provider = {
provide: DATA_PROCESSOR_TOKEN,
useClass: DataProcessor
};
이 파일은 DATA_PROCESSOR_TOKEN과 DATA_PROCESSOR_PROVIDER를 함께 내보냅니다.
애플리케이션이 이 서비스를 사용하려면, providers 배열에 DATA_PROCESSOR_PROVIDER를 직접 추가해야 합니다.
app.config.ts (패턴 1)
import { ApplicationConfig } from '@angular/core';
import { provideHttpClient } from '@angular/common/http';
// Provider를 직접 import 합니다.
import { DATA_PROCESSOR_PROVIDER } from './data-processor-explicit';
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(),
// 명시적으로 Provider를 등록하는 과정이 필수입니다.
DATA_PROCESSOR_PROVIDER,
],
};
이 패턴은 "최고의 편의성"과 "완벽한 캡슐화"를 추구합니다. 토큰 자체가 자신을 루트 인젝터에 등록하는 방법을 알고 있으므로, 소비자는 아무런 DI 설정을 할 필요가 없습니다. @Injectable({ providedIn: 'root' })와 동일한 철학입니다.
import { InjectionToken } from '@angular/core';
import { DataProcessor } from './data-processor'; // 공통 클래스 import
// 토큰 생성자에서 Provider 정보를 모두 정의합니다.
export const DATA_PROCESSOR_TOKEN_AUTO = new InjectionToken<DataProcessor>(
'DataProcessor Auto',
{
// ⭐️ 이 토큰이 루트 인젝터에 자동으로 등록되도록 합니다.
providedIn: 'root',
// ⭐️ 이 토큰이 요청될 때 실행될 팩토리 함수입니다.
factory: () => new DataProcessor(),
}
);
이제 별도의 Provider 객체가 필요 없으며, 이 파일은 오직 DATA_PROCESSOR_TOKEN_AUTO 하나만 내보냅니다.
이 패턴을 사용할 때, app.config.ts에서는 DataProcessor와 관련된 어떤 설정도 추가할 필요가 없습니다.
app.config.ts (패턴 2)
import { ApplicationConfig } from '@angular/core';
import { provideHttpClient } from '@angular/common/http';
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(),
// DataProcessor 관련 설정이 전혀 필요 없습니다.
],
};
놀랍게도, 소비자인 컴포넌트의 코드는 어떤 패턴을 사용하든 완전히 동일합니다. 오직 어떤 토큰을 import하는지만 다를 뿐입니다.
app.component.ts
import { Component, inject } from '@angular/core';
// 패턴 1을 사용한다면:
// import { DATA_PROCESSOR_TOKEN } from './data-processor-explicit';
// 패턴 2를 사용한다면:
import { DATA_PROCESSOR_TOKEN_AUTO } from './data-processor-auto';
@Component({ ... })
export class AppComponent {
// 어떤 토큰을 사용하든 주입 코드는 동일합니다.
private processor = inject(DATA_PROCESSOR_TOKEN_AUTO); // 또는 DATA_PROCESSOR_TOKEN
constructor() {
this.processor.fetch().subscribe(console.log);
}
}
| 특징 | 패턴 1 (명시적 Provider) | 패턴 2 (자동 등록 토큰) |
|---|---|---|
| 설정 방식 | 소비자가 app.config.ts에 Provider를 수동 등록 | providedIn: 'root'를 통해 자동 등록 (설정 불필요) |
| 결합도 | 상대적으로 높음 (설정 파일이 Provider를 알아야 함) | 최소화 (소비자는 토큰만 알면 됨) |
| 명시성 | 매우 높음. app.config.ts만 보면 어떤 서비스가 제공되는지 명확히 알 수 있음 | 상대적으로 낮음 ("마법"처럼 보일 수 있음) |
| 주요 사용 사례 | - 앱의 최상단에서 DI 구성을 중앙 집중적으로 관리하고 싶을 때 - 환경별로 다른 구현체(useClass, useValue 등)를 쉽게 교체해야 할 때 | - 재사용 가능한 라이브러리를 만들 때 - 모듈이나 서비스가 "그냥 작동(just work)"하도록 만들고 싶을 때 |
두 패턴 모두 @Injectable 데코레이터를 사용하지 않고도 클래스를 주입 가능하게 만드는 강력한 방법입니다.
프로젝트의 아키텍처와 요구사항에 따라 적절한 패턴을 선택하면 더 깔끔하고, 유연하며, 확장 가능한 Angular 애플리케이션을 구축할 수 있습니다.