TypeScript: 'Property has no initializer' 클래스 생성자 에러 탈출

TypeScript Property has no initializer 클래스 생성자 에러 탈출 가이드
3초 요약: 이렇게 고치세요
  • 원인: tsconfig의 strictPropertyInitialization이 "생성자 완료 시점에 값이 없는 프로퍼티"를 컴파일 에러로 막음
  • 해결: TypeORM 엔티티·DI 대상 → 확정 할당 단언(!) / 일반 도메인 객체 → 선택적 프로퍼티(?)나 | null = null
  • 주의: tsconfig에서 strictPropertyInitialization 자체를 꺼버리는 건 지양 (타입 안전성 전체가 무너짐)
id!: number;              // ORM/DI: 확정 할당 단언
amount?: number;          // 일반 도메인: 선택적 프로퍼티
gateway: string | null = null; // 일반 도메인: 유니온 + 기본값
↓ 왜 이런 에러가 나는지 원리 보기

Nest.js와 TypeORM으로 엔티티 클래스를 작성하다 보면 Property '...' has no initializer and is not definitely assigned in the constructor 에러를 마주치는 경우가 있습니다. 데이터베이스 컬럼에 타입만 정의했을 뿐인데 빌드가 막히는 이유와, 확정 할당 단언(Definite Assignment Assertion)을 비롯한 실무 대응법을 정리합니다.


문제의 핵심: strictPropertyInitialization과 비동기 하이드레이션의 충돌

이 컴파일 에러의 근본적인 원인은 타입스크립트의 "규칙서" 역할을 하는 tsconfig.json 파일 내 strictPropertyInitialization 옵션에 있습니다. 이는 비유하자면 카드 발급 안내서에 명시된 "무조건 잔고 증명 의무 조항" (Mandatory Balance Verification Clause)과 같습니다.

이 옵션은 쉽게 말해 "생성 시점에 빈 필드를 절대 허용하지 않겠다"는 타입스크립트의 무관용 정책입니다. 자바스크립트 클래스는 선언 단계에서 초깃값을 지정하지 않으면 런타임에 기본적으로 undefined로 평가됩니다. 정적 타입 검사를 수행하는 타입스크립트는 클래스의 인스턴스가 생성되는 시점(즉, new 키워드가 호출된 직후)에 소속된 모든 프로퍼티가 반드시 유효한 값을 가지고 있어야 안전하다고 판단합니다.

비유로 이해하는 컴파일 규칙: 신용카드 발급 신청 vs 잔고 증명

  • 일반 클래스 (통과): 은행(타입스크립트 컴파일러)에 신용카드를 신청하러 갑니다. 은행은 이 "무조건 잔고 증명 의무 조항"을 적용하여 엄격한 기준을 들이대며 말합니다. "현재 통장에 확실한 잔고(생성자 내의 동기적 초깃값 할당)가 증명되어야 카드를 발급(성공적인 컴파일)해 드립니다." 잔고 증명서를 바로 보여주니 은행은 흔쾌히 승인합니다.
  • TypeORM/비동기 프레임워크 (충돌): "아직 통장에 돈은 없지만, 다음 주에 회사에서 월급(데이터베이스 비동기 쿼리 결과)이 들어올 예정이에요!"라고 주장합니다. 하지만 깐깐한 은행원은 고개를 저으며 말합니다. "저희는 미래의 약속을 믿지 않습니다. 지금 당장 잔고가 입증되지 않으면 발급은 불가능합니다."

하지만 Nest.js 프로젝트에서 TypeORM과 Class-validator를 조합해 데이터 모델을 구축하는 환경에서는 이러한 원칙을 준수하기 어렵습니다. 데이터베이스 엔티티 객체의 컬럼 값들은 인스턴스가 생성되는 순간 생성자에서 채워지는 것이 아닙니다. 데이터베이스가 비동기 쿼리를 수행하고 그 결과를 라이브러리가 주입해 주는 시점에 비로소 결정됩니다. 여기서 데이터가 채워지는 과정을 하이드레이션(Hydration)이라고 부르는데, 이는 "건조하고 마른 스펀지(빈 클래스 인스턴스)가 데이터베이스의 물(실제 데이터)을 쫙 흡수해 촉촉하게 채워지는 과정"으로 이해하면 쉽습니다.

