TypeScript 7을 타입 검사에만 쓰기 — 빌드와 lint는 5.x에 남겨 둔 이유

정기창·

타입 검사 명령만 TypeScript 7로 바꿨습니다. 빌드와 lint, next build는 TypeScript 5.x에 그대로 두었습니다. 8코어 CI에서 타입 검사 단계는 24~28초에서 5~7초가 되었습니다.

「CI 테스트 시간 줄이기」 연재의 두 번째 글입니다. 1편에서는 게이트 전체를 재는 법과 러너 교체로 얻은 몫을 정리했고, 이번에는 그 안의 타입 검사 단계를 다룹니다. 줄어든 시간은 20초 남짓으로 크지 않습니다. 그래도 7을 저장소 전체가 아니라 명령 하나에만 들이며 만난 함정들은 다른 pnpm 모노레포에서도 그대로 나올 것 같다는 생각이 들어 따로 적어 둡니다.

대상은 B2B 커머스 운영 시스템을 담은 pnpm 모노레포입니다. NestJS로 만든 api와 worker, Next.js로 만든 web, 공유 패키지 다섯 개로 이뤄져 있습니다. Node 24, pnpm 10.4, TypeScript 5.9.3과 7.0.2를 썼고, 작업은 AI 코딩 에이전트(Claude Code)와 함께 했습니다.

타입 검사는 뺄 수 없는 단계였습니다

CI 게이트는 설치, 공유 패키지 빌드, 타입 검사, lint, 단위 테스트, web 빌드, e2e 순서로 돕니다. 러너를 바꾼 뒤 타입 검사 단계는 24~28초였습니다.

아예 빼면 어떨까 생각해 봤지만 그럴 수 없었습니다. vitest는 트랜스파일만 하고 타입을 보지 않아서, 타입 오류가 단위 테스트 전량 통과 뒤에 숨은 채 통합 브랜치의 타입 검사가 여섯 시간 동안 빨갛게 남은 적이 있었습니다. next build도 tsc를 대신하지 못했습니다. next build는 성공했는데 tsc --noEmit이 실패한 경우와 그 반대를 둘 다 겪었습니다. 세 검사는 서로 다른 것을 잡고 있었습니다.

솔직히 말하면 이 작업은 이득의 크기를 따져 시작한 일이 아니었습니다. 다른 작업은 미리 재 보고 러너 흔들림(±5%) 안이면 넣지 않았지만, 타입 검사만은 크기와 상관없이 해 보기로 먼저 정했습니다. 그만큼 약해지지 않았다는 것을 보이는 데 공을 들였습니다.

TypeScript 7을 통째로 들일 수 없었습니다

TypeScript 7은 컴파일러를 Go로 다시 구현한 판이고, npm 패키지 이름은 그대로 typescript입니다. 2026년 7월의 7.0 공식 발표는 7.0이 API 없이 나오며 새 API는 7.1에서 나올 것으로 예상한다고 적고 있습니다. 설치한 7.0.2를 열어 보니 exports의 .이 가리키는 lib/version.cjs는 version과 versionMajorMinor 두 값만 내보냈습니다.

그런데 저장소에는 typescript를 라이브러리로 부르는 도구가 여럿 있습니다. next build의 타입 검사, typescript-eslint, @nestjs/cli의 tsc 빌더입니다. 이 도구들이 7을 집으면 필요한 함수가 없어 멈춥니다. typescript-eslint 8.66은 다행히 시작하자마자 typescript-eslint does not support TS 7.0이라는 오류로 분명하게 멈춥니다. 그래서 역할을 나눴습니다.

쓰임TypeScript이유
각 패키지의 typecheck 스크립트7.0.2실행 파일만 필요
공유 패키지 빌드(선언 파일 생성)5.9.3산출물을 바꾸지 않기 위해
nest build · next build · ESLint5.9.3컴파일러 API 사용

공식 발표가 안내하는 길은 조금 다릅니다. 6.0 API를 다시 내보내는 호환 패키지 @typescript/typescript6을 typescript 이름에 별칭으로 걸고 7을 다른 이름으로 두는 방식입니다. 이번에는 빌드와 lint 쪽 버전을 건드리지 않기로 먼저 정했기 때문에 5.x를 그대로 두었습니다.

