[번역] Typescript Performance

이은서·2024년 12월 1일
3

원문: https://github.com/microsoft/Typescript/wiki/Performance

타입스크립트 공식레포 Wiki에 있는 Tyepscript Performance에 관한 글입니다. 구글에 검색하면 번역글이 종종 있지만 2~3년정도 지나 내용이 추가/수정된 것들이 있어 번역글을 올려봅니다.

타입스크립트의 컴파일 속도를 높이고 편집 경험을 개선할 수 있는 간단한 설정 방법들이 있습니다. 이러한 방법은 빨리 도입할수록 효과가 더 좋습니다. 기본적인 설정 외에도, 컴파일이나 편집 속도가 느려지는 원인을 파악하는 방법, 이를 해결하는 일반적인 방식, 그리고 마지막으로 타입스크립트 팀이 문제를 분석할 때 도움을 줄 수 있는 방법도 있습니다.

컴파일하기 쉬운 코드 작성하기

다음 내용은 절대적인 규칙이 아닙니다. 여러분의 코드베이스에 따라 각 규칙에 예외가 있을 수 있다는 점을 참고하세요.

Intersection(type &)보다 Interface 선호하기

대부분의 경우, 객체 타입에 대한 간단한 type은 interface와 상당히 비슷합니다.

interface Foo { prop: string }

type Bar = { prop: string };

두 개 이상의 타입을 합칠 때에는 interface를 사용해서 타입을 extends를 이용해 확장하거나 type을 '&'를 통해 이용해서 합칠 수 있는데(교차타입) 이때부터 두 방식의 차이가 중요해지기 시작합니다.

interface는 하나의 평탄화된 객체타입을 생성하며 속성 충돌을 감지하여 이를 해결 해주고 항상 일관되게 타입을 표시해줍니다. 반면 교차타입은 속성을 재귀적으로 병합하며 상황에 따라 never를 뱉어내기도 하고 다른 교차타입의 구성요소를 표시되지 못하는 경우가 있습니다.

type A = { x: number };
type B = { y: string };
type C = A & B;
type D = C & { z: boolean };

// TypeScript는 D를 아래처럼 표시하게 됨
// C & { z: boolean } 
// <교차타입의 구성요소를 표시되지 못하는 경우>
// 상황에 따라 { x: number } & { y: string } & { z: boolean }로 표시되기도 함
// <일관되지 않음>

// 반면 interface로 extends 할 경우 
// D가 { x: number: y: string; z: boolean }으로 평탄화되어 명확히 일관되게 표시됨

교차 타입은 대상과 비교할 때 "effective"/"flattened"한 타입을 확인하기 전에 각 구성 요소들을 모두 체크합니다.

교차 타입을 비교할 때, 먼저 각 구성 요소(예: A & B라면 A와 B)를 각각 검사합니다
모든 구성 요소를 확인한 후에야 최종적으로 "effective"/"flattened"한 타입으로 검사합니다.

또한 마지막으로 interface 간의 타입 관계는 전체 교차 타입와 다르게 캐시됩니다.

TypeScript는 인터페이스 간의 관계(확장, 비교 등)를 계산한 후 결과를 캐시합니다.
즉, 한 번 확인된 관계는 다시 계산하지 않으므로, 반복적으로 비교할 때 성능이 더 효율적입니다.

이러한 이유들로, type보다는 interface와 extends로 확장하는 것을 권장드립니다

- type Foo = Bar & Baz & {
-     someProp: string;
- }

+ interface Foo extends Bar, Baz {
+     someProp: string;
+ }

타입 어노테이션 사용하기

타입 어노테이션(특히 반환타입)을 추가하면 컴파일러의 작업량을 크게 줄일 수 있습니다. 익명 타입(컴파일러가 추론하는 타입)보다 named type이 더 간결하기 때문에 읽고 쓰는데 걸리는 시간을 줄일 선언 파일을 읽고 쓰는 데 걸리는 시간을 줄일 수 있습니다. 타입 추론은 아주 편리하기 때문에 이것을 코드 전체적으로 적용할 필요는 없지만 코드의 특정 부분이 느리게 작동하는 것을 알게 된다면 그것을 해결하기 위한 유용한 해결책이 될 수 있습니다.

- import { otherFunc } from "other";
+ import { otherFunc, OtherType } from "other";

