티스토리 뷰

728x90

안녕하세요 먼저 이 포스팅을 쓰기에 앞서 어떤 관점으로 작성을 해야 할지 고민했습니다.

이미 이 글을 읽는 분들이라면 DI가 무엇인지는 아는 분들일 것이고, 

nestJS의 DI도 이해하고 있을 것이라고 생각합니다.

이번 포스트에서는 오늘날 널리 사용되고 있는 nestJS 프레임워크가 영감을 받았다고 하는

typeDI를 통해 DI의 동작원리에 대해 분석해 보려고 합니다

 

이 글은

1. 나의 학습을 위한 것이면서

2. DI에 대한 이해와 함께 typeDI가 어떻게 동작하는지를 알아봅니다.

 

너무 무겁지 않으면서도 이해하기 쉽게 쓰는 글이 좋은 글이라는 생각으로 

이 기조에 어긋나지 않게 작성해 보겠습니다.

 

 

참고 링크:

typeDI 공식 깃허브

 

 

🌠 목차
✅ DI가 무엇인지 가볍게 맛보기
✅ typeDI의 구조 살펴보기
✅ typeDI에서 여러 컨테이너를 사용하는 사례와 이유

 

DI가 무엇인지 가볍게 맛보기


이 포스팅을 읽는 분이라면 이미 DI가 무엇인지는 알고, 어떻게 동작하는지가 궁금하여 들어오신 분들이라고 생각하겠습니다.

다만 이번 챕터에서는 가볍게 개념만 떠올려 봅시다.

DI란?

Dependency Injection이라는 뜻으로 의존성을 주입한다라는 뜻입니다.

아래의 예제로 DI 없이 적용한 예시와, DI가 적용된 예시를 보겠습니다

 

⬇️ DI가 적용되지 않은 예시

class UserService {
  // UserService가 직접 DatabaseConnection을 생성 (강한 결합)
  private database = new DatabaseConnection();
  
  getUsers() {
    return this.database.query('SELECT * FROM users');
  }
}

 

⬇️ DI가 적용된 예시 

class UserService {
  private database: DatabaseConnection;
  
  // 외부에서 생성된 database 객체를 주입받음
  constructor(database: DatabaseConnection) {
    this.database = database;
  }
  
  getUsers() {
    return this.database.query('SELECT * FROM users');
  }
}

// 사용 시
const dbConnection = new DatabaseConnection();
const userService = new UserService(dbConnection);

 

 

위의 예시를 통해 DI는 외부에서 생성된 객체를 다른 곳에서 주입받을 수 있습니다 

DI를 사용하게 되면 

변경에 용이하고,  E2E 테스트 코드를 작성하는데 조금 더 자유롭습니다.

 

이 개념을 nestJS의 DI 개념이 적용되어 사용되고 있습니다.

저도 최근에 안 사실이지만, nestJS프레임워크가 typeDI의 개념을 발전시켜 만들었다고 합니다.

 

 

자 그렇다면

본격적으로 어떻게 이 외부 의존성 주입이 가능한지 알아보겠습니다.

 

typeDI의 구조 살펴보기


먼저 폴더 구조를 보겠습니다.

src/
├── decorators/           # 데코레이터 관련 파일들
│   ├── inject.decorator.ts
│   ├── inject-many.decorator.ts
│   ├── service.decorator.ts
│   └── ... (기타 데코레이터)
│
├── utils/               # 유틸리티 함수들
│   ├── resolve-to-type-wrapper.util.ts
│   └── ... (기타 유틸리티)
│
├── types/               # 타입 정의
│   ├── constructable.type.ts
│   ├── service-identifier.type.ts
│   └── ... (기타 타입 정의)
│
├── error/               # 에러 클래스들
│   ├── cannot-inject-value.error.ts
│   └── ... (기타 에러)
│
├── container-registry.class.ts  # 컨테이너 레지스트리 클래스
├── token.class.ts               # 토큰 클래스
└── index.ts # 핵심

 

