PUBLISHED

재귀 조건부 타입에서의 추론 컨텍스트 손실

작성일: 2025.04.06

재귀 조건부 타입에서의 추론 컨텍스트 손실

타입스크립트에서는 조건부 타입의 분배 과정에서도 타입 추론 컨텍스트가 유지된다. 이 특성 덕분에 단순한 분기 수준을 넘어, 상당히 복잡한 조건부 타입에서도 개발자가 의도한 방향으로 타입 추론을 유도할 수 있다. 실제로 이러한 특성은 고급 유틸리티 타입을 설계할 때 매우 강력한 도구로 작용하며, 타입 시스템을 단순한 보조 수단이 아닌 정적 검증 장치로 활용할 수 있게 해준다.

그러나 Caro-Kann의 다음 버전에서 persist migration을 처리하기 위한 MigrationPipe 타입을 설계하던 과정에서, 이러한 기존 이해만으로는 설명하기 어려운 현상을 마주하게 되었다. 조건부 타입과 infer를 활용한 재귀적인 타입 설계가 논리적으로는 완결되어 보였음에도 불구하고, 실제 타입스크립트의 동작은 전혀 다른 결과를 만들어냈기 때문이다. 이 문제는 단순한 문법 오류나 타입 정의 실수가 아니라, 타입스크립트의 타입 시스템이 재귀 조건부 타입과 튜플을 다루는 방식 자체와 깊이 연결되어 있었다.

Caro-Kann은 persist된 상태의 버전을 기준으로 migration을 수행한다. 기존 구현에서는 다음과 같이 switch 문을 사용해 이전 버전의 상태를 현재 버전의 상태로 변환한다.

untitled
CSS
type Theme = { color: "light" | "dark"; ["font-size"]: number };

const strategy = (prevState: any, prevVersion: number) => {
  switch (prevVersion) {
    case 0:
      return { color: prevState, ["font-size"]: 16 };
    case 1:
      return { color: prevState.color, ["font-size"]: prevState.fontSize };
    default:
      return prevState;
  }
};

이 방식은 버전 수가 적고 상태 구조가 단순할 때는 비교적 잘 동작한다. 하지만 persist 버전이 늘어나기 시작하면 여러 한계가 빠르게 드러난다. 가장 큰 문제는 각 버전의 상태 구조가 타입 수준에서 전혀 드러나지 않는다는 점이다. prevState는 항상 any로 취급되며, 특정 버전에서 어떤 형태의 상태가 존재하는지는 오직 구현자의 기억이나 외부 문서에만 의존하게 된다.

또한 migration 로직이 잘못 작성되더라도 이를 컴파일 타임에 검증할 방법이 없다. 예를 들어, 특정 버전에서 존재하지 않는 필드에 접근하거나, 다음 버전에서 기대하는 구조와 맞지 않는 객체를 반환하더라도 타입스크립트는 아무런 경고를 제공하지 않는다. 결국 migration의 정확성은 런타임 테스트나 수동 검증에 의존할 수밖에 없으며, 상태 구조에 대한 명시적인 문서화 없이는 유지보수가 점점 어려워진다.

초기 MigrationPipe 타입과 설계 의도

이러한 문제를 해결하기 위해, migration 과정을 단순한 조건 분기가 아닌 점진적인 변환의 연속으로 바라볼 필요가 있었다. 각 버전 간의 상태 변화는 독립적인 단계로 나뉠 수 있으며, 이 단계들은 이전 상태를 입력으로 받아 다음 상태를 반환하는 순수 함수로 표현할 수 있다. 이 관점에서 보면 persist migration은 하나의 거대한 분기 로직이 아니라, 여러 개의 작은 변환 함수들이 순서대로 적용되는 pipeline에 가깝다.

이러한 구조를 채택하면 migration 로직의 의도가 훨씬 명확해진다. 특정 버전에서 어떤 상태를 입력으로 받아 어떤 형태의 상태를 출력하는지가 함수 시그니처에 그대로 드러나기 때문이다. 더 나아가, 이 함수들이 올바른 순서로 연결되어 있는지만 타입스크립트가 검증해줄 수 있다면, persist migration은 더 이상 런타임에서만 검증 가능한 로직이 아니라 타입 수준에서 안정성이 보장된 설계 요소가 된다.

여기서 핵심은 “함수의 나열 자체를 검증 대상으로 삼는 것”이다. 단일 함수의 타입 검증은 타입스크립트가 이미 잘 수행하고 있지만, 문제는 여러 함수가 배열로 나열되었을 때 이들이 서로 올바르게 연결되는지를 검증하는 데 있다. 앞 단계의 반환 타입이 다음 단계의 파라미터 타입으로 사용될 수 있는지, 그리고 이 관계가 배열 전체에 걸쳐 일관되게 유지되는지를 타입 시스템이 보장해주어야 했다.

