모듈 컴파일러 옵션 고르기
모듈 컴파일러 옵션 고르기 (Modules - Choosing Compiler Options)
여러분의 모듈 환경에 맞는 컴파일러 옵션을 어떻게 고를지, 앱을 쓰고 있는지 라이브러리를 쓰고 있는지에 따라 나눠서 안내하는 문서예요. 번들러, Node.js, ts-node, tsx, 브라우저용 ESM 그리고 라이브러리까지, 상황별로 어떤 설정이 필요한지 실제 tsconfig 예시와 함께 살펴볼게요.
출처: TypeScript 핸드북
앱을 작성하고 있을 때 (I'm writing an app)
하나의 tsconfig.json은 단일 환경만을 나타낼 수 있어요. 어떤 전역(globals)이 가능한지, 모듈이 어떻게 동작하는지 모두 말이죠. 앱 안에 서버 코드, DOM 코드, 웹 워커 코드, 테스트 코드, 그리고 이 모두가 공유할 코드가 있다면, 각각이 자기만의 tsconfig.json을 가져야 하고, 프로젝트 참조(project references)로 연결하세요. 그런 다음 이 가이드를 tsconfig.json마다 한 번씩 사용하면 됩니다. 앱 안의 라이브러리형 프로젝트, 특히 여러 런타임 환경에서 실행돼야 하는 프로젝트에는 "라이브러리를 작성하고 있을 때" 섹션을 사용하세요.
번들러를 사용하고 있을 때 (I'm using a bundler)
아래 설정을 채택하는 것에 더해, 지금은 번들러 프로젝트에서 { "type": "module" }을 설정하거나 .mts 파일을 쓰지 않는 것도 권장돼요. 일부 번들러는 이런 상황에서 TypeScript가 현재 "moduleResolution": "bundler"로 분석할 수 없는 다른 ESM/CJS 상호 운용 동작을 채택합니다. 자세한 내용은 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에서 출력을 컴파일하고 실행할 때 (I'm compiling and running the outputs in 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를 사용할 때 (I'm using ts-node)
ts-node는 Node.js에서 JS 출력을 컴파일하고 실행하기에 사용되는 것과 동일한 코드와 동일한 tsconfig.json 설정과 호환되도록 노력합니다. 자세한 내용은 ts-node 문서를 참조하세요.
tsx를 사용할 때 (I'm using tsx)
ts-node가 기본적으로 Node.js의 모듈 시스템에 최소한의 수정을 가하는 반면, tsx는 번들러에 더 가깝게 동작해요. 확장자 없는/인덱스 모듈 지정자와 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로 명시적으로 매핑할 수 있어요. nodenext의 기본 node_modules 조회나 paths에 의존해 그 bare 지정자 import를 위한 타입 선언 파일로 TypeScript를 안내하면서요:
<script type="importmap">
{
"imports": {
"lodash": "https://esm.sh/[email protected]"
}
}
</script>
import {} from "lodash";
// Browser: https://esm.sh/[email protected]
// TypeScript: ./node_modules/@types/lodash/index.d.ts
라이브러리를 작성하고 있을 때 (I'm writing a library)
라이브러리 작성자로서 컴파일 설정을 고르는 것은 앱 작성자로서 설정을 고르는 것과 근본적으로 다른 과정이에요. 앱을 쓸 때는 런타임 환경이나 번들러, 즉 알려진 동작을 가진 단일 개체를 반영하는 설정을 고릅니다. 라이브러리를 쓸 때는, 이상적으로는 가능한 모든 라이브러리 소비자 컴파일 설정에서 여러분의 코드를 검사해야 해요. 이는 비현실적이므로, 대신 가능한 가장 엄격한 설정을 사용할 수 있어요. 그것들을 만족시키는 것이 다른 모든 것을 만족시키는 경향이 있기 때문이죠.
{
"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의 모듈 시스템과 호환되면, 거의 항상 번들러에서도 동작해요. 서드파티 emitter로 ESM 출력을 emit한다면, 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로 emit될 모듈에서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". 별도의 출력 디렉터리를 사용하는 것은 항상 좋은 생각이지만, 입력 파일을 게시하는 라이브러리에는 _필수_예요. 그렇지 않으면 파일 확장자 대체(extension substitution)로 인해 라이브러리 소비자가.d.ts파일 대신 라이브러리의.ts파일을 로드하게 되어 타입 에러와 성능 문제가 생깁니다.
라이브러리 번들링에 대한 고려사항 (Considerations for bundling libraries)
번들러로 라이브러리를 emit한다면, 모든 (외부화되지 않은) 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 번들을 emit하면서tsc로 많은 개별 선언 파일을 emit한다면, 여러분의 선언 파일은"module": "nodenext"로 소비될 때 에러를 일으킬 수 있어요. 예를 들어 입력 파일:import { Component } from "./extensionless-relative-import";이 파일은 JS 번들러에 의해 import가 지워지지만, 동일한 import 문을 가진 선언 파일을 생성합니다. 그런데 그 import 문은 파일 확장자가 빠져 있으므로 Node.js에서 유효하지 않은 모듈 지정자를 포함하게 됩니다. Node.js 사용자에게 TypeScript는 그 선언 파일에서 에러를 내고, 의존성이 런타임에 충돌할 것이라고 가정해
Component를 참조하는 타입을any로 전염시킬 거예요.TypeScript 번들러가 번들된 선언 파일을 만들지 않는다면,
"moduleResolution": "nodenext"를 사용해 선언 파일에 보존된 import가 최종 사용자의 TypeScript 설정과 호환되도록 하세요. 더 나은 방법으로, 라이브러리를 번들링하지 않는 것을 고려해 보세요.
이중 emit 솔루션에 대한 참고 사항 (Notes on dual-emit solutions)
단일 TypeScript 컴파일(emit이든 타입 검사만이든)은 각 입력 파일이 하나의 출력 파일만 생성한다고 가정합니다. tsc가 아무것도 emit하지 않더라도, imported 이름에 수행하는 타입 검사는 tsconfig.json에 설정된 모듈 및 emit 관련 옵션을 기반으로 출력 파일이 런타임에 어떻게 동작할지에 대한 지식에 의존합니다. 서드파티 emitter는 tsc가 다른 emitter가 emit할 것을 이해하도록 구성할 수 있는 한 일반적으로 tsc 타입 검사와 함께 사용해도 안전하지만, 서로 다른 모듈 형식으로 두 개의 다른 출력 집합을 emit하면서 타입 검사를 한 번만 수행하는 어떤 솔루션이든 (적어도) 하나의 출력은 검사되지 않은 채 남습니다. 외부 의존성이 CommonJS와 ESM 소비자에게 다른 API를 노출할 수 있으므로, 단일 컴파일에서 두 출력이 모두 타입 안전함을 보장하는 설정은 없어요. 실제로는 대부분의 의존성이 모범 사례를 따르고 이중 emit 출력이 동작합니다. 게시 전에 모든 출력 번들에 대해 테스트와 정적 분석을 실행하면 심각한 문제가 눈에 띄지 않을 가능성이 크게 줄어듭니다.
[^1]: verbatimModuleSyntax는 JS emitter가 tsconfig.json, 소스 파일 확장자, package.json "type"을 감안해 tsc가 emit할 것과 동일한 모듈 종류를 emit할 때만 동작할 수 있어요. 이 옵션은 작성된 import/require가 emit되는 import/require와 동일하도록 강제하는 방식으로 동작합니다. 동일한 소스 파일에서 ESM과 CJS 출력을 모두 생성하는 모든 구성은 verbatimModuleSyntax와 근본적으로 호환되지 않아요. 그 목적 자체가 require가 emit될 곳 어디에서든 import를 작성하는 것을 막는 것이니까요. verbatimModuleSyntax는 서드파티 emitter가 tsc가 emit할 것과 다른 모듈 종류로 emit하도록 구성함으로써 무력화될 수도 있어요. 예를 들어 tsconfig.json에서 "module": "esnext"를 설정하면서 Babel을 CommonJS emit하도록 구성하는 경우입니다.