tsconfig를 새로 만들거나 손볼 때마다 같은 질문이 나와요. strict 하나면 충분한지, 타입을 어디까지 적을지, 검사가 느려지면 어디부터 볼지요. 결론부터 말하면 2026년 10월 기준으로는 strict(TypeScript 6.0부터 기본값)에 noUncheckedIndexedAccess를 더하고, 내보내는 함수에만 반환 타입을 적고, 설정 객체는 satisfies로 검사하는 조합이 손이 가장 덜 가요.
이 글은 검사기 원리 대신 고를 때 쓰는 기준을 다뤄요. 예제는 모두 TypeScript 5.9.3, 6.0.3, 7.0.2 세 버전으로 직접 돌려 확인했어요.
tsconfig strict 옵션 판단표 (6.0·7.0 기준)
TSConfig 레퍼런스에 따르면 strict는 아래 표의 앞 다섯 개를 포함한 9개 검사를 한 번에 켜요. 같은 문서는 앞으로 이 묶음에 검사가 더 들어올 수 있다고 적어요. 버전을 올렸더니 오류가 늘었다면 대개 이 때문이에요. TypeScript 6.0 발표 글부터는 strict 기본값이 true라서, tsconfig에 strict를 안 적었던 프로젝트는 6.0으로 올리는 순간 오류가 쏟아질 수 있어요.
| 옵션 | strict 포함 | 잡아내는 것 | 켤 때 손 가는 정도 | 추천 |
|---|---|---|---|---|
strictNullChecks | 예 | null·undefined 처리 누락 | 큼 | 반드시 |
noImplicitAny | 예 | 타입을 못 정해 any가 된 매개변수 | 중간 | 반드시 |
strictFunctionTypes | 예 | 콜백 매개변수 역방향 대입 | 작음 | 반드시 |
useUnknownInCatchVariables | 예 | catch (e)의 e를 unknown으로 | 작음 | 반드시 |
strictPropertyInitialization | 예 | 생성자에서 안 채운 클래스 필드 | 중간 | 반드시 |
noUncheckedIndexedAccess | 아니요 | arr[i]·map[key]가 없을 수 있음 | 중간~큼 | 새 프로젝트는 켬 |
exactOptionalPropertyTypes | 아니요 | a?: T에 undefined 직접 대입 | 라이브러리와 충돌 잦음 | 필요한 팀만 |
isolatedDeclarations | 아니요 | 내보낸 값의 타입을 추론에 기댐 | 큼 | 모노레포·라이브러리 |
같은 파일에 두 옵션을 더해 돌리자 prices["banana"].toFixed()와 list[5].toFixed()에 TS2532가, nickname?: string 자리에 넣은 { nickname: undefined }에 TS2375가 붙었어요. 셋 다 strict만으로는 통과했고, 앞의 두 줄은 실행하면 실제로 TypeError를 내요.
타입 주석·satisfies·as const, 어느 것을 쓸까
타입을 붙이는 방법은 네 가지이고, 남기는 정보와 잡는 실수가 서로 달라요.
| 자리 | 추천 | 이유 |
|---|---|---|
| 지역 변수, 함수 안 계산값 | 추론만 | 주석은 고칠 곳만 늘려요 |
| 모듈 밖으로 내보내는 함수 | 반환 타입 주석 | 선언 파일이 짧아지고 구현 실수가 호출부로 안 퍼져요 |
| 설정·옵션 객체 | satisfies 타입 | 오타는 잡고 리터럴 정보는 남아요 |
| 라우트·권한 코드 같은 상수 표 | as const satisfies 타입 | 값이 리터럴로 남아 다른 타입의 재료가 돼요 |
| 제네릭 함수에 넘기는 콜백 | 매개변수 주석 생략 | 주석이 타입 인자를 먼저 정해 엉뚱한 곳에 오류가 찍혀요 |
외부 API 응답, JSON.parse 결과 | 주석 + 런타임 검증 | 주석만으로는 실제 값이 맞는지 몰라요 |
type Route = { path: `/${string}`; auth: boolean };
const routes = {
home: { path: "/", auth: false },
admin: { path: "/admin", auth: true },
} as const satisfies Record<string, Route>;
const p: "/admin" = routes.admin.path; // 리터럴이 그대로 남아요
path에서 슬래시를 빼먹으면 satisfies 줄에서 오류가 나요. 같은 객체에 : Record<string, Route> 주석을 달면 routes.admin.path는 ` /${string} 으로 넓어지고, 없는 키 routes.admn도 통과해요. satisfies`는 TypeScript 4.9에서 들어왔어요.
외부 응답 타입은 실제 응답으로 초안을 뽑는 편이 빨라요. JSON → TypeScript 변환기에 응답 하나를 붙여 넣으면 interface 초안이 나와요. 이 타입은 컴파일할 때만 있으니, 실제 응답 확인 순서는 API 응답 디버깅 6단계를 참고하세요.
반환 타입은 모든 함수에 적을 필요는 없어요. TypeScript 위키 Performance 문서는 .d.ts 출력에 import("./some/path").SomeType 같은 표기나 소스에 없던 거대한 타입이 보일 때 적어 보라고 해요. TypeScript 5.5의 isolatedDeclarations는 이를 강제해서, 반환 타입 없는 export function total(xs: number[]) { return xs.reduce(...) }에 TS9013을 내요. export const rate = 0.1은 통과해요.
타입 검사가 예상과 다를 때 확인 순서
원인은 대개 몇 가지로 좁혀져요. 위에서부터 확인해요.
틀린 코드가 통과할 때
any가 섞였나?JSON.parse,response.json(), 타입 없는 패키지가 흔한 출발점이에요.any는 닿는 값마다 검사를 끄고 퍼져요.- 변수에 담았다가 넘겼나? 오타를 잡는 초과 속성 검사는 호출 자리에 바로 적은 객체에만 돌아요.
const opts = { host: "db", timeoutMS: 500 }; connect(opts)는 통과했고,satisfies ConnectOptions를 붙이자 TS2561과Did you mean to write 'timeoutMs'?가 나왔어요. - 메서드 문법인가?
strictFunctionTypes는handle: (e: X) => void속성 문법에만 적용되고,handle(e: X): void는 여전히 양방향으로 통과해요. typescript-eslint의 method-signature-style 규칙으로 속성 문법을 강제할 수 있어요. - 같은 원시형의 별칭인가?
type UserId = string은 다른string과 구별되지 않아요. 섞이면 안 되는 값은 브랜드 타입이나#private필드를 가진 클래스로 바꿔요. - 인덱스로 꺼낸 값인가?
noUncheckedIndexedAccess가 꺼져 있으면arr[999]도 그냥number예요.
맞는 코드가 막힐 때
- 오류 줄이 진짜 원인인가? 제네릭 호출에서는 먼저 정해진 타입 인자 기준으로 다른 인자에 오류가 찍혀요. 콜백 주석을 지우거나
fn<number>(...)처럼 타입 인자를 직접 줘 봐요. - 선택 속성뿐인 타입인가? 모든 속성이
?인 타입에 겹치는 속성이 없는 값을 넣으면 TS2559가 나요.{ dryrun: true }를dryRun?자리에 넣은 경우처럼 대개 진짜 오타예요. - 리터럴이 넓혀졌나?
const cfg = { method: "GET" }의method는string이라"GET" | "POST"자리에 못 들어가요. - 6.0 새 기본값 때문인가?
types기본값이[]가 되어process,describe같은 전역 타입이 사라졌을 수 있어요."types": ["node", "jest"]처럼 적어요. - 검사기 한계인가? TS2589·TS2590·TS2321은 다음 절의 표를 봐요.
타입 검사 한계값: TS2589·TS2590이 나는 숫자
아래 값은 TypeScript 5.9.3 패키지의 lib/typescript.js에서 찾았고, 6.0.3과 7.0.2(Go 포트)에서도 같은 오류가 나는지 돌려 봤어요.
| 한계 | 값 | 오류 | 실제로 걸리는 코드 |
|---|---|---|---|
| 타입 인스턴스화 깊이 | 100단계 | TS2589 | 꼬리 재귀가 아닌 재귀 제네릭 |
| 인스턴스화 횟수 (문장 하나) | 500만 번 | TS2589 | 거대한 매핑·조건부 타입 조합 |
| 꼬리 재귀 조건부 타입 | 1,000번 | TS2589 | Tuple<999> 통과, Tuple<1000> 오류 |
| 유니온 곱집합 크기 | 10만 개 | TS2590 | 템플릿 리터럴 ${D}${D}${D}${D}${D} |
| 유니온 중복 제거 비교 | 추정 100만 번 | TS2590 | 모양이 다른 객체 1,002종 배열 리터럴 |
| 튜플 길이 | 1만 개 | TS2799·TS2800 | 펼치기로 만든 긴 튜플 |
| 타입 비교 재귀 깊이 | 100단계 | TS2321 | 서로 참조하는 깊은 타입 비교 |
숫자 열 개짜리 유니온 D로 만든 네 자리 PIN 타입은 1만 개라 통과하고, 다섯 자리는 10만 개에 닿아 세 버전 모두 TS2590을 냈어요. 모든 경우를 타입으로 나열하기보다 string에 브랜드를 붙여 검증 함수 한 곳에서만 만들게 하는 편이 나아요.
객체 배열 리터럴의 TS2590
코드 생성기로 만든 폼 필드 정의처럼 원소마다 키가 다른 큰 배열을 코드에 바로 적으면 다섯째 줄에 걸려요. 검사기는 원소 타입으로 유니온을 만들고, 겹치는 것을 지우려고 타입끼리 짝지어 비교해요. 비교 횟수가 타입 종류 수의 제곱으로 늘어요.
세는 단위는 원소 수가 아니라 모양의 종류예요. 배열 안의 리터럴은 string·number로 넓혀져서 키 구성이 같은 객체는 하나로 합쳐져요. 원소 3,000개를 선택 키 조합 32가지로 만들었더니 5.9.3에서 0.33초, 7.0.2에서 0.13초에 통과했어요. 최악의 경우를 보려고 키가 모두 다른 객체 N종을 넣고 Check time(세 번 중 최솟값)을 쟀어요.
1,001종까지 통과하고 1,002종부터 두 버전 모두 TS2590으로 멈춰요. 파일 하나라 7.0의 병렬 검사 이득이 작아요.
표로 보기
| 구분 | TypeScript 5.9.3 | TypeScript 7.0.2 |
|---|---|---|
| 200 | 0.18 | 0.047 |
| 400 | 0.54 | 0.152 |
| 600 | 0.59 | 0.344 |
| 800 | 0.91 | 0.651 |
| 1000 | 1.9 | 0.818 |
경계는 두 버전이 똑같았어요. 예상과 달랐던 것은 고치는 방법이에요.
| 바꾼 방법 (모양이 다른 객체 3,000종) | 5.9.3 | 7.0.2 |
|---|---|---|
그대로 [...] | TS2590 | TS2590 |
const rows: Row[] = [...] | TS2590 | TS2590 |
[...] satisfies Row[] | TS2590 | TS2590 |
[...] as const | 통과 (0.19초) | 통과 (0.18초) |
data.json으로 옮겨 import | 통과 (0.63초) | 통과 (0.05초) |
주석을 달아도 원소 유니온은 그대로 만들어져서 소용이 없었어요. as const는 원소마다 타입이 따로 정해진 튜플이 되어 중복 제거를 건너뛰어요. 큰 데이터는 JSON 파일로 빼거나, 원소마다 키 구성을 맞추고 없는 값을 null로 채우면 풀려요.
tsc가 느릴 때 확인 순서
tsc --extendedDiagnostics로Files,Instantiations,Check time을 봐요. 파일 수가 예상보다 많으면--explainFiles로 들어온 이유를 확인하고,Instantiations가 수백만이면 제네릭 타입을 의심해요.tsc --generateTrace <폴더>로 추적 파일을 만들고 typescript-analyze-trace로 오래 걸린 위치를 뽑아요. 7.0.2의tsc도--generateTrace를 지원해요.- 위키 처방은 찾은 위치에만 써요. Performance 문서는 교차 타입 대신
interface ... extends, 반환 타입 주석, 큰 유니온 대신 공통 부모 타입, 복잡한 조건부 타입에 이름 붙이기를 권해요. interface 사이 관계는 캐시되지만 교차 타입 전체는 캐시되지 않는다고 설명해요. - 설정도 확인해요.
types를 필요한 패키지로 좁히고skipLibCheck,incremental을 켜요.
TypeScript 7 도입 전에 따질 것
TypeScript 7.0 발표 글(2026-07-08)은 Go로 옮긴 컴파일러가 보통 전체 빌드에서 8~12배 빠르다고 적어요. npm typescript의 최신 태그는 2026-10-25 현재 7.0.2예요.
CI 시간으로 바꿔 보면 vscode 규모 저장소는 한 번에 115.1초(125.7 − 10.6)가 줄어요. 하루 40번, 한 달 22일이면 115.1 × 880 = 101,288초, 약 28시간이에요. 다만 앞의 측정처럼 파일 하나에 몰린 무거운 타입은 나눠 검사할 수 없어 이득이 2~4배에 그쳤어요. 내 저장소에서 먼저 재 보세요.
- 컴파일러 API가 없어요. 7.1에서 새 API가 나올 예정이에요. typescript-eslint처럼
typescript를 import하는 도구를 위해@typescript/typescript6패키지(tsc6포함)를 npm 별칭으로typescript자리에 두고, 7.0은 다른 이름으로 함께 설치하라고 안내해요. - 6.0의 사용 중단 항목이 오류가 돼요.
target: es5,moduleResolution: node·classic,baseUrl,module: amd·umd·systemjs,esModuleInterop: false가 막혀요. 6.0에서ignoreDeprecations없이 깨끗하게 통과시킨 뒤 7.0으로 가요. - 기본값이 달라요.
strict: true,types: [],rootDir: ./,stableTypeOrdering항상 켜짐이에요. 소스가src아래 있으면rootDir를 적어야 출력 구조가 유지돼요. - 파일을 직접 주는 습관이 막혀요. tsconfig가 있는 폴더에서
tsc a.ts를 하면 7.0.2는 TS5112를 내요. 한 파일만 보려면--ignoreConfig를 붙여요. - 병렬도를 정해 둬요. 검사 작업자 기본값은 4개(
--checkers)이고, 올리면 빨라지는 대신 메모리를 더 써요. 드물게 작업자 수에 따라 결과가 달라질 수 있어 환경마다 같은 값으로 고정하는 방법도 안내해요.--builders는--checkers와 곱해져요(4 × 4 = 16개).
자주 묻는 질문
strict만 켜면 null 오류는 다 잡히나요?
아니요. arr[i]나 record[key]처럼 인덱스로 꺼낸 값은 strict만으로는 늘 있다고 가정해요. noUncheckedIndexedAccess를 따로 켜야 막혀요.
TS2589 Type instantiation is excessively deep 는 어떻게 고치나요?
재귀 제네릭이 100단계 깊이나 500만 번 인스턴스화에 닿은 거예요. 조건부 타입의 결과 자리에서 바로 자신을 부르는 꼬리 재귀로 바꾸면 한도가 1,000번이 돼요. 그래도 넘치면 런타임 검증으로 옮기는 편이 나아요.
TypeScript 7로 바로 올려도 되나요?
6.0에서 ignoreDeprecations 없이 --stableTypeOrdering을 켜고 깨끗하게 통과하면 7.0에서도 거의 같은 결과가 나온다고 공식 글이 적어요. 린터·테스트 도구가 컴파일러 API를 쓰는지 먼저 확인하세요.
큰 JSON 데이터의 타입은 어떻게 붙이나요?
.json 파일로 두고 resolveJsonModule로 가져오면 검사도 빠르고 TS2590도 피해요. JSON 문법이 깨졌다면 JSON 파싱 에러 원인 6가지부터 확인하세요.
정리
strict에noUncheckedIndexedAccess를 더하는 것이 새 프로젝트의 출발점이에요.- 지역 값은 추론, 내보내는 함수는 반환 타입, 설정 객체는
satisfies, 상수 표는as const satisfies로 나눠요. - 틀린 코드가 통과하면
any→ 변수 경유 → 메서드 문법 → 별칭 → 인덱스 접근 순으로 의심해요. - 모양이 다른 객체 1,002종 배열, 10만 개 템플릿 리터럴 유니온, 1,000번 꼬리 재귀가 실제로 걸리는 한계예요.
- TypeScript 7은 6.0을 거쳐 올리고, 컴파일러 API를 쓰는 도구와 CI 병렬도를 먼저 정해요.