TypeDI 내부 구조 분석: typeDI 코드 분석을 통한 의존성 주입(DI)이 되는 과정

2025. 4. 14. 09:38·🌟🙇🏻‍♂️ 꼭 읽어봤으면 하는 글
728x90
반응형

의존성 주입(DI)에 대해 이론적으로는 알고 있지만,

실제로 내부에서 어떻게 동작하는지 이해하기는 쉽지 않았습니다.

 

이 글에서는 TypeDI 오픈소스 코드를 직접 분석하며

데코레이터와 컨테이너가 어떻게 의존성을 관리하는지 파헤쳐봅니다.

 

NestJS 팀에서 DI 개념을 TypeDI에서 아이디어를 얻었다고 하여 TypeDI를 분석하게 되었습니다.

 

소스코드를 10번 이상 직접 따라치며 얻은 내용을 공유합니다.

평소에 당연하게 사용하던 @Injectable(), @Service() 같은 데코레이터들이

실제로는 어떤 원리로 작동하는지 핵심 로직 위주로 설명했습니다.

 

잘못된 내용이 있다면 댓글 부탁드립니다. 🙇🏻‍♂️

 

💡 이 글은 다음과 같은 분들에게 도움이 됩니다.
- TypeDI나 NestJS 같은 DI 프레임워크를 사용하지만 내부 동작을 이해하고 싶은 개발자
- JS/TS의 메타프로그래밍과 리플렉션에 관심 있는 개발자
- 직접 DI 컨테이너를 구현해보고 싶은 개발자

특히 "➡️🌟 container.get(ServiceA) 실행 그리고 동작원리" 부분은 DI의 핵심 메커니즘을 다루므로 꼭 읽어보시기 바랍니다.

 

 

 

 

✅ 아래 코드는 어떻게 실행될까요? 

import 'reflect-metadata';

if (!Reflect || !(Reflect as any).getMetadata) {
  throw new Error(
    'TypeDI requires "Reflect.getMetadata" to work. Please import the "reflect-metadata" package at the very first line of your application.'
  );
}

import { ContainerRegistry } from './container-registry';
import { ServiceA } from './serviceA';

function main() {
  console.log('start');

  const container = ContainerRegistry.getContainer('default');
  const serviceA = container.get(ServiceA);
  serviceA.serviceB.sayHello();
  serviceA.sayHello();
}

main();

 

 

 

✅ reflect-metadata로 초기 설정값 존재유무 체크

  • Reflect는 JS 내장 객체로 메타프로그래밍을 위한 메서드를 제공함
  • reflect-metadata는 Reflect API를 확장하여 메타데이터 관련 기능을 추가하는 폴리필 라이브러리
  • TS 컴파일러가 데코레이터와 함께 클래스 컴파일 시 생성자 매개변수의 "타입 정보"를 메타데이터로 저장
  • TS 컴파일시 타입 정보가 사라지는데, 이 라이브러리를 통해 타입 정보를 메타데이터로 보존함
  • 런타임에서 Reflect.getMetadata로 정보 검증
  • tsconfig.json에 다음 설정이 필수적:
{ 
	"compilerOptions": { 
		"experimentalDecorators": true,
		"emitDecoratorMetadata": true,
    }
}

 

✅ ContainerRegistry.getContainer('default'); 실행 과정


TypeDI는 의존성 주입을 위한 컨테이너 개념을 중심으로 설계되어 있습니다.

컨테이너는 서비스 인스턴스들을 관리하고 제공하는 역할을 합니다.

 

[container-registry.ts 일부]

import { ContainerInstance } from './container-instance';
import { EMPTY_VALUE } from './empty-value';
import { ServiceMetadata } from './interfaces/service-metadata';
import { ContainerIdentifier } from './types/container-identifier';
import { ServiceIdentifier } from './types/service-identifier';
import { ServiceOptions } from './types/service-options';

