Beenslab Blog

ESM 마이그레이션 중 undefined 오류? 싱글턴과 순환 종속성 문제 해결하기

beenchangseo·2024년 10월 7일Hits

들어가며

이번 글에서는 레거시 시스템을 CommonJS에서 ESM으로 마이그레이션하던 중에 겪었던 시행착오를 공유하려고 합니다. 기존 시스템은 무분별하게 싱글턴을 사용하고, 순환 종속성(Circular Dependency)도 여기저기 많이 섞여 있었습니다. 그래서 마이그레이션 과정에서 예상치 못한 undefined 에러가 발생했는데요.

이 글에서는 ESM 환경에서 왜 이런 문제가 생기는지, 그리고 어떻게 해결할 수 있는지 자세히 알아보겠습니다.

싱글턴 패턴 개념 정리

싱글턴 패턴(Singleton Pattern)은 어플리케이션 전체에서 단 하나의 인스턴스만 생성해 공유하는 디자인 패턴입니다. 예를 들어, DB 커넥션 매니저나 특정 유틸 서비스 등, 인스턴스 하나면 충분한 로직을 만들 때 다음과 같이 작성합니다.

// myService.ts (ESM)
export class MyService {
  private static instance: MyService;

  private constructor() {
    // 외부에서 new로 생성 불가
  }

  public static getInstance(): MyService {
    if (!MyService.instance) {
      MyService.instance = new MyService();
    }
    return MyService.instance;
  }

  public doSomething() {
    console.log('Singleton service doing something...');
  }
}

이제 MyService.getInstance()를 통해 어느 곳에서나 단일 인스턴스를 사용할 수 있습니다.

순환 종속성(Circular Dependency)과 ESM 초기화

ESM(ECMAScript Modules) 환경에서 기존 CommonJS(require)를 사용할 때 문제 없이 작동하던 싱글턴 패턴이, 갑작스럽게 undefined를 반환하는 상황을 겪는 경우가 있었습니다.

문제는 싱글턴이 다른 모듈(예: 컨트롤러, 다른 서비스)과 서로 순환 종속을 갖게 될 때 본격화됩니다. “싱글턴 A”에서 “싱글턴 B”를 참조하고, “싱글턴 B”에서 다시 “싱글턴 A”를 참조하면 다음과 같은 구조가 형성됩니다.

AB
BA

이런 상호 참조 상태를 순환 종속성(Circular Dependency)이라고 부릅니다. CommonJS 시절에는 require()가 런타임에 동작했기 때문에, 종종 우연히도 이 구조가 “삐걱거리지만 돌아갈” 때가 있었습니다.

하지만 ESM은 정적 로드(Static Analysis)를 통해 모듈을 미리 분석하고, 각 모듈의 초기화 순서를 엄격하게 결정합니다. 순환 종속이 있으면, 아직 생성되지 않은 인스턴스에 접근하게 되어 undefined가 반환되거나 ReferenceError가 발생합니다.

예시 코드

one-service.ts

// one-service.ts
import { SingletonServiceTwo } from './two-service';

export class SingletonServiceOne {
  private static instance: SingletonServiceOne;
  private twoService: SingletonServiceTwo;

  private constructor() {
    this.twoService = SingletonServiceTwo.getInstance();
  }

  public static getInstance(): SingletonServiceOne {
    if (!SingletonServiceOne.instance) {
      SingletonServiceOne.instance = new SingletonServiceOne();
    }
    return SingletonServiceOne.instance;
  }
}

two-service.ts

// two-service.ts
import { SingletonServiceOne } from './one-service';

export class SingletonServiceTwo {
  private static instance: SingletonServiceTwo;
  private oneService: SingletonServiceOne;

  private constructor() {
    this.oneService = SingletonServiceOne.getInstance();
  }

  public static getInstance(): SingletonServiceTwo {
    if (!SingletonServiceTwo.instance) {
      SingletonServiceTwo.instance = new SingletonServiceTwo();
    }
    return SingletonServiceTwo.instance;
  }
}

