🔥 TS 6.0 의 tsconfig: strict 기본 시대

1116자
11분

TypeScript 6.0 의 tsconfig 핵심 다섯 줄(target, module, moduleResolution, strict, verbatimModuleSyntax)이 한 JSON 파일 안에 나란히 적힌 표지

처음 tsconfig.json 을 열었을 때 나는 한 화면에 적힌 옵션 다섯 줄을 한참 봤다. strict, target, module, moduleResolution, verbatimModuleSyntax. 어떤 줄은 타입 검사 쪽 설정 같고 어떤 줄은 결과 .js 쪽 설정 같아서, 한 파일 안에서 무엇부터 읽을지 바로 정하지 못했다.

tsconfig.json 은 타입 검사 규칙과 실행 결과 규칙을 같이 적는 파일이다. 그래서 이 다섯 줄도 두 종류를 나눠서 보는 편이 낫다. 아래에서는 TS 6.0 권장 tsconfig.json 의 핵심 다섯 줄을 그 기준으로 본다.

bun init 이 적어 주는 파일

bun init 을 한 번 돌리면 디렉토리에 tsconfig.json 한 개가 생기고, 그 안에는 TS 6.0 기준 권장 조합이 거의 그대로 들어 있다. 다섯 줄 짜리 핵심은 다음과 같다.

json
{
  "compilerOptions": {
    "target": "es2025",
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "strict": true,
    "verbatimModuleSyntax": true,
    "noEmit": true,
    "skipLibCheck": true,
    "types": ["node"]
  }
}
json
{
  "compilerOptions": {
    "target": "es2025",
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "strict": true,
    "verbatimModuleSyntax": true,
    "noEmit": true,
    "skipLibCheck": true,
    "types": ["node"]
  }
}

위 다섯 줄(target, module, moduleResolution, strict, verbatimModuleSyntax)을 아래에서 하나씩 본다. 나머지 세 줄은 보조 옵션이다. noEmit 은 검사만 하고 결과 .js 를 만들지 않겠다는 뜻이다. skipLibCheck 는 외부 .d.ts 파일까지 다시 검사하지 않겠다는 뜻이다. types: ["node"] 는 Node 표준 타입 정의를 함께 읽겠다는 뜻이다. 각 줄이 값 레벨과 타입 레벨 중 한쪽에만 깔끔하게 속하는 것은 아니다. 그래도 두 차이를 먼저 구분해 두면 각 설정이 어느 쪽에 더 가까운지 금방 읽어 낼 수 있다.

strict: 여덟 가지 검사 한 묶음

strict: true 한 줄은 작은 검사 여덟 개를 한꺼번에 켠다. noImplicitAny, strictNullChecks, strictFunctionTypes, strictBindCallApply, strictPropertyInitialization, noImplicitThis, alwaysStrict, useUnknownInCatchVariables 다. TS 6.0 에서는 TypeScript가 이 묶음을 기본으로 켠다. 이전 버전에서는 옵션을 비워 두면 strict 모드가 꺼져 있었지만, 6.0 부터는 strict: true 를 따로 적지 않아도 TypeScript가 그 묶음을 켠다.

이 여덟 개 검사 가운데 자주 부딪히는 항목 하나가 strictNullChecks 다. 함수가 T | undefined 를 돌려주면, 그 값을 쓰는 쪽에서 undefined 가능성을 먼저 확인해야 한다. 다음 파일이 그 규칙을 보여 준다.

ts
// file: strict-mode.ts
export {};
 
function findUser(id: number): { name: string } | undefined {
  if (id === 1) return { name: "Ada" };
  return undefined;
}
 
const u = findUser(2);
 
// strict 모드에서는 u.name 을 바로 적을 수 없다.
// 컴파일러가 undefined 가능성을 먼저 확인하라고 막기 때문이다.
if (u !== undefined) {
  console.log(u.name);
} else {
  console.log("no user");
}
ts
// file: strict-mode.ts
export {};
 
function findUser(id: number): { name: string } | undefined {
  if (id === 1) return { name: "Ada" };
  return undefined;
}
 
const u = findUser(2);
 
// strict 모드에서는 u.name 을 바로 적을 수 없다.
// 컴파일러가 undefined 가능성을 먼저 확인하라고 막기 때문이다.
if (u !== undefined) {
  console.log(u.name);
} else {
  console.log("no user");
}