즉, 생성자가 끝나는 시점(스펀지가 막 만들어진 직후)에는 물이 없고, 런타임 특정 시점에 비동기로 물이 채워지는 라이프사이클의 모순이 빌드를 깨뜨리는 원인입니다.


해결 방안 A: 확정 할당 단언 (Definite Assignment Assertion, !)

이 문제를 아키텍처 수준에서 가장 깔끔하게 해결하는 방법은 속성 이름 뒤에 느낌표(!) 기호를 추가하는 확정 할당 단언(Definite Assignment Assertion) 연산자를 사용하는 것입니다. 이것은 일종의 "지불 보증서" 혹은 "외상 장부 보증"과 같습니다.

비유로 이해하는 ! 연산자: "이 외상값은 제가 보증합니다"

느낌표(!)를 붙이는 것은 은행(컴파일러)에 "지금 당장 잔고는 없지만, 제 고용주(TypeORM)가 런타임에 확실히 돈을 입금해 줄 테니 절 믿고 보증서(컴파일 통과)를 써 주세요. 만약 돈이 안 들어와서 부도가 나면 모든 책임은 제가 지겠습니다"라고 공식 보증서를 제출하는 것과 같습니다. 이 보증서를 확인한 컴파일러는 의심을 거두고 안심하며 빌드를 승인합니다.

실전 리팩토링 코드 (TypeORM 예시)

아래 코드는 Nest.js 결제 정산 테이블 엔티티 클래스에서 문제가 되었던 코드를 확정 할당 단언을 적용하여 안전하게 리팩토링한 결과입니다.

// 컴파일 에러를 유발하는 엔티티 클래스 코드
class PaymentSettlementEntity {
  @PrimaryGeneratedColumn()
  id: number; // 일반 변수 선언(id: number)은 초깃값이 없고 생성자에서도 할당되지 않아 컴파일 에러를 냅니다.

  @Column()
  amount: number; // 마찬가지로 초깃값이 선언되지 않고 생성자 대입도 생략되어 에러가 납니다.

  @Column()
  paymentGateway: string; // 생성자(constructor)가 실행 완료되는 시점 기준으로 채워진 값이 없어서 빌드 크래시를 유발합니다.
}


// 해결 코드

// 확정 할당 단언(!)을 적용한 안전한 코드
class SafePaymentSettlementEntity {
  @PrimaryGeneratedColumn()
  id!: number; // 확정 할당 단언 연산자입니다. 런타임에 데이터베이스가 비동기로 값을 채워주므로, 일단 빌드 시점에는 안전하다고 컴파일러에게 지불 보증서를 내어 검사를 우회합니다.

  @Column()
  amount!: number; // 런타임 시 비동기로 값이 채워지므로 경고를 소거합니다. (값이 절대 누락되지 않을 주입 장치가 있을 때만 써야 런타임 에러를 피할 수 있습니다.)

  @Column()
  paymentGateway!: string; // 컴파일러에게 "실행할 때는 무조건 값이 채워질 테니 컴파일 에러를 뿜지 말아달라"고 개발자가 전적으로 보증하는 기호입니다.
}

트레이드오프와 주의 사항: 지불 보증서(!)는 컴파일러의 잔고 확인을 패스해 주는 강력한 도구입니다. 하지만 실제로 런타임에 월급(외부 데이터)이 정상적으로 입금되지 않는다면 결국 잔고 부족으로 부도(런타임 에러 - Cannot read properties of undefined 등)가 발생하게 됩니다. 따라서 반드시 데이터베이스 혹은 종속성 주입(DI) 컨테이너가 해당 속성을 100% 확실하게 제공한다는 신뢰할 수 있는 장치(예: @Column(), @Inject())가 연결된 경우에만 제한적으로 사용해야 합니다.


해결 방안 B: 선택적 프로퍼티(?)와 유니온 타입(| null)

