TypeScript 프로젝트는 성장합니다. 파일은 늘어나고, 의존성은 얽힙니다. 그리고 결국, 빌드는 로직의 복잡성과는 상관없이, 단 하나의 선언 파일(declaration file)을 쓰기 위해 컴파일러가 온 우주를 다 읽어야 하는 상황에 직면하며 한계에 부딪힙니다.

TypeScript 6.0은 isolatedDeclarations를 통해 이 문제를 해결합니다. 이 기능은 .d.ts 파일이 생성되는 방식을 재고합니다. 선언 방출(declaration emission)을 전체 타입 체크 파이프라인에 결합하는 대신, 컴파일러가 각 소스 파일을 개별적으로 살펴보고 해당 파일을 방출할 수 있게 합니다. 그 결과, 의존성 그래프를 하나씩 따라가는 대신 수천 개의 파일을 병렬로 실행할 수 있는 빌드 프로세스가 가능해집니다.

진짜 병목 현상

현재 선언 파일을 생성하는 것은 직렬 작업입니다. --declaration을 활성화하고 컴파일러를 실행하면, TypeScript는 해당 모듈이 접하는 모든 타입을 완전히 이해하기 전까지는 특정 모듈에 대한 .d.ts 파일을 방출할 수 없습니다. 만약 utils.tstypes.ts에서 타입을 가져오고, types.tsapi.ts에서 무언가를 가져온다면, 컴파일러는 utils.ts가 무엇을 내보내는지(export) 설명하기 전에 이 체인을 모두 해결해야 합니다.

대규모 모노레포(monorepo)에서 이러한 연쇄 반응은 가혹합니다. 임포트 그래프의 루트 근처에 있는 단 하나의 파일이 수백 개의 하위 파일에 대한 선언 방출을 차단할 수 있습니다. CPU 코어는 8개지만, TypeScript가 패키지 경계를 가로질러 모든 인터페이스의 형태를 공들여 재구성하는 동안 7개의 코어는 유휴 상태로 머뭅니다. 컴파일러는 필요한 작업을 수행하고 있는 것이지만, 타입 체크와 선언 방출 사이의 결합으로 인해, 단순히 공개된 인터페이스 타입(public surface types)만 디스크에 기록하고 싶을 때조차 파일 간 분석에 따른 모든 비용을 지불해야 합니다.

isolatedDeclarations가 규칙을 바꾸는 방식

isolatedDeclarations는 그 결합을 끊어냅니다. 이 플래그를 활성화하면, 컴파일러는 다른 파일에 무엇이 무엇인지 묻지 않고 소스 파일에 대한 .d.ts 파일을 방출하기로 약속합니다. 이를 위해 간단한 계약을 요구합니다. 즉, 모든 내보내기(exported) 심볼은 선언된 위치에서 명시적이고 가시적인 타입 어노테이션(type annotation)을 가져야 합니다.

컴파일러가 소스에 바로 작성된 전체 타입을 볼 수 있다면, 타입 추론(inference)을 수행할 필요가 없습니다. 임포트를 추적할 필요도 없습니다. 다른 파일의 User 식별자가 인터페이스인지, 타입 별칭(type alias)인지, 아니면 클래스인지 알 필요도 없습니다. 그저 여러분이 작성한 그대로를 방출할 뿐입니다.

이는 파일 A와 파일 B가 동시에 선언을 생성할 수 있음을 의미합니다. 빌드 오케스트레이터는 각 파일을 별도의 스레드에 할당할 수 있습니다. 이전에는 전체 타입 체크 기능이 부족하여 .d.ts 생성을 건너뛰었던 빠른 트랜스파일러(transpilers)들도 이제는 선언 파일을 생성할 수 있습니다. 작업이 순수하게 구문론적(syntactic)인 작업이 되었기 때문입니다.

트레이드오프: 직접 작성하기

속도는 공짜로 얻어지지 않습니다. 내보내는 모든 것에 대해 타입 추론에 의존하는 것을 중단해야 합니다. 모든 공개 함수, 클래스, 변수, 상수는 타입을 명시적으로 기술해야 합니다. 만약 TypeScript가 리턴 문을 살펴보거나 제네릭 인자를 해결하여 타입을 계산해야 한다면, isolatedDeclarations는 에러를 발생시킵니다.

실제 사례는 다음과 같습니다. 플래그가 없을 때는 다음과 같이 작성할 수 있습니다:

export function fetchUser(id: number) {
  return fetch(`/users/${id}`).then(r => r.json());
}

TypeScript는 fetch, 그 다음 Promise.prototype.then, 그 다음 r.json()을 반환하는 익명 함수를 조사하여 반환 타입을 추론합니다. .d.ts를 방출하려면 컴파일러는 이 모든 분석을 수행해야 합니다.

isolatedDeclarations를 활성화하면 내보내기에 어노테이션을 추가해야 합니다:

interface User {
  id: number;
  email: string;
}

export function fetchUser(id: number): Promise<User> {
  return fetch(`/users/${id}`).then(r => r.json());
}

이제 컴파일러는 즉시 Promise<User>를 확인합니다. 선언을 방출하고 다음으로 넘어갑니다.

이 규칙은 광범위하게 적용됩니다. 내보내기 배열은 요소로부터 추론하는 대신 명시적인 타입이 필요합니다. 내보내기 객체는 그 형태가 소비자에게 중요하다면 명시적인 타입 어노테이션이 필요합니다. 제네릭 함수는 선언 위치에서 반환 타입과 제약 조건(constraints)이 보여야 합니다. 복잡한 맵드 타입(mapped type)의 결과를 내보낼 때는 전체가 작성된 이름이 있는 타입 별칭을 지정하지 않고서는 내보낼 수 없습니다.