bunx tsc --noEmit 으로 검사하면 조용히 끝난다. bunx tsx strict-mode.ts 로 실행하면 no user 한 줄이 나온다. 만약 if (u !== undefined) 분기를 지우고 console.log(u.name) 을 직접 적으면 컴파일러가 'u' is possibly 'undefined' 오류를 바로 낸다. 그 한 줄이 strict 모드의 일이다.

strict: true 한 줄을 가운데 두고 noImplicitAny, strictNullChecks 등 8개의 작은 검사 항목이 방사형으로 연결된 다이어그램

target: 결과 .js 기준

target: "es2025" 한 줄은 결과 .js 가 어느 ECMAScript 표준에 맞춰 적혀야 하는지를 정한다. ES2025 에 추가된 표준 기능을 코드에서 그대로 호출할 수 있고, 컴파일러가 그 호출을 다른 식으로 풀어 적지 않는다는 뜻이다. ES5 같은 옛 타깃은 TS 6.0 에서 deprecated 표시가 붙었고, 새로 만드는 프로젝트에서는 ES2020 이하를 적지 않는다.

타깃이 허용하는 예를 코드로 본다. ES2024 에서 표준에 들어온 Object.groupBy 를 ES2025 lib 에서는 그대로 쓸 수 있다.

ts
// file: target-es2025.ts
export {};
 
const items = [
  { kind: "fruit", name: "apple" },
  { kind: "fruit", name: "banana" },
  { kind: "veg", name: "kale" },
] as const;
 
const grouped = Object.groupBy(items, (item) => item.kind);
 
console.log(grouped.fruit?.length);
console.log(grouped.veg?.length);
ts
// file: target-es2025.ts
export {};
 
const items = [
  { kind: "fruit", name: "apple" },
  { kind: "fruit", name: "banana" },
  { kind: "veg", name: "kale" },
] as const;
 
const grouped = Object.groupBy(items, (item) => item.kind);
 
console.log(grouped.fruit?.length);
console.log(grouped.veg?.length);

bunx tsx target-es2025.ts 는 두 줄, 21 을 출력한다. tsc --noEmit 도 조용히 통과한다. ES5 타깃이면 컴파일러는 lib 정의에서 Object.groupBy 를 찾지 못해서 여기서 오류를 낸다. ES2025 타깃에서는 같은 호출을 그대로 적어도 된다.

target 옵션의 가능한 값들이 ES5 deprecated 부터 ES2025 권장까지 가로 타임라인으로 나열되고 ES2025 구간에 Object.groupBy 호출이 통과한다는 표시가 붙은 다이어그램

module + moduleResolution: nodenext 조합

module: "nodenext"moduleResolution: "nodenext" 는 보통 함께 적는다. ESM 과 CJS 가 한 프로젝트에 섞이는 Node 의 표준 동작을 그대로 따라가려는 조합이기 때문이다. package.json"type": "module" 표시, node: 프로토콜 import, ESM 의 모듈 최상단 await 가 이 조합에서 함께 자연스럽게 동작한다.

아래 짧은 파일이 그 셋을 한꺼번에 보여준다.

ts
// file: nodenext-import.ts
export {};
 
import { setTimeout as wait } from "node:timers/promises";
 
await wait(5);
console.log("nodenext + node: import + top-level await OK");
ts
// file: nodenext-import.ts
export {};
 
import { setTimeout as wait } from "node:timers/promises";
 
await wait(5);
console.log("nodenext + node: import + top-level await OK");

이 파일은 세 가지를 동시에 보여 준다. node:timers/promises 같은 node: 프로토콜 import, ESM 환경에서만 가능한 모듈 최상단 await, 그리고 첫 줄 export {}; 로 남겨 둔 ESM 표시다. tsc --noEmit 은 세 가지 모두를 그대로 통과시키고, tsx nodenext-import.ts 는 5밀리초 뒤에 한 줄을 출력한다. module: "nodenext" 가 빠지면 컴파일러는 모듈 최상단 await 에서 멈춘다.

package.json 의 type module 표시와 app.ts 의 node 프로토콜 import 와 top-level await 가 한 짝으로 동작하는 nodenext 흐름 다이어그램

verbatimModuleSyntax: import type 규칙

