[번역] tsgo는 왜 그렇게 많은 메모리를 사용하나요?

I
Inkyu Oh

Front-End2026.06.10

Zack Radisic - 2026년 5월 27일


적당한 규모의 TypeScript 프로젝트에서 tsgo를 실행하면, 기가바이트 단위의 메모리를 사용하는 것을 보는 게 드문 일은 아닙니다.
왜 그럴까요?
짧은 답변은 다음과 같습니다:
  • 멀티스레딩 시, tsgo는 스레드당 하나의 타입 체커(Type checker)를 생성합니다.
  • 각 타입 체커는 고유한 상태(타입, 심볼 등)를 가집니다.
  • 이 상태는 스레드 간 동기화 비용이 크기 때문에 공유되지 않습니다.
  • 따라서 각 타입 체커는 종종 중복되고 불필요한 메모리를 할당합니다.
  • 게다가, 할당된 타입은 문자 그대로 절대 해제되지 않습니다. [1]
TypeScript 프로젝트에서 다음과 같은 상황은 흔합니다:
  • 수천 개의 TypeScript 파일 존재
  • Zod, tRPC, Drizzle과 같이 수많은 타입 인스턴스화를 유발하는 라이브러리 사용
  • 해제되지 않는 수많은 일시적 타입을 생성하는 재귀적 제네릭 타입 사용
대규모 TypeScript 프로젝트에서 tsgo를 실행하면, 이러한 타입 생성 패턴이 복합적으로 작용하여 대량의 중복되거나 사용되지 않는 메모리를 발생시킵니다.
더 자세히 살펴보겠습니다.

힙 분석 (Heap analysis)

먼저 무엇이 그렇게 많은 메모리를 차지하고 있는지 확인하기 위해 힙(Heap) 내역을 분석해 보겠습니다.
Zod, tRPC, Drizzle 등 타입 체커가 열일하게 만드는 모든 요소가 포함된 대규모 Next.js 프로젝트에서 tsgo를 실행하겠습니다. node_modules를 포함하여 약 7,000개의 .ts 파일이 있습니다.
Go의 runtime/pprof 패키지를 사용하여 피크 힙 스냅샷을 캡처하고, pprof 도구의 -inuse_space 플래그를 사용하여 어떤 함수가 메모리를 가장 많이 할당했는지 확인해 보겠습니다.
AST, 타입 체커 등으로 카테고리를 나누면 다음과 같습니다:
Total live heap: 1471.9 MB
pprof writer self-overhead: 75.2 MB
real live data: 1321.5 MB

MB pct Family
──────────────────────────────────────────────────────────────────────────────
594.72 45.0% AST arenas (parser-allocated)
399.12 30.2% Checker (type/signature computation)
121.79 9.2% LinkStore (per-node/per-symbol caches)
63.38 4.8% OS / syscall / file I/O
62.58 4.7% Binder (symbol/flow declarations)
22.33 1.7% Parser (intern maps, etc.)
20.24 1.5% pkg: collections
15.54 1.2% Checker arenas
13.46 1.0% AST utilities
6.58 0.5% Compiler / module resolution
1.10 0.1% pkg: core
0.70 0.1% pkg: packagejson
한눈에 띄는 점은 메모리의 45%(600MB)가 AST 노드에 할당되어 있다는 것입니다. 많아 보일 수 있지만, 컴파일러가 할당하는 메모리의 대부분을 AST 노드가 차지하는 것은 사실 예상된 일입니다.
또한 AST 노드는 일반적으로 컴파일러 실행 기간 내내 유지되어야 하므로, 여기서 우리가 할 수 있는 일은 딱히 없습니다. 파일이 많다는 것은 AST 노드가 많다는 뜻이니까요!
제가 더 관심을 두는 부분은 타입 체커(소스 코드의 Checker 구조체)에 의해 할당된 메모리입니다.
tsgo--singleThreaded 옵션으로 실행하면 어떻게 될까요?
Total live heap: 797.4 MB
pprof writer self-overhead: 3.6 MB
real live data: 790.2 MB

