Published on

TS 5.5 useDefineForClassFields로 this undefined 해결

Authors

서드파티 라이브러리 업그레이드나 TS 5.5 전환 이후, 분명히 클래스 안에서 정의한 필드인데도 런타임에서 thisundefined가 되어 터지는 경험이 종종 있습니다. 특히 React, MobX, DI 컨테이너, 데코레이터 기반 프레임워크, 혹은 상속 구조가 있는 코드에서 "생성자에서 뭔가를 세팅했는데 필드 초기화가 덮어쓴다" 같은 형태로 나타납니다.

이 문제의 중심에는 클래스 필드 초기화가 실제로 어떤 순서와 의미로 실행되는지, 그리고 TypeScript 컴파일이 그 의미를 어떤 방식으로 에뮬레이션하는지가 있습니다. TS 5.5 자체가 갑자기 동작을 바꿨다기보다는, 프로젝트의 target, 번들러, 트랜스파일 파이프라인, 그리고 useDefineForClassFields 조합에 따라 동일한 소스가 서로 다른 JS로 변환되며 문제가 표면화되는 경우가 많습니다.

아래에서는 문제를 재현하고, 왜 useDefineForClassFields가 해결책이 되는지, 그리고 안전하게 적용하는 체크리스트를 정리합니다.

증상: 클래스 필드에서 this가 undefined가 되는 패턴

대표적인 증상은 다음 중 하나입니다.

  • 클래스 필드 초기화 식에서 this.someMethod() 혹은 this.dep 접근 시 TypeError: Cannot read properties of undefined가 발생
  • 상속 구조에서 자식 클래스 필드가 부모 생성자에서 세팅한 값을 덮어써서 undefined로 돌아감
  • 데코레이터나 DI가 주입한 프로퍼티가 필드 초기화 시점에 아직 준비되지 않아 this 접근이 깨짐

핵심은 "필드 초기화가 언제 실행되느냐"입니다. 클래스 필드 초기화는 대략적으로 인스턴스 생성 과정에서 생성자 본문보다 먼저/중간에 실행되며(정확히는 super() 호출 이후, 생성자 본문 실행 전), 변환 방식에 따라 부작용이 달라집니다.

배경: 필드 초기화의 두 가지 의미 (assignment vs define)

TypeScript는 오래전부터 클래스 필드를 ES5/ES2015로 다운레벨링할 때 다음 두 방식 중 하나로 변환해 왔습니다.

  1. Assignment semantics: 생성자에서 this.x = value 형태로 할당
  2. Define semantics: Object.defineProperty(this, "x", ...)로 정의

이 차이는 단순히 구현 디테일이 아니라, 아래와 같은 중요한 차이를 만듭니다.

  • 프로퍼티가 열거 가능(enumerable) 인지 여부
  • setter/getter가 있는 상위 클래스 프로퍼티를 트리거하는지 여부
  • 초기화 시점에 기존 프로퍼티를 덮어쓰는 방식

TypeScript의 useDefineForClassFields는 "클래스 필드를 표준에 더 가깝게 define 방식으로 내리자"는 옵션입니다. 그리고 이 옵션이 꺼져 있으면(또는 구성이 꼬이면) 특정 상황에서 예상치 못한 this 관련 런타임 문제가 더 쉽게 드러납니다.

재현: 상속 + 필드 초기화가 주입값을 덮어쓰는 케이스

아래 코드는 "부모가 생성자에서 값을 세팅했는데, 자식의 필드 초기화가 나중에 undefined로 덮어써서" 문제를 만드는 전형적인 패턴입니다.

class Base {
  protected dep!: { name: string };

  constructor() {
    this.dep = { name: "injected" };
  }
}

class Child extends Base {
  // 실수: 필드 초기화가 dep를 다시 세팅(혹은 초기값이 undefined)
  protected dep = undefined as any;

  print() {
    // 런타임에서 dep가 undefined면 여기서 터짐
    return this.dep.name;
  }
}