export class ContainerRegistry {
  private static readonly containerMap: Map<ContainerIdentifier, ContainerInstance> = new Map();
  public static readonly defaultContainer: ContainerInstance = new ContainerInstance('default');

  public static registerContainer(container: ContainerInstance): void {
    if (container instanceof ContainerInstance === false) {
      throw new Error('error');
    }

    if (!!ContainerRegistry.defaultContainer && container.id === 'default') {
      throw new Error('errror');
    }

    if (ContainerRegistry.containerMap.has(container.id)) {
      throw new Error('error');
    }

    ContainerRegistry.containerMap.set(container.id, container);
  }

  public static getContainer(id: ContainerIdentifier): ContainerInstance {
    const registedContainer = this.containerMap.get(id);
    if (registedContainer === undefined) {
      throw new Error('error');
    }

    return registedContainer;
  }
}

 

[container-instance.ts 일부]

export class ContainerInstance {
  public readonly id!: ContainerIdentifier;
  private metadataMap: Map<ServiceIdentifier, ServiceMetadata<unknown>> = new Map();

  constructor(identifier: ContainerIdentifier) {
    this.id = identifier;
    ContainerRegistry.registerContainer(this);
  }
}

TypeDI가 초기화될 때 다음과 같은 과정이 발생합니다:

  1. public static readonly defaultContainer: ContainerInstance = new ContainerInstance('default'); 실행
  2. ContainerInstance 생성자에서 this.id = 'default' 설정
  3. 생성자 내부에서 ContainerRegistry.registerContainer(this) 호출
  4. registerContainer 메서드에서 컨테이너 맵에 새 컨테이너 등록: containerMap.set('default', container)
  5. ContainerRegistry.getContainer('default') 호출 시 이렇게 등록된 기본 컨테이너를 반환

 

 

✅ const serviceA = container.get(ServiceA); 실행


 

container.get(ServiceA)로 service 데코레이터(함수)로 등록된 ServiceA 클래스를 조회합니다.

import { Service } from './decorators/service-decorator';
import { ServiceB } from './serviceB';

@Service()
export class ServiceA {
  constructor(public serviceB: ServiceB) {}

  public sayHello() {
    console.log('Hello, serviceA!');
  }
}

 

조회를 하기 전에 등록을 해야 하는데요.

그렇다면 어떻게 service 데코레이터로 ServiceA가 등록될 수 있을지 알아보겠습니다.

 

service-decorator 등록 

[service-decorator.ts]

export function service<T=unknown>(): Function;
export function service<T>(options: ServicOptions<T> ={}): ClassDecorator {
	return(targetConstructor) => {
    	const serviceMetadata: ServiceMetadata<T> = { 
            id: options.id || targetConstructor,
            type: targetConstructor as unknown as Constructable<T>,
            factory: undefined,
            eager: false,
            multiple: false,
            scope:'container',
            referencedBy: new Map().set(ContainerRegistry.defaultContainer.id, containerRegistry.defaultContainer),
            value: EMPTY_VALUE
		};
        ContainerRegistry.defaultContainer.set(serviceMetadata);
    };
};

 

TypeDI serviceDecorator는 많은 serviceOptions 타입을 제공하지만,

여기서는 자세히 다루지 않겠습니다.

 

[설명]

service() 데코레이터를 클래스에 적용하면 위 과정을 통해 serviceMetadata가 ContainerRegistry에 등록됩니다.

즉 defaultContainer에 ServiceA 클래스의 메타 정보들이 등록됩니다.

[service 함수 필드 역할]

  • id: 서비스로 등록할 클래스 (클래스 자체가 ID)
  • type: 서비스로 등록할 클래스의 생성자
  • factory: 팩토리 클래스 옵션 (여기서는 undefined 정의)
  • multiple: 다중 컨테이너 등록 유무 옵션 (여기서는 false)
  • eager: 즉시로딩 옵션 (여기서는 지연로딩)
  • scope: service의 스코프 옵션 설정('transient' | 'container' | 'singleton' 중 하나)
  • referencedBy: 어떤 컨테이너와 연결되어 있는지 정보를 저장
  • value: serviceMetadata 등록 전 초기값 (서비스 메타데이터가 등록되면 해당 value로 변경)

 