MB pct Family
──────────────────────────────────────────────────────────────────────────────
522.95 66.2% AST arenas (parser-allocated)
63.37 8.0% OS / syscall / file I/O
62.63 7.9% Binder (symbol/flow declarations)
51.93 6.6% Checker (type/signature computation)
23.01 2.9% LinkStore (per-node/per-symbol caches)
22.51 2.8% Parser (intern maps, etc.)
16.78 2.1% AST utilities
16.15 2.0% pkg: collections
10.21 1.3% Compiler / module resolution
0.58 0.1% pkg: packagejson
0.10 0.0% pkg: core
0.01 0.0% ** unclassified **
타입 체커가 ~400MB 대신 단 ~50MB만 차지합니다! 이는 멀티스레딩과 관련된 오버헤드가 있음을 강력하게 시사합니다.
타입 체커를 더 깊이 살펴보겠습니다.

타입 체커 (The type checker)

tsgo가 타입 체크를 멀티스레딩하는 방식은 각 스레드에 대해 Checker 풀을 생성하는 것입니다.
func newCheckerPoolWithTracing(program *Program, tr *tracing.Tracing) *checkerPool {
checkerCount := 4
if program.SingleThreaded() {
checkerCount = 1
} else if c := program.Options().Checkers; c != nil {
checkerCount = *c
}

checkerCount = max(min(checkerCount, len(program.files), 256), 1)

pool := &checkerPool{
program: program,
checkers: make([]*checker.Checker, checkerCount),
locks: make([]*sync.Mutex, checkerCount),
tracing: tr,
}

return pool
}
Checker가 생성될 때, 전체 TypeScript 프로그램 AST와 모든 파일이 제공됩니다.
func NewChecker(program Program, tracer *Tracer) (*Checker, *sync.Mutex) {
program.BindSourceFiles()

c := &Checker{}
c.id = nextCheckerID.Add(1)
c.tracer = tracer
c.program = program
c.compilerOptions = program.Options()
c.files = program.SourceFiles()
c.fileIndexMap = createFileIndexMap(c.files)

// ... 추가 코드
}
파일에 대한 타입 체크 및 진단(Diagnostics)을 수행하는 동안, 각 파일은 사용 가능한 다음 Checker에 할당됩니다.
Checker는 타입 체크를 위한 고유한 상태를 가집니다(나중에 더 자세히 살펴보겠습니다). 다음은 중복 작업의 예시입니다:
  • a.ts 파일이 Checker 1로 가서 수많은 타입을 생성합니다.
  • b.ts 파일이 a.ts에서 일부 타입을 임포트(Import)하고 Checker 2로 갑니다.
  • Checker 2는 별도의 상태를 가지고 있으므로, a.ts에 대한 데이터를 다시 계산하고 다시 할당해야 합니다.
pprof 실행 결과에서 가장 많은 할당을 기록한 Checker 함수들은 다음과 같았습니다:
  • Checker.newSymbol() (심볼)
  • Checker.newObjectType() (타입)
  • Checker.instantiateType() (타입)
Checker에서 정확히 어떤 데이터가 할당되고 있는지 살펴보겠습니다.

중복된 타입 (Duplicated types)

Checker는 생성될 수 있는 수많은 타입을 저장하기 위한 많은 저장소(Store)를 가지고 있습니다.
type Checker struct {
stringLiteralTypes map[string]*Type
numberLiteralTypes map[jsnum.Number]*Type
bigintLiteralTypes map[jsnum.PseudoBigInt]*Type
enumLiteralTypes map[EnumLiteralKey]*Type
indexedAccessTypes map[CacheHashKey]*Type
templateLiteralTypes map[CacheHashKey]*Type
stringMappingTypes map[StringMappingKey]*Type
cachedTypes map[CachedTypeKey]*Type
cachedSignatures map[CachedSignatureKey]*Signature
narrowedTypes map[NarrowedTypeKey]*Type
assignmentReducedTypes map[AssignmentReducedKey]*Type
discriminatedContextualTypes map[DiscriminatedContextualTypeKey]*Type
instantiationExpressionTypes map[InstantiationExpressionKey]*Type
substitutionTypes map[SubstitutionTypeKey]*Type
reverseMappedCache map[ReverseMappedTypeKey]*Type
reverseHomomorphicMappedCache map[ReverseMappedTypeKey]*Type
iterationTypesCache map[IterationTypesKey]IterationTypes
tupleTypes map[CacheHashKey]*Type
unionTypes map[CacheHashKey]*Type
unionOfUnionTypes map[UnionOfUnionKey]*Type
intersectionTypes map[CacheHashKey]*Type
propertiesTypes map[PropertiesTypesKey]*Type
flowLoopCache map[FlowLoopKey]*Type
flowTypeCache map[*ast.Node]*Type
errorTypes map[CacheHashKey]*Type
// 그 외 다수!
}
다음 사항을 기억하세요:
  • 이 메모리는 단일 Checker에 속하며 데이터 공유는 없습니다.
  • 할당된 타입은 절대 해제되지 않습니다.
