중첩된 객체를 탐색하며 자동 완성을 위한 점(dot)으로 구분된 경로를 생성하는 타입을 작성한다고 가정해 봅시다. 작은 테스트 객체에서는 완벽하게 작동합니다. 하지만 실제 API 페이로드에 적용하는 순간 에디터가 멈춰버립니다. 결국 TypeScript는 다음과 같은 에러를 내뱉습니다: Type instantiation is excessively deep and possibly infinite.
이 메시지는 코드가 전통적인 의미의 무한 루프를 포함하고 있다는 뜻이 아닙니다. 컴파일러가 포기했다는 의미입니다. 요청한 타입 계산이 실제로 경계가 없거나, 유한하더라도 그 크기가 너무 커서 TypeScript의 내부 한계를 초과할 정도라는 뜻입니다. 이런 상황이 발생하면 컴파일러는 IDE가 멈추기 전에 동작을 중단합니다.
TS2589가 발생하는 경우
재귀적 타입(Recursive types)이 가장 흔한 원인입니다. TypeScript는 타입을 탐욕적으로(eagerly) 평가하며, 유틸리티 타입이 자기 자신을 계속 호출할 경우(특히 조건부 로직을 통해) 계산 스택이 빠르게 증가합니다. 주로 다음과 같은 특정 시나리오에서 이 문제에 직면하게 됩니다:
- 베이스 케이스(base case)에 도달할 때까지 튜플, 객체 또는 문자열 템플릿을 반복적으로 구조 분해하는 재귀적 조건부 타입(Recursive conditional types)
{ user: { address: { street: string } } }와 같은 구조를"user" | "user.address" | "user.address.street"와 같은 문자열 리터럴 유니온으로 변환하는 깊게 중첩된 객체 경로 생성기- 문자열을 문자 하나하나 또는 토큰 하나하나씩 파싱하는 템플릿 리터럴 타입(Template literal types)
- 수십 개의 키와 여러 계층을 가진 객체에 대해 반복되는 맵드 타입(Mapped types)
- 대규모 유니온에 대해 분산(distribute)되어 모든 멤버에 대해 작업량을 조용히 배가시키는 조건부 타입(Conditional types)
중첩된 경로 예제는 특히 매력적입니다. 폼 라이브러리나 상태 관리 도구들은 필드 이름에 대한 자동 완성을 제공하기 위해 타입이 지정된 경로를 제공하는 것을 선호합니다. 얕은 객체에서 모든 유효한 점 경로를 문자열 유니온으로 생성하는 것은 사소한 일입니다. 하지만 깊거나 넓은 객체에서는 그 유니온이 폭발적으로 늘어납니다. TypeScript는 모든 순열을 작업 메모리에 한꺼번에 유지해야 합니다. 특정 깊이에 도달하면 컴파일러는 작업량이 할당된 예산을 초과하고 있음을 감지하고 비상 브레이크를 밟습니다.
해결책 1: 명시적인 깊이 제한 추가
TS2589를 해결하는 가장 직접적인 방법은 타입이 영원히 재귀할 수 있다고 가정하는 것을 멈추는 것입니다. 서킷 브레이커 역할을 하는 깊이 카운터를 도입하십시오.
실제로 이는 타입이 재귀할 때마다 감소하는 숫자형 제네릭 파라미터(종종 길이를 통해 숫자를 세는 튜플로 표현됨)를 추가하는 것을 의미합니다. 카운터가 0에 도달하면, 타입은 더 깊이 파고드는 대신 string과 같은 넓은 폴백(fallback) 타입을 반환합니다. 사용자는 여전히 처음 4~5단계에 대해서는 정확한 자동 완성을 받을 수 있으며, 이는 실제 객체의 대다수를 커버합니다. 그 이상의 단계에서는 컴파일러가 단순히 타입을 넓게 확장하고 다음으로 넘어갑니다.
이 접근 방식은 유틸리티 타입의 정확성을 의미 있는 수준에서 떨어뜨리지 않습니다. 단지 범위를 제한할 뿐입니다. 컴파일러를 충돌시키는 타입 시스템은 적절한 깊이에서 우아하게 양보하는 타입 시스템보다 유용하지 않습니다.
해결책 2: 한 번에 하나의 경로만 검증
모든 가능한 경로를 미리 생성하는 것이 너무 비용이 많이 든다면, 계약(contract)을 변경하십시오. 모든 유효한 문자열의 거대한 유니온을 생성하는 대신, 특정 문자열이 유효한 경로인지 확인하는 타입을 작성하십시오.
모든 영어 단어의 사전을 만드는 것과 단 하나의 단어가 철자가 맞는지 확인하는 것의 차이를 생각해 보십시오. 전자는 거대한 데이터 구조이지만, 후자는 가벼운 스캔입니다. TypeScript 관점에서 보면, "user.address.street" | "user.settings.theme" | ...를 반환하는 Paths<T> 유틸리티를 내보내는 대신, IsValidPath<T, "user.address.street">와 같은 것을 내보내는 것입니다. 컴파일러는 실제로 전달한 경로만 평가합니다.
이러한 전환은 API를 설계하는 방식을 바꿉니다. 함수 시그니처가 문자열을 받은 다음, 제네릭 제약 조건(generic constraint)을 사용하여 이를 객체 형태와 대조하여 검증할 수 있습니다. 개발자가 잘못된 경로를 입력하면 IDE가 여전히 경고를 표시하지만, 컴파일러가 타입 체크 중에 모든 유효한 경로 집합을 실제로 생성(materialize)할 필요는 없습니다. 대규모 객체의 경우 성능 차이가 극적으로 나타납니다.
진행을 돕는 빠른 전술들
두 가지 구조적 해결책 외에도, 몇 가지 작은 습관을 통해 재귀 타입이 한계를 넘지 않도록 관리할 수 있습니다:
타입 매개변수를 튜플로 감싸서 분포(distribution)를 방지하세요.
T extends Foo ? Bar : Baz와 같이 조건부 타입에서 단독으로 사용된 타입 매개변수는T가 유니온일 때 모든 멤버에 대해 체크를 분산시킵니다. 만약 해당 유니온에 50개의 멤버가 있다면, TypeScript는 50번의 별도 인스턴스화를 수행합니다.[T] extends [Foo] ? Bar : Baz와 같이 작성하면 조건부를 전체 유니온에 대해 한 번만 평가합니다. 각 유니온 멤버를 개별적으로 매핑할 필요가 없는 경우에는 항상 이 방식을 사용하세요.디버깅 중에는 입력값을 축소하세요. TS2589 에러가 발생하면, 프로덕션 객체 타입을 속성이 두 개이고 중첩이 한 단계뿐인 아주 작은 스텁(stub)으로 교체해 보세요. 에러가 사라진다면, 구문 오류가 아니라 깊이나 카디널리티(원소 개수)가 문제임을 확인한 것입니다. 이를 통해 구조적으로는 문제가 없는 로직을 다시 작성하는 수고를 덜 수 있습니다.
외부 공개 API 타입을 유연하게 만드세요. 내부적으로는 정밀한 타입이 필요할 수 있습니다. 하지만 외부적으로는 완벽함을 추구하는 비용이 그 이득보다 클 때가 있습니다. 약간 더 넓은 범위의 자동 완성 타입이 에디터의 2초 지연을 방지할 수 있다면, 대개 그만한 가치가 있습니다. 느슨한 타입과 함께 런타임 검증기를 사용하여 테스트 단계에서 잘못된 경로를 잡아낼 수도 있습니다.
TypeScript가 이 경계를 강제하는 이유
TypeScript는 정지 문제(halting problem)를 해결할 수 없습니다. 여러분의 재귀 타입이 결국 종료될지, 아니면 영원히 루프를 돌지 알 수 없기 때문입니다. 컴파일러 내부에서 무한 루프가 발생할 위험을 감수하는 대신, TypeScript는 보수적인 제한을 둡니다. 때로는 이 제한 때문에 충분한 시간이 주어졌다면 완료되었을 타입이 차단되기도 합니다. TS2589는 컴파일러가 "나중에 후회하는 것보다 안전한 쪽을 택하겠다"라고 인정하는 신호입니다.
이 제한을 존중하는 것은 프로덕션급 타입을 작성하는 과정의 일부입니다. 타입 정의는 컴파일러에서 실행되는 코드이며, 무거운 코드는 실제적인 결과를 초래합니다. 느린 자동 완성은 느린 런타임 코드만큼이나 개발 속도를 저하시킵니다.
핵심 요약
TS2589는 여러분이 타입 시스템 프로그래머로서 부족하다는 신호가 아닙니다. 여러분의 타입이 한 번에 너무 많은 일을 하고 있다는 신호입니다. 재귀를 제한하고, 검증을 지연시키며, 불필요한 분포를 방지하세요. 고급 타입의 목표는 컴파일 타임에 가능한 모든 진실을 증명하는 것이 아니라, 팀에게 빠르고 신뢰할 수 있는 도구를 제공하는 것입니다. 밀리초 단위로 컴파일되면서 95%의 케이스를 커버하는 타입이, 이론적으로는 완벽하지만 언어 서버를 다운시키는 타입보다 훨씬 가치 있습니다.
