모듈 환경에 맞는 컴파일러 옵션 고르기
모듈 환경에 맞는 컴파일러 옵션 고르기
컴파일러 옵션을 어떻게 세팅하느냐는 사실 '내 코드가 어떤 환경에서 돌아갈지'를 컴파일러에 알려주는 일이에요. 앱을 만들 때와 라이브러리를 만들 때는 그 환경이 서로 다르기 때문에, 이 문서는 그 두 상황을 나눠서 각각 어떤 옵션 조합이 적합한지 알려줘요. 코드가 실제로 돌아가는 런타임 환경이나 번들러와 맞지 않는 설정을 하면 나중에 아무도 고치기 어려운 문제가 생기기 쉬우니, 시작 전에 이 기준을 먼저 잡고 가는 게 좋아요.
출처: TypeScript 공식문서
본문
tsconfig.json 하나는 환경 하나만을 표현할 수 있어요. 여기서 말하는 환경이란 "어떤 전역(globals)이 쓰일 수 있는가"와 "모듈이 어떻게 동작하는가" 두 가지를 아우르는 개념이에요. 만약 앱에 서버 코드, DOM 코드, 웹 워커 코드, 테스트 코드, 그리고 이 모든 곳에서 공유되는 코드가 함께 있다면, 각각 별도의 tsconfig.json을 두고 프로젝트 레퍼런스로 서로 연결해주는 게 맞아요. 그런 다음, 이 가이드를 tsconfig.json 하나당 한 번씩 적용하면 돼요. 앱 안에 또 다른 작은 라이브러리 같은 프로젝트가 있고, 특히 그게 여러 런타임 환경에서 돌아가야 한다면 ""I'm writing a library"" 섹션을 참고하면 돼요.
번들러를 쓰는 경우
아래 설정을 따르기 전에, 지금은 번들러 프로젝트에서 { "type": "module" }을 설정하거나 .mts 파일을 쓰지 않는 걸 권장해요. 이런 상황에서 어떤 번들러들은 서로 다른 ESM/CJS interop 동작을 따라가는데, TypeScript가 현재 "moduleResolution": "bundler"로는 그걸 분석하지 못하기 때문이에요. 자세한 내용은 issue #54102을 참고해요.
{
"compilerOptions": {
// This is not a complete template; it only
// shows relevant module-related settings.
// Be sure to set other important options
// like `target`, `lib`, and `strict`.
// Required
"module": "esnext",
"moduleResolution": "bundler",
"esModuleInterop": true,
// Consult your bundler’s documentation
"customConditions": ["module"],
// Recommended
"noEmit": true, // or `emitDeclarationOnly`
"allowImportingTsExtensions": true,
"allowArbitraryExtensions": true,
"verbatimModuleSyntax": true, // or `isolatedModules`
}
}
Node.js에서 컴파일 결과를 실행하는 경우
ES 모듈로 출력(emit)할 생각이라면 "type": "module"을 설정하거나 .mts 파일을 쓰는 걸 꼭 기억해두세요.
{
"compilerOptions": {
// This is not a complete template; it only
// shows relevant module-related settings.
// Be sure to set other important options
// like `target`, `lib`, and `strict`.
// Required
"module": "nodenext",
// Implied by `"module": "nodenext"`:
// "moduleResolution": "nodenext",
// "esModuleInterop": true,
// "target": "esnext",
// Recommended
"verbatimModuleSyntax": true,
}
}
ts-node를 쓰는 경우
ts-node는 Node.js에서 JS 출력물을 컴파일·실행하는 경우에 쓰던 코드와 tsconfig.json 설정을 그대로 쓸 수 있도록 호환을 맞추려고 해요. 더 자세한 내용은 ts-node 문서를 참고해요.
tsx를 쓰는 경우
ts-node가 기본적으로 Node.js의 모듈 시스템을 최소한으로만 건드리는 반면, tsx는 번들러에 더 가깝게 동작해요. 확장자 없는(index) 모듈 스펙파이어, ESM과 CJS의 자유로운 혼용 같은 걸 허용하지요. tsx를 쓸 때는 번들러를 쓸 때와 같은 설정을 쓰면 돼요.
번들러나 모듈 컴파일러 없이 브라우저용 ES 모듈을 작성하는 경우
TypeScript에는 현재 이 시나리오 전용 옵션이 없어요. 다만 nodenext의 ESM 모듈 해석 알고리즘과 paths를 조합해서 URL·import map 지원을 대신 흉내 낼 수 있어요.
// tsconfig.json
{
"compilerOptions": {
// This is not a complete template; it only
// shows relevant module-related settings.
// Be sure to set other important options
// like `target`, `lib`, and `strict`.
// Combined with `"type": "module"` in a local package.json,
// this enforces including file extensions on relative path imports.
"module": "nodenext",
"paths": {
// Point TS to local types for remote URLs:
"https://esm.sh/[email protected]": ["./node_modules/@types/lodash/index.d.ts"],
// Optional: point bare specifier imports to an empty file
// to prohibit importing from node_modules specifiers not listed here:
"*": ["./empty-file.ts"]
}
}
}
이렇게 하면 명시적으로 나열된 HTTPS import는 로컬에 설치된 타입 선언 파일을 쓰게 되고, 원래라면 node_modules에서 해석될 import는 오류로 처리돼요:
import {} from "lodash";
// ^^^^^^^^
// File '/project/empty-file.ts' is not a module. ts(2306)
다른 방법으로는, import maps을 써서 브라우저에서 bare 스펙파이어 목록을 URL로 명시적으로 매핑하면서, TypeScript가 그 bare 스펙파이어 import에 대한 타입 선언 파일을 찾도록 nodenext의 기본 node_modules 조회나 paths에 기대는 방법도 있어요:
{
"imports": {
"lodash": "https://esm.sh/[email protected]"
}
}
import {} from "lodash";
// Browser: https://esm.sh/[email protected]
// TypeScript: ./node_modules/@types/lodash/index.d.ts
라이브러리를 작성하는 경우
라이브러리 저자로서 컴파일 설정을 고르는 일은 앱 저자로서 고르는 일과 근본적으로 달라요. 앱을 쓸 때는 런타임 환경이나 번들러라는, 동작이 알려진 단일 대상에 맞춰 설정을 고르지요. 하지만 라이브러리를 쓸 때는 이상적으로는 가능한 모든 라이브러리 소비자의 컴파일 설정에서 코드를 점검하고 싶을 거예요. 그게 현실적으로 불가능하니, 대신 가장 엄격한(strictest) 설정을 쓰는 게 좋아요. 그걸 만족시키면 나머지 설정도 대부분 만족되는 경향이 있거든요.
{
"compilerOptions": {
"module": "node18",
"target": "es2020", // set to the *lowest* target you support
"strict": true,
"verbatimModuleSyntax": true,
"declaration": true,
"sourceMap": true,
"declarationMap": true,
"rootDir": "src",
"outDir": "dist"
}
}
각 설정을 왜 골랐는지 하나씩 살펴볼게요.
module: "node18". 코드베이스가 Node.js의 모듈 시스템과 호환되면 거의 항상 번들러에서도 잘 동작해요. 서드파티 이밋터로 ESM 출력물을 만들고 있다면package.json에"type": "module"을 설정해서 TypeScript가 코드를 ESM으로 점검하게 해주세요. ESM은 Node.js에서 CommonJS보다 더 엄격한 모듈 해석 알고리즘을 쓰거든요. 예를 들어, 라이브러리가"moduleResolution": "bundler"로 컴파일되면 어떻게 되는지 봐요:
export * from "./utils";
./utils.ts(또는 ./utils/index.ts)가 존재한다고 가정하면, 번들러는 이 코드를 문제없이 받아주니까 "moduleResolution": "bundler"는 아무 불평도 하지 않아요. 그런데 "module": "esnext"로 컴파일하면, 이 export 문의 출력 JavaScript는 입력과 똑같아요. 이 JavaScript가 npm에 배포되면 번들러를 쓰는 프로젝트에서는 쓸 수 있지만, Node.js에서 실행하면 오류가 나요:
Error [ERR_MODULE_NOT_FOUND]: Cannot find module '.../node_modules/dependency/utils' imported from .../node_modules/dependency/index.js
Did you mean to import ./utils.js?
반면에 이렇게 썼다면:
export * from "./utils.js";
Node.js 그리고 번들러에서 모두 동작하는 출력물이 나와요. 요컨대 "moduleResolution": "bundler"는 전염성이 있어서, 번들러에서만 동작하는 코드가 만들어지게 허용해요. 마찬가지로 "moduleResolution": "nodenext"는 출력물이 Node.js에서 동작하는지만 점검하지만, 대부분의 경우 Node.js에서 동작하는 모듈 코드는 다른 런타임과 번들러에서도 동작해요.
-
target: "es2020". 지원하려는 ECMAScript 버전 중 가장 낮은 값으로 설정하면, 그보다 나중에 추가된 언어 기능이 출력 코드에 쓰이지 않게 보장돼요.target은lib의 값도 함께 정해주기 때문에, 오래된 환경에 없을 수도 있는 전역에 접근하지 않게 해주는 효과도 있어요. -
strict: true. 이걸 켜지 않으면 타입 레벨 코드가 결국 출력.d.ts파일에 들어가서, 소비자가strict를 켜고 컴파일할 때 오류가 나는 상황을 만들 수 있어요. 예를 들어 이extends절은:
export interface Super {
foo: string;
}
export interface Sub extends Super {
foo: string | undefined;
}
strictNullChecks 아래에서만 오류가 나요. 반대로 strict가 꺼져 있을 때만 오류가 나는 코드를 작성하는 건 매우 어려우니까, 라이브러리는 strict로 컴파일하는 걸 강력히 권장해요.
-
verbatimModuleSyntax: true. 이 설정은 라이브러리 소비자에게 문제를 일으킬 수 있는 모듈 관련 함정 몇 가지를 막아줘요. 첫째, 사용자의esModuleInterop이나allowSyntheticDefaultImports값에 따라 모호하게 해석될 수 있는 import 문을 작성하는 걸 막아줘요. 예전에는 라이브러리가esModuleInterop없이 컴파일하라고 권장하기도 했는데, 그 이유는 라이브러리에서 이걸 쓰면 사용자도 함께 써야 할 수 있기 때문이었어요. 그런데 반대로esModuleInterop없이만 동작하는 import를 작성하는 것도 가능하니까, 어떤 값으로 설정하든 라이브러리의 이식성을 보장하지는 못해요.verbatimModuleSyntax는 그런 보장을 제공하죠.1 둘째, CommonJS로 출력될 모듈에서export default를 쓰는 걸 막아줘요. 그런 코드는 번들러 사용자와 Node.js ESM 사용자가 모듈을 다르게 소비해야 하게 만들 수 있거든요. 자세한 내용은 ESM/CJS Interop 부록을 참고해요. -
**
declaration: true**는 출력 JavaScript 옆에 타입 선언 파일을 만들어줘요. 라이브러리 소비자가 어떤 타입 정보든 얻으려면 이게 필요해요. -
**
sourceMap: true**와 **declarationMap: true**는 각각 출력 JavaScript와 타입 선언 파일의 소스 맵을 만들어줘요. 이건 라이브러리가 소스 파일(.ts)도 함께 배포할 때만 유용해요. 소스 맵과 소스 파일을 배포하면 소비자가 라이브러리 코드를 좀 더 쉽게 디버깅할 수 있어요. 선언 맵과 소스 파일을 배포하면 소비자가 라이브러리의 import에 Go To Definition을 실행할 때 원래 TypeScript 소스를 볼 수 있어요. 둘 다 개발자 경험과 라이브러리 크기 사이의 트레이드오프라서, 포함할지는 여러분이 결정해요. -
**
rootDir: "src"**와outDir: "dist". 별도의 출력 디렉터리를 쓰는 건 항상 좋지만, 입력 파일을 배포하는 라이브러리에게는 필수예요. 그렇지 않으면 확장자 치환(file extension substitution) 때문에 라이브러리 소비자가.d.ts파일 대신 라이브러리의.ts파일을 로드하게 되어 타입 오류와 성능 문제가 생겨요.
라이브러리 번들링 고려사항
번들러로 라이브러리를 출력한다면, (외부화되지 않은) import는 모두 알려진 동작을 가진 번들러가 처리하게 되고, 사용자의 알 수 없는 환경이 처리하지 않아요. 이 경우 "module": "esnext"와 "moduleResolution": "bundler"를 쓸 수 있지만, 주의할 점이 두 가지 있어요.
첫째, 어떤 파일은 번들되고 어떤 파일은 외부화될 때 TypeScript는 모듈 해석을 모델링할 수 없어요. 의존성이 있는 라이브러리를 번들할 때는 자사(1st-party) 라이브러리 소스 코드는 단일 파일로 번들하되, 외부 의존성의 import는 번들 출력물에 실제 import로 남겨두는 게 일반적이에요. 이는 본질적으로 모듈 해석이 번들러와 최종 사용자 환경 사이에서 갈라지는 걸 의미해요. TypeScript에서 이를 모델링하려면 번들된 import는 "moduleResolution": "bundler"로, 외부화된 import는 "moduleResolution": "nodenext"로 처리하고 싶을 거예요(혹은 여러 옵션으로 다양한 최종 사용자 환경에서 모두 동작하는지 점검하고 싶을 테고요). 하지만 TypeScript는 한 컴파일에서 두 가지 다른 모듈 해석 설정을 쓸 수 없게 설정할 수 없어요. 그 결과 "moduleResolution": "bundler"를 쓰면 번들러에서는 동작하지만 Node.js에서는 안전하지 않은 외부화 의존성의 import를 허용하게 될 수 있어요. 반대로 "moduleResolution": "nodenext"를 쓰면 번들된 import에 지나치게 엄격한 요구를 강제할 수도 있어요.
둘째, 선언 파일도 함께 번들되도록 반드시 해야 해요. 선언 파일의 첫 번째 규칙을 떠올려보면, 모든 선언 파일은 정확히 하나의 JavaScript 파일을 나타내요. 만약 "moduleResolution": "bundler"를 쓰면서 번들러로 ESM 번들을 출력하는데 tsc로 개별 선언 파일을 여러 개 출력한다면, 그 선언 파일들이 "module": "nodenext"로 소비될 때 오류를 일으킬 수 있어요. 예를 들어 이런 입력 파일은:
import { Component } from "./extensionless-relative-import";
JS 번들러가 이 import를 지워버리지만, 선언 파일에는 동일한 import 문이 그대로 남아요. 그런데 그 import 문은 파일 확장자가 빠져 있어서 Node.js에서 유효하지 않은 모듈 스펙파이어가 돼요. Node.js 사용자에게는 TypeScript가 선언 파일에서 오류를 내고, 의존성이 런타임에 크래시할 것이라고 가정하며 Component를 참조하는 타입들을 any로 오염시키지요.
만약 여러분의 TypeScript 번들러가 번들된 선언 파일을 만들어내지 못한다면, "moduleResolution": "nodenext"를 써서 선언 파일에 보존된 import가 최종 사용자의 TypeScript 설정과 호환되도록 하세요. 더 좋은 방법은 아예 라이브러리를 번들하지 않는 거예요.
이중 출력(dual-emit) 솔루션에 대한 참고사항
단일 TypeScript 컴파일(출력 여부와 무관하게)은 각 입력 파일이 출력 파일 하나만 만든다고 가정해요. tsc가 아무것도 출력하지 않더라도, import된 이름에 대해 수행하는 타입 점검은 tsconfig.json에 설정된 모듈·출력 관련 옵션을 바탕으로 출력 파일이 런타임에 어떻게 동작할지에 대한 지식에 의존해요. 서드파티 이밋터는 tsc가 그 이밋터가 만들어낼 결과를 이해하도록 설정할 수 있다면 일반적으로 tsc 타입 점검과 함께 쓰기에 안전해요. 하지만 서로 다른 모듈 형식으로 두 가지 다른 출력 세트를 만들면서 타입 점검은 한 번만 수행하는 솔루션은, (적어도) 출력 중 하나를 점검하지 않은 채 남겨둬요. 외부 의존성은 CommonJS와 ESM 소비자에게 서로 다른 API를 노출할 수 있기 때문에, 단일 컴파일에서 두 출력이 모두 타입 안전함을 보장할 수 있는 설정은 존재하지 않아요. 실무에서 대부분의 의존성은 모범 사례를 따르고 이중 출력이 잘 동작해요. 배포 전에 모든 출력 번들을 대상으로 테스트와 정적 분석(static analysis)을 실행하면 심각한 문제가 눈에 띄지 않고 지나칠 확률을 크게 줄여줘요.
verbatimModuleSyntax는 JS 이밋터가 tsconfig.json, 소스 파일 확장자, package.json의 "type"을 감안해 tsc가 출력했을 것과 동일한 모듈 종류를 출력할 때만 동작해요. 이 옵션은 작성된 import/require가 출력되는 import/require와 동일하도록 강제함으로써 동작해요. 같은 소스 파일에서 ESM 출력과 CJS 출력을 모두 만들어내는 어떤 구성도 verbatimModuleSyntax와 근본적으로 호환되지 않아요. 이 설정의 핵심 목적이 require가 출력될 자리에 import를 작성하는 걸 막는 데 있으니까요. verbatimModuleSyntax는 또한 서드파티 이밋터를 tsc와 다른 모듈 종류로 출력하도록 구성함으로써 우회될 수도 있어요. 예를 들어 tsconfig.json에 "module": "esnext"를 설정하면서 Babel이 CommonJS를 출력하도록 구성하는 경우가 그렇죠.↩
더 알아보기
- ESM/CJS Interop — 라이브러리 코드에서 ESM과 CommonJS 사이의 호환 문제를 다루는 부록
- Theory — 모듈과 선언 파일의 역할에 대한 기초 이론
- Reference — 모듈 해석과 관련된 상세 레퍼런스
- Project References — 여러
tsconfig.json을 서로 연결하는 방법