[등록과정]

  • Service() 실행 시 정의된 필드값 세팅 및 ContainerRegistry.defaultContainer.set(serviceMetadata); 실행

 

[container-instance.ts]

export class ContainerInstance {
    private metadataMap: Map<ServiceIdentifier, ServiceMetadata<unknown>> = new Map();
    
    public set<T>(serviceOptions: ServiceOptions<T>): this {
        const newMetadata: ServiceMetadata<T> = {
          id: ((serviceOptions as any).id || (serviceOptions as any).type) as ServiceIdentifier,
          type: (serviceOptions as ServiceMetadata<T>).type || null,
          factory: (serviceOptions as ServiceMetadata<T>).factory,
          value: (serviceOptions as ServiceMetadata<T>).value || EMPTY_VALUE,
          multiple: serviceOptions.multiple || false,
          eager: serviceOptions.eager || false,
          scope: serviceOptions.scope || 'container',
          ...serviceOptions,
          referencedBy: new Map().set(this.id, this),
        };
        
        const existingMetadata = this.metadataMap.get(newMetadata.id);
        
        if (existingMetadata) {
          Object.assign(existingMetadata, newMetadata);
        } else {
          this.metadataMap.set(newMetadata.id, newMetadata);
        }
        return this;
      }
  }

 

서비스 메타데이터를 재설정하고, 컨테이너에 등록하는 함수:

  1. serviceOptions 매개변수를 ServiceMetadata 타입으로 재정의
  2. metadataMap 존재 유무 체크
  3. 이미 존재하면 존재하는 값을 재정의한 메타데이터로 덮어쓰기
  4. 미존재하면 새로운 서비스 metadataData 정의
export type ServiceIdentifier<T = unknown> =
  | Constructable<T>
  | AbstractConstructable<T>
  | CallableFunction
  | Token<T>
  | string;


export interface ServiceMetadata<T = unknown> {
  id: ServiceIdentifier;
  scope: ContainerScope;
  type: Constructable<T> | null;
  factory: [Constructable<unknown>, string] | CallableFunction | undefined;
  value: unknown | Symbol;
  multiple: boolean;
  eager: boolean;
  referencedBy: Map<ContainerIdentifier, ContainerInstance>;
}

 

🌟 container.get(ServiceA) 실행 그리고 동작원리

위에서 어떻게 service 데코레이터로 클래스가 등록되는지 보았습니다.

이제 container.get을 통해 어떻게 조회되는지 알아보겠습니다.

 

[container-instance.ts - get()]

export class ContainerInstance {
    private metadataMap: Map<ServiceIdentifier, ServiceMetadata<unknown>> = new Map();
    public get<T = unknown>(identifier: ServiceIdentifier<T>): T {
        const global = ContainerRegistry.defaultContainer.metadataMap.get(identifier);
        const local = this.metadataMap.get(identifier);

        const metadata = global?.scope === 'singleton' ? global : local;

        if (metadata) {
          return this.getServiceValue(metadata);
        }

        throw new Error('서비스 반드시 존재해야함');
      }
  }

컨테이너 인스턴스에 등록된 서비스 메타데이터 정보를 조회하는 함수입니다:

  1. 매개변수로 ServiceA가 들어옵니다.
  2. global = 싱글톤 패턴으로 전역에 적용된 defaultContainer의 서비스메타데이터를 조회합니다.
  3. local = 해당 컨테이너의 인스턴스의 메타데이터 맵에 접근하여 데이터를 조회합니다.
  4. getServiceValue를 호출합니다.

[container-instance.ts - getServiceValue()]