verbatimModuleSyntax: true 는 import 문에 직접 영향을 준다. 타입에만 쓰는 import 는 import type 으로 적어야 하고, 값을 쓰는 import 는 일반 import 로 적어야 한다. 컴파일러가 한쪽을 다른 쪽으로 바꿔 주지 않는다.

다음 파일은 그 규칙을 바로 보여 준다. node:httpIncomingMessage 는 타입으로만 쓰기 때문에 import type 으로 가져온다.

ts
// file: verbatim-syntax.ts
export {};
 
import type { IncomingMessage } from "node:http";
 
function methodOf(req: IncomingMessage): string {
  return req.method ?? "GET";
}
 
const fakeReq = { method: "POST" } as unknown as IncomingMessage;
console.log(methodOf(fakeReq));
ts
// file: verbatim-syntax.ts
export {};
 
import type { IncomingMessage } from "node:http";
 
function methodOf(req: IncomingMessage): string {
  return req.method ?? "GET";
}
 
const fakeReq = { method: "POST" } as unknown as IncomingMessage;
console.log(methodOf(fakeReq));

import type 는 컴파일 시점에만 쓰이는 import 라는 약속을 분명히 남긴다. 컴파일러는 결과 .js 에 그 줄을 남기지 않는다. 일반 import 를 적으면 verbatimModuleSyntaxIncomingMessage 를 타입 전용 import 로 가져오라고 바로 오류를 낸다.

이 규칙의 가치는 두 가지다. 첫째, 코드만 읽어도 어느 import 가 런타임 의존인지, 어느 import 가 타입 의존인지 바로 구분할 수 있다. 둘째, 컴파일러가 import 문을 자동으로 바꾸지 않으니 결과 .js 가 어떻게 남는지도 예측하기 쉽다. 값과 타입을 왜 나눠 적는지도 import 문만 보면 바로 알 수 있다.

import type IncomingMessage 가 컴파일 시점에만 살고 결과 .js 에서 사라지는 왼쪽 패널과 import Buffer 가 런타임에도 남는 오른쪽 패널이 가운데 구분선으로 나뉜 비교 다이어그램

deprecated 된 옵션도 있다

TS 6.0 에서는 함께 deprecated 된 옵션도 짚고 넘어가야 한다. --outFile 은 여러 .ts 파일을 하나의 .js 로 묶어 쓰던 옛 방식이다. ESM 표준이 보편화되면서 쓸 일이 줄었고 deprecated 표시가 붙었다. --baseUrl + paths 로 모듈 경로를 직접 매핑하던 방식도 표준 imports 필드(package.json#imports) 쪽으로 이동하고 있다. 새 프로젝트에서는 두 옵션을 보통 적지 않는다. 적어 두면 컴파일러가 deprecation 경고를 띄운다.

다섯 줄을 먼저 고정한다

이 다섯 줄을 먼저 정해 두면 TypeScript 프로젝트의 기본 설정이 흔들리지 않는다. strict 는 검사 강도를 정하고, target 은 결과 .js 가 따를 ECMAScript 기준을 정한다. modulemoduleResolution 은 Node 의 모듈 해석 방식을 맞추고, verbatimModuleSyntax 는 import 문에 타입용과 값용 구분을 남긴다.

이 기준이 먼저 잡혀 있으면 예제 코드를 읽을 때 설정 설명으로 자꾸 되돌아갈 필요가 없다. 그다음에는 코드가 실제로 무엇을 하는지에 집중하면 된다.

target / module / moduleResolution / strict / verbatimModuleSyntax 다섯 줄이 아래에 놓이고 그 위에 예제 코드 카드 세 개가 같은 설정에 연결된 요약 다이어그램

YouTube 영상

채널 보기
행렬의 가장 중요한 연산 - 행렬 곱셈 | 선형대수학
Trie 자료구조 파이썬 구현: Search와 Starts With 연산 | Trie 자료구조 이야기
투영과 예측, 그리고 선형 결합 | 선형대수학
스칼라 곱셈과 내적의 기하학적 의미 | 선형대수학
AI는 왜 수백 차원의 벡터를 사용할까? 고차원 공간과 행렬 | 선형대수학
숫자 하나가 AI 모델의 운명을 바꾼다? | 선형대수학
직교성과 벡터 투영 | 선형대수학
마지막편, 트라이 노드를 50% 이상 줄이는 방법? 압축 트라이 성능 분석 | Trie 자료구조 이야기