최근 회사 프로젝트를 하면서, 한 가지 마주한 불편함을 해소했던 것을 기록에 남겨 두고자 한다.
발단은 이러했다.
어느날, QA 엔지니어분께서 올려주신 버그 이슈 티켓.
브라우저 런타임시, 콘솔에 찍힌 로그를 살펴보니.
답은 더 분명해졌다.
그렇다...
"undefined".
이건 뭔가 데이터가 undefined일 수 있음이 뭔가 간과가 된 내 코드가 문제였다.
허나...
분명 프로젝트에서 data 스키마를 잘 정의해서 쓰고 있을 텐데.
그렇다.
현재는 open api 기반으로 코드를 제네레이팅 해 주는 코드젠을 사용하므로 undefined인 부분은 옵셔널로 받고 있었다.
그러면...
어디가 문제지?
다시 고민의 거듭하면서 혹시 싶어, data fetcing하는 곳의 코드에서 어떻게 타입이 추론되고 있는 지 살펴봤다.
그런데 이게 웬걸...
제대로 추론이 안 되는 게 바로 보였다.
예를 들어, 이런식이었다.(아래는 예시일 뿐 회사 코드는 아니다.)
interface ItemType {
id: number
item?: string[]
}
interface ShopData {
items: ItemType[]
}
async function fetchShopData(): Promise<ShopData> {
const res = await fetch('/api/shop')
return res.json()
}
async function 데이터패칭함수() {
const { items } = await fetchShopData()
items.forEach(({ id, item }) => {
console.log(id, item.length); // => 원래 이 부분은 item?.length가 되어야 할 부분.
})
}
지금 위의 예제어서 제대로 추론이 되어서 처리가 된다면 item.length가 옵셔널체이닝이 자동 완성이 되거나 했어야 했다. 허나... 현재 그게 안되고 있었다.
프로젝트 루트에는 tsconfig.json이 단 하나뿐이었다.
문제의 파일을 열어보니 다음과 같은 설정이 눈에 들어왔다.
{
"compilerOptions": {
"strict": false,
"allowJs": true,
"skipLibCheck": true,
"isolatedModules": true,
"noEmit": true,
// …생략
},
"include": [
"src",
"../vision1/src", // ← 다른 디렉토리 전체를 통째로 포함
"../vision1/src/shared/types/*.d.ts"
],
"exclude": ["commitlint.config.cjs"]
}
눈에 띈 핵심 포인트 둘
1. strict: false
컴파일러가 null / undefined 안전성을 전혀 검사하지 않는다.
→ item 이 옵셔널인데도 .length 를 그냥 호출해도 에러가 나지 않는다.
이 옵션은 널리 아시는 바와 같이, 이걸 false로 하는 순간 typescript의 다양한 strict 옵션들을 일괄적으로 꺼 버리는 것이다.
아래는 strict에 대한 typescript 공식 문서의 설명이다.

그래서 결론적으로 내가 마주한 undefined값에서 옵셔널 체이닝 등이 형성이 안 되는 이유는 바로, strictNullChecks도 꺼져 버리게 되기 때문이다.
그렇다면 이걸 그냥 true켜 버리면 끝 아닌가?

이렇게 생각하고 이걸 켠 순간... 악몽이 시작되었다. ㅜㅜ
바로...
런타임은 문제가 되지 않았지만(사실 이것도 문제였지만... 여기서는 맥락을 벗어나므로 생략), 빌드에서 타입 에러로 다 터지기 시작한 것이다...