export class ContainerInstance {
    public getServiceValue(serviceMetadata: ServiceMetadata<unknown>): any {
        let value: unknown = EMPTY_VALUE;

        if (serviceMetadata.value !== EMPTY_VALUE) {
          return serviceMetadata.value;
        }

        if (!serviceMetadata.factory && serviceMetadata.type) {
          const constructableTargetType: Constructable<unknown> = serviceMetadata.type;
          const paramTypes: unknown[] = (Reflect as any)?.getMetadata('design:paramtypes', constructableTargetType) || [];
          const params = this.initializeParams(constructableTargetType, paramTypes);

          params.push(this);
          value = new constructableTargetType(...params);
        }

        if (serviceMetadata.scope !== 'transient' && value !== EMPTY_VALUE) {
          serviceMetadata.value = value;
        }

        if (value === EMPTY_VALUE) {
          throw new Error('not ');
        }

        return value;
      }
      
    private initializeParams(target: Function, paramtypes: any[]): unknown[] {
        return paramtypes.map((paramtype, index) => {
          if (paramtype && paramtype.name && !this.isPrimitiveTypes(paramtype.name)) {
            return this.get(paramtype);
          }
          return undefined;
        });
      }

    private isPrimitiveTypes(typeName: string) {
        return ['string', 'boolean', 'number', 'object'].includes(typeName.toLowerCase());
      }
  }

serviceValue를 조회하는 과정입니다.

즉, 서비스의 인스턴스 또는 실제 객체값을 나타냅니다.

 

TypeScript는 컴파일 시 타입 정보가 사라지지만,

해당 정보를 Reflect 메타데이터로 저장합니다.

design:paramtypes 키워드로 Reflect 메타데이터를 조회합니다.

 

과정

  1. value 지역변수를 EMPTY_VALUE로 초기화합니다.
  2. 매개변수 서비스 메타데이터의 value가 EMPTY_VALUE가 아니면 serviceMetadata.value를 리턴합니다.
    • 이미 초기화 과정을 거쳐 value가 등록되었다고 판단하기 때문입니다.
    • 이를 통해 순환참조를 자동으로 처리합니다.
  3. 서비스의 생명주기
    • 서비스 등록 시: value = EMPTY_VALUE (아직 미등록)
    • 첫 요청 시: value 저장
    • 이후 요청 시: 등록된 value 인스턴스 반환
  4. serviceMetadata.factory가 정의되지 않았고, serviceMetadata.type(생성자)이 존재하기 때문에 조건문이 실행됩니다.
  5. constructableTargetType 변수를 생성자 타입, serviceMetadata매개변수의 type으로 선언합니다.
  6. Reflect 메타데이터 API를 이용하여 'design' key와 constructableTargetType value로 생성자 매개변수의 타입정보 배열을 반환합니다.
  7. initializeParams() 메서드를 실행합니다.

 

initializeParams 함수의 작동 방식과 재귀적 의존성 해결

initializeParams 함수는 서비스 클래스의 생성자에 필요한 모든 의존성을 재귀적으로 해결합니다:

  1. 의존성 분석 과정:
    • 함수는 생성자의 매개변수 타입(paramtypes)을 순회하며 각 타입을 검사합니다.
    • 각 매개변수 타입에 대해 primitive 타입인지 확인합니다(isPrimitiveTypes 함수 사용).
  2. 재귀적 의존성 해결:
    • 매개변수가 객체 타입(서비스)이면 this.get(paramtype)을 호출합니다.
    • 이 호출은 재귀적으로 해당 의존성을 가져오는 과정을 시작합니다.
    • 예를 들어, ServiceA가 ServiceB에 의존하면:
      1. ServiceA의 생성자 매개변수로 ServiceB가 필요함을 인식
      2. this.get(ServiceB) 호출 -> 재귀 시작
      3. ServiceB 인스턴스 생성 및 반환
      4. ServiceA 생성자에 ServiceB 주입하여 최종 인스턴스 생성
  3. params.push(this)
  4. value = new constructableTargetType(...params);
    • this를 params에 추가함으로써, 현재 컨테이너 인스턴스를 생성자의 마지막 매개변수로 전달합니다.
  5. 🌟🌟🌟 의존성 주입의 형태
    • params배열이 생성자 매개변수로 전달됩니다.
    • 이 과정에서 this가 생성자의 매개변수로 전달되어 객체가 컨테이너에 접근할 수 있습니다.
  6. 인스턴스가 성공적으로 생성되었다면 serviceMetadata.value에 value 등록
  7. value 반환

 

