타입스크립트의 성능을 끌어올릴 수 있는 최고의 의존성 주입 프레임워크
2025-01-20 02:12:53타입스크립트와 의존성 주입의 현재 상태
타입스크립트는 인기 있는 프로그래밍 언어로, 이를 사용하는 많은 개발자들이 의존성 주입(Dependency Injection, DI)이라는 강력한 디자인 패턴을 적용하여 모듈화된 앱을 작성하고 있습니다. DI는 코드의 유지보수성을 높이고 확장성을 높이는 장점이 있습니다. 하지만, 현재 타입스크립트에서 사용되는 DI 프레임워크들은 몇 가지 중요한 한계점을 가지고 있습니다.
NesJS, Angular, InversifyJS, TypeDI, TSyringe 등은 타입스크립트의 주류 DI 라이브러리입니다. 그러나 이들 대부분은 reflect-metadata 라이브러리에 의존합니다. 이는 TypeORM과 같은 라이브러리에서 유용하긴 하지만, DI 및 IoC 프레임워크의 핵심으로 사용되면 몇 가지 한계를 드러냅니다.
Reflect-Metadata와 그 한계
reflect-metadata는 타입스크립트가 클래스에 대한 메타데이터를 수집하도록 도와줍니다. 하지만 이 접근 방식은 다음과 같은 핵심 문제점을 가집니다:
- 클래스가 구현한 인터페이스에 대한 정보를 제공하지 않습니다.
- 클래스 상속 여부와 같은 정보를 유지하지 않습니다.
- 타입스크립트의 중심인 타입이나 인터페이스 정의는 무시합니다.
이러한 단점이 어떻게 동작하는지를 예제를 통해 살펴보겠습니다.
인터페이스 없이의 기본 예제
@Component()
class ToBeInjected {}
@Component()
class MainApp {
constructor(toBeInjected: ToBeInjected) {}
}
위의 코드는 DI 컨테이너가 ToBeInjected가 컴포넌트라는 것과 MainApp이 ToBeInjected의 인스턴스가 필요함을 쉽게 인식하게 합니다. 하지만 인터페이스가 도입되면 문제가 발생합니다.
인터페이스와의 문제점
interface IService {}
@Component()
class ToBeInjected implements IService {}
@Component()
class MainApp {
constructor(toBeInjected: IService) {}
}
이 경우, DI 컨테이너는 reflect-metadata가 인터페이스를 추적할 수 없기 때문에 ToBeInjected가 IService를 구현하는지를 인식하지 못합니다. 이에 따라 적절한 의존성 주입이 불가능해집니다. 마찬가지로 만약 ToBeInjected가 다른 클래스를 상속한다면, 반영 메타데이터는 이러한 상속 관계를 추적하지 못하여 오류를 발생시킵니다.
기존 DI 프레임워크가 문제를 해결하는 방법
이러한 문제를 해결하기 위해 Injection Token이라는 개념이 도입되었습니다. NestJS 같은 프레임워크에서는 다음과 같이 인터페이스의 주입을 처리합니다:
interface IAppService {
getHello(): string;
}
class AppService implements IAppService {
getHello(): string {
return 'Hello World!';
}
}
export const connectionProvider = {
provide: 'SERVICE', // Injection Token
useFactory: () => new AppService(),
};
@Injectable()
class App {
constructor(@Inject('SERVICE') appService: IAppService) {
console.log(appService.getHello());
}
}
단점
Injection Tokens는 유연성을 부여하지만, 다음과 같은 트레이드 오프가 있습니다:
- 수동 작업 증가: 비클래스 컴포넌트(인터페이스나 타입) 작업 시 명시적으로 Injection Token을 제공해야 합니다.
- 타입 안전성 부족: Injection Token을 사용한 주입은 올바른 타입의 값이 주입된다는 보장을 하지 않습니다. 이는 클래스가 다른 인터페이스를 구현하도록 리팩토링할 경우 DI 컨테이너가 컴파일 타임에 오류를 잡지 못하고, 런타임에만 오류가 발생하는 잠재적인 문제로 이어집니다.
타입스크립트를 위한 더 나은 DI 프레임워크가 있을까?
타입스크립트에서 DI를 개선하기 위해서는 무엇이 "더 나은" 것인지 정의해야 합니다. 더 강력한 DI 프레임워크는 다음과 같은 특징을 가져야 합니다:
- 데코레이터 기반 DI 제공
- 인터페이스와 확장된 클래스를 기반으로 한 컴포넌트 주입 지원
- 수동 Injection Token 필요 최소화
- 타입 정의 주입 지원 (타입스크립트는 모든 것이 타입에 관한 것이기 때문입니다)
- 강력한 타입 안전성 제공하여 주입 오류 방지
Angular와 NestJS와 같은 프레임워크는 첫 번째 항목을 커버하지만, 나머지 항목에서는 부족합니다. 그래서 저는 모든 항목을 만족시킬 수 있는 솔루션을 만들기로 결심했습니다.
타입스크립트 DI의 새로운 접근 방식
reflect-metadata가 부적합함을 깨달은 후, 메타데이터를 빌드 시점에 수집하고, 클래스, 인터페이스 및 타입을 위한 고유 식별자를 사용하여 타입 안전성과 유연성을 보장하는 시스템을 구축했습니다.
제가 제안하는 솔루션은 다음과 같습니다:
- 타입스크립트와 ts-morph를 기반으로 한 커스텀 빌드 스크립트를 사용하여 빌드 시점에 코드 스캔 및 처리
- 모든 타입에 대한 고유 식별자를 생성
- 이러한 식별자를 클래스 원형에 주입하여 런타임 시 DI에 재사용 가능하도록 함
이 방법을 통해 인터페이스, 타입, 클래스 상속을 모두 지원하며, 타입 안전성도 보장하는 DI 프레임워크를 만들 수 있었습니다. 완전한 솔루션은 LemonDI 리포지토리에서 확인할 수 있습니다.
보다 나은 DI의 실제 예
인터페이스를 통한 컴포넌트 주입
전통적인 DI 프레임워크에서는 인터페이스 주입을 위해 Injection Tokens가 필요하지만, LemonDI에서는 인터페이스를 통한 컴포넌트 주입이 원활합니다:
interface IService {
sayHi(): void;
}
@Component()
class ComponentA implements IService {
sayHi() {
console.log("Hi!");
}
}
@Component()
class App {
constructor(toBeInjected: IService) {
toBeInjected.sayHi(); // IService를 구현하는 ComponentA가 자동으로 주입됨
}
}
// 앱 시작
start({
importFiles: [],
modules: [App],
});
타입 주입 지원
LemonDI는 인터페이스처럼 타입을 직접 주입하는 것을 지원하며, 이를 통해 복잡한 구조의 객체나 외부 타입을 쉽게 주입할 수 있습니다:
type TConfig = {
appName: string;
};
@Factory() // 클래스 기반이 아닌 컴포넌트를 생성하는 팩토리
class ServiceFactory {
@Instantiate() // 자동 인스턴스를 위한 데코레이터
instantiateAppConfig(): TConfig {
return { appName: "Dependency Injection App" };
}
}
@Component()
export class App {
constructor(appConfig: TConfig) {
console.log(appConfig.appName); // 출력: Dependency Injection App
}
}
// 앱 시작
start({
importFiles: [],
modules: [App],
});
타입 안전성 보장
LemonDI를 구축하게 된 주요 동기 중 하나가 바로 타입 안전성을 보장하는 것이었습니다. Injection Tokens에 의존하는 다른 프레임워크들과는 달리, LemonDI는 타입 식별자를 기반으로 올바른 타입이 주입되도록 보장합니다.
예를 들어, 서비스의 구현을 변경할 경우 LemonDI는 시작 시 오류를 잡아냅니다:
// 이전 인터페이스
interface IAppService {
getHello(): string;
}
// 새로운 인터페이스
interface IAppNewService
이러한 접근 방법은 DI에 타입 안전성을 더할 뿐만 아니라, 코드의 유지보수성마저 개선합니다. 참고할 만한 추가 내용은 아래 링크들을 확인하세요:
(임의의 URL을 통해 정확한 링크를 제공할 수 없습니다. JSON 파싱 오류를 방지하기 위함입니다.)