TypeScript 7 병행 설치와 빌드 타입 게이트 도입
daisy-monorepo에 TypeScript 7을 기존 TS 5.5.4와 병행해서 넣고, 빌드 파이프라인의 타입 게이트로 세웠다.
TypeScript 7은 컴파일러를 TypeScript(JS) 대신 Go로 다시 쓴 버전이다. 문법이 새로 생긴 게 아니라 검사기 자체를 바꾼 것이라, 같은 코드를 훨씬 빠르게 검사한다. 다만 속도만 바뀌는 건 아니다. 7.0은 6.0의 새 기본값을 그대로 받고, 6.0에서 없어진 옵션과 문법은 에러로 막는다. 그래서 TS5에서 통과하던 코드가 걸리기도 한다.
npm view typescript dist-tags
# latest: 7.0.2latest 태그가 7.0.2를 가리키고 있었다. 베타가 아니라 정식 배포 상태였다. 설치하면 내 OS와 CPU에 맞는 Go 실행 파일이 같이 딸려 온다. 실행하는 명령어 이름은 예전 그대로 tsc인데, 이게 나중에 발목을 잡았다.
정식 배포 전에 미리 써보라고 나왔던 @typescript/native-preview라는 패키지도 있다. 이쪽은 명령어 이름이 tsgo라 겹치지 않지만, 7.0.0-dev.20260707.2에서 업데이트가 멈춰 정식 7.0 이전 상태다. 그래서 typescript@7을 썼다.
typescript 의존성을 그냥 7로 올릴 수 없었다
devDependency 버전만 올리면 될 것 같았지만, 이 레포에는 제약이 걸려 있었다.
| 제약 | 내용 |
|---|---|
next build | Next가 require('typescript')로 해석되는 버전으로 타입체크를 수행 |
typescript-eslint ^8.46 | @daisy/configs의 의존성. peer 범위가 6 미만 |
lint 스크립트 | pnpm type-check && eslint — 위 둘에 모두 걸림 |
버전을 갈아끼우면 type-check만이 아니라 build와 lint가 같이 깨진다. 그래서 병행 설치(side-by-side)를 택했다. pnpm은 npm:패키지@버전 형태의 별칭을 지원해서, 같은 패키지를 다른 이름으로 하나 더 설치할 수 있다.
// 루트 package.json
"devDependencies": {
"typescript7": "npm:typescript@7.0.2"
}앱 쪽은 기존 스크립트를 건드리지 않고 TS7용 스크립트를 따로 하나 더 뒀다.
// apps/<app>/package.json
"type-check": "tsc --skipLibCheck --noEmit",
"type-check:ts7": "node ../../node_modules/typescript7/bin/tsc --skipLibCheck --noEmit".bin/tsc 심링크가 조용히 덮어써지고 있었다
typescript와 typescript7은 둘 다 bin: { "tsc": ... }를 선언한다. 같은 node_modules에 나란히 설치하면 .bin/tsc 심링크가 하나뿐이라 나중에 이긴 쪽이 덮어쓴다.
처음에 앱별로 설치했다가 이 사고가 났다.
# 앱별 설치 직후
apps/lineup/node_modules/.bin/tsc --version
# Version 7.0.2 — 기존 type-check가 TS7으로 실행되고 있었다조용하다는 게 문제였다. 설치가 막히는 것도 아니고, 기존 스크립트의 의미만 바뀐다.
루트 node_modules에는 원래 typescript가 없었으므로 충돌 대상도 없었다. 그래서 루트에만 설치했다.
| 앱별 설치 | 루트 설치 (채택) | |
|---|---|---|
apps/*/.bin/tsc | 7.0.2로 덮어써짐 | 5.5.4 유지 |
기존 type-check | 몰래 TS7 | TS5 그대로 |
next build 내부 체크 | TS5 | TS5 |
루트 pnpm exec tsc | 7.0.2 | 7.0.2 (원래 없었으므로 회귀 아님) |
앱 스크립트가 bin이 아니라 node ../../node_modules/typescript7/bin/tsc처럼 경로로 직접 호출하는 것도 같은 이유다. bin 이름에 기대면 어느 쪽이 불릴지 보장되지 않는다. 설치 후에는 --version을 찍어 실제로 무엇이 불리는지 확인했다.
next build 안에서 tsc는 검사만 하고 있었다
next build가 하는 일은 두 갈래로 나뉜다.
타입은 JS로 변환될 때 어차피 다 지워진다. 타입체크는 빌드 산출물에 아무 영향을 주지 않고, "틀린 코드를 배포하지 말라"는 게이트 역할만 한다.
게이트일 뿐이라면 어디서 돌리든 상관없다. 빌드 안이든 밖이든, 실패했을 때 배포가 멈추기만 하면 같다. 그래서 타입체크를 빌드 바깥으로 꺼내기로 했다.
동시에 빌드가 4배 빨라지지 않는 이유이기도 하다. 빌드 시간 대부분은 tsc가 아니라 번들링이 쓴다.
타입 판정을 next build 바깥으로 꺼냈다
build 스크립트를 next typegen && tsc7 --noEmit && next build 체인으로 바꿨다.
turbo run build(turbo는 모노레포의 태스크 실행과 캐시를 맡는 러너다)의 dependsOn: ["^build"]는 packages/*에 build 스크립트가 없어서 아무 일도 하지 않고, 앱 3개가 병렬로 돌아간다.
1단계 next typegen은 .next/types/routes.d.ts를 생성한다. next-env.d.ts가 이 파일을 /// <reference path>로 참조하므로, .next가 없는 clean 체크아웃(CI)에서도 먼저 만들어줘야 한다. 3단계 next build는 자체 타입체크를 건너뛰고 SWC 트랜스파일, 번들, prerender/최적화만 한다.
건너뛰게 만든 건 3개 앱 공용 프리셋의 한 줄이다.
// packages/configs/src/next.cjs
typescript: {
ignoreBuildErrors: true,
},ignoreBuildErrors로 얻은 것과 잃은 것
ignoreBuildErrors의 기본값은 false다. 아무 설정도 안 하면 next build가 알아서 타입체크를 한다. 그 상태로 앞단에 tsc7을 붙이면 같은 검사를 느린 구버전으로 한 번 더 한다.
| 얻는 것 | 잃는 것 |
|---|---|
| 중복 체크 제거 (약 4s) | next build 단독 실행 시 타입체크가 전혀 없음 |
| 최신 컴파일러로 검사 | 게이트가 package.json 스크립트에 의존 |
잃는 쪽은 추상적인 걱정이 아니었다. 일부러 타입 에러를 넣고 양방향으로 확인했다.
// src/__ts7-gate-probe.ts (검증 후 삭제)
export const probe: number = 'definitely not a number'| 실행 | 결과 |
|---|---|
pnpm build | error TS2322, next build 진입 전 중단 |
next build 단독 | Compiled successfully in 2.9s, 그냥 통과 |
그래서 Vercel의 Build Command는 반드시 package.json의 build 스크립트를 거쳐야 한다. Vercel Next.js 기본값인 next build로 잡혀 있으면 게이트가 통째로 우회된다. 게이트를 타는 건 pnpm build, pnpm --filter <app> build, turbo run build --filter=<app>이고, 우회되는 건 next build와 cd apps/<app> && next build다.
같은 이유로 platform의 analyze 스크립트도 ANALYZE=true next build에서 ANALYZE=true pnpm build로 바꿨다.
게이트와 같은 검사는 type-check:ts7로 따로 돌린다. 기존 TS5 검사도 그대로 남겨뒀다.
# TS7 타입체크 (빠름, 게이트와 동일한 검사)
pnpm type-check:ts7 # 3개 앱 전체
pnpm --filter lineup type-check:ts7 # 앱 단독
# TS5 타입체크 (기존 그대로, 전 워크스페이스)
pnpm type-check
# 빌드 (typegen, tsc7 게이트, next build)
pnpm build
pnpm --filter lineup buildTS7에서만 새로 잡힌 에러 두 종류
같은 소스인데 TS7에서만 에러가 난 케이스가 둘 있었다. 버그가 생긴 게 아니라 TS7이 더 엄격해진 쪽이다.
TS2882 — side-effect import
import './globals.css'값을 가져오지 않고 파일만 불러오는 import다. globals.css는 디스크에 실제로 있지만 TS 입장에선 타입 선언이 없는 파일이고, Next 15의 next-env.d.ts는 *.css에 대한 ambient 선언(별도 import 없이 전역으로 적용되는 타입 선언)을 제공하지 않는다.
TS5는 noUncheckedSideEffectImports 기본값이 false라 side-effect import가 선언 없이도 통과했다. TS7은 이 옵션 기본값이 true로 바뀌면서 선언을 요구하고, TS2882가 났다.
앱마다 한 줄짜리 shim(빠진 타입 선언을 채워 넣는 .d.ts 파일) 하나를 두는 걸로 끝냈다.
// apps/<app>/ts7-shim.d.ts
declare module '*.css'tsconfig.json의 include에 **/*.d.ts가 있어 자동으로 잡히고, TS5에도 무해하다.
TS2322 — JSX props의 union splitting
@daisy/ui의 ButtonProps는 판별 유니온(discriminated union)이다. 특정 필드 값에 따라 나머지 필드의 허용 범위가 달라지는 타입이다.
type ValidCombo =
| { variant?: 'solid'; color?: Exclude<ColorOption, 'white'> }
| { variant?: 'outline'; color?: Exclude<ColorOption, 'white'> }
| { variant?: 'ghost'; color?: ColorOption }ghost만 white를 허용한다. 여기에 union 값을 그대로 넘기면 어느 멤버에 맞춰야 할지가 정해지지 않는다.
{VARIANTS.map((variant) => (
<Button variant={variant} />
))}variant의 타입은 'solid' | 'outline' | 'ghost'다. TS5는 통과시켰고 TS7은 TS2322를 냈다.
ui-preview 데모 코드였으므로 공용 타입을 건드리지 않고 로컬에서 우회했다. 바로 옆에 이미 같은 이유의 color as never가 있었다.
- variant={variant}
+ variant={variant as never}판별 유니온 props에 union 값을 그대로 넘기는 코드에서 TS5와 TS7의 판정이 갈렸다.
영향이 없었던 것들도 있다. baseUrl은 TS7에서 제거됐지만 이 레포엔 애초에 없고 paths가 전부 ./src/* 상대경로라 무관했다. plugins: [{ name: 'next' }]는 language service 전용이라 CLI tsc가 원래 무시한다. moduleResolution: "Bundler"도 그대로 동작했다.
4배는 타입체크였고 전체 빌드는 16%였다
타입체크 단독 비교(warm, best-of-3).
| 앱 | tsc 5.5.4 | tsc 7.0.2 | 배율 |
|---|---|---|---|
| platform | 1.03s | 0.23s | 4.5배 |
| admin | 0.95s | 0.22s | 4.3배 |
| lineup | 0.90s | 0.22s | 4.1배 |
빌드 전체는 lineup 기준, 매 회 .next를 삭제한 cold 상태에서 best-of-2로 쟀다.
| 구성 | 시간 |
|---|---|
기존: next build (내부 TS5 체크 ON) | 18.34s |
next build 순수 (체크 OFF) — 하한선 | 14.37s |
신규: pnpm build (typegen, tsc7, build) | 15.43s |
기존 18.34s = 번들링 14.37s + TS5 체크 3.97s
신규 15.43s = 번들링 14.37s + typegen 0.24s + TS7 체크 0.32s + pnpm 오버헤드 ~0.5s
절감 3.97s − 재투입 1.06s = 순 2.91s (약 16%)빌드 시간의 93%가 타입체크와 무관한 번들링이라 개선폭이 희석된다. 컴파일러를 4배 빠르게 만들어도 전체에서 7%를 차지하는 구간을 줄이는 것이라, 아무리 잘해야 7% 근처가 상한이다. 배율이 아니라 그 구간이 전체에서 차지하는 비중이 개선폭을 정한다.
체감이 큰 쪽은 빌드가 아니라 타입체크만 반복해서 돌리는 상황이다. 커밋 훅, CI의 독립 type-check job, 저장 시 검사 같은 것들.
| 구간 | 영향 | 이유 |
|---|---|---|
next dev --turbopack | 없음 | dev는 애초에 타입체크를 안 함 |
| 에디터 빨간 줄 | 없음 | VS Code가 쓰는 TS 언어 서버는 typescript7 별칭과 무관 |
pnpm lint | 없음 | typescript-eslint가 자체 TS 5.5.4 사용 |
| turbo 캐시 히트 | 0s | 소스 무변경이면 3단계 전체 스킵 |
측정할 때 첫 사이클은 전부 5~6s씩 부풀어 나왔다(23.8 / 17.68 / 15.72). 시스템 캐시 워밍 노이즈라 수렴한 2회차 값을 썼다.
위 수치는 이렇게 쟀다. 빌드 비교는 매 회 .next를 지우고, 첫 사이클은 버렸다.
cd apps/lineup
./node_modules/.bin/tsc --skipLibCheck --noEmit # TS5
node ../../node_modules/typescript7/bin/tsc --skipLibCheck --noEmit # TS7
rm -rf .next && time node_modules/.bin/next build게이트 동작은 프로브 파일을 넣고 확인했다. pnpm build는 tsc7에서 중단됐고, next build 단독은 통과했다. 통과하는 게 ignoreBuildErrors가 살아 있다는 뜻이라 정상이다.
cd apps/lineup
echo "export const probe: number = 'nope'" > src/__probe.ts
pnpm build
node_modules/.bin/next build
rm src/__probe.ts조용히 어긋날 수 있는 지점들
- Vercel Build Command가
pnpm build계열이 아니라next build로 잡혀 있으면 게이트가 통째로 우회된다. - 새 앱을 추가하면
type-check:ts7스크립트와ts7-shim.d.ts도 같이 넣어야 한다. - 루트에서
pnpm exec tsc는 이제 7.0.2다. 이전엔 루트에 tsc 자체가 없었다. packages/cli-plugins는 자체 typescript가 없어 루트로 폴백해 7.0.2를 본다..mjs패키지에type-check스크립트도 없어 현재는 무해하다.type-check:ts7은 아직lint에 엮지 않았다. 의도적으로 뺐다.
이번 작업과 무관한 기존 문제도 하나 보였다. .vscode/settings.json의 "typescript.tsdk": "apps/space/node_modules/typescript/lib"가 존재하지 않는 경로를 가리킨다(apps/space가 없다). 에디터가 VS Code 내장 TS로 폴백 중이다.
변경된 파일
| 파일 | 변경 |
|---|---|
package.json (root) | typescript7 devDep, type-check:ts7 스크립트 |
turbo.json | type-check:ts7 태스크 |
apps/*/package.json | build 체인 교체, type-check:ts7 추가 (platform은 analyze도) |
apps/*/ts7-shim.d.ts | 신규. declare module '*.css' |
packages/configs/src/next.cjs | typescript.ignoreBuildErrors: true + 머지 라인 |
apps/platform/.../ui-preview/sections/button-section.tsx | variant as never 3곳 |