const c = new Child();
console.log(c.print());

이 코드는 "왜 dep가 undefined지?"라는 혼란을 줍니다. 하지만 필드 초기화는 super() 이후에 실행되므로, 부모 생성자에서 세팅한 dep자식의 필드 초기화로 다시 덮어써질 수 있습니다.

이건 this undefined와는 조금 결이 달라 보이지만, 실제 현장에서는 depthis.serviceundefined가 되어 메서드 내부에서 this.service.do()가 터지면서 "this가 undefined인가?"처럼 보이는 형태로 이어집니다.

재현: 클래스 필드에서 this를 캡처할 때 번들러/트랜스파일과 충돌

다음은 클래스 필드에서 this를 참조해 함수를 만들거나 초기화하는 패턴입니다.

class Counter {
  value = 0;

  // 필드 초기화 시점에 this를 사용
  inc = () => {
    this.value += 1;
  };

  // 더 위험한 케이스: 다른 필드에 의존
  label = `count:${this.value}`;
}

const c = new Counter();
c.inc();
console.log(c.label);

대부분의 최신 런타임에서는 문제 없어 보이지만, 프로젝트에 따라 다음이 섞이면 문제가 발생할 수 있습니다.

  • TS 컴파일 결과를 다시 Babel이 변환
  • target은 낮은데(예: ES2017) 번들러가 클래스 필드를 또 변환
  • 데코레이터 플러그인/레거시 트랜스폼이 클래스 초기화 순서를 바꿈

이때 this가 완전히 undefined가 되는 케이스는 대개 "필드 초기화 함수가 어떤 컨텍스트에서 실행되는지"가 꼬여서 발생합니다. 예를 들어 트랜스폼된 코드가 this 바인딩 없이 초기화 함수를 호출해 버리면, 그 내부의 thisundefined가 됩니다(엄격 모드 기준).

해결의 핵심: useDefineForClassFields를 명시하고 파이프라인을 단일화

1) tsconfig에서 useDefineForClassFields를 명시

tsconfig.json에 다음을 명시합니다.

{
  "compilerOptions": {
    "target": "ES2022",
    "useDefineForClassFields": true
  }
}
  • useDefineForClassFields: true는 클래스 필드를 표준에 더 가까운 방식으로 내립니다.
  • 가능하면 target을 충분히 올려(예: ES2022 이상) 다운레벨 트랜스폼 자체를 줄이는 것이 안정적입니다.

중요한 점은 "옵션 하나로 만능 해결"이 아니라, 클래스 필드 트랜스폼이 두 번 일어나지 않도록 하는 것이 함께 필요하다는 것입니다.

2) Babel을 쓴다면 클래스 필드 관련 플러그인 설정을 점검

Babel이 TS 출력물을 다시 만지는 구조라면, 다음 중 하나를 선택해야 합니다.

  • TS는 타입 제거만 하고(트랜스폼 최소화) Babel이 클래스 필드를 책임지게 하기
  • Babel은 클래스 필드를 건드리지 않고 TS가 책임지게 하기

예를 들어 Babel을 사용한다면 @babel/plugin-proposal-class-properties@babel/plugin-proposal-private-methodsloose 설정이 TS의 useDefineForClassFields와 의미 충돌을 만들 수 있습니다. loose: true는 대체로 assignment에 가깝고, 표준 define 의미와 달라서 상속/접근자 케이스에서 차이가 커집니다.

즉, TS에서 useDefineForClassFields: true로 표준 의미를 택했다면, Babel도 그에 맞춰 "표준에 가까운" 설정을 유지하거나(대개 loose: false) 아예 중복 트랜스폼을 피해야 합니다.

왜 이 옵션이 this undefined를 줄여주나

정리하면 useDefineForClassFields가 직접적으로 this를 마법처럼 고쳐준다기보다는, 다음을 통해 문제를 줄입니다.

  • 클래스 필드 초기화가 표준 의미에 맞게 고정되어, 트랜스파일 결과가 예측 가능해짐
  • 상속/접근자/데코레이터와 결합될 때 "어떤 프로퍼티가 언제 정의되는지"가 안정화됨
  • 번들러/트랜스파일 단계가 여러 개일 때 발생하는 의미 불일치를 줄이는 방향으로 유도