그렇다. 결국은 다시 돌아가야했다.
문제는 다시 원점으로 돌아갔다.
빌드 때, 깨지는 두 부분에 대해 생각해야 했다.
먼저,
기이한 현재 회사의 프로젝트 구조를 검토해야 했다.
saige-vision-app/
├── vision1/ # Developer 제품군
├── vision2/ # Enterprise 제품군
├── node_modules/
├── dist-zip/ # 빌드 아카이브
├── .husky/ # Git hook 설정
├── .vscode/ # VSCode 환경 설정
├── scripts/ # DTS 및 환경 관련 스크립트
├── package.json # 루트 공통 패키지 설정
├── tsconfig.*.json # 타입스크립트 설정들
├── vite.*.config.ts # 각 앱별 Vite 설정
└── ...
살짝 배경 지식을 더 하자면,
현재 필자가 개발중인 제품군은 Enterprise 제품군인데,
이게 vision2라는 디렉토리 하위에서 작업중이다.
그런데 이 안에서 기존에 vision1에서 개발했던 내용들을 import해서 쓰고 오는 구조다.
그러다보니, 의존성이 엄청 심해졌고, 심지어는 지금처럼 서로 전혀 영향을 안 받을 것 같은 typescript도 이렇게 문제가 생겼다.
결국 정리해 보자면, strict를 true로 하는 순간, vision2의 타입 스크립트 설정 기반으로 vision1의 소스 코드도 전부 평가를 했고 그에 따라 ts 에러가 발생한 것이었다.
2. vision1 소스를 include
자매 프로젝트(vision1)의 TS 소스까지 한 config 에서 해석하다 보니
strict 를 켜면 수천 개의 에러가 터져 버린다.
→ 결국 “전체 strict OFF”로 굳어져 있던 상황.
그렇다면...
어떻게 또 고쳐야 할까?

행복한 고민 중독에 빠지게 되었다... ㅎㅎ
결국에는 두 가지 부분을 충족시키는 것이 키 포인트였다.
- tsconfig는 strict:true로 바꾸기.
- vision1에 영향은 주지 않으면서 vision2 기반으로만 수정할 방법 찾기.
그래서 이를 바탕으로 하나 씩 수정했다.
전반적인 전략은 위의 부제처럼, 외부 코드(vision1 등)는 타입만 가져오고, 우리의 코드(vision2) 이하 코드는 점진적인 strict 처리였다.
우선, 스크립트부터 작성했다.
아래는 스크립트와 그에 관한 설명이다.
두 개의 디렉터리(상위
../vision1, 하위vision2)가 하나의 리포 안에 공존한다.
vision2쪽에서 타입만 필요한vision1소스를 매번 컴파일-체크하지 않고
선언 파일(.d.ts) 로 한 번 캐싱해 두고,
vision1HEAD 가 바뀔 때만 새로 갱신한다.
\.vision1-head 만 남김 tsc --emitDeclarationOnly 로 vision1 소스를 선언-파일만 빌드 \.vision1-head 에 기록이렇게 하면 vision1 코드가 거대한데도 dev 서버 기동 속도는
git diff한 번 +tsc단일 패스(3~4 초)로 끝난다.
import { execSync } from 'child_process';
import fs from 'fs';
import path from 'path';
const ROOT_DIR = process.cwd(); // vision2 루트
const V1_DIR = path.resolve(ROOT_DIR, '..', 'vision1');
const CACHE_DIR = path.resolve(ROOT_DIR, 'src/caches/vision1-dts');
const STAMP_FILE = path.join(CACHE_DIR, '.vision1-head');
/* ────────────────────────────────────── ① currentHead 구하기 */
let currentHead;
try {
currentHead = execSync(
`git -C "${V1_DIR}" log -1 --format=%H -- .`,
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }
).trim() || 'no-git';
} catch {
currentHead = 'no-git'; // git 폴더가 없을 때
}
/* ────────────────────────────────────── ② cachedHead 읽기 */
let cachedHead = '';
try { cachedHead = fs.readFileSync(STAMP_FILE, 'utf8').trim(); } catch {}
/* ────────────────────────────────────── ③ 캐시 최신이면 종료 */
if (currentHead === cachedHead && fs.existsSync(CACHE_DIR)) {
console.log(`🔹 d.ts cache is up-to-date (${currentHead}). Skip generation.`);
process.exit(0);
}
/* ────────────────────────────────────── ④ 캐시 폴더 비우기 (.vision1-head 제외) */
console.log('▸ Cleaning cache (keep only .vision1-head)…');
fs.mkdirSync(CACHE_DIR, { recursive: true });
for (const name of fs.readdirSync(CACHE_DIR)) {
if (name !== '.vision1-head') fs.rmSync(path.join(CACHE_DIR, name), { recursive: true, force: true });
}
/* ────────────────────────────────────── ⑤ .d.ts 생성 */
console.log('▸ Generating d.ts from vision1 …');
try {
execSync(
[
'npx tsc',
`-p "${path.join(V1_DIR, 'tsconfig.json')}"`,
`--rootDir "${path.join(V1_DIR, 'src')}"`,
'--incremental',
`--tsBuildInfoFile "${path.join(CACHE_DIR, '.tsbuildinfo')}"`,
'--declaration --emitDeclarationOnly',
'--skipLibCheck',
'--noEmit false',
'--noEmitOnError false',
`--outDir "${CACHE_DIR}"`,
].join(' '),
{ stdio: 'inherit' }
);
} catch (e) {
console.error(e);
}
/* ────────────────────────────────────── ⑥ 성공 여부 체크 & 스탬프 갱신 */
const entries = fs.existsSync(CACHE_DIR) ? fs.readdirSync(CACHE_DIR) : [];
if (entries.length > 0) {
fs.writeFileSync(STAMP_FILE, currentHead + '\n');
console.log(`✅ d.ts cache refreshed (${currentHead})`);
process.exit(0);
} else {
console.error('⚠️ d.ts generation failed – cache empty');
process.exit(1);
}
이런 식으로 vision1의 src 코드를 d.ts로 뽑아냈다.