- export function func() {
+ export function func(): OtherType {
      return otherFunc();
  }

--declaration 출력에 import("./some/path").SomeType 같은 타입이 포함되거나, 소스 코드에 작성되지 않은 매우 큰 타입이 포함된다면, 명시적으로 타입을 작성하거나 필요할 경우 Named type을 생성해보는 것이 유용할 수 있습니다.

매우 큰 계산된 타입의 경우, 이를 출력하거나 읽는 데 비용이 많이 드는 이유는 명확합니다. 그러나 import() 코드 생성이 왜 비용이 많이 들며, 이것이 왜 문제가 되는 걸까요?

일부 경우에는 --declaration 출력이 다른 모듈의 타입을 참조해야 할 때가 있습니다. 예를 들어 다음과 같은 파일에 대한 선언 출력에서...

// foo.ts
export interface Result {
    headers: any;
    body: string;
}

export async function makeRequest(): Promise<Result> {
    throw new Error("unimplemented");
}

// bar.ts
import { makeRequest } from "./foo";

export function doStuff() {
    return makeRequest();
}

이 코드는 아래와 같은 .d.ts파일을 만들겁니다.

// foo.d.ts
export interface Result {
    headers: any;
    body: string;
}
export declare function makeRequest(): Promise<Result>;

// bar.d.ts
export declare function doStuff(): Promise<import("./foo").Result>;

import("./foo").Result를 주목하세요. TypeScript는 bar.ts의 선언 출력에서 foo.ts에 있는 Result라는 타입을 참조하기 위해 코드를 생성해야 했습니다. 이 과정에는 다음 작업이 따라옵니다.

1. 해당 타입이 로컬 이름을 통해 접근 가능한지 확인.
2. 해당 타입이 import(...)를 통해 접근 가능한지 확인
3. 해당 파일을 가져오기 위한 가장 합리적인 경로를 계산.
4. 그 타입 참조를 나타내는 새로운 노드를 생성.
5. 생성된 타입 참조 노드를 출력

매우 큰 프로젝트에서는 이러한 작업이 각 모듈 마다 계속해서 반복될 수 있습니다.

유니온 타입보다 기본 타입을 선호하기

유니온 타입은 훌륭합니다 이를 통해 타입이 가질 수 있는 값의 범위를 표현할 수 있습니다.

interface WeekdaySchedule {
  day: "Monday" | "Tuesday" | "Wednesday" | "Thursday" | "Friday";
  wake: Time;
  startWork: Time;
  endWork: Time;
  sleep: Time;
}

interface WeekendSchedule {
  day: "Saturday" | "Sunday";
  wake: Time;
  familyMeal: Time;
  sleep: Time;
}

declare function printSchedule(schedule: WeekdaySchedule | WeekendSchedule);

그렇지만 유니온 타입에는 비용이 따릅니다. printSchedule에 인수가 전달될 때마다, 해당 인수를 유니온의 각 요소와 비교해야 합니다. 물론 두 개의 요소로 이루어진 유니온이라면 비용이 거의 들지 않습니다. 하지만 유니온 타입에 요소가 12개 정도를 넘으면 컴파일 속도에 실제로 영향을 줄 수 있습니다. 예를 들어 유니온에서 중복된 멤버를 제거하려면 요소들은 N^2의 작업으로 비교해야 합니다. 이러한 검사는 큰 유니온 타입을 교차할 때 발생할 수 있습니다.

type DomElements = DivElement | ImgElement | SpanElement; // 유니온 타입
type Clickable = { onClick: () => void }; // 교집합에 사용할 타입
type ClickableElements = DomElements & Clickable;

위 코드에서 TypeScript는 유니온의 각 멤버와 교집합을 계산해야 합니다

  1. DivElement & Clickable
  2. ImgElement & Clickable
  3. SpanElement & Clickable

결과적으로, ClickableElements는 다음과 같이 거대한 타입으로 확장될 수 있습니다:

(DivElement & { onClick: () => void }) |
(ImgElement & { onClick: () => void }) |
(SpanElement & { onClick: () => void })

이 경우, 유니온의 각 멤버를 교차 처리하면 거대한 타입이 생성되고, 이를 축소해야 할 필요가 생깁니다. 이를 피하는 한 가지 방법은 유니온 대신 서브타입을 사용하는 것입니다.

// 서브타입 예시
interface Schedule {
  day: "Monday" | "Tuesday" | "Wednesday" | "Thursday" | "Friday" | "Saturday" | "Sunday";
  wake: Time;
  sleep: Time;
}

interface WeekdaySchedule extends Schedule {
  day: "Monday" | "Tuesday" | "Wednesday" | "Thursday" | "Friday";
  startWork: Time;
  endWork: Time;
}

interface WeekendSchedule extends Schedule {
  day: "Saturday" | "Sunday";
  familyMeal: Time;
}

declare function printSchedule(schedule: Schedule);

더 현실적인 예로는 모든 내장 DOM 요소 타입을 모델링하려고 할 때 발생할 수 있습니다. 이 경우, DivElement | /*...*/ | ImgElement |와 같은 모든 요소를 나열한 유니온 타입을 만드는 것보다, 공통 멤버를 가진 HtmlElement 기본 타입을 생성하고 이를 DivElement, ImgElement 등이 확장하도록 만드는 것이 더 바람직합니다.

복잡한 타입에 이름 붙이기

복잡한 타입은 타입 어노테이션이 허용되는 모든 곳에서 작성될 수 있습니다.

interface SomeType<T> {
    foo<U>(x: U):
        U extends TypeA<T> ? ProcessTypeA<U, T> :
        U extends TypeB<T> ? ProcessTypeB<U, T> :
        U extends TypeC<T> ? ProcessTypeC<U, T> :
        U;
}

이것은 편리하지만 현재는 foo가 호출될 때마다 타입스크립트는 이 조건부 타입을 다시 실행해야 합니다. 게다가, SomeType의 두 인스턴스를 비교하려면 foo의 반환 구조를 다시 비교해야 합니다.

이 예제에서 반환 타입을 타입 별칭으로 추출하면, 컴파일러가 더 많은 정보를 캐시할 수 있습니다:

type FooResult<U, T> =
    U extends TypeA<T> ? ProcessTypeA<U, T> :
    U extends TypeB<T> ? ProcessTypeB<U, T> :
    U extends TypeC<T> ? ProcessTypeC<U, T> :
    U;

interface SomeType<T> {
    foo<U>(x: U): FooResult<U, T>;
}

FooResult<U, T>라는 이름이 주어졌기 때문에, 컴파일러는 동일한 입력(U와 T)에 대해
이미 계산된 결과를 재사용합니다.

프로젝트 참고하기

새로 짜기

TypeScript로 일정 규모 이상의 코드베이스를 구축할 때, 코드를 여러 독립적인 프로젝트로 나누어 구성하는 것이 유용합니다. 각 프로젝트는 각각 tsconfig.json를 가지고 있고 다른 프로젝트에 의존성을 가지고 있을 수 있습니다. 이렇게 하면 한 번의 컴파일에서 너무 많은 파일을 로드하지 않도록 방지할 수 있으며, 특정 코드베이스 레이아웃 전략을 더 쉽게 구현할 수 있습니다.

코드베이스를 프로젝트로 구성하는 몇 가지 기본적인 방법이 있습니다. 예를 들어, 클라이언트를 위한 프로젝트, 서버를 위한 프로젝트, 그리고 두 프로젝트 간에 공유되는 코드를 위한 프로젝트로 나눌 수 있습니다.

              ------------
              |          |
              |  Shared  |
              ^----------^
             /            \
            /              \
------------                ------------
|          |                |          |
|  Client  |                |  Server  |
-----^------                ------^-----

테스트들도 역시 별도의 프로젝트로 분리할 수 있습니다.

              ------------
              |          |
              |  Shared  |
              ^-----^----^
             /      |     \
            /       |      \
------------  ------------  ------------
|          |  |  Shared  |  |          |
|  Client  |  |  Tests   |  |  Server  |
-----^------  ------------  ------^-----
     |                            |
     |                            |
------------                ------------
|  Client  |                |  Server  |
|  Tests   |                |  Tests   |
------------                ------------

프로젝트 참조에 대한 더 자세한 내용은 여기에서 확인할 수 있습니다.

존재하는 코드에 적용하기

작업 공간이 너무 커져서 에디터가 처리하기 어려워질 때(그리고 성능 추적을 통해 특정 병목 현상이 없으며, 규모가 문제라는 결론에 도달했을 경우), 이를 서로 참조하는 여러 프로젝트 모음으로 나누는 것이 도움이 될 수 있습니다.
모노레포에서 작업 중이라면, 각 패키지에 대해 하나의 프로젝트를 생성하고, 패키지 간의 의존성 그래프를 프로젝트 참조에 그대로 반영하는 방식으로 간단히 구성할 수 있습니다.
그렇지 않은 경우, 프로세스는 보다 유연하게 진행됩니다. 디렉터리 구조를 따라 프로젝트를 구성할 수도 있고, 신중하게 선택한 include와 exclude 패턴(Globs)을 사용해야 할 수도 있습니다. 다음 사항들을 염두에 두세요

  • 비슷한 크기의 프로젝트를 목표로 하세요. 거대한 프로젝트 하나에 작은 위성 프로젝트가 많이 붙는 구조는 피해야 합니다.
  • 함께 편집될 가능성이 높은 파일들을 그룹화하세요. 이렇게 하면 에디터가 로드해야 할 프로젝트 수를 줄일 수 있습니다.
  • 테스트 코드를 분리하면 제품 코드가 테스트 코드에 실수로 의존하게 되는 일을 방지할 수 있습니다.

성능 고려하기

다른 캡슐화 메커니즘과 마찬가지로, 프로젝트도 비용이 따릅니다. 예를 들어, 모든 프로젝트가 동일한 패키지(예: 인기 있는 UI 프레임워크)에 의존하는 경우, 해당 패키지의 타입 일부가 각 프로젝트에서 소비될 때마다 한번씩 반복적으로 검사됩니다. 경험적으로 볼 때, 작업공간에 여러 프로젝트가 있을 경우 5~20개 프로젝트가 적절한 범위로 보입니다. 프로젝트가 너무 적으면 에디터 성능 저하가 발생할 수 있고, 너무 많으면 과도한 오버헤드가 생길 수 있습니다. 프로젝트를 분리해야 할 몇 가지 좋은 이유는 다음과 같습니다.

  • output 위치가 다른 경우 (모노레포에서 하나의 패키지로 관리되기 때문)
  • 다른 설정이 필요한 경우 (lib 또는 moduleResolution 설정이 다를 때)
  • 전역 선언을 포함하고 있으며, 이를 범위(scope)로 제한하고자 하는 경우 (캡슐화를 위해서이거나 비용이 많이 드는 전역 재빌드를 제한하기 위해)
  • 에디터의 언어 서비스가 코드를 단일 프로젝트로 처리하려다 메모리가 부족해지는 경우
    • 이 경우, 프로젝트 로딩을 제한하기 위해 "disableReferencedProjectLoad": true와 "disableSolutionSearching": true를 설정하는 것이 좋습니다.

disableReferencedProjectLoad: 프로젝트 참조를 통해 연결된 프로젝트를 로드하지 않음. 대신 .d.ts 파일을 사용하여 타입 정보를 제공.
disableSolutionSearching: 작업공간에서 추가적으로 프로젝트를 검색하지 않음. 현재 프로젝트만 로드.
(from chatGPT)

tsconfig.json 또는 jsconfig.json 설정하기

TypeScript와 JavaScript 사용자들은 항상 tsconfig.json 파일을 사용하여 컴파일 설정을 구성할 수 있습니다. JavaScript 사용자들은 jsconfig.json 파일을 사용해 편집 환경을 설정할 수도 있습니다.

파일 지정하기

설정 파일이 한번에 너무 많은 파일을 포함하지 않도록 항상 확인해야 합니다.
tsconfig.json에서는 프로젝트 내 파일을 지정하는 두 가지 방법이 있습니다.

  • files 리스트
  • include, exclude 리스트

files은 소스 파일의 파일 경로 목록을 기대하고, include/exclude는 파일을 매칭하기 위해 글로빙 패턴을 사용한다는 점이 주된 차이점 입니다.

files를 사용하여 TypeScript가 파일을 직접 빠르게 로드할 수 있도록 할 수 있지만, 프로젝트에 많은 파일이 있고 단지 몇 개의 최상위 진입점만 있는 경우에는 번거로울 수 있습니다. 또한, 새 파일을 tsconfig.json에 추가하는 것을 잊기 쉬워서, 새 파일이 잘못 분석되어 이상한 에디터 동작을 유발할 수 있습니다. 이러한 점들이 불편함을 초래할 수 있습니다.

include/exclude는 이러한 파일을 명시적으로 지정할 필요를 줄여주지만, 그에 따른 비용이 있습니다. TypeScript는 포함된 디렉터리를 탐색하면서 파일을 찾아야 하므로, 폴더가 많을 경우 컴파일 속도가 느려질 수 있습니다.
또한, 컴파일 과정에서 불필요한 .d.ts 파일이나 테스트 파일이 포함되어 컴파일 시간과 메모리 사용량이 증가할 수 있습니다.
마지막으로, exclude가 기본적으로 합리적인 설정을 제공하지만, 모노레포 같은 특정 환경에서는 node_modules와 같은 "무거운" 폴더가 여전히 포함될 수 있는 문제가 발생할 수 있습니다.

최선의 practice로 다음을 권장드립니다:

  • 프로젝트에서 입력 폴더만 지정하세요 (컴파일/분석에 포함하고자 하는 소스 코드가 있는 폴더만 포함).
  • 같은 폴더에서 다른 프로젝트의 소스 파일을 섞지 마세요
  • 테스트 파일을 같은 폴더에 유지해야 한다면, 별도의 이름을 부여하세요
  • node_modules와 같은 대규모 빌드 산출물 및 의존성 폴더를 소스 디렉토리에 포함하지 마세요

참고: exclude 목록이 없을 경우, node_modules는 기본적으로 제외됩니다. 그러나 exclude 목록을 추가하는 순간, node_modules를 명시적으로 목록에 추가하는 것이 중요합니다.

{
    "compilerOptions": {
        // ...
    },
    "include": ["src"],
    "exclude": ["**/node_modules", "**/.*/"],
}

@types include 제어하기

기본적으로 TypeScript는 node_modules 폴더에서 발견된 모든 @types 패키지를 자동으로 포함합니다. 이는 해당 패키지를 명시적으로 import하지 않더라도 Node.js, Jasmine, Mocha, Chai와 같은 도구나 패키지가 "그냥 동작"하도록 하기 위한 것입니다. 이러한 도구/패키지는 import되지 않고 글로벌 환경에 로드되기 때문입니다.

때로는 이러한 로직이 컴파일 및 편집 상황에서 프로그램 생성 시간을 느리게 만들 수 있으며, 충돌하는 선언을 가진 여러 global package가 있을 경우 문제를 일으킬 수도 있습니다. 이런 상황에서는 다음과 같은 오류가 발생할 수 있습니다:

Duplicate identifier 'IteratorResult'.
Duplicate identifier 'it'.
Duplicate identifier 'define'.
Duplicate identifier 'require'.

글로벌 패키지가 필요하지 않은 경우, tsconfig.json 또는 jsconfig.json 파일의 "types" 옵션에 빈 필드를 지정하는 것으로 간단히 해결할 수 있습니다

// src/tsconfig.json
{
   "compilerOptions": {
       // ...

       // 자동으로 include하지 않습니다.
       // 오직 import가 필요한 @types패키지만 포함하세요
       "types" : []
   },
   "files": ["foo.ts"]
}

만약 global package가 필요하면 , types필드에 넣으세요

// tests/tsconfig.json
{
   "compilerOptions": {
       // ...

       //`@types/node`와 `@types/mocha`만 포함하기.
       "types" : ["node", "mocha"]
   },
   "files": ["foo.test.ts"]
}

증분 프로젝트 Emit

--incremental 플래그를 사용하면 TypeScript는 마지막 컴파일의 상태를 .tsbuildinfo 파일에 저장합니다. 이 파일은 마지막 실행 이후 다시 검사하거나 다시 Emit해야 할 파일의 최소 집합을 결정하는 데 사용됩니다. 이는 TypeScript의 --watch 모드가 동작하는 방식과 유사합니다.

증분 컴파일은 프로젝트 참조를 위해 composite 플래그를 사용하는 경우 기본적으로 활성화됩니다. 그러나 이를 선택적으로 설정하면 다른 프로젝트에서도 동일한 속도 향상을 누릴 수 있습니다.

.d.ts 체크 스킵하기

기본적으로 TypeScript는 프로젝트 내 모든 .d.ts 파일을 완전히 다시 검사하여 문제나 불일치를 찾습니다. 그러나 대부분의 경우 이는 불필요합니다. .d.ts 파일은 이미 정상적으로 동작한다고 간주되며, 타입 확장이 올바르게 이루어지는지 한 번 확인된 상태입니다. 또한, 중요한 선언은 어차피 다시 검사됩니다.

TypeScript는 skipDefaultLibCheck 플래그를 사용하여, TypeScript와 함께 제공되는 .d.ts 파일(lib.d.ts)의 타입 검사를 건너뛸 수 있는 옵션을 제공합니다.
또한, skipLibCheck 플래그를 활성화하면 컴파일 시 모든 .d.ts 파일의 타입 검사를 건너뛸 수 있습니다.

이 두 옵션은 .d.ts 파일의 잘못된 구성이나 충돌을 종종 숨길 수 있으므로, 빌드 속도를 빠르게 해야 할 때만 사용하는 것을 권장합니다.

더 빠른 Variance Check 사용하기

"개들의 리스트는 동물들의 리스트가 될 수 있는가?", 즉, List를 List에 할당할 수 있는가? 이를 확인하는 가장 간단한 방법은 타입을 member별로 구조적으로 비교하는 것입니다.
안타깝게도, 이는 매우 비용이 많이 들 수 있습니다. 하지만 List에 대해 충분히 알고 있다면, 이 할당 가능성 검사를 Dog가 Animal에 할당 가능한지 확인하는 문제로 줄일 수 있습니다. (즉, List의 각 member를 고려하지 않아도 됩니다.)(특히, 우리는 타입 매개변수 T의 variance에 대해 알아야 합니다.) 컴파일러는 strictFunctionTypes 플래그가 활성화된 경우에만 이러한 잠재적인 속도 향상을 최대한 활용할 수 있습니다. 그렇지 않으면 더 느리지만 관대하게 동작하는 구조적 검사를 사용합니다. 이러한 이유로, --strict 옵션을 활성화하면 기본적으로 포함되는 --strictFunctionTypes와 함께 빌드하는 것을 권장합니다.

기본적으로 --strictFunctionTypes를 쓰지 않으면 이변적으로 파라미터를 다루고 이는 버그를 야기할 수 있으니 --strictFunctionTypes를 통해 반공변성에 의해 파라미터를 다루도록 하는 것이 좋습니다. 또한 --strictFunctionTypes를 사용하면 이변적으로 처리하지 않기 때문에 컴파일러는 함수 타입 전체를 구조적으로 비교할 필요 없고 함수 타입의 매개변수만을 검사하여 최적화가 가능합니다.

다른 빌드 도구 구성하기

TypeScript 컴파일은 종종 다른 빌드 도구를 염두에 두고 수행되며, 특히 번들러를 사용하는 웹 애플리케이션을 작성할 때 자주 활용됩니다. 여기서는 몇 가지 빌드 도구에 대한 제안을 제공하지만, 이상적으로는 이러한 기술을 일반화하여 적용할 수 있습니다.

이 섹션을 읽는 것 외에도, 선택한 빌드 도구에서 성능에 대한 자료를 확인하는 것이 중요합니다. 예를 들어:

동시 타입 검사

타입 검사는 일반적으로 다른 파일의 정보를 필요로 하며, 코드 변환이나 출력과 같은 다른 단계에 비해 상대적으로 비용이 많이 드는 작업입니다. 타입 검사에 시간이 더 소요될 수 있기 때문에, 개발의 내부 루프에 영향을 미칠 수 있습니다. 즉, 수정-컴파일-실행 과정이 길어질 수 있으며, 이는 개발자에게 좌절감을 줄 수 있습니다.

이러한 이유로 일부 빌드 도구는 타입 검사를 별도의 프로세스에서 실행하여 코드 출력을 방해하지 않도록 설정할 수 있습니다. 이 방식은 잘못된 코드가 TypeScript가 오류를 보고하기 전에 실행될 수 있음을 의미하지만, 일반적으로 에디터에서 먼저 오류를 확인할 수 있습니다. 따라서 동작하는 코드를 실행하는 데 방해를 덜 받게 됩니다.

이 방식의 예로는 Webpack용 fork-ts-checker-webpack-plugin 플러그인이나, 때로는 동일한 작업을 수행하는 awesome-typescript-loader가 있습니다.

개별 파일 출력

기본적으로 TypeScript의 출력은 파일에 국한되지 않은 의미적 정보를 필요로 합니다. 이는 const enum이나 namespace와 같은 기능을 출력할 때 필요한 정보를 이해하기 위해서입니다. 하지만 특정 파일의 출력을 생성하기 위해 다른 파일을 확인해야 하는 경우, 출력 과정이 느려질 수 있습니다.

비지역적 정보를 필요로 하는 기능은 드문 경우에 해당합니다. 예를 들어, const enum 대신 일반 enum을 사용할 수 있고, namespace 대신 모듈을 사용할 수 있습니다. 이러한 이유로, TypeScript는 isolatedModules 플래그를 제공하며, 비지역적 정보에 의존하는 기능에서 오류를 발생시킵니다.

isolatedModules를 활성화하면 코드베이스는 transpileModule과 같은 TypeScript API를 사용하는 도구나 Babel과 같은 대체 컴파일러에서도 안전하게 작동할 수 있습니다.

isolatedModules란?
isolatedModules는 TypeScript에서 각 파일을 독립적으로 트랜스파일 가능하도록 강제하는 옵션입니다.
이 플래그는 TypeScript 코드베이스가 Babel, Webpack 등과 같은 트랜스파일러와 안전하게 작동하도록 보장합니다.
즉, 파일 간의 의존성을 제거하거나 최소화하여, 각 파일을 독립적인 컴파일 단위로 처리합니다.

isolatedModules가 필요한가?
1. 비지역적 정보 의존성 문제 해결:

  • TypeScript의 일부 기능은 다른 파일의 정보를 참조해야 올바르게 동작합니다.
    하지만 Babel과 같은 트랜스파일러는 파일을 독립적으로 처리하므로, 비지역적 정보에 의존하는 기능이 오류를 일으킬 수 있습니다.
  • 예를 들어, const enum이나 namespace 같은 기능은 다른 파일에서 정보를 가져와야 하기 때문에 isolatedModules에서 오류를 발생시킵니다.
  1. Babel 및 기타 트랜스파일러와의 호환성:
  • TypeScript는 원래 타입 검사와 코드 변환(emit)을 함께 수행합니다.
    그러나 Babel은 타입 검사를 하지 않고 코드만 변환하므로, 비지역적 정보를 참조하는 TypeScript 코
    드는 올바르게 처리되지 않을 수 있습니다.
  • isolatedModules는 이런 문제를 방지합니다.

3.대규모 코드베이스 최적화:

  • 모든 파일이 독립적으로 처리되므로, 병렬 트랜스파일과 같은 최적화가 가능해집니다.

예를 들어, 아래 코드는 isolatedModules 옵션을 사용하는 경우 런타임에서 제대로 작동하지 않을 수 있습니다. 이는 const enum 값이 인라인 처리되기를 기대하기 때문입니다. 하지만 다행히도, isolatedModules는 이를 초기 단계에서 감지하고 오류를 알려줍니다.

  // ./src/fileA.ts

export declare const enum E {
    A = 0,
    B = 1,
}

// ./src/fileB.ts

import { E } from "./fileA";

console.log(E.A);
//          ~
// error: Cannot access ambient const enums when the '--isolatedModules' flag is provided.

Remember: isolatedModules는 코드 생성 속도를 자동으로 빠르게 만들어주지 않습니다. 대신, 지원되지 않을 수 있는 기능을 사용하려고 할 때 이를 미리 알려주는 역할을 합니다.

편집 경험 최적화; ts-server의 퍼포먼스

에디터 내 진단 정보는 일반적으로 타이핑이 멈춘 후 몇 초 후에 가져옵니다. ts-server의 성능 특성은 항상 tsc를 사용하여 전체 프로젝트의 타입 검사를 수행하는 성능과 관련이 있으므로, 여기에서 제공한 다른 성능 최적화 지침도 편집 경험을 향상시키는 데 적용됩니다.

타이핑을 할 때, 체커는 완전히 처음부터 시작하지만, 당신이 타이핑하는 것에 대한 정보만 요청합니다. 이는 TypeScript가 현재 편집 중인 타입을 검사하기 위해 얼마나 많은 작업을 수행해야 하는지에 따라 편집 경험이 달라질 수 있음을 의미합니다.

대부분의 에디터(VS Code 등)에서는 진단 정보가 전체 프로젝트가 아닌 모든 열려 있는 파일에 대해 요청됩니다. 따라서 진단 정보는 tsc로 전체 프로젝트를 검사하는 것보다 더 빠르게 나타나지만, 호버로 타입을 보는 것보다는 느립니다. 이는 호버로 타입을 볼 때는 TypeScript에게 해당 특정 타입만 계산하고 검사하도록 요청하기 때문입니다.

문제 조사하기

문제가 무엇인지 파악하는 데 도움을 줄 수 있는 몇 가지 방법이 있습니다.

에디터 플러그인 비활성화

에디터의 성능과 반응성은 플러그인에 영향을 받을 수 있습니다. 특히 JavaScript/TypeScript와 관련된 플러그인을 비활성화해보고, 성능 문제나 반응 속도 문제가 해결되는지 확인해 보세요.

또한, 일부 에디터는 자체적으로 성능 문제 해결 가이드를 제공합니다. 예를 들어, Visual Studio Code는 성능 문제 해결에 대한 전용 페이지를 가지고 있으니 참고해 보세요.

extendedDiagnostics

TypeScript를 --extendedDiagnostics 옵션과 함께 실행하면, 컴파일러가 어디에서 시간을 소비하는지에 대한 상세한 출력을 확인할 수 있습니다.

Files:                         6
Lines:                     24906
Nodes:                    112200
Identifiers:               41097
Symbols:                   27972
Types:                      8298
Memory used:              77984K
Assignability cache size:  33123
Identity cache size:           2
Subtype cache size:            0
I/O Read time:             0.01s
Parse time:                0.44s
Program time:              0.45s
Bind time:                 0.21s
Check time:                1.07s
transformTime time:        0.01s
commentTime time:          0.00s
I/O Write time:            0.00s
printTime time:            0.01s
Emit time:                 0.01s
Total time:                1.75s

총 시간은 그 이전에 나열된 모든 시간의 합계와 일치하지 않을 수 있습니다. 이는 일부 작업이 중복되거나, 일부 작업이 측정 대상에 포함되지 않았기 때문입니다.

대부분의 사용자에게 가장 관련성이 높은 정보는 다음과 같습니다:

FieldMeaning
Files프로그램이 포함하고 있는 파일의 수입니다. 어떤 파일이 포함되어 있는지 확인하려면 --listFilesOnly 옵션을 사용하세요.
I/O Read time파일 시스템에서 데이터를 읽는 데 소비된 시간으로, include된 폴더를 탐색하는 시간도 포함됩니다.
Parse time프로그램을 스캔하고 구문을 분석하는 데 소비된 시간입니다.
Program time파일 시스템에서 데이터 읽기, 프로그램 스캔 및 구문 분석, 그리고 프로그램 그래프를 계산하는 데 소비된 시간을 합친 것입니다. 이 단계들은 import와 export를 통해 포함된 파일들을 해결하고 로드해야 하므로 서로 얽혀 있으며, 하나로 통합되어 측정됩니다.
Bind time단일 파일에 국한된 다양한 의미적 정보를 구축하는 데 소비된 시간입니다.
Check time프로그램의 타입 검사를 수행하는 데 소비된 시간입니다.
transformTime timeTypeScript AST를 오래된 런타임에서도 작동할 수 있는 형태로 변환하는 데 소비된 시간입니다.
commentTime출력 파일에 포함된 주석을 계산하는 데 소비된 시간입니다.
I/O Write time디스크에 파일을 쓰기 또는 업데이트하는 데 소비된 시간입니다.
printTime출력 파일의 문자열 표현을 계산하고 이를 디스크에 내보내는 데 소비된 시간입니다.

이를 통해 다음과 같은 질문을 고려할 수 있습니다:

  • 파일 수나 코드 줄 수가 프로젝트 내 파일 수와 대체로 일치하나요? 그렇지 않다면 --listFiles 옵션을 실행해 파일 목록을 확인해 보세요.
  • Program time 또는 I/O Read time이 지나치게 높은 것 같나요? include/exclude 설정이 올바르게 구성되어 있는지 확인하세요.
  • 다른 시간들이 비정상적으로 높아 보이나요? 이 경우, 문제를 보고(issue)를 작성하는 것을 고려해 보세요. 진단에 도움을 줄 수 있는 작업:
    • emitDeclarationOnly를 사용해 실행 (printTime이 높을 경우).
profile
프론트엔드 개발자

0개의 댓글