특히 "부모에서 주입한 값을 자식 필드가 덮어쓴다" 같은 케이스는 define/assignment 차이와 더불어 초기화 순서 이해가 필수인데, 표준 의미로 맞춰두면 디버깅이 훨씬 쉬워집니다.

실전 체크리스트: 적용 전에 꼭 확인할 것

1) 클래스 필드에서 this 의존 초기화를 줄이기

가능하면 아래처럼 "필드에서 this를 바로 쓰는 초기화"를 생성자로 옮기는 것이 안전합니다.

class SafeCounter {
  value = 0;
  label: string;

  constructor() {
    this.label = `count:${this.value}`;
  }

  inc = () => {
    this.value += 1;
  };
}
  • 문자열/객체 초기화가 다른 필드에 의존한다면 생성자에서 순서를 통제하세요.
  • DI/데코레이터로 주입되는 값은 "주입 이후"에 접근하도록 구조를 바꾸는 것이 좋습니다.

2) 상속 구조에서 동일한 이름의 필드 재정의를 피하기

부모가 dep를 관리한다면 자식에서 같은 이름으로 필드를 다시 선언하지 않는 것이 안전합니다.

class Base {
  protected dep!: { name: string };
  constructor(dep: { name: string }) {
    this.dep = dep;
  }
}

class Child extends Base {
  // dep를 다시 선언하지 않음
  print() {
    return this.dep.name;
  }
}

3) 트랜스파일 파이프라인을 한 군데로 모으기

  • TS tsc로 JS까지 내리고 Babel은 안 쓰기
  • 혹은 Babel로만 트랜스폼하고 TS는 isolatedModules 기반으로 타입만 제거

둘 중 하나로 단순화하면 "같은 문법을 두 번 변환"하는 문제를 크게 줄일 수 있습니다.

디버깅 팁: 컴파일 결과를 직접 확인

문제가 재현되는 최소 코드를 만든 뒤, 다음을 비교해 보세요.

  • useDefineForClassFields: true vs false
  • targetES2022로 올린 경우 vs 낮춘 경우

그리고 출력 JS에서 클래스 필드가 다음 중 어떤 형태인지 확인합니다.

  • this.x = ... 형태인지
  • Object.defineProperty(this, "x", ...) 형태인지

출력물을 보면 "부모 생성자 이후에 자식 필드 초기화가 어떻게 들어가는지"가 드러나고, 왜 값이 덮어써지는지 혹은 왜 바인딩이 깨지는지 실마리가 빨리 잡힙니다.

관련해서 함께 보면 좋은 글

TypeScript 버전 업에서 새 문법/옵션 때문에 타입 추론이나 런타임 의미가 어긋나는 문제는 자주 같이 발생합니다. 다음 글도 같은 결의 마이그레이션 이슈를 다룹니다.

빌드 파이프라인이 복잡해지면서 "캐시 때문에 설정 변경이 반영되지 않는" 문제도 동반되곤 하니, CI에서 이상하게만 재현된다면 아래도 참고할 만합니다.

결론

TS 5.5 전환 이후 보이는 this undefined 류의 문제는 대개 "클래스 필드 초기화 의미"와 "트랜스파일 파이프라인"의 조합에서 발생합니다. useDefineForClassFields를 명시해 표준 의미로 고정하고, 클래스 필드 트랜스폼이 중복되지 않도록 파이프라인을 단순화하면 재현 불가능하게 사라지는 경우가 많습니다.

마지막으로, 클래스 필드에서 this 의존 초기화를 줄이고(특히 다른 필드/주입값 의존), 상속 구조에서 동일 이름 필드 재정의를 피하는 것만으로도 런타임 안정성이 크게 올라갑니다.