이러한 요구사항을 만족시키기 위해 설계한 것이 MigrationPipe 타입이다. MigrationPipe는 migration 함수들의 배열을 입력으로 받아, 각 함수의 입력과 출력 타입이 순차적으로 호환되는지를 재귀적으로 검사한다. 이 검사를 통과한 경우에만 해당 함수 배열을 유효한 migration pipeline으로 인정하고, 하나라도 어긋나는 경우에는 타입 수준에서 오류를 발생시키는 것이 목표였다.

이를 위해 migration 단계는 다음과 같은 형태를 갖는다고 가정했다.

untitled
(prevState: V0) => V1
(prevState: V1) => V2
(prevState: V2) => V3

이러한 함수들이 배열로 전달되었을 때, 타입스크립트가 각 단계의 입력과 출력이 정확히 맞물려 있는지를 컴파일 타임에 검증해주기를 기대했다. 그리고 이러한 검증 로직을 타입 수준에서 구현하기 위해, 아래와 같은 초기 버전의 MigrationPipe 타입을 정의하게 되었다.

untitled
TS
type MigrationFn = (props: any) => unknown;

type MigrationPipe<
  T extends Array<MigrationFn>,
  BeforeFnReturnType = any
> = T extends [infer First, ...infer Rest extends Array<MigrationFn>]
  ? First extends (props: infer InferPropsType) => infer InferReturnType
    ? InferPropsType extends BeforeFnReturnType
      ? [First, ...MigrationPipe<Rest, InferReturnType>]
      : never
    : never
  : [];

이 타입은 함수 배열 T가 주어졌을 때, 첫 번째 함수 First를 분리하고 그 파라미터 타입을 infer로 추론한다. 이후 이 타입이 이전 단계의 반환 타입(BeforeFnReturnType)과 호환되는지를 검사한다. 조건이 성립하면 해당 함수를 결과 튜플에 포함시키고, 반환 타입을 기준으로 나머지 함수들에 대해 같은 검사를 반복한다. 이 과정을 통해 모든 함수가 올바르게 체이닝될 수 있다면 최종적으로 원본 함수 배열과 동일한 구조의 튜플이 반환되고, 중간에 하나라도 조건을 만족하지 못하면 never로 수렴하게 된다.

이 시점에서 보면 MigrationPipe 타입은 논리적으로도, 타입스크립트 문법적으로도 특별히 문제가 없어 보인다. 실제로 각 단계의 타입 관계만 놓고 보면, 이 타입이 의도한 검증을 수행하지 못할 이유는 없어 보인다.

실제 적용 시 발생한 예상 밖의 타입 에러

그러나 이 MigrationPipe 타입을 실제 Caro-Kann의 persist migration에 적용하자 예상과는 전혀 다른 결과가 나타났다. 정상적인 migration 함수 배열을 전달했음에도 불구하고, 타입스크립트는 해당 위치에서 “대상에서 0개만 허용된다”는 에러를 발생시켰다.

이 에러 메시지는 MigrationPipe의 결과 타입이 []로 추론되고 있음을 의미한다. 즉, 재귀 조건부 타입의 가장 바깥 조건인 다음 분기가 false로 평가되고 있다는 뜻이다.

untitled
JS
T extends [infer First, ...infer Rest]

직관적으로는 이해하기 어려운 상황이다. T는 분명 하나 이상의 migration 함수를 담고 있는 배열이며, 구조적으로 보았을 때 튜플 패턴 매칭이 실패할 이유가 없어 보이기 때문이다. 이 지점에서 처음으로 “내가 타입스크립트의 추론 동작을 잘못 이해하고 있는 것이 아닐까”라는 의문이 들기 시작했다.

재귀 결과를 그대로 반환하면 사라지는 문제

문제를 조금 더 단순화하기 위해 MigrationPipe의 반환 타입을 임시로 변경해 보았다. 검증 로직은 그대로 두되, 재귀적으로 타입을 다시 구성하지 않고 단순히 제네릭 T를 그대로 반환하도록 수정한 것이다.

untitled
TS
type MigrationPipe<T extends Array<MigrationFn>, BeforeFnReturnType = any> =
  T extends [infer First, ...infer Rest extends Array<MigrationFn>]
    ? First extends (props: infer InferPropsType) => infer InferReturnType
      ? InferPropsType extends BeforeFnReturnType
        ? T
        : never
      : never
    : [];

이렇게 변경하자 앞서 발생하던 타입 에러는 완전히 사라졌다. 즉, 동일한 조건 검사 로직을 사용하더라도, 반환값이 재귀적으로 구성된 타입이 아닌 단순한 제네릭 T일 경우에는 타입스크립트가 아무런 문제를 제기하지 않았다.

