[번역] TypeScript 성능 문제 해결: 사례 연구

I
Inkyu Oh

Front-End2026.01.22

Solomon Hawk - 2025-10-16


최근 진행한 TypeScript 프로젝트에서 에디터 성능이 저하되는 상황에 직면했습니다. TypeScript 컴파일러(및 언어 서버)가 코드베이스의 특정 영역에서 점점 더 어려움을 겪으면서 인텔리센스(Intellisense)가 느려지고, 타입 체크 시간이 길어지며, 때때로 오래된 타입 정보가 표시되는 등 많은 좌절감을 안겨주었습니다.
이 프로젝트는 7개의 TypeScript 패키지로 구성된 모노레포(Monorepo)입니다. 초기 설계자들 덕분에 이미 프로젝트 레퍼런스(Project References)(composite 설정 필요)와 증분 컴파일(Incremental Compilation)을 사용하고 있었지만, prisma, kysely, ts-pattern, hotscript와 같은 무거운 TypeScript 의존성들도 포함되어 있었습니다.

문제 진단

어떤 종류의 트러블슈팅을 하든, 우리는 가장 먼저 문서를 확인합니다. 제가 알기로는 https://www.typescriptlang.org/에 이 주제만을 위한 전용 페이지는 없지만, 빠르게 검색해 보면 유용한 조언과 제안이 가득한 성능 관련 GitHub 위키 페이지를 찾을 수 있습니다.
먼저 일반적인 원인들부터 점검하는 것이 현명합니다:
  • 플러그인과 확장을 포함하여 에디터가 최신 상태인지 확인합니다.
  • 모든 에디터 확장을 비활성화하여 용의 선상에서 제외합니다.
  • 다른 프로세스가 시스템 리소스를 독점하고 있지 않은지 확인합니다.
  • 사용 가능한 최신 버전의 TypeScript를 사용합니다(많은 릴리스에 성능 수정 및 최적화가 포함되어 있습니다).
  • 가능한 경우 의존성을 최신 버전으로 업데이트합니다(의존성에 필요한 @types 선언 포함).
그다음부터는 TypeScript 컴파일러가 제공하는 추가 정보를 깊이 있게 살펴볼 수 있습니다.

소스 파일 포함 여부 검증

최소한의 소스 파일 세트만 컴파일러에 의해 처리되고 있는지 확인하십시오.
$ tsc --listFilesOnly
이유: 파일이 적을수록 타입이 적어지고, 컴파일러가 할 일도 줄어듭니다.
컴파일에 포함될 것으로 예상하지 못한 파일이 보인다면, 컴파일러에게 왜 그 파일들이 포함되었는지 물어볼 수 있습니다:
$ tsc --explainFiles > explanations.txt
컴파일에서 불필요한 파일을 제외하기 위해 필요한 경우 tsconfiginclude/exclude/types/typeRoots/paths 설정을 조정하십시오.

성능 지표 및 측정

성능 튜닝에는 세심한 계획과 실행이 필요합니다. 두 컴파일 결과를 의미 있게 비교하려면 동일한 하드웨어와 동일한 조건(시스템 부하, 배터리/전원 등)에서 실행하는 것이 중요합니다. 세심하게 관리된 조건에서도 이상치(Outlier)가 결과를 왜곡할 수 있으므로, 편차를 파악하기 위해 컴파일을 여러 번 실행하는 것이 좋습니다. 엄격한 접근 방식은 표준 편차를 기반으로 통계적 유의성을 계산하는 것이지만, 컴파일 시간이 길다면 이는 지나치게 많은 시간을 소모할 수 있습니다.
최적화 작업을 할 때는 이후의 변경 사항이 유의미한 긍정적 영향을 미쳤는지 평가하기 위해 기준점(Baseline)을 세우는 것이 중요합니다. 이를 위해 TypeScript 컴파일러는 컴파일에 대한 상세 지표를 출력하는 플래그를 제공합니다: tsc --extendedDiagnostics. 이 명령을 실행하면 다음과 같은 출력을 얻게 됩니다(컴파일에 여러 TypeScript 프로젝트가 포함된 경우 보고서가 여러 개 보일 수 있습니다).
지표
Files
6
Lines
24,906
Nodes
112,200
Identifiers
41,097
Symbols
27,972
Types
8,298
Memory used
77,984K
Assignability cache size:
33,123
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
이 분석이 보고하는 많은 데이터 포인트 중에서 가장 유용한 지표는 파일 및 타입의 수, 사용된 메모리 양, 그리고 I/O, 파싱(Parsing), 타입 체크(Type-checking)에 소요된 시간입니다. I/O 시간이 길거나 파일/라인 수가 많다면 소스 파일 포함 여부 검증 단계를 확인하십시오.