최종 정리

container.get(ServiceA) 호출 시 실행 과정:

  1. 컨테이너는 ServiceA의 서비스 메타데이터를 조회
  2. getServiceValue() 함수 호출로 인스턴스 생성 과정 시작
  3. getServiceValue() 내부에서:
    • ServiceA의 생성자 매개변수 타입 정보를 Reflect API로 조회
    • paramTypes = [ServiceB] 배열 가져옴
    • initializeParams(ServiceA, [ServiceB]) 호출
  4. initializeParams() 내부에서:
    • 매개변수 타입 배열([ServiceB])을 순회
    • ServiceB가 primitive 타입이 아니므로 재귀적으로 this.get(ServiceB) 호출
  5. 재귀적 this.get(ServiceB) 처리:
    • ServiceB의 서비스 메타데이터 조회
    • ServiceB에 대한 getServiceValue() 함수 호출
    • ServiceB의 생성자 매개변수 타입 정보를 조회
    • 만약 ServiceB에 의존성이 없다면 빈 배열 반환 ([])
    • new ServiceB() 인스턴스 생성하여 반환
  6. ServiceA 인스턴스 생성 완료:
    • new ServiceA(serviceB) 형태로 의존성이 주입된 ServiceA 인스턴스 생성
    • 완성된 ServiceA 인스턴스를 반환
  7. 결과적으로:
    • const serviceA = container.get(ServiceA) 구문이 실행되면 ServiceA 인스턴스 생성
    • 이 인스턴스의 serviceB 속성에는 이미 ServiceB 인스턴스가 주입되어 있음
    • 따라서 serviceA.serviceB.sayHello() 함수 호출이 가능

 

 

코드 실행 최종 결과.

 

 

 

✅ serviceA와 serviceB의 순환참조가 발생하지 않는 이유

import { Service } from './decorators/service-decorators';
import { ServiceB } from './serviceB';

@Service()
export class ServiceA {
  constructor(public serviceB: ServiceB) {}

  public sayHello() {
    console.log('Hello, serviceA!');
  }
}

 

import { Service } from './decorators/service-decorators';
import { ServiceA } from './serviceA';

@Service()
export class ServiceB {
  constructor(public serviceA: ServiceA) {}
  public sayHello() {
    console.log('Hello, ServiceB');
  }
}

 

아래와 같이 호출해도 순환참조가 발생하지 않는 이유는 무엇일까요.

function main() {
  console.log('start');

  const container = ContainerRegistry.getContainer('default');
  const serviceA = container.get(ServiceA);
  serviceA.serviceB.sayHello();
  serviceA.sayHello();
}

main();

 

지연 초기화

  // container-instance.ts
   private getServiceValue(serviceMetadata: ServiceMetadata<unknown>): any {
     let value: unknown = EMPTY_VALUE;
     if (serviceMetadata.value !== EMPTY_VALUE) {
       return serviceMetadata.value;
     }
     // ...
   }
  1. 서비스 인스턴스는 실제로 container.get()이 호출되기 전까지 생성되지 않습니다.
  2. serivceMetadata.value가 EMPTY_VALUE일 때만 인스턴스가 생성됩니다.

이를 통해 불필요한 초기화를 방지하고 순환참조를 예방합니다.

 

 