✅ index.ts -> Registry.ts -> instance.ts -> Registry.ts 이 구조를 기억합시다

 

 

index.ts 파일 일부분입니다.

 

typeDI의 흐름도를 살펴보겠습니다. 

코드의 일부를 발췌했습니다. 이해를 돕기 위해 [   ]는 현재 파일, 클래스 위치를 명시합니다.

 

 

1. [index.ts] 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.'
  );
}

- typeDI가 의존성 주입에 필요한 타입 메타데이터를 수집하기 때문에 relfect-metadata가 반드시 필요합니다.

 

2. [index.ts] ContainerRegistry.defaultContainer 조회

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

export * from './decorators/inject.decorator';
export * from './decorators/service.decorator';

export { Handler } from './interfaces/handler.interface';
export { ServiceMetadata } from './interfaces/service-metadata.interface';
export { ServiceOptions } from './interfaces/service-options.interface';
export { Constructable } from './types/constructable.type';
export { ServiceIdentifier } from './types/service-identifier.type';

export { ContainerInstance } from './container-instance.class';
export { Token } from './token.class';

// ✅
export const Container = ContainerRegistry.defaultContainer;
export default Container;

- 여기서 이해가 잘 안 됐었는데요. 쉽게 말해 UserService, FirebaseService 등등 모든 컨테이너가 defaultContainer에 각각의 key로 저장이 됩니다. 

- 저는 defaultContainer라는 하나의 "디폴트"값에 어떻게 UserService, FirebaseService가 저장되지?라고 생각했었습니다.

코드를 보다 보니 각각 클래스들이 defaultContainer에 Map 데이터구조에 다른 키값으로 저장이 됩니다.

 

3. [ContainerRegistry] defaultContainer의 ContainerRegistry 인스턴스 생성. 

export class ContainerRegistry{ 
//생략
  public static readonly defaultContainer: ContainerInstance = new ContainerInstance('default');
//생략
}

- 여기서 'default' 키워드에 [잘못] 꽂혀서 꽤 많은 시간을 날려버렸습니다...ㅠㅠ 

여기까지만 보고 실제 코드에 적용한다고 했을 때 아래와 같은 코드를 띄고 있을 것입니다

import { Container } from 'typedi';

// 기본 컨테이너 사용 - ID는 내부적으로 'default'
@Service()
class UserService {
  getUsers() { return ['user1', 'user2']; }
}

const userService = Container.get(UserService);

// 기본 컨테이너 사용 - ID는 내부적으로 'default'
@Service()
class FeedService {
  getFeeds() { return ['feed1', 'feed2']; }
}

const feedService = Container.get(FeedService);

저는 여기서 UserService가 아니라 FeedService등과 같이 다른 서비스가 생성되면

그때도 ID가 default인가? 그러면 동일 ID로 인해 중첩되는 것이 아닌가?라는 의문이 해결되지 않았었습니다.

 

여기서 사용되는 컨테이너 ID는, 여러 컨테이너의 인스턴스를 구분하기 위한 것입니다.

- default 컨테이너는 애플리케이션 전체에서 1개밖에 존재하지 않습니다.

- 이 하나의 컨테이너에. 여러 개의 서비스가 등록됩니다.

 

서비스는 컨테이너 내부에서 자신의 타입이나 토큰으로 구분합니다(아래 typeDI에서 여러 컨테이너를 사용하는 사례와 이유에서 다룸)

 @Service()
   class UserService { /* ... */ }
   
   @Service()
   class FeedService { /* ... */ }
   
   // 두 서비스는 같은 기본 컨테이너에 등록되지만, 다른 타입으로 구분됨
   const userService = Container.get(UserService);  // UserService 타입으로 조회
   const feedService = Container.get(FeedService);  // FeedService 타입으로 조회

 

 

4. [ContainerInstance] 생성자 본인 및 핸들러 등록