7은 별도 패키지에 두고, 루트는 5.x로 고정했습니다

처음에는 루트에 "typescript7": "npm:typescript@7.0.2" 같은 별칭으로 깔 생각이었습니다. 하지만 pnpm은 별칭이 아니라 진짜 이름인 typescript를 봤습니다. 루트의 typescript-eslint와 commitlint가 peer로 요구하는 typescript가 7로 풀렸고, 실행 파일 이름 tsc가 겹치자 루트 node_modules/.bin/tsc도 7을 가리켰습니다.

그래서 7을 워크스페이스 패키지 tools/tsc7 안에만 설치했지만, 이것만으로는 부족했습니다. 루트에 typescript를 직접 선언하지 않은 상태라 루트 도구들의 peer가 여전히 7로 풀렸고, 루트에 "typescript": "^5.7.2"를 명시하고서야 정리됐습니다. 아래는 이 글을 쓰며 pnpm 10.4.1로 작은 저장소를 만들어 다시 확인한 결과입니다.

구성루트 typescript-eslint의 typescript루트 .bin/tsc
7 없음(기준)5.9.35.9.3
루트에 별칭7.0.27.0.2
별도 패키지만7.0.27.0.2
별도 패키지 + 루트에 5.x 명시5.9.35.9.3

눈여겨볼 것은 설치가 실패하지 않는다는 점입니다. unmet peer 경고가 몇 줄 찍히고 종료 코드는 0이라, 경고를 놓치면 lint가 멈추고 나서야 알게 됩니다. 워크스페이스 목록에 tools/*를 더하고, tools/tsc7/package.json에는 7만 둡니다.

# pnpm-workspace.yaml
packages:
  - 'apps/*'
  - 'packages/*'
  - 'tools/*'
{
  "name": "@acme/tsc7",
  "version": "0.0.0",
  "private": true,
  "devDependencies": {
    "typescript": "7.0.2"
  }
}

설치 뒤에는 두 가지를 확인합니다. 잠금 파일에 (typescript@7이 보이면 어떤 패키지의 peer가 7로 풀렸다는 뜻입니다.

pnpm exec tsc -v                        # 루트에서 Version 5.x 가 나와야 합니다
grep -n "(typescript@7" pnpm-lock.yaml  # 아무것도 나오지 않아야 합니다

실행기는 버전을 확인하고 분명하게 실패합니다

각 패키지의 typecheck는 tsc를 이름이 아니라 실행기의 경로로 부릅니다(node ../../tools/tsc7/tsc7.mjs -p tsconfig.typecheck.json --noEmit). 실행기는 자기 위치를 기준으로 typescript를 찾으므로 tools/tsc7 안의 7.0.2를 집습니다.

// tools/tsc7/tsc7.mjs
import { readFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import { dirname, join } from 'node:path';
import { pathToFileURL } from 'node:url';

const require = createRequire(import.meta.url);

let manifestPath;
try {
  manifestPath = require.resolve('typescript/package.json');
} catch {
  console.error('tsc7: typescript 가 없습니다 — 루트에서 pnpm install 을 실행하세요.');
  process.exit(2);
}

const { version } = JSON.parse(readFileSync(manifestPath, 'utf8'));
if (!/^7\./.test(String(version))) {
  console.error(`tsc7: typescript 가 ${version} 으로 풀렸습니다 — 7.x 여야 합니다.`);
  process.exit(2);
}

await import(pathToFileURL(join(dirname(manifestPath), 'lib', 'tsc.js')).href);

버전을 확인하는 이유는 초록불의 뜻을 지키기 위해서입니다. 7이 빠지거나 다른 버전으로 풀린 채 통과하면 7로 검사했다는 초록이 거짓이 됩니다. 그래서 실행기 자신의 실패는 종료 코드 2로 나가게 했습니다. 마지막 줄의 lib/tsc.js는 인자와 종료 코드를 네이티브 실행 파일과 그대로 주고받습니다.

상황종료 코드
정상0
7에서 타입 오류 · 설정 파일 없음1
typescript가 5.x로 풀림 · 없음2

같은 타입 오류에 5.9는 2를, 7은 1을 돌려줬으니 특정 종료 코드와 비교하는 스크립트가 있다면 손봐야 합니다.

7이 거절한 tsconfig 옵션은 7 전용 설정에서만 덮었습니다

원래 설정 그대로는 7이 돌지 않았습니다. 걸린 것은 네 가지였습니다.

  • moduleResolution: "node"(node10)가 없어졌습니다(TS5108). 공식 발표는 nodenext나 bundler를 권합니다.
  • baseUrl이 없어졌습니다(TS5102).
  • types의 기본값이 빈 배열이 되어 @types/*를 자동으로 싣지 않습니다. 1,500건이 넘는 오류의 대부분이 Buffer나 fetch를 찾지 못한 것이었습니다.
  • web에서는 import './globals.css' 같은 부작용 import가 TS2882로 막혔습니다.

작은 재현에서 앞의 두 가지를 다시 낸 출력입니다(긴 줄은 줄였습니다).

tsconfig.json(5,25): error TS5108: Option 'moduleResolution=node10' has been removed. ...
tsconfig.baseurl.json(4,5): error TS5102: Option 'baseUrl' has been removed. ...

원래 설정을 고칠 수도 없었습니다. 5.x는 반대로 bundler와 module: commonjs의 조합을 TS5095로 거절합니다. 빌드까지 node16으로 맞추는 길은 5.x 빌드의 동적 import 출력이 바뀌어 운영 동작이 달라질 수 있어 쓰지 않았습니다. 검사를 위해 빌드를 바꾸는 것은 순서가 거꾸로라는 생각이 들었습니다. 그래서 프로젝트마다 7 전용 설정을 두고, 원래 설정을 extends한 뒤 두 가지만 덮었습니다. api와 worker, 백엔드 쪽 공유 패키지의 tsconfig.typecheck.json입니다.

{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "moduleResolution": "bundler",
    "types": ["node"]
  }
}

web은 이미 bundler를 쓰고 있어 moduleResolution은 두고, types와 함께 "noUncheckedSideEffectImports": false를 넣어 부작용 import 검사를 5.x의 기본과 맞췄습니다. CSS는 Next가 번들하기 때문입니다. Node 타입이 필요 없는 공유 타입 패키지는 moduleResolution만 덮었고, 테스트 파일을 따로 검사하는 패키지는 테스트용 설정을 extends하는 7 전용 설정을 하나 더 두었습니다. 공식 발표에 따르면 types: ["*"]로 예전처럼 모두 싣게 할 수도 있는데, 우리는 node만 적었습니다.

baseUrl은 원래 설정에서 지웠습니다. api와 worker에는 거기에 기대는 import가 없었고, web의 paths는 baseUrl이 없으면 설정 파일 위치를 기준으로 풀리는데 그 위치가 원래 값과 같았습니다.

해석 방식을 바꾸자 선언 두 벌이 드러났습니다

설정을 맞춘 뒤에도 DB 패키지의 스키마 테스트에서 오류 4건이 남았습니다. drizzle-orm 타입이 서로 대입되지 않는다는 TS2345였고, 5.x에서는 나지 않던 오류였습니다.

원인은 버전보다 해석 방식에 있었습니다. drizzle-orm 0.38.4의 CommonJS용 선언(.d.cts)이 getTableConfig()의 반환 타입에서 ESM용 선언(.d.ts)을 가리키고 있어서, exports를 읽는 bundler 해석에서는 직접 import한 MySqlColumn과 getTableConfig()가 돌려준 컬럼 타입이 서로 다른 사본이 되었습니다. protected 멤버가 있는 클래스 타입이라 두 사본은 대입되지 않았고, exports를 읽지 않는 node10 해석의 5.x에서는 드러나지 않았던 것으로 보입니다. 작은 재현에서도 5.x는 통과하고 7만 같은 오류를 냈는데, 메시지에 Two different types with this name exist, but they are unrelated라는 줄이 있었습니다. 함수가 쓰는 것은 getSQLType() 하나뿐이어서 인자 타입을 그만큼 좁혔습니다.

import type { MySqlColumn } from 'drizzle-orm/mysql-core';

// 전: function sqlTypeOf(column: MySqlColumn): string
export function sqlTypeOf(column: Pick<MySqlColumn, 'getSQLType'>): string {
  return column.getSQLType();
}

빨라진 만큼 약해지지 않았는지 확인했습니다

빠르지만 덜 잡는 검사는 느린 검사보다 나쁩니다. 초록불의 뜻이 조용히 줄어들기 때문입니다. 그래서 계획 단계부터 이 확인을 별도 단계로 떼어 두었습니다.

먼저 앱과 패키지 다섯 곳(api와 worker 진입점, DB 패키지 소스와 테스트, web 공용 모듈)에 const x: number = 'a'를 한 줄씩 넣고 5.x와 7을 모두 돌렸습니다. 다섯 곳 모두 두 버전이 함께 실패했고, 확인 뒤 되돌렸습니다. 저장소의 @ts-expect-error 21개를 무력화한 판에서도 두 버전이 같은 줄에서 오류를 냈습니다.

다음으로 설정 쌍 12개(5.x 원래 설정과 7 전용 설정)의 검사 대상 파일 목록을 비교했고, node_modules 밖의 목록은 모두 같았습니다. --noEmit을 빼면 설정에 따라 7이 출력 경로 오류(TS5011)를 섞어 내므로 붙여 두는 편이 안전합니다.

cd apps/api
pnpm exec tsc -p tsconfig.json --noEmit --listFilesOnly | grep -v node_modules | sort > /tmp/f5.txt
node ../../tools/tsc7/tsc7.mjs -p tsconfig.typecheck.json --noEmit --listFilesOnly | grep -v node_modules | sort > /tmp/f7.txt
diff /tmp/f5.txt /tmp/f7.txt && echo "same"

빌드 쪽은 baseUrl을 지운 전후로 공유 패키지 다섯 개의 dist와 api·worker의 nest build 결과물을 diff -r로 비교해 같음을 확인했습니다. web은 next build가 전후 모두 성공했고 라우트 표가 크기까지 같았습니다.

배포 이미지에는 7을 넣지 않았습니다. Dockerfile이 복사하는 매니페스트에서 tools/tsc7/package.json을 일부러 뺐고, 그래도 pnpm install --frozen-lockfile이 같은 패키지 수로 통과함을 같은 파일 구성으로 재현해 확인했습니다. 이미지 빌드 자체는 그 자리에서 돌리지 않았습니다.

push 훅은 바뀐 경로로 검사를 고르는데, 실행기만 바뀐 push에서는 타입 검사가 켜지지 않아 그 경로를 조건에 더했습니다. 검사기가 바뀌면 대상 코드가 그대로여도 검사는 다시 돌아야 한다는 생각이 들었습니다.

측정: CI에서 20초, push 훅에서 10~20초

CI는 GitHub Actions 8코어 러너에서, 코드와 무관한 lint와 web 빌드 시간으로 러너 상태를 가른 뒤 같은 등급끼리만 비교했습니다. 로컬은 M2 Pro 12코어입니다.

측정5.x7
CI 타입 검사 단계(정상 등급, 각 3회)24 · 28 · 26초7 · 6 · 6초
로컬 pnpm -r typecheck 첫 회21.6 · 20.8초5.7 · 6.6초
push 훅 타입 검사(첫 회 / 이후)26.5 / 16.5초6.9 / 6.1초

첫 회와 이후의 차이는 web 설정의 incremental 때문이고, 7 전용 설정도 그것을 물려받습니다. 프로젝트별 이득은 고르지 않았습니다. 도입 전에 설정별로 잰 값으로 web은 14.5초에서 1.0초, api는 9.7초에서 1.8초가 되었습니다. 반면 DB 패키지는 확정한 설정에서도 1.6초에서 1.5초로 거의 그대로였습니다.

공식 발표는 전체 빌드에서 대체로 8~12배 빨라진다고 적고 있지만, 우리 명령 단위로는 3~4배였습니다. pnpm이 패키지마다 프로세스를 띄우는 시간처럼 7과 무관한 몫이 섞였을 것이고, --checkers 같은 병렬 옵션도 기본값 그대로였습니다. 재는 대상이 다르니 섞어 읽지 않았으면 합니다.

게이트 전체로 보면 작은 숫자입니다. 개선 전 8코어 게이트 한 번(약 11분)에서 타입 검사 26초는 4% 정도였고, 연재에서 손댄 세 단계(타입 검사, 단위 테스트, e2e)에서 줄인 158초 가운데 타입 검사 몫은 20초입니다. 개선을 모두 넣은 게이트는 지금 약 9분입니다. 이 단계만 보면 2 vCPU에서 1.3분이던 것을 26초 안팎으로 만든 러너 교체가 훨씬 큰 몫을 했습니다.

차이가 크게 느껴지는 곳은 push 훅입니다. CI의 20초는 다른 일을 하며 흘려보낼 수 있지만, 훅의 10~20초는 push 명령이 끝나기를 그대로 기다리는 시간입니다. 반면 편집기는 이번 변경과 무관합니다. 언어 서비스가 여전히 5.x나 편집기 내장 버전을 쓰므로, 편집 중 반응 속도에 대해서는 이 글이 말할 것이 없습니다.

이 방법이 맞지 않는 경우

  • 타입 검사를 라이브러리로 하는 도구에 기대는 저장소: 공식 발표는 Volar처럼 TypeScript를 자기 컴파일러에 넣어 쓰는 도구가 아직 6.0에 기댄다고 적고 있습니다. 그런 도구로 검사한다면 7로 옮길 명령이 남지 않습니다.
  • 이미 6.0을 쓰는 저장소: 공식 호환 패키지를 쓰는 편이 단순할 수 있습니다. 시험해 보지는 않았지만, pnpm이라면 그때도 앞의 잠금 파일 확인은 해 두는 편이 안전합니다.
  • 타입 검사가 이미 몇 초 안에 끝나는 저장소: 설정 파일과 실행기라는 층이 늘어, 줄어드는 시간보다 관리할 것이 많아질 수 있습니다.
  • 빌드까지 7로 옮기려는 경우: 이 글의 범위가 아닙니다. 작은 재현에서 --noEmit을 빼자 rootDir를 요구하는 TS5011이 나왔듯 출력 쪽 변경이 더 필요합니다.

이번 측정이 증명하지 않는 것

  • 두 버전이 모든 코드를 똑같이 판정한다는 것은 아닙니다. 확인한 것은 node_modules 밖의 파일 목록, 일부러 넣은 오류 다섯 건, @ts-expect-error 21줄이고, 외부 선언이 해석 방식에 따라 다르게 실릴 수 있음은 drizzle 사례가 보여 줬습니다.
  • 검사와 빌드가 같은 세계를 본다는 것도 아닙니다. 백엔드의 타입 검사는 이제 bundler 해석으로, 빌드는 node10 해석으로 돕니다.
  • 자동으로 실리던 다른 전역 타입: 5.x는 api에서 body-parser·multer·supertest, web에서 react·react-dom의 타입을 자동으로 실었지만 7 전용 설정은 node만 싣습니다. 그 전역에 기대는 코드가 생기면 7 검사가 시끄럽게 실패하므로 조용히 약해지지는 않습니다.
  • CI 수치의 정밀도: 전후 각 3회이고 러너 흔들림이 몇 초씩 있어, 20초 안팎 줄었다는 것 이상으로 읽기는 어렵습니다.

마치며

정리하고 보니 7을 쓰게 하는 변경은 실행기 한 파일과 패키지마다 스크립트 한 줄이었습니다. 나머지는 7이 들어가면 안 되는 자리(루트의 tsc, lint의 peer, 배포 이미지)를 막고, 7이 거절하는 옵션을 7 쪽에서만 덮고, 검사가 약해지지 않았는지 확인하는 일이었습니다. 빠른 도구를 들이는 일은 그 도구가 닿을 범위를 정하는 일에 더 가깝다는 생각이 들었습니다.

7.1에서 새 API가 나오고 lint와 빌드 도구가 그것을 지원하게 되면, 실행기와 설정 두 벌을 둔 이 구성은 다시 판단할 대상이 됩니다. 다음 편에서는 단위 테스트 단계로 넘어가 vitest의 격리를 일부 파일에서만 끈 이야기를 하겠습니다.

TypeScriptTypeScript 7타입 검사pnpm모노레포CI