의존성 해결

   // container-instance.ts
   private initializeParams(paramTypes: any[]) {
     return paramTypes.map(type => {
       if (type && type.name && !this.isPrimitiveTypes(type.name)) {
         return this.get(type);
       }
       return undefined;
     });
   }
  1. serviceA가 초기화될 때 serviceB를 의존성으로 요청합니다.
  2. serviceB가 초기화될 때 serviceA를 의존성으로 요청하지만, 이미 serviceA는 초기화중이므로 EMPTY_VALUE가 아닌 serviceMetadata.value를 반환합니다.

 

흐름

  1. index.ts에서 container.get(ServiceA) 호출
  2. ServiceA 초기화 시작
  3. ServiceA의 생성자에서 ServiceB 의존성 요청
  4. ServiceB 초기화 시작
  5. ServiceB의 생성자에서 ServiceA 의존성 요청
  6. ServiceA는 이미 초기화 중이라 EMPTY_VALUE가 아닌 값을 반환
  7. ServiceB 초기화 완료
  8. ServiceA 초기화 완료

즉, TypeDI는 자동으로 의존성 주입을 관리하여 순환참조를 방지합니다.

 

 

결론

TypeDI는 프로토타입에서 주로 사용하고, 복잡한 설정 없이도 알아서 자동으로 처리하는 데 중점을 둡니다. 더 간단하고 유연한 의존성 주입을 제공합니다.

NestJS에서는 forwardRef()를 사용하여 명시적으로 의존성 주입 시스템에 순환참조가 있음을 알리고, 이를 처리하는 방법을 제공합니다. 개발자가 순환참조를 인식하고 명시하여 직접 처리하도록 하는 데 중점을 둡니다.

 

 

 

소스코드 본인 깃허브 계정

https://github.com/chlcken2/typedi

 

GitHub - chlcken2/typedi: typedi 구현

typedi 구현. Contribute to chlcken2/typedi development by creating an account on GitHub.

github.com

 

 

 

 

 

 

728x90
반응형
저작자표시 비영리 변경금지 (새창열림)

'🌟🙇🏻‍♂️ 꼭 읽어봤으면 하는 글' 카테고리의 다른 글

2024년 연말회고  (2) 2025.01.01
백엔드 게시판 DB 스냅샷 구조 vs 히스토리 구조 차이점 및 정리  (0) 2024.07.21
[암호화] 양방향 vs 단방향 암호화 feat. SHA256  (4) 2023.05.26
'🌟🙇🏻‍♂️ 꼭 읽어봤으면 하는 글' 카테고리의 다른 글
  • 2024년 연말회고
  • 백엔드 게시판 DB 스냅샷 구조 vs 히스토리 구조 차이점 및 정리
  • [암호화] 양방향 vs 단방향 암호화 feat. SHA256
foodev
foodev
이것저것 개발과 이것저것 리뷰 합니다.
    250x250
  • foodev
    개발 개맛집
    foodev
  • 전체
    오늘
    어제
    • 분류 전체보기 (110)
      • 🌟🙇🏻‍♂️ 꼭 읽어봤으면 하는 글 (4)
      • 💻 개발 (73)
        • 설정 및 세팅 (4)
        • DB&서버&네트워크&암호 (11)
        • React (0)
        • JPA, Querydsl (14)
        • 알고리즘 (7)
        • 언어 (15)
        • 프레임워크 (12)
        • HTML, CSS (10)
      • ✍🏻 (32)
        • 회고록 (14)
        • 독서록 (7)
        • 일지록 (10)
        • 세미나 (1)
      • 💡 리뷰 (1)
        • 제품리뷰 (1)
  • 인기 글

  • 최근 댓글

  • 최근 글

  • 반응형
  • hELLO· Designed By정상우.v4.10.3
foodev
TypeDI 내부 구조 분석: typeDI 코드 분석을 통한 의존성 주입(DI)이 되는 과정
상단으로

티스토리툴바