또 다른 해결법은 프로퍼티 자체를 선택적 속성(Optional Property)으로 지정하거나, null 또는 undefined가 할당될 수 있는 타입으로 확장하는 것입니다. 이 방식은 클래스 내부 상태의 불완전성을 솔직하게 인정하고, 타입 선언 단계에 이를 명문화하는 접근 방식입니다.

선택적 프로퍼티 및 유니온 타입 적용 코드

// 선택적 프로퍼티 및 null 허용 유니온 타입 적용
class FlexPaymentSettlementEntity {
  @Column({ nullable: true })
  amount?: number; // 물음표(?): "선택적 프로퍼티(Optional Property)" 선언입니다. 이 속성은 number일 수도 있고, 아예 값이 비어있을(undefined) 수도 있음을 공식 선언하여 컴파일러의 잔고 검사를 통과합니다.

  @Column({ nullable: true })
  paymentGateway: string | null = null; // 파이프(| null)와 대입(= null): "유니온 타입"으로 string 또는 null을 모두 허용하고, 디폴트 초깃값으로 null을 즉시 대입(= null)해 두어 생성 즉시 값을 채우는 잔고 조항을 만족시킨 기법입니다.
}

트레이드오프와 주의 사항: 이 방식은 타입스크립트의 기본 정적 검사를 완전하게 활용하므로 가장 안전합니다. 하지만 클래스의 멤버 프로퍼티를 비즈니스 로직 영역에서 참조할 때마다 매번 조건문(if (entity.amount))을 사용하거나 옵셔널 체이닝(entity.amount?.toFixed())을 수행해야 하는 코드 가독성 저하와 개발 피로도라는 기회비용이 수반됩니다.

실제로 정산 배치 스크립트를 마이그레이션할 때, 처음엔 컴파일 에러를 빨리 없애고 싶어서 엔티티 필드마다 !를 붙였습니다. 그런데 배치가 특정 조건에서 컬럼 하나를 아예 채우지 않고 저장하는 경로가 있다는 걸 나중에야 알았고, 그 필드를 읽는 곳에서 undefined.toFixed() 같은 런타임 에러가 났습니다. 결국 그 필드만 ?로 바꾸고 나머지 확실한 필드는 !를 유지하는 식으로 필드별로 나눠서 정리했습니다.


요약: 초기화 제어 기법의 현실적 비교 매트릭스

상황에 맞는 최선의 기법을 결정할 수 있도록 각 해결책의 트레이드오프를 매트릭스로 요약했습니다.

적용 기법 타입 안전성 가독성 및 개발 피로도 추천 상황
생성자 직접 대입 최상 (강제적) 보통 (코드양 증가) 일반 순수 데이터 객체
확정 할당 단언 (!) 하 (개발자 전적 보증) 최상 (간결하고 직관적) ORM 엔티티, DI 영역
선택적 프로퍼티 (?) 상 (런타임 안전) 하 (매번 조건문 수반) 비즈니스 도메인 영역

타입스크립트를 활용한 현대적인 백엔드 시스템 설계 과정에서는 이처럼 컴파일 타임 안전성과 런타임 동적 동작이 충돌하는 모순을 종종 발견할 수 있습니다. 단순히 빌드를 통과시키기 위해 전체 컴파일러 옵션에서 strictPropertyInitialization을 비활성화하는 쉬운 길을 택하기보다, 각 프로퍼티의 생명주기를 고민하여 알맞은 지시어를 적용하는 것이 낫습니다.

추가 추천 자료 및 공식 레퍼런스

클래스 초기화 옵션의 동작 지침과 관련 글은 아래에서 확인할 수 있습니다.

호마다의 웹개발 팁

타입스크립트의 엄격한 초기화 검사도 결국 전역 타입 확장이나 선언 병합 같은 기초 문법을 알아야 응용이 편해집니다. 관련 글은 [호마다의 IT 개발 입문 블로그] 에서 확인하실 수 있습니다.

댓글

이 블로그의 인기 게시물

TypeScript: Unknown vs Any 타입 차이점과 안전한 타입 가드(Type Guard)

Next.js 하이드레이션 오류 원인과 해결법 완벽 정리

Next.js App Router에서 getServerSideProps 대체 및 데이터 페칭법