장점은 공개 API가 자체 문서화(self-documenting)된다는 것입니다. 소비자(그리고 컴파일러)는 더 이상 구현 세부 사항으로부터 의도를 역공학(reverse-engineer)할 필요가 없습니다. 타입은 의도된 계약입니다.

시간이 절약되는 곳

대규모 코드베이스에서 그 효과는 즉각적입니다. 선언 방출이 더 이상 병목 지점(long pole in the tent)이 아니기 때문에, 몇 분씩 걸리던 빌드 시간이 몇 초로 단축될 수 있습니다. 각 파일이 독립적으로 방출되므로, 프로세스는 임포트 그래프의 깊이가 아니라 보유한 코어의 수에 따라 확장됩니다.

이 기능은 사용할 수 있는 도구도 변경합니다. esbuild나 swc와 같은 트랜스파일러는 이미 TypeScript를 JavaScript로 변환하는 속도가 매우 빠르지만, 많은 팀이 여전히 .d.ts 파일을 생성하기 위해 tsc를 별도로 실행합니다. isolatedDeclarations를 사용하면 이러한 빠른 도구들이 두 가지 작업을 모두 처리할 수 있습니다. 선언 파일을 생성하기 위해 TypeScript의 전체 타입 시스템을 복제할 필요 없이, 구문을 파싱하고 사용자가 제공한 명시적 타입을 복사하기만 하면 됩니다. 덕분에 대안적인 툴체인을 사용한 엔드 투 엔드 TypeScript 빌드가 훨씬 더 실용적이 됩니다.

분산 및 증분 빌드도 더 간단해집니다. 지속적 통합(CI) 환경에서 원격 캐시나 샤딩된 빌드는 전체 전이적 의존성 그래프(transitive dependency graph)를 먼저 다운로드하지 않고도 패키지에 대한 선언을 생성할 수 있습니다. 소스 코드에 타입이 명시되어 있다면, 빌드 샤드(shard)는 필요한 모든 것을 갖추게 됩니다.

변하지 않는 것

제약 사항은 export에만 적용됩니다. 모듈 내부의 삶은 평소와 다름없이 계속됩니다. 지역 변수, private 클래스 멤버, 그리고 export되지 않은 헬퍼 함수는 여전히 완전한 타입 추론에 의존할 수 있습니다. TypeScript는 루프 변수나 클로저 파라미터의 타입을 아무런 불만 없이 기꺼이 추론할 것입니다.

export function calculateTotal(items: Item[]): number {
  // Local variable: inference is fine
  const taxRate = 0.08;
  
  // Private class member inside a local class: inference is fine
  class Helper {
    private cache = new Map();
  }
  
  return items.reduce((sum, item) => sum + item.price * (1 + taxRate), 0);
}

오직 export된 함수의 시그니처에만 어노테이션이 필요합니다. 내부 로직은 여전히 유연하고 표현력이 풍부하게 유지됩니다. 이는 작성 부담을 감당할 수 있는 수준으로 유지해 줍니다. 모든 곳을 완전히 명시적인 스타일로 바꾸는 것이 아니라, 각 모듈의 경계에서 계약(contract)을 공식화하는 것뿐입니다.

귀하의 코드베이스에 적합할까요?

isolatedDeclarations를 채택하면 시간을 쓰는 방식이 달라집니다. export를 작성할 때 몇 번의 키 입력을 더 투자하는 대신, 매 빌드마다 지불해야 했던 비용(이자)을 멈출 수 있습니다. 라이브러리 제작자에게 이는 대개 설득하기 쉬운 제안입니다. 공개 API는 어차피 어노테이션을 달아야 할 가능성이 높기 때문입니다. 폐쇄적인 모노레포 내부에서 작업하는 애플리케이션 개발자에게는 초기 비용이 불필요한 절차처럼 느껴질 수 있습니다. 하지만 팀의 빌드 시간을 커피 한 잔 마시는 시간으로 측정한다면, 이 거래는 빠르게 매력적으로 다가올 것입니다.

점진적으로 도입할 수 있습니다. 플래그를 활성화하고 컴파일러를 실행한 뒤, export된 심볼에서 발생하는 오류를 수정하면 됩니다. 오류 메시지는 어떤 공개용 타입이 암시적인지 정확히 알려줍니다. 그것들을 수정하고 내부 코드는 그대로 두면, 선언 생성 단계가 가속화되는 것을 볼 수 있습니다.

한 가지 기억해야 할 점은, 이 플래그가 TypeScript 타입 체커 자체를 빠르게 만들지는 않는다는 것입니다. 에디터에서 더 빠른 피드백을 원하거나 tsc --noEmit 실행 속도를 높이고 싶다면, 여전히 프로젝트 참조(project references), 엄격한 파일 포함(file inclusion) 또는 기타 아키텍처 측면의 수정이 필요합니다. isolatedDeclarations는 구체적으로 .d.ts 파일의 생성(emission)을 대상으로 합니다. 이는 빌드 최적화이지, 타입 체크 최적화가 아닙니다.

핵심 요약

isolatedDeclarations는 공개 타입을 일급 산출물(first-class artifacts)로 다룰 것을 요구합니다. 컴파일러가 타입을 추론하게 두지 마세요. 직접 작성하세요. 일단 그렇게 하면, 컴파일러는 선언 파일을 생성할 때마다 전체 의존성 그래프를 샅샅이 뒤지는 일을 멈춥니다. 병렬로 생성(emit)이 이루어지고, esbuild나 swc 같은 도구들이 전체 TypeScript 워크플로우를 처리할 수 있게 되어, 모노레포 빌드가 더 이상 지연되지 않습니다.

비용이 빌드 시간에서 작성 시간으로 이동합니다. 성장하는 대부분의 팀에게 이는 충분히 가치 있는 거래입니다.