export class ContainerInstance {
constructor(id: ContainerIdentifier) {
    this.id = id;

    ContainerRegistry.registerContainer(this);

    this.handlers = ContainerRegistry.defaultContainer?.handlers || [];
  }
  }

- this.id = id; : 컨테이너 ID 설정(이전에 'default'로 전달받음) 

- ContainerRegistry.registerContainer(this); : 컨테이너 레지스트리에 자기자신을 등록

- this.handler = ContainerRegistry.defaultContainer?. handler|| [] : 기본 핸들러

 

5. [ContainerRegistry] 예외 및 cotainerMap저장

export class ContainerRegistry {
    public static registerContainer(container: ContainerInstance): void {
        if (container instanceof ContainerInstance === false) {
          throw new Error('Only ContainerInstance instances can be registered.');
        }

        if (!!ContainerRegistry.defaultContainer && container.id === 'default') {
          throw new Error('You cannot register a container with the "default" ID.');
        }

        if (ContainerRegistry.containerMap.has(container.id)) {
          throw new Error('Cannot register container with same ID.');
        }

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

- ContainerRegistry의 registerContainer 함수에서 containerMap.set()을 호출하여

containerId, 와 container를 저장합니다.

 

6. [ContainerRegistry] 예외 및 cotainerMap 

export type ContainerIdentifier = string | Symbol;
export class ContainerRegistry {
	private static readonly containerMap: Map<ContainerIdentifier, ContainerInstance> = new Map();
}

- 컨테이너 ID를 키로, 해당 컨테이너 인스턴스를 값으로 저장하는 맵입니다.

- 즉, 어떤 ID의 컨테이너가 어떤 인스턴스인지 관리하는 레지스터리입니다.

 

이렇게 컨테이너가 등록되는 방법에 대해 알아보았습니다.

 

 

typeDI에서 여러 컨테이너를 사용하는 사례와 이유


 

격리된 의존성 관리

가장 큰 이유는 서로 다른 컨텍스트나 모듈 간의 의존성을 격리하기 위함입니다. 여러 컨테이너를 사용하면:

// 첫 번째 컨테이너 생성
const container1 = new ContainerInstance('container1');

// 두 번째 컨테이너 생성
const container2 = new ContainerInstance('container2');

// 각 컨테이너에 동일한 타입의 서비스를 다른 구현체로 등록
container1.set(LoggerService, new ConsoleLoggerService());
container2.set(LoggerService, new FileLoggerService());

이렇게 하면 같은 서비스 인터페이스에 대해 컨테이너별로 다른 구현체를 제공할 수 있습니다.

 

테스트 격리

단위 테스트나 통합 테스트에서 특히 유용합니다:

// 테스트용 컨테이너
const testContainer = new ContainerInstance('test');

// 실제 구현 대신 목(mock) 서비스 등록
testContainer.set(DatabaseService, new MockDatabaseService());
testContainer.set(EmailService, new MockEmailService());

// 테스트 실행
const userService = testContainer.get(UserService);
// userService는 실제 구현 대신 목 서비스에 의존

이렇게 하면 테스트 환경에서 실제 외부 서비스에 의존하지 않고 테스트할 수 있습니다.

 

여러 컨테이너 사용 시 주의사항

  • 메모리 관리: 사용하지 않는 컨테이너는 명시적으로 제거해야 메모리 누수를 방지할 수 있습니다.
  • 복잡성 증가: 여러 컨테이너를 사용하면 의존성 추적이 더 복잡해질 수 있습니다.
  • 기본 컨테이너와의 관계: 사용자 정의 컨테이너는 기본 컨테이너에서 서비스를 상속받지 않습니다. 필요하다면 수동으로 서비스를 복제해야 합니다.

 

 

 

글 재미나게 보셨으면 좋아요 눌러주고 가세요

관심받는 거 좋아합니다

 

728x90
댓글
250x250
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday