- Published on
TS 5.5+ useDefineForClassFields로 깨진 this 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서론
TypeScript 5.5+로 올리면서 “갑자기 this가 undefined가 됐다”, “클래스 필드에서 참조하던 값이 초기화되지 않았다”, “상속 구조에서 base 생성자 호출 중에 터진다” 같은 이슈를 겪는 경우가 있습니다. 겉으로는 런타임 바인딩 문제(이벤트 핸들러에서 this가 날아감)처럼 보이지만, 실제로는 클래스 필드(class fields) 초기화 방식이 바뀌면서 초기화 시점/순서가 달라져 this가 기대한 상태가 아닌 채로 접근되는 문제가 자주 발생합니다.
이 글에서는 TS 5.5+ 환경에서 useDefineForClassFields가 어떤 영향을 주는지, 어떤 코드가 깨지는지(재현), 그리고 가장 안전하게 고치는 방법(패턴/설정/리팩터링)을 정리합니다.
문제의 핵심: 클래스 필드 초기화 방식(Define vs Assign)
TypeScript는 클래스 필드(예: foo = 1, handler = () => {})를 JS로 내릴 때 두 가지 방식 중 하나로 변환할 수 있습니다.
- Assign semantics(구식): 생성자 내부에서
this.foo = ...로 할당하는 방식 - Define semantics(표준에 가까움):
Object.defineProperty(this, "foo", ...)로 “정의”하는 방식
useDefineForClassFields는 이 변환 방식을 결정합니다.
useDefineForClassFields: false→ 대개this.foo = ...형태로 내려감useDefineForClassFields: true→Object.defineProperty기반으로 내려감(스펙에 더 근접)
여기서 중요한 건 단순히 문법이 아니라 상속/초기화 순서와 프로퍼티 섀도잉(shadowing), 그리고 base 생성자 실행 중 접근되는 필드의 상태가 달라질 수 있다는 점입니다.
가장 흔한 깨짐 패턴 1: base 생성자에서 오버라이드된 필드/메서드 호출
상속 구조에서 base 클래스 생성자에서 this.someMethod()를 호출하는 패턴은 원래도 위험합니다. 그런데 TS 컴파일 결과가 바뀌면 “전에는 우연히 동작하던” 코드가 더 쉽게 깨집니다.
재현 코드
class Base {
constructor() {
// ⚠️ 파생 클래스가 아직 완전히 초기화되기 전에 호출될 수 있음
this.init();
}
init() {
console.log("Base init");
}
}
class Derived extends Base {
// 클래스 필드 초기화에서 this 사용
name = "derived";
init() {
// name이 아직 초기화되지 않은 상태일 수 있음
console.log("Derived init:", this.name.toUpperCase());
}
}
new Derived();
증상은 환경에 따라 다양하지만, 대표적으로 다음과 같은 형태로 나타납니다.
this.name이undefined라서toUpperCase에서 터짐- 초기화 순서가 달라져 특정 필드가 base 생성자 시점에 비어 있음
왜 이런 일이 생기나?
JS 클래스 필드 초기화는 “생성자 본문 실행 이후”가 아니라, super() 호출 이후 파생 클래스 인스턴스가 만들어지는 과정에서 적용됩니다. 즉, base 생성자에서 파생 클래스의 오버라이드 메서드를 호출하면, 그 메서드가 참조하는 필드가 아직 초기화되지 않았을 수 있습니다.
useDefineForClassFields가 true일 때는 표준 동작에 더 가까워지며, 이런 “운 좋게 통과하던” 코드가 더 엄격하게 문제를 드러내는 경우가 많습니다.
해결
- base 생성자에서 오버라이드 가능한 메서드 호출 금지
- 초기화가 필요하면 팩토리/정적 생성자, 혹은
afterInit()같은 명시적 호출로 분리
class Base {
protected setup() {
// base 내부 초기화만
}
}
class Derived extends Base {
name = "derived";
constructor() {
super();
this.setup();
this.initAfterFields();
}
private initAfterFields() {
console.log(this.name.toUpperCase());
}
}
가장 흔한 깨짐 패턴 2: 필드 초기화에서 this가 아직 기대 상태가 아님
클래스 필드 초기화는 간단한 리터럴이면 안전하지만, 아래처럼 다른 필드/메서드/DI 컨테이너/환경 값을 참조하기 시작하면 순서 의존성이 생깁니다.
class Service {
config = this.loadConfig();
private loadConfig() {
// 어떤 환경에서는 아직 준비되지 않은 값에 접근
return { endpoint: process.env.API_URL! };
}
}
이 자체가 항상 틀린 건 아니지만, “초기화 순서가 바뀌면 깨질 수 있는 코드”가 됩니다.
해결: 필드 초기화에서 복잡한 로직 제거 → 생성자/초기화 메서드로 이동
class Service {
config: { endpoint: string };
constructor() {
this.config = this.loadConfig();
}
private loadConfig() {
return { endpoint: process.env.API_URL ?? "" };
}
}
가장 흔한 깨짐 패턴 3: 프로퍼티 섀도잉과 defineProperty의 차이
useDefineForClassFields: true는 Object.defineProperty를 사용해 필드를 정의합니다. 이때 setter/getter가 있는 상위 프로토타입 체인과 상호작용이 달라질 수 있습니다.
예를 들어, base 클래스(또는 데코레이터/프록시)가 특정 프로퍼티에 setter를 걸어두고, 파생 클래스가 같은 이름의 필드를 선언하면:
- Assign 방식(
this.x = ...)은 setter를 호출할 수 있음 - Define 방식은 “자기 자신의 데이터 프로퍼티”를 정의해 setter를 우회할 수 있음
재현 예시
class Base {
private _value = 0;
set value(v: number) {
console.log("setter called", v);
this._value = v;
}
get value() {
return this._value;
}
}
class Derived extends Base {
// 같은 이름의 필드 선언
value = 123;
}
const d = new Derived();
console.log(d.value);
환경/타깃에 따라 setter called가 찍히던 코드가 안 찍히거나, 반대로 기대와 다른 값이 남는 등 “관찰 가능한 변화”가 생깁니다.
해결
- 상위 클래스에 setter/getter가 있는 이름과 동일한 필드 선언을 피하기
- 정말 필요하면 필드 대신 접근자(override getter/setter)로 설계
class Derived extends Base {
constructor() {
super();
this.value = 123; // setter를 명시적으로 타게 함
}
}
“this가 깨졌다”로 보이는 또 다른 원인: 콜백 컨텍스트 손실과 혼동
TS 5.5 업그레이드 이후 useDefineForClassFields 이슈와 함께, 이벤트 핸들러/콜백에서 this가 날아가는 고전적인 버그가 동시에 드러나는 경우가 많습니다.
class View {
count = 0;
onClick() {
this.count += 1;
}
}
const v = new View();
button.addEventListener("click", v.onClick); // ❌ this는 button 또는 undefined
이건 useDefineForClassFields와 직접적 관련은 없지만, 업그레이드 과정에서 번들/타깃/트랜스파일 경로가 바뀌며 기존에 숨겨져 있던 문제가 노출되곤 합니다.
해결: 화살표 필드(바인딩) 또는 명시적 bind
class View {
count = 0;
onClick = () => {
this.count += 1;
};
}
button.addEventListener("click", new View().onClick);
또는:
button.addEventListener("click", v.onClick.bind(v));
TS 5.5+에서 무엇을 점검해야 하나
1) tsconfig의 관련 옵션 확인
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true
}
}
target이 높아질수록(ES2022 등) 네이티브 클래스 필드/표준 동작에 가까워집니다.- 프레임워크/번들러(예: Babel, SWC)가 TS 대신 클래스 필드를 변환하는 경우, TS 옵션을 바꿔도 결과가 달라지지 않을 수 있습니다. “누가 트랜스파일하는지”를 먼저 확정해야 합니다.
2) 상속 구조에서 base 생성자 호출 패턴 검색
다음 패턴이 있으면 우선 의심하세요.
- base
constructor에서this.someMethod()호출 - base
constructor에서this.someField참조 - 파생 클래스에서 필드 초기화가 다른 필드/메서드에 의존
3) 동일 이름 필드 vs 접근자(get/set) 충돌
- base에
get value()/set value()가 있는데 derived에value = ...가 있는지 - 데코레이터가 프로퍼티를 후킹하는데, derived가 같은 이름 필드로 덮는지
실전 해결 전략: “설정으로 되돌리기” vs “코드로 고치기”
옵션 A: 단기 대응 — useDefineForClassFields 끄기
레거시 코드가 많고 즉시 배포가 급하면 다음처럼 임시로 꺼서 회귀를 막을 수 있습니다.
{
"compilerOptions": {
"useDefineForClassFields": false
}
}
다만 이는 장기적으로 표준과 멀어지는 선택일 수 있고, 라이브러리/런타임이 기대하는 동작과 충돌할 여지가 있습니다. “왜 깨졌는지”를 이해하고 점진적으로 코드 개선하는 편이 안전합니다.
옵션 B: 권장 — 초기화 순서 의존 제거
- base 생성자에서 오버라이드 호출 제거
- 필드 초기화에서 복잡한 this 의존 로직 제거
- setter/getter와 동일 이름 필드 제거
이 3가지만 해도 대부분의 “TS 업그레이드로 this가 깨짐” 이슈는 사라집니다.
디버깅 팁: 컴파일 결과를 직접 확인하기
원인 파악이 애매하면 “내 코드가 실제로 어떻게 내려가는지”를 확인하는 게 가장 빠릅니다.
npx tsc -p tsconfig.json --pretty false
그리고 문제 클래스가 있는 출력 JS를 열어 다음을 확인하세요.
this.x = ...로 할당되는지Object.defineProperty(this, "x", ...)로 정의되는지super()호출 전후로 어떤 코드가 배치되는지
이 과정을 통해 “내가 생각한 실행 순서”와 “실제 런타임 순서”의 차이를 빠르게 좁힐 수 있습니다.
마이그레이션 체크리스트
- base
constructor에서 오버라이드 가능한 메서드 호출 제거 - 클래스 필드 초기화에서 다른 필드/메서드/DI 접근 최소화
- base 접근자(get/set)와 derived 필드 이름 충돌 제거
- 콜백으로 전달되는 메서드는 화살표 필드 또는
bind로 고정 - 트랜스파일러가 TS인지(Babel/SWC인지) 파이프라인 확정
마무리
TS 5.5+에서 useDefineForClassFields로 인해 “this가 깨진 것처럼 보이는” 문제는 대개 클래스 필드 초기화의 표준화된 동작이 더 강하게 적용되면서, 기존 코드의 초기화 순서 의존성이 드러나는 형태로 나타납니다. 단기적으로는 옵션을 꺼서 회귀를 막을 수 있지만, 장기적으로는 base 생성자 설계와 필드 초기화 패턴을 정리하는 것이 가장 확실한 해결책입니다.
업그레이드 후 런타임에서만 터지는 문제를 추적하는 과정은 꽤 비슷한 패턴을 가집니다. 원인 규명이 길어질 때는 “재현 → 컴파일 결과 확인 → 순서/섀도잉 점검” 루틴으로 접근하는 게 좋습니다. 비슷한 결로, 런타임에서만 드러나는 캐시/상태 문제를 다루는 글로는 Next.js 14 RSC 캐시 꼬임·stale 데이터 해결법도 함께 참고할 만합니다.