30MB JSON 을 import 했더니 tsc 가 heap OOM — ?url + fetch 로 우회
resolveJsonModule 은 JSON 값에서 리터럴 타입을 통째로 추론한다. 런타임과 번들링은 멀쩡한데 타입체크만 죽고, 힙을 키워도 해결되지 않는다.
Vite + TypeScript 프로젝트에서 수십 MB 짜리 정적 JSON 을 다른 데이터 파일들처럼 그냥 import 했다. 정적이든 동적이든 상관없이 tsc -b 가 타입체크 단계에서 죽었다.
FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory
<--- Last few GCs --->
[...] Mark-Compact 4085.7 (4096.7) -> 4085.6 MB [...] allocation failure
두 가지가 이상했다.
--max-old-space-size=8192로 힙을 키워도 여전히 죽는다. 더 오래 먹다가 죽을 뿐이다.- 런타임과
vite build번들링은 멀쩡하다. tsc 타입체크만 터진다.
원인 — 리터럴 타입 추론
tsconfig 의 "resolveJsonModule": true 는 import 한 JSON 의 값에서 리터럴 타입을 통째로 추론한다. { "a": 1 } 을 import 하면 타입이 { a: number } 가 아니라 그 구조 그대로다.
배열 29,364개에 각 항목이 중첩 객체·배열이면, TypeScript 가 그 전체를 리터럴 유니언과 튜플로 모델링하려 든다. 파일이 클수록 비용이 급격히 커진다.
같은 프로젝트의 5~16MB JSON 들은 아슬아슬하게 통과했다. 30MB 에서 확실히 넘어갔다. 크기 임계가 있다는 뜻이고, 그래서 어느 날 갑자기 터진 것처럼 느껴진다. 데이터가 조금 늘었을 뿐인데.
힙을 키워도 소용없는 이유가 여기 있다. 메모리가 모자란 게 아니라 추론 자체가 폭발하는 것이다.
허탈한 건 그다음이다. 호출부는 어차피 이렇게 쓰고 있었다.
const arr = crossRefsJson as unknown as RawEntry[];
즉시 캐스팅해서 버린다. 리터럴 타입이 쓸모도 없는데 추론 비용만 낸 것이다.
해결 — ?url 로 URL 만 받는다
ESM JSON import 대신 Vite 의 ?url 접미사를 쓴다. 문자열 URL 만 받고 런타임에 fetch 로 읽으면, tsc 는 JSON 내용을 볼 일이 없어 추론 자체가 사라진다.
// import crossRefsJson from '../data/cross-references.json'; // ❌ tsc OOM
import crossRefsUrl from '../data/cross-references.json?url'; // ✅ URL 문자열만
const res = await fetch(crossRefsUrl);
const arr = (await res.json()) as RawEntry[];
부수 효과가 나쁘지 않다.
- Vite 가 파일을 정적 에셋으로 emit 하고 해시 URL 을 준다.
vite/client타입에*.json?url→string선언이 이미 들어 있어 별도 declaration 이 필요 없다. - 코드 스플릿이 유지된다. 오히려 JS 청크에 안 실리니 더 깔끔하다.
작은 JSON 들은 굳이 안 바꿔도 된다. 문제되는 큰 파일만 이 방식으로 옮긴다.
통하지 않은 우회들
기록해둔다. 그럴듯해 보여서 시간을 썼다.
전역으로 JSON 추론 끄기 — *.d.ts 에 이렇게 넣어봤다.
declare module '*.json' { const v: unknown; export default v }
효과 없었다. Vite client 타입의 기존 JSON 선언과 병합·우선순위 문제로 tsc 가 여전히 실제 파일을 추론했다. 게다가 전역이라 통했다 해도 다른 JSON 의 정상 타입까지 죽인다.
import attributes — with { type: 'json' } 는 resolveJsonModule 추론을 끄지 않는다. 모듈 형식을 명시하는 문법이지 타입 추론과 무관하다.
헤드리스 검증에서 한 번 더 걸린다
?url 이 만드는 건 root-relative 경로다.
/src/data/cross-references.json
브라우저에서는 origin 이 붙어 정상 동작한다. 그런데 Node 에서 ssrLoadModule 로 통합테스트를 돌리면 location 이 없어서 터진다.
TypeError: Failed to parse URL
검증 스크립트에서는 Vite 미들웨어를 실제 http 서버로 띄우고 fetch 를 base URL 로 보정한다.
const httpServer = http.createServer(server.middlewares).listen(0);
const base = `http://localhost:${httpServer.address().port}`;
const realFetch = globalThis.fetch;
globalThis.fetch = (u, i) =>
realFetch(typeof u === 'string' && u.startsWith('/') ? base + u : u, i);
브라우저 실행에는 이 보정이 필요 없다. 검증 전용이다. ssrLoadModule 통합테스트를 짜는 방법 자체는 Vite ssrLoadModule 로 브라우저 없이 통합테스트에 따로 정리했다.
정리
resolveJsonModule 은 작은 설정 JSON 을 타입 안전하게 읽으려고 만든 기능이다. 수만 건짜리 데이터 파일에 쓰라고 만든 게 아니다.
판단 기준은 단순하다. JSON 의 실제 구조에서 타입을 얻을 생각이 없다면 import 할 이유가 없다. ?url 로 받아서 fetch 하고, 타입은 직접 선언한 인터페이스로 붙인다.