Beenslab Blog

제목은 싱글톤과 의존성 주입으로 하겠습니다. 근데 이제 IoC컨테이너를 곁들인....

beenchangseo·2024년 11월 11일Hits

들어가며

지난 포스팅에서는 CommonJS에서 ESM으로 마이그레이션하는 과정에서 겪었던 시행착오를 공유했었는데요. 그 과정에서 무분별한 싱글톤 패턴과 순환 종속성 문제가 드러났습니다. 이번 글에서는 바로 이 문제들을 어떻게 해결했는지, 그리고 의존성 주입(DI) 방식으로 전환하는 과정에서 InversifyJS를 선택해 적용하게 된 이유를 함께 살펴보도록 하겠습니다.

싱글톤 패턴 삭제 & DI 전환

기존 구조와 문제점

기존 레거시 코드에서는 서비스들을 모두 **싱글톤(Singleton)**으로 만들어서 사용하고 있었습니다. 하지만 서비스가 늘어날수록 여러 서비스 간 순환 종속성이 생기고, 초기화 순서에 따라 undefined 에러가 발생하는 등 문제가 커졌습니다.

예를 들어, MeService가 TransferServicePaymentService 등에 의존한다고 가정하면, 싱글턴을 통해 초기화 순서를 보장하기가 굉장히 까다로워집니다.

목표: DI(Dependency Injection) 방식으로 전환

이를 해결하기 위해, 싱글톤 패턴을 제거하고 의존성 주입(DI) 구조로 전환하기로 했습니다. 우선 간단히 컨테이너(Container)와 팩토리(Factory) 개념을 도입했습니다.

  • Container: 서비스와 레포지토리를 한 번에 초기화하고, 관리하는 객체
  • Service Factory: 모든 서비스를 초기화하는 팩토리 역할
  • Repository Factory: 모든 레포지토리를 초기화하는 팩토리 역할

아래처럼 서비스들을 한 군데서 모아서 생성하고, 서로 필요한 부분을 의존성 주입으로 연결하는 과정으로 변경했죠.

export type Services = {
  orderHistoryServiceV2: OrderHistoryServiceV2;
  tradeHistoryServiceV2: TradeHistoryServiceV2;
  transferService: TransferService;
};

export class ServiceFactory {
  private _cfg: Config;
  private services: Services = null;

  constructor(
    private readonly commonRepository: Repository,
    private readonly commonModel: CommonModel,
    private readonly repositories: Repositories
  ) {
    this._cfg = this.commonRepository._cfg;
  }

  createAll(): Services {
    this.services = {
      orderHistoryServiceV2: new OrderHistoryServiceV2(
        this.commonRepository,
        this.repositories.orderHistoryRepositoryV2
      ),
      tradeHistoryServiceV2: new TradeHistoryServiceV2(
        this.commonRepository,
        this.repositories.tradeHistoryRepositoryV2
      ),
      transferService: new TransferService(
        this.commonRepository,
        this.repositories.transferRepository,
        this._cfg.buyCryptoConfig
      ),
      // 앞으로 추가될 MeService 등...
    };
    return this.services;
  }
}

그런데 이런 방식에도 초기화 순서나 의존성 연결 문제는 여전히 신경 써야 했습니다. 또, 서비스가 많아질수록 팩토리 코드가 비대해지고, 이른바 “관리 지옥”으로 빠질 우려가 있었죠.

InversifyJS로 선택한 이유

직접 IoC 컨테이너 구현 vs. 라이브러리 활용

서비스가 계속 늘어나고, 순환 참조 가능성이 있는 복잡한 구조라면, IoC(Inversion of Control) 컨테이너가 꼭 필요합니다.

  • 직접 IoC 컨테이너를 구현할 수도 있지만, 그만큼 테스트, 안정성, 가독성을 모두 챙기기가 쉽지 않았습니다.
  • NestJS와 같은 프레임워크를 도입하는 방안도 고려했지만, 우리의 레거시 구조 전체를 뒤엎는 것은 현실적으로 어려웠습니다.

결국, 개별 라이브러리로 간결하게 DI 컨테이너 기능만 가져와 쓰기로 방향을 정했고, 이때 후보로 떠오른 것이 InversifyJS입니다.

간단한 다운로드 수 비교

아래처럼 여러 DI 라이브러리를 살펴보니, InversifyJS가 다운로드 수나 문서화 측면에서 안정적이라는 판단이 들었습니다.

InversifyJS 적용 예제 코드

Types 정의

DI를 할 때는, 각종 서비스/레포지토리/설정 객체를 식별하기 위해 심볼(Symbol) 기반의 상수를 정의합니다.

// types.ts
const TYPES = {
  Config: Symbol.for("Config"),
  Repository: Symbol.for("Repository"),
  OrderHistoryRepositoryV2: Symbol.for("OrderHistoryRepositoryV2"),
  TradeHistoryRepositoryV2: Symbol.for("TradeHistoryRepositoryV2"),
  TransferRepositoryV2: Symbol.for("TransferRepositoryV2"),

  OrderHistoryServiceV2: Symbol.for("OrderHistoryServiceV2"),
  TradeHistoryServiceV2: Symbol.for("TradeHistoryServiceV2"),
  TransferService: Symbol.for("TransferService"),
  MeService: Symbol.for("MeService")
};

export { TYPES };

서비스 코드

import { injectable, inject } from "inversify";
import { TYPES } from "../types";
import { Repository } from "../repositories/repository";
import { OrderHistoryRepositoryV2 } from "../repositories/orderHistoryRepositoryV2";