이는 많은 중복 메모리가 그대로 남아 있을 수 있음을 의미합니다.
이를 확인하기 위해, 튜플(Tuple)을 생성하는 코드가 포함된 파일을 만들어 보겠습니다.
type BuildTuple<L extends number, T extends any[] = []> =
T['length'] extends L ? T : BuildTuple<L, [...T, any]>;

type TC = BuildTuple<100>;
declare const x: TC;
export const c0 = x[0];
export const cLen: 100 = x.length;
BuildTuple<L, T> 타입은 빈 튜플 타입 []부터 100개의 any가 들어있는 튜플([any, any, ... any])까지 재귀적으로 튜플 타입을 생성합니다.
재귀의 각 반복은 새로운 튜플을 생성하고 이를 영구적으로 캐싱합니다. [2]
위와 같은 내용의 파일 4개를 만들어 tsgo로 실행하면, 100개의 튜플 타입이 생성되어 4개의 타입 체커에 중복되어 나타나야 합니다(또한 100개의 숫자 리터럴 타입도 마찬가지입니다).
결과를 확인해 봅시다:
단일 체커 4개 체커
───────────────── ─────────────────────────────
tupleTypes 102 [102 102 102 102] → 408
numberLiteralTypes 101 [101 101 101 101] → 404
이는 두 가지를 보여줍니다:
  • 타입이 서로 다른 스레드에서 불필요하게 중복 생성됩니다.
  • 재귀적 제네릭 타입은 메모리를 차지하는 수많은 일시적 타입을 생성할 수 있습니다.
이것은 아주 사소한 예시일 뿐입니다. 수천 개의 파일을 타입 체크할 때 발생할 수 있는 중복 수준을 상상해 보세요.

중복된 심볼 (Duplicated symbols)