이걸 다시 pacakge.json의 스크립트에 등록해서 사용했다.(이 부분에 대해서는 다시 설명 예정)
자!
다음은 뭐다?!
바로 vision2(현재 필자가 작업중인 폴더)의 점진적인 strict 적용.
허나, 기존에 작업된 코드를 막 바꿀 수는 없기에...
여기도 스크립트를 하나 만들었다.
내용은 다음과 같다.
목적
✅ 새로 작성하는 파일은 strict-TS 로 검증하고
⚠️ 기존 레거시 코드(수천 개)는 일단 빌드될 수만 있게 둔다.
→ 타입 오류가 있는 옛 소스 에// @ts-nocheck를 자동 주입한다.
| 단계 | 내용 |
|---|---|
| ① | src/ 디렉터리 순회 – *.ts *.tsx 파일 탐색 |
| ② | src/caches/** (생성된 .d.ts 캐시) 건너뛰기 |
| ③ | 파일에 이미 @ts-nocheck 가 있으면 skip |
| ④ | 없으면 파일 헤더에 주석 두 줄 삽입 |
| ⑤ | 완료 로그 출력 |
주입되는 헤더
// TODO 타입 에러 해결 필요. // @ts-nocheck
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const ROOT = path.resolve(__dirname, '..'); // 리포 루트 (vision2)
const SRC_DIR = path.join(ROOT, 'src'); // 대상 루트
console.log('▶ tagging every .ts /.tsx under src/ …');
function processDir(dir: string) {
for (const dirent of fs.readdirSync(dir, { withFileTypes: true })) {
const fullpath = path.join(dir, dirent.name);
// ── 폴더면 재귀
if (dirent.isDirectory()) {
// caches/vision1-dts 같은 생성 산출물은 제외
if (fullpath.includes(path.join('src', 'caches'))) continue;
processDir(fullpath);
continue;
}
// ── .ts / .tsx 파일만
const isTS = dirent.isFile() && (fullpath.endsWith('.ts') || fullpath.endsWith('.tsx'));
if (!isTS) continue;
const content = fs.readFileSync(fullpath, 'utf8');
// 이미 태그돼 있으면 패스
if (content.includes('@ts-nocheck')) {
console.log(` ↷ skip: ${fullpath}`);
continue;
}
// 헤더 삽입
console.log(` ✔ patch: ${fullpath}`);
const header = `// TODO 타입 에러 해결 필요.\n// @ts-nocheck\n\n`;
fs.writeFileSync(fullpath, header + content, 'utf8');
}
}
processDir(SRC_DIR);
console.log('✅ done. all .ts/.tsx files (except src/caches) are tagged with @ts-nocheck');
이렇게 스크립트를 통해 vision2쪽은 ts에러가 나는 곳은 @ts-nocheck와 todo 등을 남겼다.

이것도 물론 pacakge.json에서 스크립트 등록했다.
자!
이제 끝났을까?

그렇다...
아직 해결해야 할 지점들이 존재한다.
다음은 예고드렸듯, 추가 고민 거리 및 해결 Point들이다.
단순히 스크립트만 짰다고 끝이 아니라,
결국 이걸 어떻게 사용하고 나 외에 누군가 함께 프로젝트를 한다고 가정했을 때, 추후의 확장 포인트까지 고려하는 것이 중요했다.
그래서 아래처럼 스크립트를 구성하고 고려했다.
컨텍스트
vision2/(신규 프런트) 안에서vision1/(레거시) 코드를 타입만 의존해 사용.- “레거시→점진적 타입 개선” 전략을 위해 세 가지 자동 스크립트를 추가했다.
| 스크립트 | 역할 | 왜 필요한가? |
|---|---|---|
tag:ts-nocheck | 기존 src/**/*.ts?(x) 파일에 // @ts-nocheck 자동 주입 | 레거시 코드에 쌓인 수백 개의 타입 오류를 한방에 mute → 새 코드만 strict 검사 |
generate:vision1-dts | vision1 전체를 declaration-only 빌드 → src/caches/vision1-dts/ 에 .d.ts 캐시 생성·업데이트 | 라이브 의존성 대신 타입 세이프만 가져와 컴파일 속도·격리성 ↑ |
ensure-dts-cache (postinstall) | ① 캐시 폴더가 비어 있으면 → generate:vision1-dts 실행② 이미 있으면 skip | 클론 후 첫 install 때만 1 회 캐시를 보장. 매 패키지 설치마다 느려지는 것 방지 |
typecheck | tsconfig.typecheck.json(strict on) 로 새 코드만 타입 검사 | 빌드 전에 “레거시 제외 + strict” 검증 |
dev:entp | ① generate:vision1-dts (캐시 최신화) → ② Vite dev 서버 | 개발 시에도 레거시 변경 사항 반영 + HMR 속도 유지 |
build:entp | ① 캐시 최신화 → ② strict 타입체크 → ③ Vite 빌드 | CI/배포용. 타입 깨지면 빌드 실패 하도록 파이프라인 잠금 |
여기서 추가로, postinstall 쪽도 고려했다.
이유는 신규로 이 프로젝트를 누가 했을 때, npm install만 해도 별도의 설정 없이 바로 이용하도록 하기 위함이었다.
그래서 스크립트에 추가 및 아래 내용으로 ensure-dts-cache.js 스크립트를 만들어 뒀다.
import { existsSync } from 'fs';
import { execSync } from 'child_process';
const cacheDir = 'src/caches/vision1-dts';
if (!existsSync(cacheDir)) { //
console.log('[vision-dts] cache not found – generating…');
// generate:vision1-dts 는 declaration-only 빌드 스크립트 -> vision1 캐시 생성
execSync('npm run generate:vision1-dts', { stdio: 'inherit' });
} else {
console.log('[vision-dts] cache already exists – skip'); // -> 이후에는 스킵
}
┌────────────────── npm install ───────────────────┐
│ ▼
ensure-dts-cache (postinstall) ───► d.ts 캐시 있음? ──► yes ──► skip
▲
no
│
generate:vision1-dts (1 회)
필자에게 이렇게 하고난 뒤에 하나의 문제가 발생했다.
바로, ide에서 원본 소스 코드를 추적하면 d.ts 캐시 파일만 추적이 되어 불편했다.
이를 확인해 보니, run 타임이나, ide는 tsconfig.json이라는 파일만 찾아서 확인한다고 한다.
그래서, 바로 tsconfig.json을 총 4개의 파일들로 역할을 분리해서 만드는 것이 낫겠다고 판단했다.

