ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • TypeScript를 다른 도구로 다루기 위한 컴파일러 옵션
    Javascript 2024. 5. 11. 21:27

     

    ** 목차 **


    TypeScript 프로젝트에서는 빌드 속도가 중요합니다.

     

    TypeScript의 빌드 속도는 CI/CD의 효율성과 직결되기 때문입니다.

     

    하지만 tsc가 그렇게 빠르지 않다는 것은 잘 알려진 사실이며, tsc 외에 다른 도구를 TypeScript 빌드 파이프라인에 섞어 쓰는 것도 자주 있습니다.

     

    TypeScript(tsc) 컴파일러 옵션 중에는 이런 사용 사례를 지원하기 위한 옵션들이 있습니다.

     

    공식 문서에서는 'Interop Constraints'로 분류되어 있는 옵션들(그 중 일부)입니다.

     

    이 글에서는 그러한 옵션들을 소개하겠습니다.

    isolatedModules

    isolatedModules는 TypeScript의 오랜 컴파일러 옵션 중 하나로, tsc 외에 다른 도구로 TypeScript를 트랜스파일링하는 사용 사례를 지원해 줍니다.

     

    TypeScript의 주된 역할은 TypeScript 소스 코드에 대한 타입 검사를 수행하는 것이지만, 트랜스파일링이라는 역할도 있습니다.

     

    이는 TypeScript 구문으로 작성된 소스 코드를 JavaScript로 변환하여 JavaScript 엔진이 이해할 수 있게 하는 것을 의미합니다.

     

    (새로운 JavaScript 구문을 구버전의 구문으로 변환하는 것도 트랜스파일링에 포함되지만, 이 글에서는 크게 관련이 없으므로 그 부분은 다루지 않겠습니다.)

     

    타입 검사는 TypeScript에서만 가능하지만, 트랜스파일링은 TypeScript 외에서도 가능하며, 현재 다양한 도구들이 TypeScript 트랜스파일링을 지원하고 있습니다.

     

    Babel을 비롯하여 esbuild, SWC 등이 대표적입니다.

    isolatedModules가 해결하는 문제

    트랜스파일링을 담당하는 대부분의 도구는 소스 코드를 파일 단위로 처리합니다.

     

    그러나 다른 파일의 내용을 모르면 트랜스파일링 결과를 확정할 수 없는 경우가 존재합니다.

     

    tsc는 애초에 프로젝트 전체를 검사한 후에 트랜스파일링하므로 이러한 경우도 문제없이 처리할 수 있었지만, 다른 트랜스파일러는 그렇지 못합니다.

     

    그래서 트랜스파일러가 곤란해지는 경우를 오류로 처리하는 것이 isolatedModules의 역할입니다.

     

    즉, isolatedModules를 활성화하면 규칙이 좀 더 엄격해집니다.

     

    이 추가된 규칙을 통해 트랜스파일러가 곤란해지지 않도록 보장합니다.

    isolatedModules의 동작 예시

    구체적으로 isolatedModules의 유무에 따라 결과가 달라지는 경우는 다음과 같습니다.

    foo.ts

    export type FooType = string;
    export const fooConst = "foo";

    index.ts

    import { FooType, fooConst } from "./foo.js";
    
    export { FooType, fooConst };

     

    이 예제에서는 isolatedModules가 비활성화되어 있다면 문제없이 컴파일할 수 있습니다.

     

    index.ts는 다음과 같이 컴파일됩니다.

     

    컴파일 후 (index.js)

    import { fooConst } from "./foo.js";
    export { fooConst };

     

    핵심은 트랜스파일링 결과에서 FooType이 사라진다는 점입니다.

     

    그 이유는 명백합니다.

     

    FooType은 타입이므로 런타임에는 존재하지 않아야 하기 때문에 트랜스파일링 후의 코드에서는 제거되어야 합니다.

     

    여기서 FooType은 타입이지만 fooConst는 타입이 아닙니다.

     

    따라서 FooType만 제거할 필요가 있습니다.

     

    하지만 이것은 foo.ts의 내용을 보지 않고는 알 수 없습니다.

     

    즉, index.ts의 트랜스파일링 결과가 다른 파일에 의존하게 되는 것입니다.

     

    이렇게 다른 파일에 의존하는 파일은 isolatedModules가 활성화되면 컴파일 에러가 발생합니다.

    src/index.ts:3:10 - error TS1205: 'isolatedModules'가 활성화된 상태에서 타입을 재내보내려면 'export type'을 사용해야 합니다.
    
    3 export { FooType, fooConst };
               ~~~~~~~

    에러 해결 방법

    isolatedModules 에러를 해결하려면 이 파일만 보고도 트랜스파일링 결과를 확정할 수 있도록 만들어야 합니다.

     

    예를 들어 위의 에러 메시지에서 제안된 대로 export type 구문을 사용할 수 있습니다.

    import { FooType, fooConst } from "./foo.js";
    
    export type { FooType };
    export { fooConst };

     

    이 경우 export type는 타입만 내보내는 구문이므로, 트랜스파일러가 무조건 제거할 수 있습니다.

     

    따라서 fooConst만 내보내지고 FooType은 전혀 사용되지 않으므로 import에서도 제거됩니다.

     

    다음과 같은 방식도 가능합니다.

    import { FooType, fooConst } from "./foo.js";
    
    export { type FooType, fooConst };

     

    다른 방법으로는 import 측에서 타입임을 명시하는 방식도 있습니다.

    import type { FooType } from "./foo.js";
    import { fooConst } from "./foo.js";
    
    export { FooType, fooConst };

     

    이 경우에도 FooType가 import type 구문으로 import되었기 때문에 FooType가 타입임을 구문적으로 판단할 수 있습니다.

     

    따라서 트랜스파일링 결과의 export에서도 FooType을 제거할 수 있습니다.

     

    다음과 같은 방식도 가능합니다.

    import { type FooType, fooConst } from "./foo.js";
    
    export { FooType, fooConst };

    verbatimModuleSyntax

    이 옵션은 다른 도구 활용이라는 맥락과 직접적인 관련은 없지만, 위의 내용과 관련이 있으므로 소개하겠습니다.

     

    verbatimModuleSyntax가 활성화되면 이전에는 유효했던 다음 코드가 컴파일 에러가 발생합니다.

    import type { FooType } from "./foo.js";
    import { fooConst } from "./foo.js";
    
    export { FooType, fooConst };
    src/index.ts:4:10 - error TS1205: 'verbatimModuleSyntax'가 활성화된 상태에서 타입을 재내보내려면 'export type'을 사용해야 합니다.
    
    4 export { FooType, fooConst };
               ~~~~~~~

     

    즉, 이 옵션이 활성화된 상태에서는 FooType가 타입임을 이미 알고 있더라도 export 측에서도 명시적으로 type을 지정해야 합니다.

    import type { FooType } from "./foo.js";
    import { fooConst } from "./foo.js";
    // 이렇게 하면 OK
    export { type FooType, fooConst };

     

    트랜스파일러 입장에서도 FooType의 출처를 기억할 필요가 없고, 로컬에서 트랜스파일링 결과를 결정할 수 있다는 이점이 있습니다.

     

    하지만 현재로서는 이 이점을 활용한 트랜스파일러는 없는 것 같습니다.

     

    (만약 있다면 verbatimModuleSyntax가 활성화된 코드가 아니면 트랜스파일링할 수 없다는 제한이 생기겠지만, 그런 트랜스파일러는 들어본 적이 없습니다)

     

    마찬가지로 타입을 import할 때도 type을 명시해야 합니다.

    // 에러 
    import { FooType, fooConst } from "./foo.js";
    export { type FooType, fooConst };
    
    // 이렇게 하면 OK
    import { type FooType, fooConst } from "./foo.js";
    export { type FooType, fooConst };

    verbatimModuleSyntax의 의의

    verbatimModuleSyntax가 진가를 발휘하는 곳은 모듈의 부작용 주변 이야기라고 할 수 있습니다.

     

    이 옵션이 활성화되면 import/export 선언의 트랜스파일링 규칙이 매우 단순해집니다.

     

    즉, "type이라고 쓰여 있으면 제거하고 나머지는 그대로 출력한다"는 규칙이 됩니다.

     

    트랜스파일링 전

    import type { FooType } from "./foo.js";
    import { type BarType } from "./bar.js";
    import { fooConst } from "./foo.js";
    
    export { fooConst };
    export { type FooType };
    export type { BarType };

     

    트랜스파일링 후

    import {} from "./bar.js";
    import { fooConst } from "./foo.js";
    export { fooConst };
    export {};

     

    주목할 점은 import type { FooType }와 import { type BarType }의 동작이 다르다는 것입니다.

     

    전자는 import 선언 자체가 완전히 제거되지만, 후자는 import {}가 남습니다.

     

    이렇게 타입만 import하는 경우에도 type을 어디에 쓰느냐에 따라 의미가 달라지는 것입니다.

     

    (verbatimModuleSyntax가 비활성화되어 있다면 두 가지 쓰기 방식 모두 타입만 import하는 선언은 제거됩니다.)

     

    일반적으로 import {} from "./bar.js"는 아무것도 import하지 않으므로 제거해도 안전하지 않습니다.

     

    왜냐하면 아무것도 import하지 않더라도 이 구문이 있으면 bar.js가 실행되기 때문입니다.

     

    모듈에 부작용이 있는 경우 import하는 것만으로도 어떤 영향이 발생할 수 있습니다.

     

    따라서 verbatimModuleSyntax가 비활성화되어 있다면 TypeScript가 import 선언을 제거해버려 의도한 부작용이 발생하지 않을 수 있습니다.

     

    반대로 TypeScript가 import 선언을 제거해 주는 동작에 의존했다가 어떤 이유에서인지 제거되지 않아 문제가 발생할 수도 있습니다.

     

    verbatimModuleSyntax를 활성화해 두면 동작이 명확해져 문제를 미연에 방지할 가능성이 높아집니다.

     

    여기서 import 자체를 제거하면 import된 모듈이 실행되지 않게 되어 동작에 변화가 생길 수 있지만, import된 개별 변수를 제거하는 것(import { fooConst }를 import {}로 바꾸는 것)은 ECMAScript 명세상 안전하게 수행할 수 있습니다.

     

    isolatedDeclarations의 아이디어

    isolatedDeclarations의 아이디어는 의외로 간단해서, isolatedModules의 대비를 통해 이해할 수 있습니다.

     

    isolatedModules가 활성화되면 .ts → .js의 트랜스파일링을 tsc 외의 도구로도 할 수 있게 되었듯이, isolatedDeclarations가 활성화되면 .ts → .d.ts, 즉 타입 정의 파일 생성을 tsc 외의 도구로도 할 수 있게 됩니다.

     

    tsc 외의 도구로도 가능하다는 것은, 실제 타입 검사를 수행하지 않고도 구문적인(트랜스파일러가 하는 것과 같은) 변환을 통해 .ts에서 .d.ts를 생성할 수 있다는 뜻입니다.

     

    즉, .ts에서 .d.ts로의 "트랜스파일링"을 가능하게 하는 것이 isolatedDeclarations의 목적입니다.

     

    이것이 가능해지면 여러 패키지에 걸친 TypeScript 프로젝트의 타입 검사를 기존보다 높은 병렬성으로 수행할 수 있습니다.

     

    구체적으로는 A ← B ← C 와 같은 의존 관계가 있다면, 기존에는 타입 검사를 직렬로 수행해야 했습니다.

    1. A의 타입 검사를 수행하고 A의 .d.ts가 생성됩니다.
    2. B의 타입 검사를 수행하고 B의 .d.ts가 생성됩니다.
    3. C의 타입 검사를 수행하고 C의 .d.ts가 생성됩니다.

    .ts에서 .d.ts로의 "트랜스파일링"이 가능해지면 다음과 같아집니다.

     

    A, B, C의 .d.ts 생성을 병렬로 수행합니다.


    A, B, C의 타입 검사를 병렬로 수행합니다.


    패키지 간에 의존 관계가 있음에도 각 패키지의 타입 검사를 병렬로 수행할 수 있게 되는 것입니다.

     

    이를 통해 계산 자원이 있다면 소요 시간을 줄일 수 있고, 패키지 단위의 캐싱 등도 더 잘 활용할 수 있게 됩니다.

    isolatedDeclarations에 따른 제약 사항

    아직 구현되지 않아서 isolatedDeclarations의 명세를 정확히 설명하기는 어렵지만, isolatedModules에 비해 상당히 엄격한 제약이 있을 것은 분명합니다.

     

    예를 들어 다음 코드는 isolatedDeclarations가 활성화되면 에러가 발생할 것입니다.

    export function random() {
      return Math.floor(Math.random() * 100); 
    }

     

    이유를 이해하려면 TypeScript가 위 코드에 대해 생성하는 타입 정의를 봐야 합니다.

    export declare function random(): number;

     

    핵심은 random의 반환 값입니다. 타입 정의에는 number라고 쓰여 있지만, 이는 원본 소스 코드에는 없었습니다.

     

    즉, 이는 TypeScript가 추론한 것입니다. 타입 추론은 TypeScript의 전유물이므로, 트랜스파일러로는 할 수 없는 처리입니다.

     

    따라서 이는 트랜스파일러가 할 수 없는 작업을 요구하므로, isolatedDeclarations 하에서는 허용되지 않을 것입니다.

     

    isolatedDeclarations가 활성화된 상태에서도 검사를 통과하려면 반환 값의 타입을 미리 명시해야 합니다.

    // 이렇게 하면 OK
    export function random(): number {
      return Math.floor(Math.random() * 100);
    }

     

    이렇게 하면 함수 본문 부분을 제외한 나머지로 타입 정의를 완성할 수 있습니다(다르게 말하면 AST 조작만으로 .d.ts를 생성할 수 있습니다).

     

    다른 여러 가지 제약도 있을 것입니다. 특히 export되는 변수나 함수 등에 대해서는 완벽하게 타입 주석이 필요할 것입니다.

     

    함수 내부 구현이나 export되지 않는 함수 등에 대해서는 .d.ts에 나타나지 않으므로 제약이 없습니다.

     

    이렇게 isolatedDeclarations가 활성화되면 TypeScript 코딩 스타일이 꽤 달라집니다.

     

    TypeScript 프로젝트의 빌드 속도를 크게 높이려면 이 정도의 대가를 치러야 한다는 뜻입니다.

     

    다행히 기존의 TypeScript-ESLint 규칙인 explicit-module-boundary-types가 비슷한 분위기라서, 이것을 활용하는 분들이라면 그렇게 어색하지 않게 사용할 수 있을 것 같습니다.

    마무리

    isolatedModules가 도입된 2015년경부터 본가 tsc 외의 도구로 TypeScript를 다루는 일이 있었던 것 같습니다.

     

    Babel이 TypeScript를 지원하기 시작한 것은 2018년이었고, TypeScript를 지원하는 파서도 그 수를 늘려가고 있습니다.

     

    이렇게 TypeScript 주변 도구의 역사는 오래되었고, 지금도 다양화가 지속되고 있습니다.

     

    이 배경에서 TypeScript 측의 대응도 어느 정도 이뤄지고 있습니다.

     

    이 글에서는 다루지 않았지만 esModuleInterOp 등도 이름 그대로 다른 도구와의 상호운용성을 목적으로 하는 부분입니다.

     

    주변 도구의 진화와 그에 따른 TypeScript 본체의 진화를 기대하며 지켜봐야 겠습니다.

Designed by Tistory.