컴파일러에서 이름이 붙은 것들(함수, 변수 등의 식별자)은 종종 "심볼(Symbol)"이라는 간접 계층에 기록됩니다.
보통 이를 통해 이름의 스코프(Scope)를 지정할 수 있고(전역 스코프의 "foo"와 함수 스코프의 "foo"는 서로 다른 것을 의미함), 이름을 바꿀 경우(예: 미니피케이션)를 대비해 안정적인 핸들을 제공할 수 있습니다.
Checker는 수많은 심볼을 저장합니다.
type Checker struct {
// ... 추가 코드
symbolArena core.Arena[ast.Symbol]
// ... 추가 코드
}
심볼도 많이 중복되고 있을까요?
4개의 스레드를 실행할 때 상위 심볼 이름(심볼의 문자열 부분)을 덤프하도록 tsgo를 수정했습니다.
tsgo --checkers 4
심볼
종류
개수
at
Method
34,500
_
Property
25,600
name
Property
24,700
value
FuncVar
22,800
@@iterator
Method
22,300
data
Property
22,100
enumValues
Property
21,900
columnType
Property
21,000
dataType
Property
21,000
generated
Property
19,500
at 심볼 개수를 살펴봅시다. 만약 싱글 스레드 tsgo에서 이 수치가 감소한다면, 다른 스레드들이 이를 복제하고 있다는 뜻일 것입니다.
tsgo --checkers 1
심볼
종류
개수
props
FuncVar
16,800
at
Method
14,600
children
Property
10,400
value
FuncVar
10,200
@@iterator
Method
9,500
className
Property
9,200
data
Property
8,500
forEach
Method
8,100
map
Method
8,000
find
Method
7,900
4개의 스레드로 tsgo를 실행했을 때 약 2만 개나 더 많은 at 심볼이 생성되었습니다!
작은 테스트 파일을 만들어 검증해 보겠습니다.
at 심볼은 Array<T>.prototype.at에서 옵니다. Array<T>를 생성하고 그에 대한 속성 조회를 수행함으로써 TypeScript가 이 심볼을 생성하도록 강제할 수 있습니다. 이렇게 하면 TypeScript가 Array 객체의 모든 멤버를 확인(Resolve)하고 해당 심볼들을 생성하게 됩니다. [3]
declare const arr: Array<string>;
export const len = arr.length;
이제 정확히 동일한 내용의 파일 4개를 만듭니다. --checkers 4 옵션으로 tsgo를 실행하면 각 파일이 하나의 Checker로 할당될 것이고, at 심볼이 중복되는지 확인할 수 있습니다.
--checkers 1 --checkers 4
───────────── ──────────────────────────────
합계 합계 c0 c1 c2 c3
at 1 4 1 1 1 1
각 체커가 Array<string>.prototype.at에 대한 심볼을 중복 생성했습니다.
또한 타입 파라미터의 모든 새로운 인스턴스화에 대해 새로운 심볼이 생성된다는 점에 유의하세요. 따라서 Array<string>, Array<number> 등은 모두 at 및 다른 멤버들에 대해 각자의 심볼을 갖게 됩니다. 이는 지극히 정상적이고 표준적인 동작입니다.
하지만 tsgo가 다른 스레드에서 얼마나 쉽게 많은 심볼을 중복 생성할 수 있는지 알 수 있습니다.
여러분의 코드가 데이터 구조를 위해 많은 필드와 메서드를 가진 제네릭 타입을 생성한다고 가정해 봅시다.
type MyDataStructure<T> = {
field1: T;
field2: string;
// ...
field100: string;
}
각 인스턴스화는 100개의 심볼을 생성합니다. 그리고 이 타입을 많은 파일에서 임포트한다면, 하나 이상의 Checker에서 이 타입이 조회되고 중복될 가능성이 매우 높습니다.
실제 사례로는 Zod 객체가 있습니다. Zod의 메서드 체이닝 API는 서로 다른 타입 파라미터로 인스턴스화된 ZodObject를 반환합니다.
const emailSchema = z.string().email().min(5).max(120).toLowerCase();
.string(), .email() 등은 새로운 ZodObject<Shape, Config> 타입을 인스턴스화하고, 속성 체이닝은 TypeScript가 심볼을 확인하고 생성하게 만듭니다(개별 타입 할당은 물론이고요!).
Drizzle, tRPC와 같이 유사한 동작을 하는 API들이 많으며, 멀티 스레드와 결합될 때 이는 막대한 메모리 사용으로 이어집니다.

결론 (Conclusion)

tsgo 소스 코드를 깊이 파고드는 즐거운 시간이었습니다.
앞으로 메모리 사용량을 어떻게 개선할 수 있을까요?
**타입 가비지 컬렉션(Garbage collecting types)**은 유망해 보입니다. 특히 TypeScript 타입은 프로그래밍 언어의 일반 값처럼 동작하기 때문입니다. 일시적인 타입은 AST 노드 등에 묶이지 않고 GC될 수 있을 것입니다.
**영속적이고 공유되는 데이터 구조(Persistent, shared data structures)**는 TypeScript처럼 수많은 일시적 값을 생성하는 문제를 가진 함수형 프로그래밍(FP) 언어에서 사용됩니다. 이는 튜플과 같은 타입의 메모리 사용량을 줄이는 데 도움이 될 수 있습니다.
또 다른 흥미로운 참고 사례는 Zig 컴파일러의 InternPool이 컴파일 타임(comptime) 값과 타입에 대해 유사한 문제를 어떻게 해결했는지 살펴보는 것입니다. [4]
여기 제 포크(Fork) 버전에서 pprof 데이터를 처리하는 스크립트와 프로파일링 데이터를 출력하도록 수정한 tsgo 코드를 확인할 수 있습니다.