여기서 SingletonServiceOne은 내부에서 SingletonServiceTwo를 불러오고, SingletonServiceTwo는 다시 SingletonServiceOne을 불러와 인스턴스를 참조합니다. 순환 종속성 때문에 ESM의 초기화가 맞물려 제대로 완료되지 않은 상태에서 각 getInstance()가 동작하므로, 결과적으로 undefined나 에러가 터질 수 있습니다.

왜 CommonJS에서는 됐는데 ESM에서 안 될까?

  • CommonJS(require): 런타임에 require() 호출이 실행되므로, 순환 참조가 있어도 어떤 모듈이 먼저 초기화됐는지 “운 좋게” 정해져서 동작할 때가 있습니다.
  • ESM(import/export): 정적 분석을 통해 모듈 그래프를 미리 결정하고 로드 순서를 지정합니다. 모듈 간에 순환 의존이 있으면 초기화되지 않은 인스턴스에 접근해 undefined가 발생합니다.

CommonJS vs ESM에서의 차이

특징CommonJS (require)ESM (import)
모듈 로딩/실행 방식require 호출 시점에 동기적으로 해당 모듈 실행 및 캐싱실행 전 모듈 그래프 정적 분석 후, 순서에 따라 초기화 및 할당
순환 의존성 처리부분적으로 실행된 모듈 상태 접근 가능값 할당 전 참조 시 undefined 발생 가능

CommonJS에서는 require가 호출되는 순간 해당 모듈이 즉시 실행되어 결과를 반환하기 때문에, 어느 정도 순환 의존성이 있더라도 부분적으로 초기화된 상태에 접근하는 것이 상대적으로 용이했습니다.

반면 ESM에서는 모듈 로딩 시점에 모든 import를 정적으로 분석한 뒤, 각 모듈의 export 변수들에 대한 참조를 미리 설정합니다. 이후 이들이 실제로 어떤 값으로 초기화되는 과정은 순서에 따라 일어납니다. 순환 의존성이 있다면, 특정 모듈의 값이 실제 할당되기 이전에 해당 값을 참조하려 할 때 undefined가 반환될 수 있습니다.


해결 방법

순환 의존성을 제거하자 (가장 권장)

싱글턴 간 양방향 참조는 최대한 지양해야 합니다. 대신 상위 레이어에서 인스턴스를 주입해주거나, 한쪽만 다른 쪽을 참조하도록 단방향 구조로 바꾸면 문제가 해결됩니다.

예를 들어,

  • one-service.tstwo-service.ts를 직접 import하지 않는다.
  • 상위 index.tsapp.ts에서 SingletonServiceOne.getInstance()SingletonServiceTwo.getInstance()를 각각 생성한 뒤, 필요할 때만 서로 전달해준다(의존성 주입, DI).
// index.ts
import { SingletonServiceOne } from './one-service';
import { SingletonServiceTwo } from './two-service';

const serviceOne = SingletonServiceOne.getInstance();
const serviceTwo = SingletonServiceTwo.getInstance();

// 혹은 serviceOne.setTwoService(serviceTwo); 처럼 의존을 주입.

동적 import 혹은 DI 컨테이너 활용

순환 참조가 불가피하다면, **동적 import()**나 DI 컨테이너를 사용하는 방법도 있습니다.

CommonJS 혼합 지양하기

처음엔 “CommonJS로 작성한 컨트롤러”와 “ESM으로 작성한 서비스”가 섞여 있어서 에러가 없었는데, 전부 ESM으로 바꾼 순간 문제가 드러나는 경우가 흔합니다. 장기적으로 모든 모듈을 ESM으로 맞추고, 순환 참조를 제거해주는 편이 안정적입니다.

결론

  • ESM정적 로드로 인해 초기화 순서가 엄격합니다.
  • 싱글턴 패턴을 적용한 서비스 간에 순환 종속성이 있으면, 한쪽이 아직 fully initialized되지 않은 상태에서 다른 쪽이 getInstance()를 호출해 undefined를 반환하게 됩니다.
  • CommonJS에서는 비직관적으로라도 동작했던 코드를, ESM으로 전환하며 문제가 발생할 수 있다.