React에서 `useState`를 가져와서 사용한다고 해보자. `import` 구문에서 `"react"`는 상대 경로도, 절대 경로도 아니고 단순히 패키지 이름인데 어떻게 react의 node_modules 경로를 찾아내서 가져올 수 있는 걸까?ECMAScript 모듈(ESM) 표준은 모듈 식별자를 별도로 해석하지 않고`"react"` 그대로 모듈을 찾기 때문에 오류가 발생한다. 반면에, CommonJS(CJS) 모듈에서는Node.js 모듈 해석 방식을 따라 모듈 식별자가 상대 경로도, 절대 경로도 아니라면 `/node_modules/react/package.json` 파일을 찾아간다.
어떠한 node 모듈의 `package.json`이 아래와 같이 되어 있다고 해보자. Node.js는 해당 패키지의 파일 경로를 결정하기 위해 `package.json`의 `"main"`, `"exports"` 필드를 참조한다.
"main": 패키지의 진입점 경로, 기본값은 {프로젝트 루트}/index.js이다. "exports": "main"을 대체하는 현대적인 방법으로, 진입점을 여러 곳으로 설정할 수 있다.
`"main"`은 `require("express")`와 같이 CommonJS 방식으로 모듈을 가져올 때 참조되는 진입점으로,`"main"` 필드가 존재하지 않는다면 해당 패키지의 루트 폴더에 있는 `index.js`가 기본값으로 사용된다. `"exports"`는`"main"`을 대체하기 위해 도입된 필드로, 어떤 프로젝트가 해당 패키지를 import하는 방식(ESM 또는 CJS)에 따라 여러 진입점을 설정할 수 있고,상황에 따른 조건부 진입점도 설정할 수도 있다.`"exports"` 필드를 사용했을 때 모듈 경로를 찾는 구체적인 알고리즘은여기에서 확인할 수 있다.
Vite는 `package.json`의 exports 필드가 위와 같이 설정되어 있다. `"."`는 기본 엔트리에 대한 진입점으로, `import ___ from "vite"`를 호출하면 그 하위의 `"import"` 필드값을, `require("vite")`를 호출하면`"require"` 필드값을 이용해 파일을 찾는다. `"./client"` 필드는`import ___ from "vite/client"`를 호출했을 때 사용되는데 하위에`"types"` 필드만 설정되어 있으므로 타입 정의 파일만 가져올 수 있음을 알 수 있다.
2. 타입스크립트의 모듈 해석 방식
A. 타입스크립트 언어 서버와 컴파일러
import path from 'node:path'
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
resolve: {
alias: {
// __dirname은 vite.config.ts가 위치하는 경로이다
'@demo': path.resolve(__dirname, './src'),
'@hook': path.resolve(__dirname, '../packages/hook/src/index.ts'),
},
},
plugins: [react()],
})
Vite + React + TypeScript 프로젝트에서 경로 alias를 사용하고자 하는 경우, `tsconfig.json`의 `compilerOptions.paths`와 `vite.config.ts`의 `resolve.alias` 모두 alias 설정을 해줘야 한다. Vite alias 설정은 번들링 과정에서 필요하다고 하더라도 타입스크립트 설정은 왜 존재하는 걸까? 결론부터 간단하게 말하면 타입스크립트가 타입 검사를 하는데 필요한 정보이기 때문이다.
우선 타입스크립트로 개발을 하는데 필요한 타입스크립트 언어 서버(TypeScript Language Server)와 타입스크립트 컴파일러(TSC)에 대한 개념을 알아야 한다. 타입스크립트 언어 서버는 VS Code와 같은 IDE에서 실시간으로 코드 분석과 타입 검사, 자동 완성 등의 기능을 수행하는 도구이다. 언어 서버가 파일을 검사하고 타입 오류를 IDE에 알려주기 때문에 우리가 파일을 저장하지 않아도 실시간으로 타입 오류에 대한 정보를 얻을 수 있다. 타입스크립트 컴파일러는 타입 검사를 수행하고 TypeScript 파일을 JavaScript 파일로 트랜스파일링 해주는 도구이다. 타입스크립트 언어 서버가 IDE에서 실시간으로 동작하는 반면, 타입스크립트 컴파일러는 우리가 `tsc` 명령어를 실행해야 동작한다.
VS Code 실행 시에 TS 언어 서버가 tsconfig.json을 불러오고 있
타입스크립트 언어 서버 1. 코드 분석, 타입 검사, 자동 완성 2. IDE에서 자동 실행
타입스크립트 컴파일러 1. 타입 검사, TypeScript 파일을 JavaScript 파일로 트랜스파일링 2. `tsc` 명령어를 통해 수동 실행
`tsconfig.json` 설정은 타입스크립트 언어 서버와 타입스크립트 컴파일러가 위와 같은 기능들을 수행할 때 사용하는 파일이다. `tsconfig.json`만으로는 Vite와 같은 번들러가 경로 alias에 대한 정보를 알 수 없기 때문에 번들링이 불가능하고, 반대의 경우도 마찬가지이다. TypeScript 파일에서 "모듈 또는 타입 선언을 찾을 수 없습니다."라는 타입 오류가 발생한다고 해서 번들링 도구에서 개발 서버 실행과 번들링이 실패하지 않는 경우는 이 때문이다.
import sayHello from "./greetings";
sayHello("world");
이러한 코드를 작성했다고 해보자. 타입스크립트는 `sayHello`가 함수가 맞는지, 첫번째 인수로 string 타입의 값을 전달하는 게 맞는지 등 타입을 검사해야 한다. JavaScript에서 모듈은 런타임에 평가되는데 타입스크립트가 모든 `.ts`, `.tsx` 파일을 일일히 전부 실행해서 타입에 대한 정보를 얻을 수는 없지 않은가? 이렇게 스크립트 파일을 실행해보지 않고 타입 체크를 하기 위해 타입스크립트 언어 서버와 타입스크립트 컴파일러는 어떤 식으로 모듈을 해석할지에 대해서도 알고 있어야 한다.
앞서 언급했듯이 CommonJS 방식에서는Node.js 모듈 해석 방식을 따라 모듈의 위치를 찾는다. 반면에,ESM 방식에서는 `"./greetings"`라는 모듈 식별자(module specifiers)를 어떻게 해석할지는 호스트(Host)에 위임하고 있다. 여기서호스트란 입력 코드를 소비하여 모듈 로딩 동작을 제어하는 시스템이라고 할 수 있는데 Node.js 런타임, 브라우저 JavaScript 엔진 등이 이에 해당한다. 반면, TS 컴파일러는 이러한 모듈 로딩에 대한 호스트의 동작을 모델링할 뿐이기 때문에 호스트라고는 할 수 없다.
import monkey from "🐒"; // './eats/bananas.js'를 탐색
import cow from "🐄"; // './eats/grass.js'를 탐색
import lion from "🦁"; // './eats/you.js'를 탐색
극단적인 예시로🐒, 🐄,🦁와 같은 모듈 식별자를 위와 같은 경로로 해석하는 JavaScript 엔진이 있다고 해보자. ECMAScript에서는 모듈 식별자를 어떻게 해석해야 하는지 규정하고 있지 않기 때문에 이 엔진이 ECMAScript 표준을 따르고 있다고 주장해도 문제가 없다. 하지만 이러한 호스트에서 실행될 파일을 TypeScript로 작성한다고 했을 때 타입스크립트가 `monkey`, `cow`, `lion`에 대해 타입 검사를 할 수 있을까? 타입스크립트에 추가적인 정보를 제공하지 않는다면 불가능할 것이다. 그것이 바로 "이 스크립트 파일을 실행하는 호스트는 이런 방식으로 모듈 식별자를 해석한다."라는 정보라고 할 수 있는데 `tsconfig.json`의 `moduleResolution` 설정이 그 역할을 한다.
TS 컴파일러의 역할 1. 유효한 모듈 형식으로 컴파일해야 한다. 2. 컴파일 결과물에서 `import`하는 대상 파일을 찾아낼 수 있어야 한다. 3. `import`한 값의 타입 정보를 알아야 한다.
TS 컴파일러의 핵심적인 역할은 3가지라고 할 수 있다. 1번 역할을 수행하기 위해 `tsconfig.json`에 `module` 설정이, 2~3번 역할을 위해 `moduleResolution` 설정이 존재한다. `moduleResolution`에는 크게 `node__`와 `bundler`가 존재하는데 `node__`는 스크립트를 Node.js 런타임에서 실행하는 경우, `bundler`는 Webpack, Vite 등 현대적인 번들러를 사용하는 경우에 설정해야 한다.
/* ./math.ts */
export function add(a: number, b: number) {
return a + b;
}
/* ./main.ts */
import { add } from "./math";
add(1, 2);
타입스크립트는 모듈 로딩에 대한 호스트의 동작을 모델링해야 한다고 했는데 구체적으로 어떤 식으로 이뤄지는 걸까?TS 컴파일러는 모듈 식별자 `"./math"`를 `./math.ts`로 해석하는 걸까?
main.ts와 math.ts의 관계가 맞을까?
우리가 React + TypeScript 프로젝트에서 대부분의 파일들을 `.ts`, `.tsx` 확장자로서 작성하기 때문에 왠지 그것이 당연해 보이지만 이는 잘못된 생각이다.입력 파일 `main.ts`에서의 모듈 식별자(`"./math"`)와 동일한 모듈 식별자를 포함하는 출력 파일 `main.js`이 런타임에서도 문제없이 작동해야 하기 때문에 TS 컴파일러는 `main.ts`, `math.ts` 두 입력 파일이 각각의 출력 파일과 어떻게 연결되는지 이해하고 있어야 한다. 즉, TS 컴파일러는 모듈 식별자를 자체적으로 해석해서는 안 되고 호스트의 모듈 식별자 해석을 그대로 따라야 하는 것이다. 그 관계를 도식화한 것이 아래와 같다.
main.ts와 math.ts의 실제 관계
타입스크립트가 왜 이렇게 우회적(?)으로 입력 파일을 맵핑하는지 언뜻 이해가 되지 않을 수 있다. 그 이유를 이해하기 위해 이번에는 예시를 더 비틀어서 파일 확장자까지 포함해 import하는 경우를 생각해보자.
/* ./math.mts */
export function add(a: number, b: number) {
return a + b;
}
/* ./main.ts */
// 1. ✅ 유효
import { add } from "./math.mjs";
// 2. ❌ allowImportingTsExtensions: true일 때만 유효
import { add } from "./math.mts";
/* ./math.mjs */
export function add(a: number, b: number) {
return a + b;
}
/* ./main.ts */
// 3. ⁉️ 타입 선언 파일 math.d.mts가 존재하면 유효
import { add } from "./math.mjs";
// 4. ❌ allowImportingTsExtensions: true일 때만 유효
import { add } from "./math.mts";
`math`라는 파일이 `.mts` 또는 `.mjs` 확장자로서 존재할 때 `main.ts`에서는 1~4번처럼 해당 모듈을 import할 수 있다. 분명 내가 작성한 모듈은 `./math.mts`인데도 1번에선 타입스크립트가 어떠한 오류도 출력하지 않는 건 조금 이상해보인다. 2~4번에선 특정 조건에 한해서는 오류가 발생하지 않는다. 그렇다면 타입 선언 파일이 존재하고 `allowImportingTsExtensions: true`라면, 아래와 같이 존재하지도 않는 모듈 `math.mjs` 또는 `math.mts`를 import해도 타입스크립트는 아무런 불평도 하지 않는다는 의미가 될 것이다.
언뜻 난장판처럼 보이는 타입스크립트 모듈 해석은 입력 파일과 출력 파일 사이의 관계를 이해한다면 그리 이상한 것도 아니다. 타입스크립트는 1번의 경우번들러가`math.mts` 파일을 변환할 수 있어 `math.mjs` 파일이 반드시 존재할 것이라고, 2번의 경우에는번들러가`math.mts` 파일을 찾을 수 있고 JavaScript로도 변환할 수 있다고(물론 번들러가 모듈 식별자 `"/.math.mts"` 를 `"/.math.mjs"`로 변환해줄 수 있다는 보장도 있어야 한다.) 믿기 때문에 어떠한 오류도 출력하지 않는다. 물론 위의 두 예시는 타입스크립트 모듈 해석 방식을 이해하기 위한 극단적인 경우이기 때문에 실제로는 번들링 시에 번들러가 파일을 찾을 수 없다고 불평할 것이다. 실제로 동작하는 현실적인 예시를 든다면 아래와 같을 것이다.
/* ./math.js */
export function add(a: number, b: number) {
return a + b;
}
/* ./main.js */
// ❌ 1. 확장자가 없기 때문에 오류 발생
import { add } from "./math";
// ✅ 2. 확장자까지 정확히 지정해줘야 정상 작동
import { add } from "./math.js";
ESM 방식에서 모듈을 import할 때는 해당 모듈의 확장자 `.js`, `.mjs`까지 정확하게 표기해줘야 한다. 보통 React 프로젝트에서 확장자까지 지정해 모듈을 import하는 경우는 흔치 않은데 이 경우에 오류가 발생하지 않는 것은 번들러가 `"./math"` 식별자에 해당하는 `.ts`, `.mts`, `.cts`, `.js`, `.mjs`, `.cjs` 파일을 직접 찾아서 개발 서버나 번들된 스크립트를 실행하기 때문이다.
/* ./math.mjs */
export function add(a: number, b: number) {
return a + b;
}
/* ./main.js */
// ❌ 1. ./math.js 파일이 없기 때문에 오류 발생
const math = require("./math");
// ✅ 2. 확장자까지 정확히 지정해줘야 정상 작동
const math = require("./math.mjs");
CJS 방식에서도 해당 모듈의 확장자까지 정확하게 지정해줘야 하는 건 동일하지만 `.js` 파일일 경우에는 확장자 생략이 가능하다.
LOAD_AS_FILE(X) 1. If X is a file, load X as its file extension format. STOP 2. If X.js is a file, a. Find the closest package scope SCOPE to X. b. If no scope was found, MAYBE_DETECT_AND_LOAD(X.js) c. If the SCOPE/package.json contains "type" field, 1. If the "type" field is "module", load X.js as an ECMAScript module. STOP 2. If the "type" field is "commonjs", load X.js as a CommonJS module. STOP d. MAYBE_DETECT_AND_LOAD(X.js) 3. If X.json is a file, load X.json to a JavaScript Object. STOP 4. If X.node is a file, load X.node as binary addon. STOP
/* ./math.js */
module.exports = {
add: function add(a, b) {
return a + b;
}
}
/* ./main.js */
// ❌ 1. ./math.mjs 파일이 없기 때문에 오류 발생
const math = require("./math.mjs");
// ✅ 2. 확장자까지 정확히 지정해줘야 정상 작동
const math = require("./math.js");
// ✅ 3. 파일 확장자를 생략할 경우 Node.js가 ___.js 파일인지 확인
const math = require("./math");
Node.js v16+에서는 `require`를 사용하는 CJS 방식과 `import`를 사용하는 ESM 방식을 모두 지원하는데 모듈 파일의 확장자, `package.json`의 `"type"` 필드 값, ESM 방식에서만 파싱할 수 있는 코드의 존재 유무 등으로 해당 모듈이 ESM인지 CJS인지 결정한다. #
/* ./math.js */
export function add(a, b) {
return a + b;
}
/* ./main.js */
// ❌ 1. 상대경로임에도 파일 확장자가 없기 때문에 오류 발생
import { add } from './math'
// ✅ 2. 해당 패키지의 `package.json`에 `exports` 필드 값이 존재하는 경우
import sp from 'some-package/shuffle'
Node.js 환경에서 `import`를 사용하는 경우, ESM의 엄격한 모듈 해석 방식이 적용되기 때문에 상대 경로에 반드시 파일 확장자도 포함해줘야 한다.
c. 경로 alias와의 관계
`tsconfig.json`에 `compilerOptions.paths`로 alias를 설정하더라도 `tsc -b` 결과물에서의 모듈 식별자가 원래의 경로로 치환되지 않는 걸 확인할 수 있는데, 앞서 설명했듯이 경로 alias 즉, 모듈 식별자를 해석하는 건 TS 컴파일러가 아니라 호스트의 역할이기 때문이다.
호스트가 기대하는 모듈의 타입을 설정하는 옵션이다. 브라우저는 ESM 방식의 모듈만 해석할 수 있고, Node.js v11 이하는 CJS 방식만, Node.js v12+는 ESM과 CJS 모두 해석할 수 있기 때문에 타입스크립트가 어떤 방식으로 모듈을 해석해 컴파일을 할지 정해주는 것이 중요하다. 물론 이 옵션이 JavaScript 파일로 변환할 때만 사용되는 것은 아니고 타입스크립트의 모듈 해석(module resolution) 방식과 각각의 모듈이 어떤 방식으로 import되어야 하는지, 모듈과 관련한 기능이 사용 가능한지 등을 결정하는 데에도 영향을 준다.
`node16`: Node.js v16+의 모듈 시스템 반영. ESM과 CJS 방식의 상호운용(interoperability) 지원. `node18`: Node.js v18+의 모듈 시스템 반영.import 속성지원. `nodenext`: 최신 Node.js의 모듈 시스템 반영.
Node.js는 ESM과 CJS 방식의 모듈을 모두 지원하기 때문에 파일 확장자와 `package.json`의 `"type"` 필드값을 통해 해당 파일이 어떤 방식의 모듈인지 결정한다. `module`의 옵션 값이 `node16`, `node18`, `nodenext`일 때 타입스크립트는 아래와 같은 규칙으로 입력 파일이 어떤 방식의 모듈인지 파악하는데 이는 Node.js가 모듈 시스템을 결정하는 방법과 거의 동일하다. 타입스크립트는 런타임에서 발생할지도 모르는 오류를 컴파일 시에 잡아내야 하므로 호스트(Node.js 런타임)의 모듈 결정 방식을 따라갈 수밖에 없기 때문이다.
1. `.mts` `.mjs` `.d.mts`: ESM 방식 2. `.cts` `.cjs` `.d.cts`: CJS 방식 3. `.ts` `.tsx` `.js` `.jsx` `.d.ts`: 가장 가까운 부모 폴더의 `package.json`으로 판단 - `"type": "module"`이면 ESM 방식 - 그외에는 CJS 방식
타입스크립트는 만약 입력 파일이 `.mts`이라면 Node.js가 출력 파일(`.mjs`)을 ESM 방식으로, `.cts`이라면 출력 파일(`.cjs`)을 CJS 방식으로, `.ts`라면 출력 파일이 `.js`이므로 `package.json` 설정에 따라 ESM, CJS 방식으로 처리할 것이라 기대한다. TS 컴파일러는 `module` 설정값에 따라 타입스크립트 파일 트랜스파일링 시 ESM, CJS 중 어떤 형식으로 모듈을 변환할지 결정한다.
Node.js가 아닌 브라우저 환경에서 모듈을 사용하는 경우라면 어떻게 될까? 앞서 Node.js는 ESM과 CJS 방식 모두를 지원하기 때문에 파일 확장자와 `package.json` 설정을 통해 어떤 방식의 모듈인지 해석한다고 했다. 하지만 브라우저에서는 ESM 방식만 지원하기 때문에 Node.js의 방식과 충돌한다. 따라서 Node.js를 사용하지 않는다면 `module` 옵션으로 `node16` 또는 `nodenext`를 사용할 이유가 없다. 그런 경우에는 `es__`를 사용할 수 있는데 아래와 같은 값을 설정할 수 있다.
`es2015`: ES2015(ES6)의 모듈 시스템을 반영. `import`, `export` 키워드 도입. `es2020`:import.meta와 `export * as` 문법 지원. `es2022`: 모듈최상위 await지원 `esnext`: 최신 ECMAScript 스펙 반영. 모듈과 관련한 Stage 3+ 제안을 포함할 수 있다.
await new Promise((r) => setTimeout(r, 1000));
export const add = (a: number, b: number) => a + b;
예를 들어, `"module": "es2020"`일 때 모듈의 최상위 스코프에서 `await` 키워드를 사용하면 타입스크립트는 Top-level 'await' expressions are only allowed when the 'module' option is set to 'es2022', 'esnext', 'system', 'node16', 'nodenext', or 'preserve', and the 'target' option is set to 'es2017' or higher.와 같은 오류를 출력한다.
Q. 왜 최상위 `await`은 `module` 옵션의 영향을 받는 걸까?
`import.meta`와 `export * as`는 직접적으로 모듈 기능과 관계가 있다는 게 쉽게 이해가 된다. 하지만 최상위 `await`은 모듈보다는 비동기와 관련된 기능 같은데 왜 `target`, `lib` 옵션이 아니라 `module` 설정을 바꿔줘야 TS 오류가 사라지는 걸까? 일반적인 JavaScript 파일은 동기적으로 실행된다. 이것은 모듈에 대해서도 마찬가지인데 최상위 `await`이 사용되면 해당 모듈은 더이상 동기적으로 실행될 수 없다. 모듈이 비동기적으로 실행되어야 하기 때문에 기존(ES2022 이전)의 ESM 방식으로는 모듈을 제대로 해석할 수 없다. ES2022에 도입된 기능이라 `target`, `lib` 옵션을 설정해주는 것과는 별개로 타입스크립트가 새로운 모듈 시스템을 해석할 수 있도록 해줘야 하기 때문에 `module` 설정 또한 변경해줘야 하는 것이다.
"node16", "nodenext" 1. Node.js의 모듈 해석 방식을 따른다. 2. `node_modules` 폴더를 탐색하여 모듈을 찾는다. 3. `package.json`의 `main` 또는 `exports` 필드를 참조한다.
"bundler" 1. 번들러(Webpack, Rollup 등)가 사용하는 import 경로를 그대로 유지하는 방식 2. `package.json`의 `exports` 필드를 엄격하게 따른다.
타입스크립트가 따를 호스트의 모듈 해석 방식을 설정하는 옵션이다. Node.js 환경에서 타입스크립트를 사용한다면 `node16` 또는 `nodenext`를 사용해야 하며 이 방식은 Node.js가 ESM 또는 CJS 방식의 모듈을 해석하는 방법을 그대로 따른다. 반면에 Webpack이나 Vite 등 현대 번들러를 사용한다면 `bundler`로 설정해야 하며, Node.js의 `node_modules` 탐색 방식에 더해 `index` 파일 해석, 확장자 없는 파일 해석, `package.json`의 `"exports"` 필드를 이용해 모듈을 해석한다. moduleResolution에 대한 자세한 정보는 여기에서 확인할 수 있다.
C. module과 moduleResolution
`module`과 `moduleResolution` 옵션은 우리가 코드를 작성할 때 어떤 영향을 주는 걸까?아래와 같이 `math.ts` 파일을 작성하고 `index.ts` 파일에서 함수 `add`를 import하는 경우를 생각해보자. `module` 옵션을 `"node16"` 또는 `"nodenext"`, `moduleResolution` 옵션도 `module`에 맞게 `"node16"` 또는 `"nodenext"`로 설정했다고 하자.
/* ./math.ts */
export function add(a: number, b: number) {
return a + b;
}
`index.ts`에서 ESM 방식으로 `add`를 import하면Relative import paths need explicit file extensions in ECMAScript imports when '--moduleResolution' is 'node16' or 'nodenext'.라는 오류가 발생한다.
만약 `compilerOptions.paths`에 경로 alias로 "@"가 설정되어 있는 경우에는 Cannot find module '@/math' or its corresponding type declarations.라는 오류가 발생하는데 두 경우 모두 오류 메시지가 달라 원인도 다를 것 같지만 실제로는 동일하다. Node.js v16+에서 `import` 키워드를 사용하면 ESM의 엄격한 모듈 해석 방식이 적용되는데 파일 확장자를 설정하지 않았기 때문이다. 단지 후자의 경우는 `"@/math"`가 파일이 아닌 `math`라는 폴더 또는 다른 node_modules 패키지를 가리킬 수도 있기 때문에 확장자가 없다는 오류 대신 모듈을 찾을 수 없다는 오류가 뜨는 것이다.
// 1. 확장자 명확히 표시
import { add } from './math.js';
import { add } from '@/math.js';
// 2. allowImportingTsExtensions, rewriteRelativeImportExtensions 옵션과 함께
import { add } from './math.ts';
import { add } from '@/math.ts';
위 오류에 대한 해결 방법은 입력 파일의 확장자 `.ts`가 아닌 출력 파일 `.js` 확장자를 명확히 표시해주거나 `.ts` 확장자를 사용하고 싶다면 `allowImportingTsExtensions`, `rewriteRelativeImportExtensions` 옵션을 설정해줘야 한다.
D. allowImportingTsExtensions, rewriteRelativeImportExtensions #
` allowImportingTsExtensions`은 모듈을 import할 때 `import foo from "./foo.ts"`처럼 `.ts`, `.cts`, `.mts`, `.tsx`와 같은 타입스크립트 파일 확장자를 허용할지 여부를 결정하는 옵션이다. 이 옵션을 활성화하려면 TS 컴파일러로 타입스크립트파일을 트랜스파일링한다면 `rewriteRelativeImportExtensions` 옵션도 활성화하거나 타입스크립트파일을 해석하고 번들링할 수 있는 번들러와 같이 사용해야 한다.
TS 컴파일러가 타입스크립트파일을 JavaScript 파일로 트랜스파일링 하지 않도록 하는 옵션. Webpack, Vite 등의 번들러를 사용해 타입스크립트파일을 번들링 하는 경우 굳이 TS 컴파일러는 필요치 않으므로 `noEmit` 설정을 사용하는 것이 일반적이다.
tsc -b && vite build
`noEmit: true`로 설정된 상태에서 위와 같은 명령으로 프로젝트를 빌드하면 TS 컴파일러는 빌드 전에 타입 검사만을 해주는 역할을 한다.
4. 그 외 중요한 tsconfig
A. baseUrl
`baseUrl`이 설정되지 않았다면 해당 `tsconfig.json` 파일이 위치한 폴더가 `baseUrl`의 기준 위치로 설정된다. 하지만 다른 위치의 `tsconfig.base.json`을 extends한다면`tsconfig.base.json`의 `baseUrl`이 대신 사용되는 점에 주의해야 한다. `compilerOptions.baseUrl`은 ` compilerOptions` 내부의 경로에만 영향을 주는 점에 유의해야 한다. 예를 들어, 폴더 구조는 아래와 같고 `/tsconfig.base.json`, `/demo/tsconfig.json`가 다음과 같이 작성되어 있다고 해보자.
`/demo/tsconfig.json`에서 `tsconfig.base.json`을 extends했지만 별도로 `baseUrl`을 설정하지 않았기 때문에 `paths`는 `/demo`를 기준으로 해석되는 게 아니라 프로젝트 루트 폴더를 기준으로 해석된다.
// ✅ 문제없이 파일을 import할 수 있다
import { useMyHook } from '@hook/index'
function App() {
const value = useMyHook()
return (/* ... */)
}
export default App
이렇게 설정된 상태에서`demo/src/App.tsx`에서 `packages/hook`의 `useMyHook`를 가져와서 사용하면 해당 패키지를demo의`dependencies`로 추가하지 않더라도 타입스크립트 컴파일러는 문제 없이 파일을 찾아낼 수 있다.
B. paths
타입스크립트가 타입 검사 또는 컴파일 시 경로를 해석하는 데 필요한 설정이다. 모듈을 import 할 때 상대 경로만 사용하거나 프로젝트 루트 기준의 경로를 그대로 사용하는 경우에는 설정할 필요가 없지만, 경로를 축약하는 등 alias를 사용하는 경우에는 반드시 설정해줘야 한다. `tsconfig.json`의 `paths`를 아래와 같이 설정했다고 해보자.
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
// 모듈 식별자가 'demo/src/App'에서 간략하게 표현될 수 있다
import App from '@demo/App'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
위와 같이 길어질 수 있는 모듈 경로를 간단하게 축약할 수 있고, VS Code 등 IDE에서도 해당 alias로 모듈 import 자동완성이 지원된다. 경로 alias는 편리한 점이 많기 때문에 많이 사용되지만 그만큼 사용 시 주의해야 점이 몇 가지 있다.
첫번째로 `extends`로 상위 폴더의 `tsconfig.base.json`를 확장하는 경우 `paths` 속성값은 적용되지 않기 때문에 현재 폴더의 `tsconfig.json`과 `tsconfig.base.json`에 별개로 설정해주거나 가장 말단의 `tsconfig.json`에만 `paths`를 설정해줘야 한다.
// packages/A/consumer.ts에서 packages/B의 someFn를 가져와서 사용한다.
import { someFn } from "@myLib/B/someFn"
`tsconfig.base.json`에 `"@myLib/B"`에 대해 alias를 설정했다고 하더라도 `packages/A/tsconfig.json`에도 동일한 내용의 `paths`를 추가해주지 않으면 Cannot find module '@myLib/B/someFn' or its corresponding type declarations.와 같은 타입 오류가 발생할 수 있다. 따라서 아래와 같이 `tsconfig.base.json`의 `baseUrl`을 고려해 `packages/A/tsconfig.json`도 수정해줘야 한다.
// packages/A/tsconfig.json
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"paths": {
// ✅ packages/A/tsconfig.json에도 alias 추가
"@myLib/B/*": ["packages/B/*"]
}
}
}
두번째로는 `tsconfig.json`이 존재하는 여러 패키지를 넘나들며 파일을 가져올 때 A 폴더의 `tsconfig.json`에 적용된 `paths`는 해당 A 폴더 내부 파일의 경로를 해석할 때만 유효하다는 점이다. 앞선 상황과 동일하게 `packages/B`에 존재하는 파일을 로컬로 가져와 `packages/A`에서 사용하려 할 때, `packages/B/tsconfig.json`에도 별도의 `paths`가 설정되어 있다고 해보자.
// packages/A/consumer.ts에서 packages/B의 someFn를 가져와서 사용한다.
import { someFn } from "@myLib/B/someFn"
// packages/B/someFn.ts에서도 별도의 alias를 사용하고 있다.
import { someFnUtil1 } from "@B/someFnUtils"
`packages/A/consumer.ts`에서 `"@myLib/B"`라는 alias를 이용해 `packages/B/someFn.ts` 모듈을 import 한다고 해보자. `packages/B/someFn.ts`에서도 `"@B"`라는 alias로 `packages/B/someFnUtils.ts`에 정의된 함수를 import하고 있다.
`packages/B` 내부에서는 `tsconfig.json`에 설정한 alias를 사용해 모듈을 import 해도 아무런 오류가 발생하지 않는다. 하지만, `packages/A`에서 `"@B"`을 사용한 파일을 import할 때는 문제가 발생한다. 이 사례와 유사하게 `packages/B/index.ts`에서 alias를 사용해 프로젝트 내부의 파일 `packages/B/someFnUtils.ts`와 `packages/B/someFnExtraUtils.ts`를 다시 export 한다고 해보자.
// packages/B/index.ts
export { someFnUtil1 } from "@B/someFnUtils"
export * from "@B/someFnExtraUtils"
// packages/A/consumer.ts에서 B의 barrel 파일(packages/B/index)을 가져온다.
import { someFnUtil1, someFnExtraUtil1 } from "@myLib/B/index"
`packages/A/consumer.ts`에서 `packages/B/index.ts`를 import 하면 이번에는 두 가지 기묘한(?) 일이 발생한다. ① `someFnUtil1`에 대해서는 아무런 오류도 발생하지 않지만 타입스크립트가 `someFnUtil1`의 타입 정보를 제대로 가져오지 못한다. ② `"@B/someFnExtraUtils"`에 분명히 `someFnExtraUtil1`이 존재함에도 Module "@myLib/B/index" has no exported member 'someFnExtraUtil1'.라는 타입 오류가 발생한다. ①, ② 오류 둘 다 타입스크립트가 `"@B"`라는 경로 alias를 해석하는데 실패했기 때문에 발생한 일이다. 이 오류는 `packages/A/tsconfig.json`의 `paths`에도 `"@B"` 정보를 추가해주면 해결된다.
위 상황들은 `paths`에 대한 심층적인 이해를 위해 도입한 것으로, 실제 모노레포 프로젝트에서는 개별 패키지를 배포해서 사용하는 것이 간단하고 바람직하다.
`paths`와 관련한 유의점 1. `extends`로 설정을 확장할 경우 확장한 tsconfig(ex. `tsconfig.base.json`)의 `paths`는 적용되지 않는다. 2. `paths` 설정은 해당 폴더 내에서만 유효하다. 3. 매칭되는 `paths`가 둘 이상이라면 가장 구체적인 패턴(`*`를 제외한 가장 긴 패턴)이 선택된다. #
한편, `paths`는 TS 컴파일러가 파일과 타입 정의를 찾을 때 참고하는 값일 뿐 빌드된 JavaScript에서도 이 alias에 맞게 파일을 알아서 찾는 것은 아님에 주의해야 한다. 설정한 alias에 대응되는 파일을 찾을 수 있도록 번들러(ex. Vite, Webpack)에서도 추가적인 설정을 해줘야 한다.
TS 컴파일러가 타입 체크, 빌드 시에 포함할 파일 이름이나 패턴을 지정한다. 이때 `paths`, `include`, `exclude` 경로는 `compilerOptions.baseUrl`이 아니라 `tsconfig.json`이 존재하는 폴더를 기준으로 해석된다. 한편, `compilerOptions.paths`, `include`, `exclude`에 경로 작성 시 경로의 맨 앞에 "/"를 포함시키면 파일 시스템의 루트 경로를 기준으로 잘못 해석할 수 있으므로 절대 경로는 사용하지 않는 것이 좋다.
타입스크립트의 모듈 해석 방식과 compilerOptions
1. 모듈 경로 해석과 package.json
React에서 `useState`를 가져와서 사용한다고 해보자. `import` 구문에서 `"react"`는 상대 경로도, 절대 경로도 아니고 단순히 패키지 이름인데 어떻게 react의 node_modules 경로를 찾아내서 가져올 수 있는 걸까? ECMAScript 모듈(ESM) 표준은 모듈 식별자를 별도로 해석하지 않고 `"react"` 그대로 모듈을 찾기 때문에 오류가 발생한다. 반면에, CommonJS(CJS) 모듈에서는 Node.js 모듈 해석 방식을 따라 모듈 식별자가 상대 경로도, 절대 경로도 아니라면 `/node_modules/react/package.json` 파일을 찾아간다.
어떠한 node 모듈의 `package.json`이 아래와 같이 되어 있다고 해보자. Node.js는 해당 패키지의 파일 경로를 결정하기 위해 `package.json`의 `"main"`, `"exports"` 필드를 참조한다.
`"main"`은 `require("express")`와 같이 CommonJS 방식으로 모듈을 가져올 때 참조되는 진입점으로, `"main"` 필드가 존재하지 않는다면 해당 패키지의 루트 폴더에 있는 `index.js`가 기본값으로 사용된다. `"exports"`는 `"main"`을 대체하기 위해 도입된 필드로, 어떤 프로젝트가 해당 패키지를 import하는 방식(ESM 또는 CJS)에 따라 여러 진입점을 설정할 수 있고, 상황에 따른 조건부 진입점도 설정할 수도 있다. `"exports"` 필드를 사용했을 때 모듈 경로를 찾는 구체적인 알고리즘은 여기에서 확인할 수 있다.
Vite는 `package.json`의 exports 필드가 위와 같이 설정되어 있다. `"."`는 기본 엔트리에 대한 진입점으로, `import ___ from "vite"`를 호출하면 그 하위의 `"import"` 필드값을, `require("vite")`를 호출하면 `"require"` 필드값을 이용해 파일을 찾는다. `" ./client"` 필드는 `import ___ from "vite/client"`를 호출했을 때 사용되는데 하위에 `"types"` 필드만 설정되어 있으므로 타입 정의 파일만 가져올 수 있음을 알 수 있다.
2. 타입스크립트의 모듈 해석 방식
A. 타입스크립트 언어 서버와 컴파일러
Vite + React + TypeScript 프로젝트에서 경로 alias를 사용하고자 하는 경우, `tsconfig.json`의 `compilerOptions.paths`와 `vite.config.ts`의 `resolve.alias` 모두 alias 설정을 해줘야 한다. Vite alias 설정은 번들링 과정에서 필요하다고 하더라도 타입스크립트 설정은 왜 존재하는 걸까? 결론부터 간단하게 말하면 타입스크립트가 타입 검사를 하는데 필요한 정보이기 때문이다.
우선 타입스크립트로 개발을 하는데 필요한 타입스크립트 언어 서버(TypeScript Language Server)와 타입스크립트 컴파일러(TSC)에 대한 개념을 알아야 한다. 타입스크립트 언어 서버는 VS Code와 같은 IDE에서 실시간으로 코드 분석과 타입 검사, 자동 완성 등의 기능을 수행하는 도구이다. 언어 서버가 파일을 검사하고 타입 오류를 IDE에 알려주기 때문에 우리가 파일을 저장하지 않아도 실시간으로 타입 오류에 대한 정보를 얻을 수 있다. 타입스크립트 컴파일러는 타입 검사를 수행하고 TypeScript 파일을 JavaScript 파일로 트랜스파일링 해주는 도구이다. 타입스크립트 언어 서버가 IDE에서 실시간으로 동작하는 반면, 타입스크립트 컴파일러는 우리가 `tsc` 명령어를 실행해야 동작한다.
`tsconfig.json` 설정은 타입스크립트 언어 서버와 타입스크립트 컴파일러가 위와 같은 기능들을 수행할 때 사용하는 파일이다. `tsconfig.json`만으로는 Vite와 같은 번들러가 경로 alias에 대한 정보를 알 수 없기 때문에 번들링이 불가능하고, 반대의 경우도 마찬가지이다. TypeScript 파일에서 "모듈 또는 타입 선언을 찾을 수 없습니다."라는 타입 오류가 발생한다고 해서 번들링 도구에서 개발 서버 실행과 번들링이 실패하지 않는 경우는 이 때문이다.
이러한 코드를 작성했다고 해보자. 타입스크립트는 `sayHello`가 함수가 맞는지, 첫번째 인수로 string 타입의 값을 전달하는 게 맞는지 등 타입을 검사해야 한다. JavaScript에서 모듈은 런타임에 평가되는데 타입스크립트가 모든 `.ts`, `.tsx` 파일을 일일히 전부 실행해서 타입에 대한 정보를 얻을 수는 없지 않은가? 이렇게 스크립트 파일을 실행해보지 않고 타입 체크를 하기 위해 타입스크립트 언어 서버와 타입스크립트 컴파일러는 어떤 식으로 모듈을 해석할지에 대해서도 알고 있어야 한다.
B. 타입스크립트의 모듈 해석(Module Resolution) #
앞서 언급했듯이 CommonJS 방식에서는 Node.js 모듈 해석 방식을 따라 모듈의 위치를 찾는다. 반면에, ESM 방식에서는 `"./greetings"`라는 모듈 식별자(module specifiers)를 어떻게 해석할지는 호스트(Host)에 위임하고 있다. 여기서 호스트란 입력 코드를 소비하여 모듈 로딩 동작을 제어하는 시스템이라고 할 수 있는데 Node.js 런타임, 브라우저 JavaScript 엔진 등이 이에 해당한다. 반면, TS 컴파일러는 이러한 모듈 로딩에 대한 호스트의 동작을 모델링할 뿐이기 때문에 호스트라고는 할 수 없다.
극단적인 예시로 🐒, 🐄, 🦁와 같은 모듈 식별자를 위와 같은 경로로 해석하는 JavaScript 엔진이 있다고 해보자. ECMAScript에서는 모듈 식별자를 어떻게 해석해야 하는지 규정하고 있지 않기 때문에 이 엔진이 ECMAScript 표준을 따르고 있다고 주장해도 문제가 없다. 하지만 이러한 호스트에서 실행될 파일을 TypeScript로 작성한다고 했을 때 타입스크립트가 `monkey`, `cow`, `lion`에 대해 타입 검사를 할 수 있을까? 타입스크립트에 추가적인 정보를 제공하지 않는다면 불가능할 것이다. 그것이 바로 "이 스크립트 파일을 실행하는 호스트는 이런 방식으로 모듈 식별자를 해석한다."라는 정보라고 할 수 있는데 `tsconfig.json`의 `moduleResolution` 설정이 그 역할을 한다.
TS 컴파일러의 핵심적인 역할은 3가지라고 할 수 있다. 1번 역할을 수행하기 위해 `tsconfig.json`에 `module` 설정이, 2~3번 역할을 위해 `moduleResolution` 설정이 존재한다. `moduleResolution`에는 크게 `node__`와 `bundler`가 존재하는데 `node__`는 스크립트를 Node.js 런타임에서 실행하는 경우, `bundler`는 Webpack, Vite 등 현대적인 번들러를 사용하는 경우에 설정해야 한다.
타입스크립트는 모듈 로딩에 대한 호스트의 동작을 모델링해야 한다고 했는데 구체적으로 어떤 식으로 이뤄지는 걸까? TS 컴파일러는 모듈 식별자 `"./math"`를 `./math.ts`로 해석하는 걸까?
우리가 React + TypeScript 프로젝트에서 대부분의 파일들을 `.ts`, `.tsx` 확장자로서 작성하기 때문에 왠지 그것이 당연해 보이지만 이는 잘못된 생각이다. 입력 파일 `main.ts`에서의 모듈 식별자(`"./math"`)와 동일한 모듈 식별자를 포함하는 출력 파일 `main.js`이 런타임에서도 문제없이 작동해야 하기 때문에 TS 컴파일러는 `main.ts`, `math.ts` 두 입력 파일이 각각의 출력 파일과 어떻게 연결되는지 이해하고 있어야 한다. 즉, TS 컴파일러는 모듈 식별자를 자체적으로 해석해서는 안 되고 호스트의 모듈 식별자 해석을 그대로 따라야 하는 것이다. 그 관계를 도식화한 것이 아래와 같다.
타입스크립트가 왜 이렇게 우회적(?)으로 입력 파일을 맵핑하는지 언뜻 이해가 되지 않을 수 있다. 그 이유를 이해하기 위해 이번에는 예시를 더 비틀어서 파일 확장자까지 포함해 import하는 경우를 생각해보자.
`math`라는 파일이 `.mts` 또는 `.mjs` 확장자로서 존재할 때 `main.ts`에서는 1~4번처럼 해당 모듈을 import할 수 있다. 분명 내가 작성한 모듈은 `./math.mts`인데도 1번에선 타입스크립트가 어떠한 오류도 출력하지 않는 건 조금 이상해보인다. 2~4번에선 특정 조건에 한해서는 오류가 발생하지 않는다. 그렇다면 타입 선언 파일이 존재하고 `allowImportingTsExtensions: true`라면, 아래와 같이 존재하지도 않는 모듈 `math.mjs` 또는 `math.mts`를 import해도 타입스크립트는 아무런 불평도 하지 않는다는 의미가 될 것이다.
언뜻 난장판처럼 보이는 타입스크립트 모듈 해석은 입력 파일과 출력 파일 사이의 관계를 이해한다면 그리 이상한 것도 아니다. 타입스크립트는 1번의 경우 번들러가 `math.mts` 파일을 변환할 수 있어 `math.mjs` 파일이 반드시 존재할 것이라고, 2번의 경우에는 번들러가 `math.mts` 파일을 찾을 수 있고 JavaScript로도 변환할 수 있다고(물론 번들러가 모듈 식별자 `"/.math.mts"` 를 `"/.math.mjs"`로 변환해줄 수 있다는 보장도 있어야 한다.) 믿기 때문에 어떠한 오류도 출력하지 않는다. 물론 위의 두 예시는 타입스크립트 모듈 해석 방식을 이해하기 위한 극단적인 경우이기 때문에 실제로는 번들링 시에 번들러가 파일을 찾을 수 없다고 불평할 것이다. 실제로 동작하는 현실적인 예시를 든다면 아래와 같을 것이다.
ESM 방식에서 모듈을 import할 때는 해당 모듈의 확장자 `.js`, `.mjs`까지 정확하게 표기해줘야 한다. 보통 React 프로젝트에서 확장자까지 지정해 모듈을 import하는 경우는 흔치 않은데 이 경우에 오류가 발생하지 않는 것은 번들러가 `"./math"` 식별자에 해당하는 `.ts`, `.mts`, `.cts`, `.js`, `.mjs`, `.cjs` 파일을 직접 찾아서 개발 서버나 번들된 스크립트를 실행하기 때문이다.
CJS 방식에서도 해당 모듈의 확장자까지 정확하게 지정해줘야 하는 건 동일하지만 `.js` 파일일 경우에는 확장자 생략이 가능하다.
Node.js v16+에서는 `require`를 사용하는 CJS 방식과 `import`를 사용하는 ESM 방식을 모두 지원하는데 모듈 파일의 확장자, `package.json`의 `"type"` 필드 값, ESM 방식에서만 파싱할 수 있는 코드의 존재 유무 등으로 해당 모듈이 ESM인지 CJS인지 결정한다. #
Node.js 환경에서 `import`를 사용하는 경우, ESM의 엄격한 모듈 해석 방식이 적용되기 때문에 상대 경로에 반드시 파일 확장자도 포함해줘야 한다.
c. 경로 alias와의 관계
`tsconfig.json`에 `compilerOptions.paths`로 alias를 설정하더라도 `tsc -b` 결과물에서의 모듈 식별자가 원래의 경로로 치환되지 않는 걸 확인할 수 있는데, 앞서 설명했듯이 경로 alias 즉, 모듈 식별자를 해석하는 건 TS 컴파일러가 아니라 호스트의 역할이기 때문이다.
3. 관련 tsconfig
A. module #
호스트가 기대하는 모듈의 타입을 설정하는 옵션이다. 브라우저는 ESM 방식의 모듈만 해석할 수 있고, Node.js v11 이하는 CJS 방식만, Node.js v12+는 ESM과 CJS 모두 해석할 수 있기 때문에 타입스크립트가 어떤 방식으로 모듈을 해석해 컴파일을 할지 정해주는 것이 중요하다. 물론 이 옵션이 JavaScript 파일로 변환할 때만 사용되는 것은 아니고 타입스크립트의 모듈 해석(module resolution) 방식과 각각의 모듈이 어떤 방식으로 import되어야 하는지, 모듈과 관련한 기능이 사용 가능한지 등을 결정하는 데에도 영향을 준다.
Node.js는 ESM과 CJS 방식의 모듈을 모두 지원하기 때문에 파일 확장자와 `package.json`의 `"type"` 필드값을 통해 해당 파일이 어떤 방식의 모듈인지 결정한다. `module`의 옵션 값이 `node16`, `node18`, `nodenext`일 때 타입스크립트는 아래와 같은 규칙으로 입력 파일이 어떤 방식의 모듈인지 파악하는데 이는 Node.js가 모듈 시스템을 결정하는 방법과 거의 동일하다. 타입스크립트는 런타임에서 발생할지도 모르는 오류를 컴파일 시에 잡아내야 하므로 호스트(Node.js 런타임)의 모듈 결정 방식을 따라갈 수밖에 없기 때문이다.
타입스크립트는 만약 입력 파일이 `.mts`이라면 Node.js가 출력 파일(`.mjs`)을 ESM 방식으로, `.cts`이라면 출력 파일(`.cjs`)을 CJS 방식으로, `.ts`라면 출력 파일이 `.js`이므로 `package.json` 설정에 따라 ESM, CJS 방식으로 처리할 것이라 기대한다. TS 컴파일러는 `module` 설정값에 따라 타입스크립트 파일 트랜스파일링 시 ESM, CJS 중 어떤 형식으로 모듈을 변환할지 결정한다.
Node.js가 아닌 브라우저 환경에서 모듈을 사용하는 경우라면 어떻게 될까? 앞서 Node.js는 ESM과 CJS 방식 모두를 지원하기 때문에 파일 확장자와 `package.json` 설정을 통해 어떤 방식의 모듈인지 해석한다고 했다. 하지만 브라우저에서는 ESM 방식만 지원하기 때문에 Node.js의 방식과 충돌한다. 따라서 Node.js를 사용하지 않는다면 `module` 옵션으로 `node16` 또는 `nodenext`를 사용할 이유가 없다. 그런 경우에는 `es__`를 사용할 수 있는데 아래와 같은 값을 설정할 수 있다.
예를 들어, `"module": "es2020"`일 때 모듈의 최상위 스코프에서 `await` 키워드를 사용하면 타입스크립트는 Top-level 'await' expressions are only allowed when the 'module' option is set to 'es2022', 'esnext', 'system', 'node16', 'nodenext', or 'preserve', and the 'target' option is set to 'es2017' or higher.와 같은 오류를 출력한다.
Q. 왜 최상위 `await`은 `module` 옵션의 영향을 받는 걸까?
`import.meta`와 `export * as`는 직접적으로 모듈 기능과 관계가 있다는 게 쉽게 이해가 된다. 하지만 최상위 `await`은 모듈보다는 비동기와 관련된 기능 같은데 왜 `target`, `lib` 옵션이 아니라 `module` 설정을 바꿔줘야 TS 오류가 사라지는 걸까? 일반적인 JavaScript 파일은 동기적으로 실행된다. 이것은 모듈에 대해서도 마찬가지인데 최상위 `await`이 사용되면 해당 모듈은 더이상 동기적으로 실행될 수 없다. 모듈이 비동기적으로 실행되어야 하기 때문에 기존(ES2022 이전)의 ESM 방식으로는 모듈을 제대로 해석할 수 없다. ES2022에 도입된 기능이라 `target`, `lib` 옵션을 설정해주는 것과는 별개로 타입스크립트가 새로운 모듈 시스템을 해석할 수 있도록 해줘야 하기 때문에 `module` 설정 또한 변경해줘야 하는 것이다.
B. moduleResolution #
타입스크립트가 따를 호스트의 모듈 해석 방식을 설정하는 옵션이다. Node.js 환경에서 타입스크립트를 사용한다면 `node16` 또는 `nodenext`를 사용해야 하며 이 방식은 Node.js가 ESM 또는 CJS 방식의 모듈을 해석하는 방법을 그대로 따른다. 반면에 Webpack이나 Vite 등 현대 번들러를 사용한다면 `bundler`로 설정해야 하며, Node.js의 `node_modules` 탐색 방식에 더해 `index` 파일 해석, 확장자 없는 파일 해석, `package.json`의 `"exports"` 필드를 이용해 모듈을 해석한다. moduleResolution에 대한 자세한 정보는 여기에서 확인할 수 있다.
C. module과 moduleResolution
`module`과 `moduleResolution` 옵션은 우리가 코드를 작성할 때 어떤 영향을 주는 걸까? 아래와 같이 `math.ts` 파일을 작성하고 `index.ts` 파일에서 함수 `add`를 import하는 경우를 생각해보자. `module` 옵션을 `"node16"` 또는 `"nodenext"`, `moduleResolution` 옵션도 `module`에 맞게 `"node16"` 또는 `"nodenext"`로 설정했다고 하자.
`index.ts`에서 ESM 방식으로 `add`를 import하면 Relative import paths need explicit file extensions in ECMAScript imports when '--moduleResolution' is 'node16' or 'nodenext'.라는 오류가 발생한다.
만약 `compilerOptions.paths`에 경로 alias로 "@"가 설정되어 있는 경우에는 Cannot find module '@/math' or its corresponding type declarations.라는 오류가 발생하는데 두 경우 모두 오류 메시지가 달라 원인도 다를 것 같지만 실제로는 동일하다. Node.js v16+에서 `import` 키워드를 사용하면 ESM의 엄격한 모듈 해석 방식이 적용되는데 파일 확장자를 설정하지 않았기 때문이다. 단지 후자의 경우는 `"@/math"`가 파일이 아닌 `math`라는 폴더 또는 다른 node_modules 패키지를 가리킬 수도 있기 때문에 확장자가 없다는 오류 대신 모듈을 찾을 수 없다는 오류가 뜨는 것이다.
위 오류에 대한 해결 방법은 입력 파일의 확장자 `.ts`가 아닌 출력 파일 `.js` 확장자를 명확히 표시해주거나 `.ts` 확장자를 사용하고 싶다면 `allowImportingTsExtensions`, `rewriteRelativeImportExtensions` 옵션을 설정해줘야 한다.
D. allowImportingTsExtensions, rewriteRelativeImportExtensions #
` allowImportingTsExtensions`은 모듈을 import할 때 `import foo from "./foo.ts"`처럼 `.ts`, `.cts`, `.mts`, `.tsx`와 같은 타입스크립트 파일 확장자를 허용할지 여부를 결정하는 옵션이다. 이 옵션을 활성화하려면 TS 컴파일러로 타입스크립트 파일을 트랜스파일링한다면 `rewriteRelativeImportExtensions` 옵션도 활성화하거나 타입스크립트 파일을 해석하고 번들링할 수 있는 번들러와 같이 사용해야 한다.
E. noEmit #
TS 컴파일러가 타입스크립트 파일을 JavaScript 파일로 트랜스파일링 하지 않도록 하는 옵션. Webpack, Vite 등의 번들러를 사용해 타입스크립트 파일을 번들링 하는 경우 굳이 TS 컴파일러는 필요치 않으므로 `noEmit` 설정을 사용하는 것이 일반적이다.
`noEmit: true`로 설정된 상태에서 위와 같은 명령으로 프로젝트를 빌드하면 TS 컴파일러는 빌드 전에 타입 검사만을 해주는 역할을 한다.
4. 그 외 중요한 tsconfig
A. baseUrl
`baseUrl`이 설정되지 않았다면 해당 `tsconfig.json` 파일이 위치한 폴더가 `baseUrl`의 기준 위치로 설정된다. 하지만 다른 위치의 `tsconfig.base.json`을 extends한다면 `tsconfig.base.json`의 `baseUrl`이 대신 사용되는 점에 주의해야 한다. `compilerOptions.baseUrl`은 ` compilerOptions` 내부의 경로에만 영향을 주는 점에 유의해야 한다. 예를 들어, 폴더 구조는 아래와 같고 `/tsconfig.base.json`, `/demo/tsconfig.json`가 다음과 같이 작성되어 있다고 해보자.
`/demo/tsconfig.json`에서 `tsconfig.base.json`을 extends했지만 별도로 `baseUrl`을 설정하지 않았기 때문에 `paths`는 `/demo`를 기준으로 해석되는 게 아니라 프로젝트 루트 폴더를 기준으로 해석된다.
이렇게 설정된 상태에서 `demo/src/App.tsx`에서 `packages/hook`의 `useMyHook`를 가져와서 사용하면 해당 패키지를 demo의 `dependencies`로 추가하지 않더라도 타입스크립트 컴파일러는 문제 없이 파일을 찾아낼 수 있다.
B. paths
타입스크립트가 타입 검사 또는 컴파일 시 경로를 해석하는 데 필요한 설정이다. 모듈을 import 할 때 상대 경로만 사용하거나 프로젝트 루트 기준의 경로를 그대로 사용하는 경우에는 설정할 필요가 없지만, 경로를 축약하는 등 alias를 사용하는 경우에는 반드시 설정해줘야 한다. `tsconfig.json`의 `paths`를 아래와 같이 설정했다고 해보자.
위와 같이 길어질 수 있는 모듈 경로를 간단하게 축약할 수 있고, VS Code 등 IDE에서도 해당 alias로 모듈 import 자동완성이 지원된다. 경로 alias는 편리한 점이 많기 때문에 많이 사용되지만 그만큼 사용 시 주의해야 점이 몇 가지 있다.
첫번째로 `extends`로 상위 폴더의 `tsconfig.base.json`를 확장하는 경우 `paths` 속성값은 적용되지 않기 때문에 현재 폴더의 `tsconfig.json`과 `tsconfig.base.json`에 별개로 설정해주거나 가장 말단의 `tsconfig.json`에만 `paths`를 설정해줘야 한다.
`tsconfig.base.json`에 `"@myLib/B"`에 대해 alias를 설정했다고 하더라도 `packages/A/tsconfig.json`에도 동일한 내용의 `paths`를 추가해주지 않으면 Cannot find module '@myLib/B/someFn' or its corresponding type declarations.와 같은 타입 오류가 발생할 수 있다. 따라서 아래와 같이 `tsconfig.base.json`의 `baseUrl`을 고려해 `packages/A/tsconfig.json`도 수정해줘야 한다.
두번째로는 `tsconfig.json`이 존재하는 여러 패키지를 넘나들며 파일을 가져올 때 A 폴더의 `tsconfig.json`에 적용된 `paths`는 해당 A 폴더 내부 파일의 경로를 해석할 때만 유효하다는 점이다. 앞선 상황과 동일하게 `packages/B`에 존재하는 파일을 로컬로 가져와 `packages/A`에서 사용하려 할 때, `packages/B/tsconfig.json`에도 별도의 `paths`가 설정되어 있다고 해보자.
`packages/A/consumer.ts`에서 `"@myLib/B"`라는 alias를 이용해 `packages/B/someFn.ts` 모듈을 import 한다고 해보자. `packages/B/someFn.ts`에서도 `"@B"`라는 alias로 `packages/B/someFnUtils.ts`에 정의된 함수를 import하고 있다.
`packages/B` 내부에서는 `tsconfig.json`에 설정한 alias를 사용해 모듈을 import 해도 아무런 오류가 발생하지 않는다. 하지만, `packages/A`에서 `"@B"`을 사용한 파일을 import할 때는 문제가 발생한다. 이 사례와 유사하게 `packages/B/index.ts`에서 alias를 사용해 프로젝트 내부의 파일 `packages/B/someFnUtils.ts`와 `packages/B/someFnExtraUtils.ts`를 다시 export 한다고 해보자.
`packages/A/consumer.ts`에서 `packages/B/index.ts`를 import 하면 이번에는 두 가지 기묘한(?) 일이 발생한다. ① `someFnUtil1`에 대해서는 아무런 오류도 발생하지 않지만 타입스크립트가 `someFnUtil1`의 타입 정보를 제대로 가져오지 못한다. ② `"@B/someFnExtraUtils"`에 분명히 `someFnExtraUtil1`이 존재함에도 Module "@myLib/B/index" has no exported member 'someFnExtraUtil1'.라는 타입 오류가 발생한다. ①, ② 오류 둘 다 타입스크립트가 `"@B"`라는 경로 alias를 해석하는데 실패했기 때문에 발생한 일이다. 이 오류는 `packages/A/tsconfig.json`의 `paths`에도 `"@B"` 정보를 추가해주면 해결된다.
위 상황들은 `paths`에 대한 심층적인 이해를 위해 도입한 것으로, 실제 모노레포 프로젝트에서는 개별 패키지를 배포해서 사용하는 것이 간단하고 바람직하다.
한편, `paths`는 TS 컴파일러가 파일과 타입 정의를 찾을 때 참고하는 값일 뿐 빌드된 JavaScript에서도 이 alias에 맞게 파일을 알아서 찾는 것은 아님에 주의해야 한다. 설정한 alias에 대응되는 파일을 찾을 수 있도록 번들러(ex. Vite, Webpack)에서도 추가적인 설정을 해줘야 한다.
C. include, exclude #
TS 컴파일러가 타입 체크, 빌드 시에 포함할 파일 이름이나 패턴을 지정한다. 이때 `paths`, `include`, `exclude` 경로는 `compilerOptions.baseUrl`이 아니라 `tsconfig.json`이 존재하는 폴더를 기준으로 해석된다. 한편, `compilerOptions.paths`, `include`, `exclude`에 경로 작성 시 경로의 맨 앞에 "/"를 포함시키면 파일 시스템의 루트 경로를 기준으로 잘못 해석할 수 있으므로 절대 경로는 사용하지 않는 것이 좋다.
[참고]
TypeScript: Modules - Theory
'TypeScript' 카테고리의 다른 글