각주 (Footnotes)

[1] 처음에는 미친 소리처럼 들릴 수 있고, 실제로 어느 정도 그렇습니다. TypeScript 타입은 튜링 완전(Turing complete)하기 때문에, 이는 메모리를 절대 해제하지 않는 프로그램을 작성하는 것과 같습니다. 하지만 어떻게 이 지경에 이르렀는지는 이해가 갑니다. 컴파일러에서 AST 노드를 아레나(Arena, 프로그램 종료 시까지 해제되지 않음)에 할당하는 것은 꽤 일반적인 관행입니다. AST는 컴파일러 실행 내내 유지되기 때문입니다. 마찬가지로 "일반적인" 타입을 가진 컴파일러에서는 타입을 AST 노드와 연결하며, 일단 연결되면 AST가 살아있는 한 영원히 유지됩니다. 하지만 튜링 완전하고 수많은 일시적 타입을 생성하는 반복적 재귀가 가능한 TypeScript의 타입 시스템에서는 이것이 최선의 전략이 아닐 수 있습니다.
[2] TypeScript 타입 체커에서 객체의 속성을 가져오면 멤버를 확인하게 되며, 이때 instantiateSymbolTable()이 호출됩니다.
[3] 저는 Zig 컴파일러 내부 전문가는 아니지만, InternPool에 대한 제 이해는 다음과 같습니다:
  • TypeScript 타입은 본질적으로 컴파일 타임 값입니다.
  • Zig는 TypeScript처럼 튜링 완전한 컴파일 타임 실행(comptime) 기능을 가지고 있으며, 수많은 컴파일 타임 값을 생성합니다(Zig의 comptime에서 타입 또한 값입니다).
  • Zig의 시맨틱 분석 패스(comptime 실행을 수행함) 또한 멀티스레딩과 데이터 공유 또는 중복 문제를 가지고 있습니다. 현재 Semal은 싱글 스레드이지만, 그 값을 읽는 다른 AstGen 스레드들이 있습니다.
  • Zig의 솔루션은 RCU에서 약간의 영감을 받았습니다.
  • 기본적으로: 스레드는 데이터를 공유할 수 있고, 읽기 작업은 락 프리(Lock-free)이며, 쓰기 작업은 읽기 작업을 차단하지 않고 공유 값을 업데이트할 수 있습니다.
  • 각 스레드는 컴파일 타임 값을 저장할 수 있는 고유한 스레드 로컬 저장소(Thread local storage)를 가집니다.
  • 값이 어느 스레드에 있는지 찾기 위해 사용할 수 있는 전역 "샤드(Shard)" 목록이 있습니다(기본적으로 값의 해시 -> (스레드, 인덱스)를 매핑하는 맵).
  • 각 값은 해시되어 어느 샤드에 속할지 결정됩니다.
  • 값을 조회할 때, 해시를 생성하고 원자적 연산(Atomics)을 사용하는 샤드에서 조회하며 락은 사용하지 않습니다.
  • 스레드가 값을 생성할 때, 자신의 스레드 로컬 저장소에 저장하고 쓰기 뮤텍스(Writer mutex)를 사용하여 해당 샤드를 업데이트합니다.
  • 이는 몇 가지 성능 특성을 가집니다:
  • 읽기에는 락이 전혀 필요 없어 매우 빠릅니다.
  • 쓰기에는 락이 필요하지만 샤드 단위로만 걸리며, 데이터가 여러 샤드로 나뉘어 있어 단일 읽기/쓰기 뮤텍스보다 경합(Contention)이 적습니다.
  • Zig의 comptime 값은 불변(Immutable)이므로, 쓰기는 본질적으로 생성 시점에만 발생합니다.
0
1

댓글

?

아직 댓글이 없습니다.

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

Inkyu Oh님의 다른 글

더보기

유사한 내용의 글