원문: https://www.viget.com/articles/fixing-typescript-performance-problems
대규모 타입스크립트 모노레포(monorepo)에서 심각한 성능 문제를 디버깅한 경험을 소개합니다.

최근 진행한 타입스크립트 프로젝트에서 에디터 성능이 점점 저하됐습니다. 타입스크립트 컴파일러(언어 서버 포함)가 코드베이스의 특정 영역에서 버벅이기 시작하면서 인텔리센스가 느려지고 타입 검사 시간이 길어졌습니다. 타입 정보가 오래된 상태로 남아 있는 일도 잦아졌고, 팀원들의 불만도 커졌습니다.
이 프로젝트는 타입스크립트 패키지 7개로 구성된 모노레포입니다. 이전 개발자들이 프로젝트 참조(project references)(composite 설정 필수)와 증분 컴파일(incremental compilation)을 이미 적용해 둔 상태였지만, prisma, kysely, ts-pattern, hotscript처럼 무거운 타입스크립트 의존성도 함께 쓰고 있었습니다.
문제 진단하기
어떤 문제든 디버깅은 공식 문서부터 찾아보는 것으로 시작합니다. 제가 아는 한 https://www.typescriptlang.org/에는 이 주제만을 다루는 페이지가 없지만, 검색해보면 GitHub 위키의 Performance 페이지를 어렵지 않게 찾을 수 있습니다. 쓸 만한 조언과 제안이 가득합니다.
흔한 용의자들부터 점검하는 게 현명합니다.
- 플러그인과 확장 프로그램을 포함해 에디터를 최신 버전으로 유지
- 모든 에디터 확장 프로그램을 비활성화해 용의자 목록에서 제외
- 다른 프로세스가 시스템 리소스를 과도하게 점유하지 않는지 확인
- 사용 가능한 최신 버전의 타입스크립트 사용 (많은 릴리즈에 성능 수정 및 최적화가 포함됨)
- 가능하면 의존성을 최신 버전으로 업데이트 (의존성에 필요한
@types선언 포함) - 특별한 이유가 없다면 엄격한 컴파일 사용 (컴파일러가 더 많은 종류의 잠재적 문제를 잡아내고, 빠른 분산 검사처럼 더 효율적인 알고리즘을 활용함)
이런 기본 점검을 끝냈다면 타입스크립트 컴파일러가 제공하는 추가 정보를 분석할 차례입니다.
소스 파일 포함 범위 검증
컴파일러가 최소한의 소스 파일만 처리하는지 확인합니다.
$ tsc --listFilesOnly
왜냐하면, 파일이 적을수록 타입도 적고 컴파일러가 처리할 작업도 줄어들기 때문입니다.
컴파일 결과에 예상치 못한 파일이 보이면 컴파일러에게 그 파일이 왜 포함됐는지 물어볼 수 있습니다.
$ tsc --explainFiles > explanations.txt
불필요한 파일이 컴파일에 포함되지 않도록 tsconfig의 include/exclude/types/typeRoots/paths를 상황에 맞게 조정하세요.
성능 지표 측정
성능 튜닝은 신중한 계획과 실행이 필요합니다. 두 컴파일 결과를 의미 있게 비교하려면 동일한 하드웨어, 동일한 환경(시스템 부하, 배터리/전원 상태 등)에서 실행해야 합니다. 환경을 잘 통제해도 이상치가 결과를 왜곡할 수 있으니, 컴파일을 여러 번 돌려 분산 정도를 파악하는 게 좋습니다. 표준 편차로 통계적 유의성을 따지는 엄밀한 방법도 있지만, 컴파일 시간이 길면 시간이 너무 많이 듭니다.
최적화 작업에 앞서 베이스라인을 설정하는 일은 매우 중요합니다. 그래야 이후 변경이 실제로 효과를 냈는지 평가할 수 있습니다. 타입스크립트 컴파일러는 컴파일 지표를 자세히 출력하는 플래그를 제공합니다. tsc --extendedDiagnostics를 실행하면 다음과 같은 출력을 얻습니다 (타입스크립트 프로젝트가 여러 개라면 보고서도 여러 개 나올 수 있습니다).
| 지표 | 값 |
|---|---|
| 파일 수 | 6 |
| 라인 수 | 24,906 |
| 노드 수 | 112,200 |
| 식별자 수 | 41,097 |
| 심볼 수 | 27,972 |
| 타입 수 | 8,298 |
| 메모리 사용량 | 77,984K |
| 할당 가능성 캐시 크기 | 33,123 |
| 동일성 캐시 크기 | 2 |
| 하위 타입 캐시 크기 | 0 |
| I/O 읽기 시간 | 0.01s |
| 파싱 시간 | 0.44s |
| 프로그램 구성 시간 | 0.45s |
| 바인딩 시간 | 0.21s |
| 타입 검사 시간 | 1.07s |
| 변환 시간 | 0.01s |
| 주석 처리 시간 | 0.00s |
| I/O 쓰기 시간 | 0.00s |
| 출력 생성 시간 | 0.01s |
| 파일 출력 시간 | 0.01s |
| 전체 시간 | 1.75s |
이 분석이 쏟아내는 데이터 포인트 중 가장 쓸모 있는 것은 파일 수와 타입 수, 메모리 사용량, I/O·파싱·타입 검사 소요 시간입니다. I/O 시간이 높거나 파일/라인 수가 많다면 소스 파일 포함 범위 검증을 참고하세요.
컴파일러 트레이스로 더 깊이 분석하기
컴파일러가 파싱(parse), 바인딩(bind), 검사(check) 단계에서 시간을 많이 잡아먹는다면, 트레이스(trace)로 어느 부분이 비용을 가장 많이 차지하는지 알아낼 수 있습니다. 컴파일러의 모든 작업을 계측해 더 깊은 분석에 쓸 수 있는 데이터셋을 만들어주는 플래그가 있습니다. tsc --generateTrace <output_dir>(타입스크립트 4.1부터 사용 가능)입니다. 이 명령어가 제대로 동작하지 않으면 -f 인수(빌드 모드용)나 --incremental false(일반 컴파일용)를 전달해 증분 빌드가 아닌지 확인하세요.
이 명령어를 실행하면 지정된 디렉터리에 파일들이 출력됩니다. tsc를 빌드 모드(-b)로 실행하는지 여부에 따라 출력 형태가 조금씩 다르지만, 어느 쪽이든 types와 trace에 대한 JSON 파일이 하나 이상 생성됩니다. 가장 큰 파일부터 살펴보거나 관련 문서에서 자세한 설명을 확인하세요.
트레이스는 Chrome의 트레이스 뷰어(Chromium 기반 브라우저에서 about://tracing으로 접근 가능, Arc 포함)로 분석할 수 있습니다. 더 최신인 Perfetto UI도 써볼 수 있지만, 구버전 방식이 더 잘 동작하는 경우가 많았습니다.
겪었던 문제들
tsc가 확장 진단 및/또는 트레이스 실행 중 Heap Out of Memory 오류로 실패하는 경우
기본적으로 Node.js 프로세스는 버전 12부터 시스템의 가용 메모리에 따라 최대 힙(heap) 크기가 정해집니다. 최신 시스템에서는 보통 약 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 스크립트가 아무것도 출력하지 않는 경우
Performance Tracing 문서에서는 매우 큰 트레이스를 샘플링해 더 작은 트레이스로 만드는 process-tracing 스크립트를 추천합니다. 실제로 써보니 이 스크립트는 빈 파일만 내놨습니다. 환경에 따라 다를 수 있습니다.
근본 원인 찾기
저희 경우에는 @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 헬퍼 함수가 반환하는 익명 람다 함수의 시작 부분입니다. 그렇다면 왜 이 헬퍼 함수의 타입이 타입스크립트에게 그토록 어려웠을까요?
몇 가지 이유가 있지만, 결국 다음으로 좁혀집니다.
Db는 약 30개의 데이터베이스 테이블과 각 테이블의 수많은 필드(테이블당 수십 개)를 매핑한 대형 인터페이스kysely는 타입 추론(type inference)에 크게 기대며 대규모 유니언(union)에 걸쳐 분산되도록 설계되어 있음 (즉, 데이터베이스의 테이블들, 또는 테이블의 필드들) — 어떤 GitHub 코멘터는 일부 엣지 케이스에서 복잡도가 O(n^x) 수준이라고 짚기도 했음- 람다의 인수에 명시적 타입 선언이 있더라도(
EB, 제네릭에서 전달됨), 타입스크립트는 여전히 이 람다의 반환 타입을 추론해야 하고, 그 과정은 각각 반환 타입을 추론해야 하는 중첩된 함수 호출들의 복잡한 연쇄로 이뤄짐
kysely가 강력한 타입 시스템으로 구현한 기능은 거의 마법 같습니다. 하지만 이런 기능을 재사용 가능한 헬퍼 함수로 분리하면, 데이터베이스가 충분히 크면 예상치 못한 타입 검사 병목이 생길 수 있습니다.
해결 방법
성능을 끌어올리려고 여러 가지를 손봤지만, 가장 큰 성과는 문제가 되는 kysely 헬퍼 함수들을 삭제하고 해당 쿼리를 사용 지점에 직접 인라인한 것이었습니다.
그 외에 도움이 됐던 작업은 다음과 같습니다.
- 순환 의존성(circular dependency) 수정 (madge 참고)
- 미사용 타입 제거
prisma-zod-generator가 생성한 타입 제거- 미사용 의존성 제거
- 중복 패키지 제거
- 배럴 파일(barrel files) 정리 및 제거
- 패키지를 최신 버전으로 업그레이드
node를 업그레이드하고 모든 패키지가 같은 버전을 쓰도록 통일syncpack을 추가하고 모노레포 린팅 규칙 설정
결과
성능은 중요합니다. 개발자 경험을 결정하는 요소는 여러 가지입니다. 팀이 가치관과 문화로 성능 유지·개선을 충분히 챙기더라도, 다른 요인들이 일상적인 생산성을 갉아먹기도 합니다.
이 프로젝트는 다행히 성공한 사례로 남길 수 있었습니다. 숫자만 봐도 거의 완벽한 압승입니다.
| 지표 | 이전 | 이후 | 변화량 | 변화율 |
|---|---|---|---|---|
| 파일 수 | 14,628 | 10,445 | -4,183 | -28.6% |
| 라이브러리 라인 수 | 85,573 | 87,322 | +1,749 | +2.0% |
| 선언 파일 라인 수 | 1,563,401 | 1,458,375 | -105,026 | -6.7% |
| TypeScript 라인 수 | 205,561 | 89,162 | -116,399 | -56.6% |
| JavaScript 라인 수 | 0 | 0 | 0 | 0.0% |
| JSON 라인 수 | 197,264 | 197,258 | -6 | -0.0% |
| 기타 라인 수 | 0 | 0 | 0 | 0.0% |
| 식별자 수 | 2,591,445 | 1,983,548 | -607,897 | -23.5% |
| 심볼 수 | 12,162,183 | 6,238,779 | -5,923,404 | -48.7% |
| 타입 수 | 4,605,085 | 2,303,043 | -2,302,042 | -50.0% |
| 인스턴스화 수 | 41,244,435 | 25,282,289 | -15,962,146 | -38.7% |
| 메모리 사용량 | 7,065,937K | 3,522,442K | -3,543,495K | -50.2% |
| 할당 가능성 캐시 크기 | 7,530,942 | 989,151 | -6,541,791 | -86.9% |
| 동일성 캐시 크기 | 36,263 | 41,336 | +5,073 | +14.0% |
| 하위 타입 캐시 크기 | 58,647 | 20,457 | -38,190 | -65.1% |
| 엄격한 하위 타입 캐시 크기 | 111,640 | 147,930 | +36,290 | +32.5% |
| 트레이싱 시간 | 2.72s | 0.46s | -2.26s | -83.1% |
| I/O 읽기 시간 | 1.30s | 0.93s | -0.37s | -28.5% |
| 파싱 시간 | 2.69s | 1.56s | -1.13s | -42.0% |
| 모듈 해석 시간 | 1.00s | 0.66s | -0.34s | -34.0% |
| 타입 참조 해석 시간 | 0.03s | 0.02s | -0.01s | -33.3% |
| 라이브러리 해석 시간 | 0.02s | 0.02s | 0.00s | 0.0% |
| 프로그램 구성 시간 | 6.59s | 4.11s | -2.48s | -37.6% |
| 바인딩 시간 | 1.88s | 0.97s | -0.91s | -48.4% |
| 타입 검사 시간 | 226.19s | 38.23s | -187.96s | -83.1% |
| 변환 시간 | 1.10s | 0.24s | -0.86s | -78.2% |
| 주석 처리 시간 | 0.15s | 0.02s | -0.13s | -86.7% |
| I/O 쓰기 시간 | 0.40s | 0.06s | -0.34s | -85.0% |
| 출력 생성 시간 | 2.60s | 0.51s | -2.09s | -80.4% |
| 파일 출력 시간 | 2.61s | 0.51s | -2.10s | -80.5% |
| 타입 덤프 시간 | 132.73s | 33.63s | -99.10s | -74.7% |
| 설정 파일 파싱 시간 | 0.08s | 0.04s | -0.04s | -50.0% |
| 최신 상태 확인 시간 | 0.00s | 0.00s | 0.00s | 0.0% |
| 빌드 시간 | 374.32s | 78.55s | -295.77s | -79.0% |
가장 큰 개선 지표
- I/O 쓰기 시간: -85.0% (0.40s → 0.06s)
- 트레이싱 시간: -83.1% (2.72s → 0.46s)
- 타입 검사 시간: -83.1% (226.19s → 38.23s)
- 파일 출력 시간: -80.5% (2.61s → 0.51s)
- 출력 생성 시간: -80.4% (2.60s → 0.51s)
- 빌드 시간: -79.0% (374.32s → 78.55s)
리소스 사용량
- 메모리 사용량: -50.2% (7.1GB → 3.5GB)
- 처리된 파일 수: -28.6% (14,628 → 10,445)
- 타입스크립트 코드 라인 수: -56.6% (205K → 89K)
전체 영향
이번 최적화로 전체 빌드 시간이 6.2분에서 1.3분으로 단축됐고, 컴파일 속도는 79% 개선됐습니다.
좋은 성과지만, 캐시 없이 전체를 새로 컴파일하는 경우는 드뭅니다. 진짜 의미 있는 변화는 반응이 빠른 언어 서버와 즉각적인 인텔리센스입니다.
자신이 쓰는 도구를 잘 알고, 시간과 인내심을 들이고, 더 나아지려는 의지가 있다면 불가능해 보이는 과제 앞에서도 할 수 있는 일은 많습니다.
'Web Frontend Developer' 카테고리의 다른 글
| [번역] 컴포넌트가 WCAG를 준수할 수 있을까요? (0) | 2026.06.05 |
|---|---|
| [번역] 리액트는 기본값으로 승리했습니다 – 그리고 프런트엔드 혁신을 죽이고 있습니다 (0) | 2026.05.08 |
| [번역] 자바스크립트 Date 계산은 얼마나 잘못될 수 있을까요? (0) | 2026.05.08 |
| [번역] CSS in 2026: 프런트엔드 개발을 바꾸는 새로운 기능들 (0) | 2026.03.27 |
| [번역] 이제 모던 CSS가 SPA를 끝낼 때입니다 (12) | 2026.03.27 |