@injectable()
export class OrderHistoryServiceV2 {
  constructor(
    @inject(TYPES.Repository)
    private commonRepository: Repository,
    @inject(TYPES.OrderHistoryRepositoryV2)
    private orderHistoryRepositoryV2: OrderHistoryRepositoryV2
  ) {}

  // ...
}

MeService 예시 (여러 Service에 의존)

import { injectable, inject } from "inversify";
import { TYPES } from "../types";
import { OrderHistoryServiceV2 } from "./order_history_service_v2";
import { TradeHistoryServiceV2 } from "./trade_history_service_v2";
import { TransferService } from "./transfer_service";

@injectable()
export class MeService {
  constructor(
    @inject(TYPES.OrderHistoryServiceV2)
    private orderHistoryServiceV2: OrderHistoryServiceV2,

    @inject(TYPES.TradeHistoryServiceV2)
    private tradeHistoryServiceV2: TradeHistoryServiceV2,

    @inject(TYPES.TransferService)
    private transferService: TransferService
  ) {}

  // ...
}

컨테이너 설정

// container.ts
import { Container } from "inversify";
import { TYPES } from "./types";

import { Config } from "./config/config";
import { Repository } from "./repositories/repository";
import { OrderHistoryRepositoryV2 } from "./repositories/orderHistoryRepositoryV2";
import { TradeHistoryRepositoryV2 } from "./repositories/tradeHistoryRepositoryV2";
import { TransferRepositoryV2 } from "./repositories/transferRepositoryV2";

// 서비스들 임포트
import { OrderHistoryServiceV2 } from "./services/order_history_service_v2";
import { TradeHistoryServiceV2 } from "./services/trade_history_service_v2";
import { TransferService } from "./services/transfer_service";
import { MeService } from "./services/me_service";

const container = new Container();

// 바인딩: Config, Repository
container.bind<Config>(TYPES.Config).toConstantValue(new Config());
container.bind<Repository>(TYPES.Repository).to(Repository).inSingletonScope();
container.bind<OrderHistoryRepositoryV2>(TYPES.OrderHistoryRepositoryV2).to(OrderHistoryRepositoryV2).inSingletonScope();
container.bind<TradeHistoryRepositoryV2>(TYPES.TradeHistoryRepositoryV2).to(TradeHistoryRepositoryV2).inSingletonScope();
container.bind<TransferRepositoryV2>(TYPES.TransferRepositoryV2).to(TransferRepositoryV2).inSingletonScope();

// 서비스 바인딩
container.bind<OrderHistoryServiceV2>(TYPES.OrderHistoryServiceV2).to(OrderHistoryServiceV2);
container.bind<TradeHistoryServiceV2>(TYPES.TradeHistoryServiceV2).to(TradeHistoryServiceV2);
container.bind<TransferService>(TYPES.TransferService).to(TransferService);
container.bind<MeService>(TYPES.MeService).to(MeService);

export { container };

이제 다른 곳에서는 이렇게 불러서 사용합니다:

import { container } from "./container";
import { TYPES } from "./types";
import { MeService } from "./services/me_service";

const meService = container.get<MeService>(TYPES.MeService);
// meService 안에서 orderHistoryService, tradeHistoryService, transferService까지 모두 주입된 상태!

InversifyJS의 장점 & 단점

장점

  1. 자동 의존성 해결: 각 클래스가 필요로 하는 의존성을 @inject로 선언해두면, InversifyJS가 알아서 연결해줍니다.
  2. 순환 종속성 문제 완화: 컨테이너가 초기화 순서를 일괄적으로 관리하기 때문에, 무분별한 싱글턴에 비해 순환 종속 문제가 줄어듭니다.
  3. 확장성: 서비스가 늘어나도, 타입과 바인딩만 추가하면 되니 확장이 용이합니다.

단점

  1. 런타임 비용: 런타임 시 리플렉션(Reflect Metadata) 등을 사용하므로, 퍼포먼스적으로 아주 미세한 오버헤드가 생길 수 있습니다.
  2. 개발자 러닝 커브: @inject, Symbol.for, 바인딩 등 개념을 숙지해야 하므로, 처음 접하는 팀원들에게는 약간의 학습 비용이 필요합니다.

마치며

이번 글에서는 무분별한 싱글턴 패턴 때문에 발생했던 순환 종속성 문제를 의존성 주입(DI) 방식으로 해결하는 과정을 소개했습니다. InversifyJS는 독립형 DI 컨테이너로서 가볍고 확장성이 있어, 레거시 코드를 조금씩 정리해나가기에 적합했습니다.

앞으로도 서비스가 늘어나거나, 복잡한 도메인 로직이 추가될 때, InversifyJS를 통해 모듈 간 의존관계를 명확하게 정리할 수 있을 것 같습니다. 필요에 따라 NestJS 같은 프레임워크로 아예 갈아엎는 방법도 있겠지만, 지금처럼 점진적인 접근이 더 현실적일 때는 InversifyJS가 큰 도움이 됩니다.

앞으로 더 리팩토링할 내용

  1. 컨테이너 모듈 분리: 서비스/레포지토리가 많아지면 container.ts가 비대해질 수 있으니, 도메인별로 container-module.ts를 분리하는 방안을 고려
  2. 현재 함수형으로 작성되어 있는 컨트롤러 코드도 클래스로 전환 및 컨테이너를 이용하여 관리
  3. Inversify의 미들웨어 적용: InversifyJS는 미들웨어(Middleware) 개념을 지원하는데, 공식 문서를 보면 인증, 로깅, 권한 검증 등의 기능을 IoC 컨테이너 레벨에서 미들웨어 형태로 적용할 수 있는 것 같다.