먼저, 각 파일별 용도 및 전략부터 살펴보자.
| 파일 | 용도 | paths 전략 | 실행 시점 |
|---|---|---|---|
| tsconfig.base.json | 모든 프로파일이 extends 하는 공통 옵션 묶음 (컴파일러 옵션, includes 등) | ― | 항상 extends |
| tsconfig.json (IDE & dev) | Vite dev + VS Code 인텔리센스 | ~/* → ../vision1/src/*@saige-ui/* 등 원본 소스 | npm run dev:entp(HMR · Go-to-Definition) |
| tsconfig.typecheck.json | 새 코드 strict 검사 전용 | ~/* → src/caches/**(오직 .d.ts 캐시) | npm run typechecknpm run build:entp |
| tsconfig.node.json | Vite·Vitest 등 Node 측 설정 | 필요한 경로만 | 툴 체인 내부 |
핵심 아이디어
1️⃣ IDE & dev 서버에서는 원본 소스를 바라보게 해서 코드를 추적하기 편하게.
2️⃣ 빌드·CI 시엔 vision1 코드를 아예 컴파일하지 않고 .d.ts 캐시만 읽어 속도·안정성을 확보.
→ 두 개 tsconfig(dev vs typecheck)로 역할을 완전히 분리.
{
"extends": "./tsconfig.base.json",
"compilerOptions": {
"paths": {
"@/*": ["./src/*"],
"~/*": ["../vision1/src/*"],
"@saige-ui/*": ["../vision1/src/components/DesignSystem/*"],
"@saige-konva": ["../vision1/src/components/DesignSystem/konva/index.ts"],
"react": ["./node_modules/@types/react"]
}
},
"exclude": ["../vision1/**", "commitlint.config.cjs", "src/caches/**"]
}
• Go to Definition → .d.ts 말고 실제 vision1 소스 열림.
2) tsconfig.typecheck.json (빌드·CI strict 검증)
{
"extends": "./tsconfig.base.json",
"compilerOptions": {
"paths": {
"@/*": ["./src/*"],
"~/*": ["src/caches/vision1-dts/*"], // ⬅️ 오직 .d.ts 캐시만!
"@saige-ui/*": ["src/caches/vision1-dts/components/DesignSystem/*"],
"@saige-konva": [
"src/caches/vision1-dts/components/DesignSystem/konva/index.d.ts"
],
"react": ["./node_modules/@types/react"]
}
},
"include": [
"src/**/*",
"src/caches/vision1-dts/**/*.d.ts",
"test/setupTests.ts",
"./node_modules/@saige/elements/**/*.d.ts",
"test-submodule/Test.tsx"
],
"exclude": ["../vision1/**", "commitlint.config.cjs"]
}
• vision1 원본 코드는 완전 배제 → strict:true.
• 새 파일부터 발생할 undefined 류 버그를 빌드 전에 차단.
3) tsconfig.base.json (공통 뼈대)
{
"compilerOptions": {
"types": ["vite/client", "@emotion/react/types/css-prop"],
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"skipLibCheck": true,
"allowImportingTsExtensions": true,
"allowUnusedLabels": true,
"allowJs": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"baseUrl": ".",
"strict": true,
"noImplicitAny": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"esModuleInterop": true
},
"include": [
"src/**/*",
"test/setupTests.ts",
"test-submodule/Test.tsx",
"./node_modules/@saige/elements/**/*.d.ts"
],
"references": [{ "path": "./tsconfig.node.json" }]
}
불변 옵션만 두고, strict·paths 같이 상황 따라 바뀌는 건 각 파생 tsconfig가 Override.
4) tsconfig.node.json (Node-side only)
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.enterprise.ts", "package.json"]
}
• Vite 설정‧Vitest 등 Node 스크립트만 최소 포함.
• composite:true 로 tsc 의 의존성 그래프가 깨지지 않도록.
이번 글은, 필자가 회사에서 마주한 특수한 상황에서 의존성을 최대한 분리한 채로 어떻게 typescript의 기능 들을 활용하면서 해결했는 지에 대해 적어봤다.

혹시 깨진 유리창 이론이라고 들어보셨는가?
최근 "실용주의 프로그래머"라는 책을 읽으면서 본 비유다.
그때 깨달었던 바 중에 하나가, 당신의 코드에 깨진 유리창을 방치하면, 더 큰 문제가 될 수 있다고 한다.
그래서 필자는 이번에 회사에서 이런 dx 개선을 통해 조금이라도 덜 깨졌을 때, 유지보수하는 것이 진짜 중요하다고 다시 한 번 생각이 들었다.
긴 글 읽어주셔서 감사하며, 다음 글로 다시 돌아오겠다.