컴파일러 트레이스를 통한 심층 분석

컴파일러가 파싱, 바인드(Bind), 또는 체크 단계에서 많은 시간을 보내고 있다면, 트레이스(Trace)를 캡처하여 프로그램의 어느 부분이 이러한 비용에 가장 많이 기여하는지 식별하는 데 도움을 받을 수 있습니다. 컴파일러는 수행하는 모든 작업을 기록하여 심층적인 통찰을 얻을 수 있는 데이터셋을 생성하는 플래그를 제공합니다: tsc --generateTrace <output_dir> (TypeScript 4.1부터 사용 가능). 이 명령 실행에 문제가 있다면, -f 인자(빌드 모드용)를 전달하거나 --incremental false(일반 컴파일용)를 사용하여 증분 빌드를 수행하지 않도록 설정하십시오.
이 명령을 실행하면 지정된 디렉토리에 몇 개의 파일이 출력됩니다. tsc를 빌드 모드(-b)로 실행하는지 여부에 따라 출력이 약간 다를 수 있지만, 어느 쪽이든 typestrace에 대한 하나 이상의 JSON 파일이 생성됩니다. 시작점으로 가장 큰 파일을 찾거나, 더 완전한 설명을 위해 이 문서를 확인하십시오.
이 트레이스들은 Chrome의 트레이스 뷰어(Trace Viewer)(Arc를 포함한 Chromium 기반 브라우저의 about://tracing에서 사용 가능)로 분석할 수 있습니다. 더 최신 도구인 Perfetto UI를 시도해 볼 수도 있지만, 저는 예전 방식이 더 효과적이었습니다.

제가 겪은 문제들:

확장 진단 및 트레이스 실행 시 tsc가 힙 메모리 부족(Heap Out of Memory) 오류로 실패함

기본적으로 node 프로세스(버전 12 이후)는 시스템의 가용 메모리에 따라 최대 힙 크기가 결정됩니다. 많은 현대적 시스템에서 이는 약 2GB로 기본 설정됩니다. 메모리 관련 오류가 발생하면 node에 플래그를 전달하여 허용되는 최대 메모리 사용량을 높일 수 있습니다(이 명령은 한 번의 컴파일 실행 동안 확장 진단과 트레이스 파일 생성을 모두 수행합니다).
$ node --max-old-space-size=8192 ./node_modules/.bin/tsc -b --extendedDiagnostics --generateTrace ./ts-trace

트레이스 파일을 트레이스 뷰어에서 분석할 수 없음

트레이스 파일은 매우 커질 수 있으며, about://tracing이나 Perfetto UI 같은 도구들이 처리를 거부할 정도일 수 있습니다. 저의 경우 일부 트레이스 파일이 그랬고, 결국 이를 이해하기 위해 @typescript/analyze-trace에 의존하게 되었습니다.

권장되는 process-tracing 스크립트가 아무것도 출력하지 않음

성능 트레이싱 문서에서는 다루기 쉬운 작은 트레이스를 만들기 위해 초대형 트레이스를 샘플링하는 process-tracing 스크립트 사용을 제안합니다. 실제로 제가 시도했을 때 이 스크립트는 빈 파일만 출력했습니다. 결과는 상황에 따라 다를 수 있습니다(YMMV).

근본 원인 식별

우리의 구체적인 사례에서, 가장 문제가 되는 애플리케이션 코드를 식별하는 핵심은 @typescript/analyze-trace였습니다. 이 프로젝트의 모노레포 구조 때문에 고려해야 할 트레이스 파일이 6~7개 있었습니다. 모든 파일에 명백한 문제가 있는 것은 아니었지만, 결국 이 파일을 발견하고 즉시 흥미를 느꼈습니다.
$ npx analyze-trace ./ts-trace
Analyzed /<client>/<project>/packages/<package>/tsconfig.json (trace.12493-5.json)
Hot Spots
├─ Check file [35m/<client>/<project>/packages/<package>/src/tasks/extractions/common.ts (80609ms)
│ └─ Check deferred node from (line 10, char 10) to (line 29, char 4) (80608ms)
│ └─ Check expression from (line 11, char 12) to (line 28, char 7) (80607ms)
│ └─ Check expression from (line 11, char 15) to (line 28, char 6) (80607ms)
│ ├─ Check expression from (line 13, char 7) to (line 27, char 8) (51716ms)
│ │ └─ Check expression from (line 14, char 9) to (line 26, char 12) (51711ms)
│ │ ├─ Check expression from (line 24, char 18) to (line 25, char 69) (38423ms)
│ │ │ └─ Check expression from (line 25, char 13) to (line 25, char 69) (38422ms)
│ │ │ └─ Check expression from (line 25, char 44) to (line 25, char 68) (14922ms)
│ │ └─ Check expression from (line 14, char 9) to (line 24, char 17) (13287ms)
│ │ └─ Check expression from (line 14, char 9) to (line 23, char 12) (13287ms)
│ │ └─ Check expression from (line 14, char 9) to (line 17, char 21) (13262ms)
│ │ └─ Check expression from (line 14, char 9) to (line 16, char 42) (13261ms)
│ │ └─ Check expression from (line 16, char 19) to (line 16, char 41) (4364ms)
│ └─ Check expression from (line 12, char 7) to (line 12, char 32) (28891ms)
└─ Check file /<client>/<project>/packages/<package>/src/tasks/transforms/operation.ts (712ms)
└─ Check expression in /<client>/<project>/packages/<package>/src/tasks/extractions/operation.ts from (line 46, char 50) to (line 46, char 57) (611ms)
└─ Check expression from (line 48, char 17) to (line 191, char 10) (502ms)
└─ Check expression from (line 48, char 17) to (line 190, char 19) (502ms)
└─ Check expression from (line 48, char 17) to (line 190, char 13) (500ms)
└─ Check expression from (line 48, char 17) to (line 188, char 4) (500ms)
└─ Check expression from (line 168, char 10) to (line 187, char 6) (322ms)
└─ Check expression from (line 169, char 5) to (line 187, char 6) (322ms)
└─ Check expression from (line 169, char 15) to (line 186, char 59) (321ms)
└─ Check expression from (line 170, char 7) to (line 186, char 59) (321ms)
└─ Check expression from (line 170, char 7) to (line 186, char 18) (320ms)
└─ Check expression from (line 170, char 7) to (line 185, char 63) (320ms)
└─ Check expression from (line 170, char 7) to (line 185, char 18) (320ms)
└─ Check expression from (line 170, char 7) to (line 184, char 10) (320ms)
└─ Check expression from (line 170, char 7) to (line 180, char 18) (319ms)
└─ Check expression from (line 170, char 7) to (line 179, char 10) (319ms)
└─ Check expression from (line 170, char 7) to (line 174, char 18) (317ms)
└─ Check expression from (line 170, char 7) to (line 173, char 55) (317ms)
└─ Check expression from (line 170, char 7) to (line 173, char 18) (316ms)
└─ Check expression from (line 170, char 7) to (line 172, char 40) (316ms)
한눈에 들어오지는 않지만, 이 트레이스에는 타입 체크에 80,609ms(80초)가 걸린 파일이 하나 있습니다. 정말 긴 시간입니다! 왜 이렇게 오래 걸렸을까요?
다음 줄을 보면 이 예외적인 경우에서 최상위 체크가 지연된 노드(Deferred node)임을 알 수 있습니다: Check deferred node from (line 10, char 10) to (line 29, char 4) (80608ms). 이는 우리가 확인해 볼 라인과 컬럼 번호를 제공합니다. 이 문맥에서 deferred는 컴파일러가 아직 타입을 결정할 충분한 정보를 가지고 있지 않으며, 이 지연된 노드를 완전히 해결하기 위해 더 많은 정보(종종 다른 추론된 타입들)를 수집하며 컴파일을 계속해야 함을 의미합니다.
해당 파일을 열어 10행 10열을 확인했을 때 발견한 내용은 다음과 같습니다(세부 사항은 난독화되었습니다):
import type { Db } from '@lib/kysely/db';
import type { ExpressionBuilder, ExpressionWrapper } from 'kysely';

export const existsValidThing = <
const T extends keyof Db,
EB extends ExpressionBuilder<Db, keyof Db>,
>(
thingIdRef: ExpressionWrapper<Db, T, string | null>,
) => {
return ({ eb, or, exists }: EB) => {
return or([
eb(thingIdRef, 'is', null),
exists(
eb
.selectFrom('thing as t')
.select(eb.lit(1).as('exists'))
.innerJoin('category', 'category', 't.category_id')
.where('t.id', '=', thingIdRef),
),
]);
};
};
조금 어색해 보이죠? 10행 10열은 이 existsValidThing 헬퍼 함수에서 반환되는 익명 람다 함수의 시작 부분입니다. 그렇다면 왜 TypeScript가 이 헬퍼 함수의 타입을 해결하는 것을 그토록 어려워했을까요?
몇 가지 이유가 있지만 궁극적으로는 다음과 같습니다:
  1. Db는 약 30개의 데이터베이스 테이블과 각 테이블당 수십 개의 필드 매핑으로 가득 찬 거대한 인터페이스입니다.
  1. kysely는 타입 추론과 거대한 유니온(예: 데이터베이스의 테이블들 또는 테이블의 필드들)에 걸친 분산에 크게 의존하는 방식으로 구축되었습니다. 한 GitHub 댓글 작성자는 일부 엣지 케이스에서 복잡도가 O(n^x) 수준이라고 넌지시 언급하기도 했습니다.
람다의 인자(EB, 제네릭에서 옴)에 대해 명시적인 타입 선언이 있었음에도 불구하고, TypeScript는 여전히 이 람다의 반환 타입을 추론해야 했습니다. 이 반환 타입 자체는 각각의 반환 타입을 결정하기 위해 추론이 필요한 복잡한 중첩 함수 호출의 연속이었습니다.
kysely가 강력한 타이핑으로 가능하게 하는 일들은 상당히 마법 같습니다. 하지만 그러한 작업들을 재사용 가능한 헬퍼로 분리하려고 시도하면, 충분히 큰 데이터베이스 환경에서는 예상치 못한 타입 체크 병목 현상이 발생할 수 있습니다.

해결책

성능을 개선하기 위해 조정한 사항이 많았지만, 가장 큰 성과는 문제가 되는 kysely 헬퍼 함수들을 삭제하고 쿼리가 사용되는 곳에 인라인으로 작성한 것에서 얻었습니다.
그 외에 도움이 된 작업들은 다음과 같습니다:
  1. 순환 의존성 수정(madge 참고)
  1. 사용하지 않는 타입 제거
  1. prisma-zod-generator로 생성된 타입들
  1. 사용하지 않는 의존성 삭제
  1. 패키지 중복 제거
  1. 배럴 파일(Barrel Files) 정리 및 제거
  1. 패키지를 최신 버전으로 업그레이드
  1. node를 업그레이드하고 모든 환경의 버전을 통일
  1. syncpack을 추가하고 모노레포 린팅 규칙 설정

결과

성능은 중요합니다. 많은 요인이 개발자 경험에 영향을 미칩니다. 팀의 가치와 문화가 성능 유지 및 개선에 충분한 비중을 두더라도, 다른 요소들이 결합하여 일상적인 생산성을 위협할 수 있습니다.
이 특정 프로젝트의 이야기는 승리의 기록입니다. 수치상으로는 압도적인 변화였습니다.
지표
이전
이후
변화
변화율(%)
Files
14,628
10,445
-4,183
-28.6%
Lines of Library
85,573
87,322
+1,749
+2.0%
Lines of Definitions
1,563,401
1,458,375
-105,026
-6.7%
Lines of TypeScript
205,561
89,162
-116,399
-56.6%
Lines of JavaScript
0
0
0
0.0%
Lines of JSON
197,264
197,258
-6
-0.0%
Lines of Other
0
0
0
0.0%
Identifiers
2,591,445
1,983,548
-607,897
-23.5%
Symbols
12,162,183
6,238,779
-5,923,404
-48.7%
Types
4,605,085
2,303,043
-2,302,042
-50.0%
Instantiations
41,244,435
25,282,289
-15,962,146
-38.7%
Memory used
7,065,937K
3,522,442K
-3,543,495K
-50.2%
Assignability cache size
7,530,942
989,151
-6,541,791
-86.9%
Identity cache size
36,263
41,336
+5,073
+14.0%
Subtype cache size
58,647
20,457
-38,190
-65.1%
Strict subtype cache size
111,640
147,930
+36,290
+32.5%
Tracing time
2.72s
0.46s
-2.26s
-83.1%
I/O Read time
1.30s
0.93s
-0.37s
-28.5%
Parse time
2.69s
1.56s
-1.13s
-42.0%
ResolveModule time
1.00s
0.66s
-0.34s
-34.0%
ResolveTypeReference time
0.03s
0.02s
-0.01s
-33.3%
ResolveLibrary time
0.02s
0.02s
0.00s
0.0%
Program time
6.59s
4.11s
-2.48s
-37.6%
Bind time
1.88s
0.97s
-0.91s
-48.4%
Check time
226.19s
38.23s
-187.96s
-83.1%
transformTime time
1.10s
0.24s
-0.86s
-78.2%
commentTime time
0.15s
0.02s
-0.13s
-86.7%
I/O Write time
0.40s
0.06s
-0.34s
-85.0%
printTime time
2.60s
0.51s
-2.09s
-80.4%
Emit time
2.61s
0.51s
-2.10s
-80.5%
Dump types time
132.73s
33.63s
-99.10s
-74.7%
Config file parsing time
0.08s
0.04s
-0.04s
-50.0%
Up-to-date check time
0.00s
0.00s
0.00s
0.0%
Build time
374.32s
78.55s
-295.77s
-79.0%

가장 유의미한 감소:

  • I/O 쓰기 시간(I/O Write time): -85.0% (0.40s → 0.06s)
  • 트레이싱 시간(Tracing time): -83.1% (2.72s → 0.46s)
  • 체크 시간(Check time): -83.1% (226.19s → 38.23s)
  • 에밋 시간(Emit time): -80.5% (2.61s → 0.51s)
  • 프린트 시간(Print time): -80.4% (2.60s → 0.51s)
  • 빌드 시간(Build time): -79.0% (374.32s → 78.55s)

리소스 사용량:

  • 메모리 사용량: -50.2% (7.1GB → 3.5GB)
  • 처리된 파일 수: -28.6% (14,628 → 10,445)
  • TypeScript 라인 수: -56.6% (205K → 89K)

전반적인 영향:

최적화를 통해 전체 빌드 시간이 6.2분에서 1.3분으로 단축되었으며, 이는 컴파일 속도가 79% 개선되었음을 의미합니다.
이 수치들도 훌륭하지만, 우리가 캐시 없이 처음부터 전체 컴파일을 실행하는 경우는 드뭅니다. 진짜 영향은 응답성이 뛰어난 언어 서버와 즉각적인 인텔리센스입니다.
도구를 잘 알고, 약간의 시간과 인내심을 가지며, 상황을 개선하려는 의지만 있다면 불가능해 보이는 과제에 직면하더라도 할 수 있는 일은 많습니다.
0
9

댓글

?

아직 댓글이 없습니다.

첫 번째 댓글을 작성해보세요!

Inkyu Oh님의 다른 글

더보기

유사한 내용의 글