이 사실은 매우 중요한 단서를 제공한다. 타입 에러의 원인은 조건식 자체나 infer 사용 방식이 아니라, 바로 [First, ...MigrationPipe<Rest, InferReturnType>]라는 재귀적으로 구성된 반환 타입에 있다는 점이 드러났기 때문이다.

재귀 조건부 타입에서 튜플이 유지되지 않는 이유

개발자 입장에서 보면 [First, ...MigrationPipe<Rest, InferReturnType>]는 논리적으로 원본 함수 배열 T와 동일한 구조를 갖는다. 첫 번째 요소를 떼어내고 나머지를 재귀적으로 다시 붙이는 과정이기 때문에, 모든 조건이 만족된다면 결국 원래의 튜플로 되돌아올 것이라 기대하는 것이 자연스럽다.

그러나 타입스크립트는 이러한 “결과적 동일성”을 전혀 가정하지 않는다. T는 이미 외부에서 주어진 확정된 제네릭 튜플 타입인 반면, 재귀 조건부 타입의 결과는 조건부 타입, infer, spread가 결합된 계산된 타입이다. 타입스크립트는 이 두 타입 사이에 어떤 동치 관계도 자동으로 설정하지 않는다.

더 중요한 점은, 재귀 타입의 중간 결과가 타입스크립트 내부에서 더 이상 “확실한 튜플”로 유지되지 않는다는 사실이다. [First, ...SomeType] 형태의 타입은 표면적으로는 튜플처럼 보이지만, SomeType이 조건부 타입의 결과일 경우 타입스크립트는 이를 점차 일반적인 배열 타입으로 승격시킨다. 이 순간 해당 타입은 고정 길이를 가진 튜플이 아니라, 단순히 요소 타입만 보장된 배열로 취급된다.

이러한 변화는 다음 조건식에 직접적인 영향을 미친다.

untitled
JS
T extends [infer First, ...infer Rest]

이 조건은 단순히 “배열이냐 아니냐”를 묻는 것이 아니라, T패턴 매칭이 가능한 튜플 타입인지를 검사한다. 재귀 과정에서 반환 타입이 일반 배열로 간주되는 순간, 이 조건은 더 이상 성립하지 않는다. 그 결과 타입스크립트는 조건부 타입의 마지막 분기인 []를 선택하게 된다.

이 현상은 타입스크립트가 “결과를 먼저 보고 조건을 다시 평가한다”거나, 추론 컨텍스트를 임의로 손실시키기 때문이 아니다. 단순히 타입스크립트가 재귀적으로 생성된 타입을 튜플로 끝까지 보존하지 않기 때문에 발생하는, 비교적 명확한 동작 결과다.

튜플 성질을 명시적으로 보존하는 해결책

이 문제를 해결하기 위해서는, 재귀적으로 구성된 타입이 여전히 원본 튜플 T의 부분 구조임을 타입스크립트에 명시적으로 알려줄 필요가 있다. 이를 위해 다음과 같은 조건을 추가했다.

untitled
TS
type MigrationPipe<
  T extends Array<MigrationFn>,
  BeforeFnReturnType = any
> = T extends [infer First, ...infer Rest extends Array<MigrationFn>]
  ? First extends (props: infer InferPropsType) => infer InferReturnType
    ? InferPropsType extends BeforeFnReturnType
      ? [First, ...MigrationPipe<Rest, InferReturnType>] extends T
        ? [First, ...MigrationPipe<Rest, InferReturnType>]
        : never
      : never
    : never
  : [];

이 조건은 재귀 결과가 여전히 T에 할당 가능하다는 사실을 강제한다. 그 결과 타입스크립트는 중간 결과를 일반 배열로 승격시키지 않고, 튜플로 취급할 수 있는 근거를 얻게 된다. 이로써 재귀 전반에 걸쳐 튜플 성질이 유지되고, MigrationPipe는 처음 의도한 대로 정상 동작하게 된다.

결론

MigrationPipe 사례는 타입스크립트의 타입 시스템이 얼마나 강력하면서도 동시에 보수적인지를 잘 보여준다. 타입스크립트는 개발자가 “논리적으로 동일하다”고 생각하는 타입이라 하더라도, 이를 증명할 수 없다면 동일하게 취급하지 않는다. 특히 재귀 조건부 타입과 튜플 조작이 결합될 경우, 중간 결과가 튜플로 유지된다는 보장은 어디에도 없다.

따라서 복잡한 타입 로직을 설계할 때는, 타입스크립트가 스스로 확신하지 못하는 정보를 명시적인 제약으로 보강해주어야 한다. MigrationPipe에서 추가한 extends T 조건은 단순한 트릭이 아니라, 타입스크립트의 추론 한계를 이해하고 이를 보완하기 위한 필수적인 설계 선택이었다.