TypeScript 순환 의존성(Circular Dependencies) 런타임 에러 해결하기

TypeScript 프로젝트에서 컴파일은 성공하는데 런타임에서만 에러가 발생하는 경우가 있다. 특히 abstract class와 이를 상속받은 클래스가 서로를 참조할 때 이런 문제가 자주 발생한다.

TypeError: Object prototype may only be an Object or null: undefined

이 글에서는 이 문제의 원인과 해결 방법을 정리한다.


문제 상황

시나리오: Base Entity와 Specialized Entity

// entity.ts
import { SpecialEntity } from './special-entity';

export abstract class Entity {
  constructor(public id: string) {}

  // 다른 Entity로 변환하는 메서드
  abstract clone(): Entity;

  // 특수 Entity로 변환
  toSpecial(): SpecialEntity {
    return new SpecialEntity(this.id, 'default');
  }
}
// special-entity.ts
import { Entity } from './entity';

export class SpecialEntity extends Entity {
  constructor(
    id: string,
    public type: string
  ) {
    super(id);
  }

  clone(): Entity {
    return new SpecialEntity(this.id, this.type);
  }
}

컴파일은 성공, 런타임은 실패

$ tsc
# 성공! 에러 없음

$ node dist/index.js
TypeError: Object prototype may only be an Object or null: undefined
    at setPrototypeOf (<anonymous>)
    ...

왜 이런 문제가 발생할까?

JavaScript 모듈 로딩 순서

  1. entity.ts가 로드될 때 SpecialEntity를 import 시도
  2. special-entity.ts가 로드될 때 Entity를 import 시도
  3. 아직 Entity 클래스가 완전히 로드되지 않은 상태에서 extends Entity 실행
  4. Entity === undefined 상태로 클래스 정의 실패

컴파일러는 왜 못 잡을까?

TypeScript 컴파일러는 정적 타입 검사만 수행한다. 실제 모듈 로딩 순서와 런타임 동작은 확인하지 않는다.


해결 방법 1: 지연 초기화 (Lazy Initialization)

클래스 참조를 실제 사용 시점으로 미룬다.

// entity.ts
import type { SpecialEntity } from './special-entity';

export abstract class Entity {
  constructor(public id: string) {}

  abstract clone(): Entity;

  // 지연 초기화
  toSpecial(): SpecialEntity {
    // 동적 import 또는 지연 참조
    const { SpecialEntity } = require('./special-entity');
    return new SpecialEntity(this.id, 'default');
  }
}
// special-entity.ts
import { Entity } from './entity';

export class SpecialEntity extends Entity {
  constructor(
    id: string,
    public type: string
  ) {
    super(id);
  }

  clone(): Entity {
    return new SpecialEntity(this.id, this.type);
  }
}

포인트: import type은 타입만 가져오고 런타임 코드는 생성하지 않는다.


해결 방법 2: 팩토리 패턴 사용

객체 생성 로직을 별도 파일로 분리한다.

// entity.ts
export abstract class Entity {
  constructor(public id: string) {}
  abstract clone(): Entity;
}
// special-entity.ts
import { Entity } from './entity';

export class SpecialEntity extends Entity {
  constructor(
    id: string,
    public type: string
  ) {
    super(id);
  }

  clone(): Entity {
    return new SpecialEntity(this.id, this.type);
  }
}
// entity-factory.ts
import { Entity } from './entity';
import { SpecialEntity } from './special-entity';

export class EntityFactory {
  static createSpecial(id: string, type: string): SpecialEntity {
    return new SpecialEntity(id, type);
  }

  static toSpecial(entity: Entity): SpecialEntity {
    return new SpecialEntity(entity.id, 'default');
  }
}

장점: 순환 참조 없이 깔끔한 구조


해결 방법 3: 인터페이스로 분리

구현과 타입 정의를 분리한다.

// types.ts
export interface IEntity {
  id: string;
  clone(): IEntity;
  toSpecial(): ISpecialEntity;
}

export interface ISpecialEntity extends IEntity {
  type: string;
}
// entity.ts
import type { ISpecialEntity } from './types';

export abstract class Entity implements IEntity {
  constructor(public id: string) {}

  abstract clone(): IEntity;

  toSpecial(): ISpecialEntity {
    // 런타임에 구현체 import
    const { SpecialEntity } = require('./special-entity');
    return new SpecialEntity(this.id, 'default');
  }
}
// special-entity.ts
import { Entity } from './entity';
import type { ISpecialEntity } from './types';

export class SpecialEntity extends Entity implements ISpecialEntity {
  constructor(
    id: string,
    public type: string
  ) {
    super(id);
  }

  clone(): IEntity {
    return new SpecialEntity(this.id, this.type);
  }
}

해결 방법 4: Dependency Injection

외부에서 의존성을 주입받도록 설계한다.

// entity.ts
export abstract class Entity {
  // 변환 함수를 외부에서 주입
  static toSpecialFn?: (entity: Entity) => any;

  constructor(public id: string) {}

  abstract clone(): Entity;

  toSpecial(): any {
    if (!Entity.toSpecialFn) {
      throw new Error('toSpecialFn not configured');
    }
    return Entity.toSpecialFn(this);
  }
}
// special-entity.ts
import { Entity } from './entity';

export class SpecialEntity extends Entity {
  constructor(
    id: string,
    public type: string
  ) {
    super(id);
  }

  clone(): Entity {
    return new SpecialEntity(this.id, this.type);
  }
}
// main.ts - 초기화
import { Entity } from './entity';
import { SpecialEntity } from './special-entity';

// 의존성 설정
Entity.toSpecialFn = (entity) => new SpecialEntity(entity.id, 'default');

// 이제 정상 동작
const entity = new SpecialEntity('1', 'test');
const special = entity.toSpecial();

실제 사례: 데이터 분석 라이브러리

GitHub Issue #20361에서는 데이터 분석 라이브러리에서 발생한 유사한 문제가 보고되었다:

// series.ts
import { DataFrame } from './dataframe';

export class Series extends DataFrame {
  // DataFrame을 상속
}
// dataframe.ts
import { Series } from './series';

export class DataFrame {
  // Series로 변환하는 메서드
  deflate(): Series {
    return new Series(...);
  }
}

이 경우도 동일한 런타임 에러가 발생하며, 위의 해결 방법들로 해결할 수 있다.


권장사항

상황 권장 방법
단순한 순환 참조 import type + 지연 초기화
복잡한 객체 생성 팩토리 패턴
대규모 프로젝트 인터페이스 분리 + DI
프레임워크 사용 Dependency Injection 컨테이너 활용

요약

  • TypeScript 컴파일은 성공하지만 런타임에서 실패할 수 있다
  • 순환 의존성은 모듈 로딩 순서 때문에 발생한다
  • 해결책:
    1. import type + 지연 초기화
    2. 팩토리 패턴으로 생성 로직 분리
    3. 인터페이스로 타입과 구현 분리
    4. Dependency Injection 사용

💡 TL;DR: abstract class와 subclass 간 순환 참조 시 런타임 에러가 발생하면 import type과 지연 초기화를 사용하자.


참고 자료