TypeScript 모듈 이론
TypeScript 모듈 이론 (Theory)
도입: 모듈은 JavaScript가 파일 하나에 갇힌 채로는 해결하지 못하는 문제—서로 다른 파일의 코드를 안전하게 나누고 연결하는 문제—를 푸는 방법이에요. 이 문서는 TypeScript가 JavaScript의 모듈 시스템을 어떻게 이해하고 흉내 내는지, 즉 ESM과 CommonJS라는 두 축을 중심으로 컴파일러가 어떤 질문들을 풀어내는지를 차근차근 설명해요. 코드가 실제로 어디서, 어떤 방식으로 실행될지(host)를 알고 있어야 타입 검사가 제대로 동작한다는 것, 그래서 module과 moduleResolution 같은 설정이 왜 중요한지를 함께 이해하게 돼요.
출처: TypeScript 공식문서
본문
JavaScript의 스크립트와 모듈
JavaScript가 아직 브라우저에서만 돌아가던 초창기에는 모듈이라는 개념이 없었어요. 그래도 HTML에서 script 태그를 여러 개 쓰면 웹 페이지의 JavaScript를 여러 파일로 나눌 수는 있었죠:
<html>
<head>
<script src="a.js"></script>
<script src="b.js"></script>
</head>
<body></body>
</html>
이 방식은 웹 페이지가 커지고 복잡해질수록 문제가 드러났어요. 특히, 같은 페이지에 올라온 모든 스크립트는 같은 스코프, 소위 "전역 스코프(global scope)"를 공유하기 때문에, 서로의 변수와 함수를 실수로 덮어쓰지 않도록 아주 조심해야 했어요.
파일마다 자기만의 스코프를 주면서도, 다른 파일이 쓸 수 있게 코드 조각을 내보내는 방법까지 제공하는 어떤 시스템이든 "모듈 시스템(module system)"이라고 부르면 돼요. (모듈 시스템 안의 파일 하나하나를 "모듈"이라고 부른다는 건 당연해 보이지만, 이 용어는 모듈 시스템 밖, 전역 스코프에서 실행되는 스크립트 파일과 대비해서 쓰는 경우가 많아요.)
모듈 시스템은 여러 가지가 있고, TypeScript도 몇 가지를 내보낼 수 있어요. 하지만 이 문서에서는 오늘날 가장 중요한 두 시스템, 즉 ECMAScript Modules(ESM)와 CommonJS(CJS)에 집중할게요.
ECMAScript Modules(ESM)는 언어 자체에 내장된 모듈 시스템이라, 최신 브라우저와 Node.js v12부터 지원돼요. 전용 import/export 문법을 써요:
// a.js
export default "Hello from a.js";
// b.js
import a from "./a.js";
console.log(a); // 'Hello from a.js'
CommonJS(CJS)는 ESM이 언어 사양에 들어가기 전부터 Node.js에 들어 있던 모듈 시스템이에요. 지금도 ESM과 함께 Node.js에서 지원되고 있어요. exports와 require라는 평범한 JavaScript 객체와 함수를 사용해요:
// a.js
exports.message = "Hello from a.js";
// b.js
const a = require("./a");
console.log(a.message); // 'Hello from a.js'
그래서 TypeScript는 파일이 CommonJS 모듈인지 ECMAScript 모듈인지 감지하면, 그 파일은 자기만의 스코프를 가질 거라고 가정하고 시작해요. 다만 그 지점을 넘어서면 컴파일러의 일은 조금 더 복잡해져요.
모듈에 관한 TypeScript의 역할
TypeScript 컴파일러의 가장 큰 목표는 실행 시점에 발생할 수 있는 특정 런타임 오류를 컴파일 시점에 잡아내는 거예요. 모듈이 관련되든 아니든, 컴파일러는 코드가 실행될 환경—예를 들어 어떤 전역 값이 쓰일 수 있는지—에 대해 알아야 해요. 모듈이 얽혀 있으면 컴파일러가 일을 하려면 추가로 답해야 할 질문이 몇 가지 생겨요. 분석에 필요한 정보가 뭔지 생각해 보려고 입력 코드 몇 줄을 예로 들어볼게요:
import sayHello from "greetings";
sayHello("world");
이 파일을 검사하려면 컴파일러는 sayHello의 타입(문자열 인자를 하나 받는 함수인지)을 알아야 해요. 그러면 질문이 꽤 많이 따라와요:
- 모듈 시스템이 이 TypeScript 파일을 직접 로드할지, 아니면 이 TypeScript 파일에서 (나 혹은 다른 컴파일러가) 만들어낸 JavaScript 파일을 로드할지
- 주어진 파일 이름과 디스크상의 위치에서, 모듈 시스템이 어떤 종류의 모듈을 기대할지
- 출력 JavaScript를 만들어낸다면, 이 파일 안의 모듈 문법이 출력 코드에서 어떻게 변환될지
"greetings"로 지정된 모듈을 찾으려고 모듈 시스템이 어디를 살펴볼지, 그리고 그 탐색이 성공할지- 그 탐색 결과로 해석된 파일이 어떤 종류의 모듈인지
- (2)에서 감지된 모듈 종류가 (3)에서 결정된 문법으로 (5)에서 감지된 모듈 종류를 참조하는 걸 모듈 시스템이 허용할지
"greetings"모듈을 분석하고 나면,sayHello에는 그 모듈의 어떤 조각이 바인딩될지
이 질문들은 전부 host(호스트)의 성격에 달려 있다는 걸 눈치채셨나요? host란 출력 JavaScript(혹은 상황에 따라 원본 TypeScript)를 최종적으로 소비하면서 모듈 로딩 동작을 주도하는 시스템이에요. 보통 런타임(Node.js 같은)이나 번들러(Webpack 같은)가 그 역할을 해요.
ECMAScript 사양은 ESM의 import/export가 서로 어떻게 연결되는지는 정의하지만, (4)의 파일 탐색, 이른바 모듈 해석(module resolution)이 어떻게 일어나는지는 정의하지 않아요. CommonJS 같은 다른 모듈 시스템에 대해서도 아무것도 언급하지 않아요. 그래서 런타임과 번들러는, 특히 ESM과 CJS 둘 다 지원하려는 것들은, 자기만의 규칙을 자유롭게 설계할 여지가 커요. 결과적으로 위 질문들에 TypeScript가 답하는 방식은 코드가 어디서 실행되느냐에 따라 크게 달라질 수 있어요. 정답이 하나로 정해져 있지 않으니, 컴파일러는 설정 옵션을 통해 규칙을 전달받아야 해요.
기억해 둘 또 하나의 핵심 아이디어는, TypeScript가 거의 항상 이 질문들을 입력 단계의 TypeScript 파일 기준이 아니라 출력 단계의 JavaScript 파일 기준으로 생각한다는 점이에요. 요즘은 일부 런타임과 번들러가 TypeScript 파일을 직접 로드하기도 하는데, 그런 경우에는 입력/출력 파일을 따로 생각할 필요가 없어요. 이 문서의 대부분은 TypeScript 파일이 JavaScript 파일로 컴파일되고, 그 JavaScript를 런타임의 모듈 시스템이 로드하는 경우를 다뤄요. 이 경우를 먼저 살펴보는 게 컴파일러의 옵션과 동작을 이해하는 데 꼭 필요해요—esbuild, Bun 같은 TypeScript-우선 런타임과 번들러를 생각할 때는 거기서 출발해서 단순화하는 게 쉬워요. 그래서 지금은 모듈에 관한 TypeScript의 역할을 출력 파일 기준으로 정리할 수 있어요:
- 코드를 유효한 출력 모듈 형식으로 컴파일하고,
- 그 출력물 안의 import들이 성공적으로 해석되도록 보장하며,
- import된 이름에 어떤 타입을 할당할지 아는 것.
host는 누구인가?
진행하기 전에 host라는 용어에 대해 같은 이해를 하고 있는지 확인하고 싶어요. 자주 등장할 단어라서요. 앞에서 host를 "모듈 로딩 동작을 주도하기 위해 출력 코드를 최종적으로 소비하는 시스템"이라고 정의했어요. 다시 말해 TypeScript 바깥에 있으면서 TypeScript의 모듈 분석이 흉내 내려고 하는 시스템이에요:
- 출력 코드(
tsc든 서드파티 transpiler든 누가 만들었든)가 Node.js 같은 런타임에서 직접 실행되면, 그 런타임이 host예요. - 런타임이 TypeScript 파일을 직접 소비해서 "출력 코드"가 없는 경우에도, 여전히 런타임이 host예요.
- 번들러가 TypeScript 입력이나 출력을 소비해서 번들을 만들면, 그 번들러가 host예요. 번들러는 원래의 import/require 집합을 보고, 그것들이 참조하는 파일을 찾아낸 뒤, 원래 import/require가 알아볼 수 없을 만큼 지워지거나 변형된 새 파일(들)을 만들어내기 때문이에요. (그 번들 자체도 모듈들로 이뤄질 수 있고, 그 번들을 실행하는 런타임이 그 번들의 host가 되겠지만, TypeScript는 번들러 이후에 일어나는 일은 알지 못해요.)
- 다른 transpiler나 optimizer, formatter가 TypeScript의 출력 위에서 돌더라도, 그것이 보는 import/export를 그대로 두기만 한다면 TypeScript가 신경 쓸 host는 아니에요.
- 웹 브라우저에서 모듈을 로드할 때, TypeScript가 모델링해야 할 동작은 사실 웹 서버와 브라우저 안에서 도는 모듈 시스템 사이에 나뉘어 있어요. 브라우저의 JavaScript 엔진(또는 RequireJS 같은 스크립트 기반 모듈 로딩 프레임워크)이 어떤 모듈 형식을 받아들일지 정하고, 웹 서버는 한 모듈이 다른 모듈 로딩을 요청했을 때 어떤 파일을 보낼지 정해요.
- TypeScript 컴파일러 자체는 host가 아니에요. 다른 host들을 모델링하려는 것 말고 모듈과 관련된 동작을 제공하는 게 없기 때문이에요.
모듈 출력 형식
어느 프로젝트에서든 모듈에 관한 첫 질문은 "host가 어떤 종류의 모듈을 기대하는가"예요. 그래야 TypeScript가 파일마다 출력 형식을 그에 맞출 수 있으니까요. 어떤 host는 모듈을 한 종류만 지원하기도 해요—예컨대 브라우저에서는 ESM, Node.js v11 이하에서는 CJS가 그랬어요. Node.js v12 이상은 CJS와 ES 모듈을 둘 다 받지만, 각 파일이 어떤 형식인지는 파일 확장자와 package.json으로 판단하고, 파일 내용이 기대 형식과 안 맞으면 에러를 던져요.
module 컴파일러 옵션이 이 정보를 컴파일러에 전달해요. 이 옵션의 주목적은 컴파일 중에 내보내지는 JavaScript의 모듈 형식을 제어하는 거예요. 다만 그와 함께 각 파일의 모듈 종류를 어떻게 감지할지, 서로 다른 모듈 종류가 서로를 어떻게 import할 수 있는지, import.meta나 최상위 await 같은 기능을 쓸 수 있는지도 컴파일러에 알리는 역할을 해요. 그래서 TypeScript 프로젝트가 noEmit을 쓰더라도 module 설정을 올바르게 고르는 건 여전히 중요해요. 앞서 말했듯 컴파일러는 import를 타입 검사(그리고 IntelliSense 제공)하려면 모듈 시스템을 정확히 이해해야 하거든요. 프로젝트에 맞는 module 설정을 고르는 지침은 Choosing compiler options를 참고해요.
쓸 수 있는 module 설정은 이래요:
node16: Node.js v16+의 모듈 시스템을 반영해요. 특정한 상호운용성과 감지 규칙으로 ES 모듈과 CJS 모듈을 함께 지원해요.node18: Node.js v18+의 모듈 시스템을 반영하고, import attributes를 지원해요.nodenext: Node.js의 모듈 시스템이 진화함에 따라 최신 Node.js 버전을 반영하는 이동 타깃이에요. TypeScript 5.8 기준으로nodenext는 ECMAScript 모듈의require를 지원해요.es2015: JavaScript 모듈에 대한 ES2015 언어 사양(언어에import와export를 처음 도입한 버전)을 반영해요.es2020:es2015에import.meta와export * as ns from "mod"지원을 추가해요.es2022:es2020에 최상위await지원을 추가해요.esnext: 현재는es2022와 동일하지만, 앞으로는 최신 ECMAScript 사양과 다가올 사양 버전에 포함될 모듈 관련 Stage 3+ 제안들을 반영하는 이동 타깃이 될 거예요.commonjs,system,amd,umd: 각각 해당 모듈 시스템의 모든 것을 내보내고, 모든 요소가 그 모듈 시스템으로 성공적으로 import될 수 있다고 가정해요. 새 프로젝트에는 더 권장되지 않고, 이 문서에서 자세히 다루지 않을게요.
Node.js의 모듈 형식 감지 및 상호운용 규칙 때문에, Node.js에서 돌릴 프로젝트에 module을 esnext나 commonjs로 지정하는 건 틀린 일이에요. tsc가 내보낸 파일이 전부 각각 ESM이거나 CJS라 해도 마찬가지예요. Node.js에서 돌릴 의도의 프로젝트에 올바른 module 설정은 node16과 nodenext뿐이에요. 전부 ESM인 Node.js 프로젝트의 내보낸 JavaScript는 esnext와 nodenext 어느 쪽으로 컴파일하든 똑같아 보일 수 있는데도, 타입 검사는 달라질 수 있어요. 자세한 내용은 nodenext에 대한 reference 섹션을 봐요.
모듈 형식 감지(Module format detection)
Node.js는 ES 모듈과 CJS 모듈 둘 다 이해하지만, 각 파일의 형식은 파일 확장자와, 그 파일의 디렉터리 및 모든 상위 디렉터리를 뒤져 처음 찾은 package.json 파일의 type 필드로 결정돼요:
.mjs파일은 항상 ES 모듈,.cjs파일은 항상 CJS 모듈로 해석돼요..js파일은 가장 가까운package.json파일의type필드 값이"module"이면 ES 모듈로 해석돼요.package.json이 없거나type필드가 없거나 다른 값이면.js파일은 CJS 모듈로 해석돼요.
이 규칙으로 ES 모듈로 판정된 파일은, Node.js가 평가 중에 CommonJS의 module과 require 객체를 그 파일의 스코프에 주입하지 않아요. 그래서 그 파일이 그것들을 쓰려고 하면 크래시가 나요. 반대로 CJS 모듈로 판정된 파일에서 import/export 선언이 나오면 문법 에러로 크래시가 나요.
module 컴파일러 옵션이 node16, node18, nodenext로 설정되면 TypeScript는 같은 알고리즘을 프로젝트의 입력 파일에 적용해서 각각 대응하는 출력 파일의 모듈 종류를 판단해요. --module nodenext를 쓰는 예제 프로젝트에서 모듈 형식이 어떻게 감지되는지 볼까요.
입력 파일 확장자가 .mts나 .cts면, TypeScript는 그 파일을 각각 ES 모듈이나 CJS 모듈로 취급해요. Node.js가 출력된 .mjs 파일을 ES 모듈로, .cjs 파일을 CJS 모듈로 다루기 때문이에요. 입력 파일 확장자가 .ts면 TypeScript는 가장 가까운 package.json을 봐서 모듈 형식을 판단해야 해요. 이것도 Node.js가 출력 .js 파일을 만났을 때 할 일이기 때문이에요. (같은 규칙이 pkg 의존성의 .d.cts와 .d.ts 선언 파일에도 적용된다는 걸 눈치채셨나요? 이 선언 파일들은 이번 컴파일에서 출력 파일을 만들지는 않지만, .d.ts 파일이 존재한다는 건 대응하는 .js 파일이 존재한다는 걸 뜻해요—아마 pkg 라이브러리 작성자가 자기 입력 .ts 파일에 tsc를 돌려 만들었을 거예요—그리고 Node.js는 그 .js 파일을, .js 확장자와 /node_modules/pkg/package.json의 "type": "module" 필드 때문에 ES 모듈로 해석해야 해요. 선언 파일은 뒤쪽 섹션에서 더 자세히 다룰게요.)
입력 파일의 감지된 모듈 형식은 TypeScript가 각 출력 파일에서 Node.js가 기대하는 출력 문법을 내보내도록 하는 데 쓰여요. 만약 TypeScript가 /example.js에 import/export 문을 넣어 내보내면, Node.js는 파일을 파싱하다가 크래시할 거예요. /main.mjs에 require 호출을 넣어 내보내면, Node.js는 평가 중에 크래시할 거예요. 출력(emit) 외에도, 모듈 형식은 타입 검사와 모듈 해석의 규칙을 정하는 데도 쓰여요. 이건 다음 섹션에서 다룰게요.
TypeScript 5.6부터는 다른 --module 모드(esnext, commonjs 같은)도 형식별 파일 확장자(.mts, .cts)를 파일 수준의 출력 형식 오버라이드로 존중해요. 예를 들어 main.mts라는 파일은 --module이 commonjs로 설정돼 있어도 ESM 문법을 main.mjs로 내보내요.
다시 강조하지만, --module node16, --module node18, --module nodenext에서의 TypeScript 동작은 전적으로 Node.js의 동작에서 비롯된 거예요. TypeScript의 목표는 실행 시점의 잠재적 런타임 오류를 컴파일 시점에 잡는 것이니까, 런타임에 실제로 일어날 일을 아주 정확하게 모델링해야 해요. 모듈 종류 감지를 위한 이 다소 복잡한 규칙 집합은 Node.js에서 돌릴 코드를 검사하는 데는 필요하지만, Node.js가 아닌 host에 적용하면 지나치게 엄격하거나 아예 틀릴 수도 있어요.
입력 모듈 문법
입력 소스 파일에서 보이는 입력 모듈 문법은 JS 파일로 내보내지는 출력 모듈 문법과 어느 정도 분리되어 있다는 점을 짚어둘게요. 즉 ESM import가 있는 파일이라면:
import { sayHello } from "greetings";
sayHello("world");
이 파일은 ESM 형식으로 그대로 내보내질 수도 있고, CommonJS로 내보내질 수도 있어요:
Object.defineProperty(exports, "__esModule", { value: true });
const greetings_1 = require("greetings");
(0, greetings_1.sayHello)("world");
이건 module 컴파일러 옵션(그리고 module 옵션이 모듈을 두 종류 이상 지원할 때는 해당하는 모듈 형식 감지 규칙)에 달려 있어요. 일반적으로 말해서, 입력 파일의 내용만 보고 그 파일이 ES 모듈인지 CJS 모듈인지 판단할 수는 없다는 뜻이에요.
오늘날 대부분의 TypeScript 파일은 출력 형식과 무관하게 ESM 문법(import/export 문)으로 작성돼요. 이건 ESM이 널리 지원되기까지 오래 걸린 여정의 유산이 커요. ECMAScript 모듈은 2015년에 표준화됐고, 2017년에 대부분의 브라우저가 지원했으며, 2019년에 Node.js v12에 들어왔어요. 이 기간의 상당 부분 동안 ESM이 JavaScript 모듈의 미래라는 건 분명했지만, 실제로 소비할 수 있는 런타임은 거의 없었어요. Babel 같은 도구 덕에 JavaScript를 ESM으로 작성하고서 Node.js나 브라우저에서 쓸 수 있는 다른 모듈 형식으로 다운레벨할 수 있게 됐어요. TypeScript도 뒤를 따랐는데, ES 모듈 문법 지원을 추가하고 원래 CommonJS에서 영감을 받은 import fs = require("fs") 문법의 사용을 1.5 릴리스에서 은근히 권장하지 않기 시작했어요.
이 "ESM으로 작성하고, 어느 형식이든 출력" 전략의 장점은 TypeScript가 표준 JavaScript 문법을 쓸 수 있어서 입문자에게 익숙한 작성 경험을 주고, (이론적으로) 나중에 ESM 출력을 타기팅하기 쉬워진다는 점이었어요. 그런데 단점도 세 가지나 있고, 이 단점들은 ESM과 CJS 모듈이 Node.js에서 공존하며 상호운용되도록 허용된 뒤에야 완전히 드러났어요:
- Node.js에서 ESM/CJS 상호운용이 어떻게 될지에 대한 초기 가정이 틀렸고, 오늘날 상호운용 규칙은 Node.js와 번들러 사이에서도 달라요. 결과적으로 TypeScript에서 모듈의 설정 공간이 커졌어요.
- 입력 파일의 문법이 전부 ESM처럼 보이면, 작성자나 코드 리뷰어가 파일이 런타임에서 어떤 종류의 모듈인지 놓치기 쉬워요. 그리고 Node.js의 상호운용 규칙 때문에 각 파일이 어떤 모듈 종류인지가 아주 중요해졌어요.
- 입력 파일을 ESM으로 작성하면 타입 선언 출력(
.d.ts파일)의 문법도 ESM처럼 보여요. 그런데 대응하는 JavaScript 파일이 어떤 모듈 형식으로 내보내졌을지는 알 수 없기 때문에, TypeScript는 타입 선언 내용만 보고 파일이 어떤 모듈인지 알 수 없어요. 그리고 다시 ESM/CJS 상호운용의 특성상, TypeScript는 올바른 타입을 제공하고 크래시날 import를 막으려면 모든 것이 어떤 모듈인지 알아야 해요.
TypeScript 5.0에서는 verbatimModuleSyntax라는 새 컴파일러 옵션이 도입됐는데, TypeScript 작성자가 자신의 import/export 문이 정확히 어떻게 내보내질지 알 수 있게 해줘요. 이 플래그를 켜면 입력 파일의 import/export를 출력(emit) 전에 가장 적게 변형되는 형태로 작성해야 해요. 그래서 파일이 ESM으로 내보내질 거면 import/export를 ESM 문법으로 써야 하고, CJS로 내보내질 거면 CommonJS에서 영감을 받은 TypeScript 문법(import fs = require("fs"), export = {})으로 써야 해요. 이 설정은 대부분 ESM을 쓰지만 CJS 파일이 몇 개 섞인 Node.js 프로젝트에 특히 권장돼요. 지금은 CJS를 타기팅하지만 나중에 ESM을 타기팅할 수도 있는 프로젝트에는 권장되지 않아요.
ESM과 CJS 상호운용
ES 모듈이 CommonJS 모듈을 import할 수 있을까요? 된다면 default import는 exports에 연결되나요, exports.default에 연결되나요? CommonJS 모듈이 ES 모듈을 require할 수 있을까요? CommonJS는 ECMAScript 사양의 일부가 아니어서, ESM이 2015년에 표준화된 이후 런타임·번들러·transpiler는 이 질문들에 제각각 답해왔고, 표준화된 상호운용 규칙 집합은 없어요. 오늘날 대부분의 런타임과 번들러는 대략 세 부류 중 하나에 속해요:
- ESM 전용. 브라우저 엔진 같은 일부 런타임은 언어의 실제 일부인 ECMAScript Modules만 지원해요.
- 번들러류. 어떤 주요 JavaScript 엔진이 ES 모듈을 실행하기 전에, Babel이 개발자들이 ES 모듈을 CommonJS로 transpile해서 쓰게 했어요. 이렇게 ESM이 CJS로 transpile된 파일들이 손으로 쓴 CJS 파일들과 상호작용하는 방식은 관대한 상호운용 규칙 집합을 암시했고, 그게 번들러와 transpiler의 사실상 표준이 됐어요.
- Node.js. Node.js v20.19.0 이전까지 CommonJS 모듈은 ES 모듈을 동기적으로(
require로) 로드할 수 없었어요. 동적import()호출로 비동기적으로만 로드할 수 있었죠. ES 모듈은 CJS 모듈을 default import할 수 있고, 이는 항상exports에 바인딩돼요. (이 때문에__esModule이 있는 Babel류 CJS 출력의 default import는 Node.js와 일부 번들러 사이에서 다르게 동작해요.)
TypeScript는 (특히 default) import에 올바른 타입을 주고, 런타임에서 크래시날 import에 에러를 내려면 이 규칙 집합 중 어떤 걸 가정할지 알아야 해요. module 컴파일러 옵션이 node16, node18, nodenext로 설정되면 Node.js의 버전별 규칙이 강제돼요.1 다른 모든 module 설정은 esModuleInterop 옵션과 함께 TypeScript에서 번들러류 상호운용을 만들어내요. (--module esnext를 쓰는 게 CommonJS 모듈을 작성하지 못하게는 하지만, 의존성으로 import하는 걸 막지는 않아요. 브라우저 직행 코드에 적절한, ES 모듈이 CommonJS 모듈을 import하는 걸 막아줄 TypeScript 설정은 현재 없어요.)
모듈 지정자(specifier)는 기본적으로 변환되지 않아요
module 컴파일러 옵션이 입력 파일의 import/export를 출력 파일의 다른 모듈 형식으로 변환할 수는 있지만, 모듈 지정자(import 하거나 require에 넘기는 그 문자열)는 있는 그대로 내보내져요. 예를 들어 입력이 이렇다면:
import { add } from "./math.mjs";
add(1, 2);
이건 둘 중 하나로 내보내질 수 있어요:
import { add } from "./math.mjs";
add(1, 2);
또는:
const math_1 = require("./math.mjs");
math_1.add(1, 2);
module 컴파일러 옵션에 따라 다르지만, 모듈 지정자는 어느 쪽이든 "./math.mjs"예요. 기본적으로 모듈 지정자는 코드가 타기팅하는 런타임이나 번들러에서 동작하는 방식으로 작성해야 하고, TypeScript의 역할은 그런 출력 기준 지정자를 이해하는 거예요. 모듈 지정자가 참조하는 파일을 찾는 과정을 모듈 해석(module resolution)이라고 불러요.
TypeScript 5.7은 --rewriteRelativeImportExtensions 옵션을 도입했어요. 이 옵션은 .ts, .tsx, .mts, .cts 확장자를 가진 상대 모듈 지정자를 출력 파일에서 JavaScript 대응 확장자로 변환해요. 이 옵션은 개발 중에 Node.js에서 직접 실행 가능한 TypeScript 파일을 만들면서도, 배포나 프로덕션 사용을 위해 JavaScript 출력으로 컴파일되도록 하려는 경우에 유용해요.
이 문서는 --rewriteRelativeImportExtensions가 도입되기 전에 쓰였고, 제시하는 정신 모델은 host 모듈 시스템이 자신의 입력 파일(번들러라면 TypeScript 파일, 런타임이라면 .js 출력)에 대해 수행하는 동작을 모델링하는 데 기반을 둬요. --rewriteRelativeImportExtensions를 쓸 때 이 정신 모델을 적용하는 방법은 두 번 적용하는 거예요. 한 번은 TypeScript 입력 파일을 직접 처리하는 런타임이나 번들러에, 그리고 다시 한 번 변환된 출력을 처리하는 런타임이나 번들러에요. 이 문서의 대부분은 입력 파일만, 또는 출력 파일만 로드될 것이라고 가정하지만, 제시하는 원칙은 둘 다 로드되는 경우로 확장할 수 있어요.
모듈 해석(Module resolution)
첫 번째 예제로 돌아가서 지금까지 배운 걸 정리해볼게요:
import sayHello from "greetings";
sayHello("world");
지금까지 host의 모듈 시스템과 TypeScript의 module 컴파일러 옵션이 이 코드에 어떻게 영향을 미칠 수 있는지 다뤘어요. 입력 문법은 ESM처럼 보이지만, 출력 형식은 module 컴파일러 옵션, 잠재적으로 파일 확장자, 그리고 package.json의 "type" 필드에 달려 있다는 걸 알았어요. sayHello가 무엇에 바인딩되는지, 심지어 그 import가 허용되는지조차 이 파일과 타깃 파일의 모듈 종류에 따라 달라질 수 있다는 것도 알았어요. 하지만 아직 타깃 파일을 어떻게 찾는지는 다루지 않았어요.
모듈 해석은 host가 정의해요
ECMAScript 사양은 import/export 문을 어떻게 파싱하고 해석할지 정의하지만, 모듈 해석은 host에 맡겨요. 새로운 핫한 JavaScript 런타임을 만든다면, 이런 모듈 해석 체계를 자유롭게 만들 수 있어요:
import monkey from "🐒"; // Looks for './eats/bananas.js'
import cow from "🐄"; // Looks for './eats/grass.js'
import lion from "🦁"; // Looks for './eats/you.js'
그리고 여전히 "표준 준수 ESM"을 구현한다고 주장할 수 있어요. 말할 것도 없이, TypeScript는 이 런타임의 모듈 해석 알고리즘에 대한 내장 지식 없이는 monkey, cow, lion에 어떤 타입을 할당할지 전혀 알 수 없어요. module이 host가 기대하는 모듈 형식을 컴파일러에 알리듯이, moduleResolution은 host가 모듈 지정자를 파일로 해석하는 데 쓰는 알고리즘을 몇 가지 커스터마이즈 옵션과 함께 지정해요. 이건 또한 TypeScript가 emit 중에 import 지정자를 수정하지 않는 이유도 명확히 해줘요. import 지정자와 디스크의 파일(존재한다면) 사이의 관계는 host가 정의하는 것이고, TypeScript는 host가 아니니까요.
쓸 수 있는 moduleResolution 옵션은 이래요:
classic: TypeScript의 가장 오래된 모듈 해석 모드로, 아쉽게도module이commonjs,node16,nodenext외의 값일 때의 기본값이에요. 아마도 넓은 범위의 RequireJS 설정에 best-effort 해석을 제공하려고 만들어진 것 같아요. 새 프로젝트(RequireJS나 다른 AMD 모듈 로더를 안 쓰는 오래된 프로젝트에도)에는 쓰면 안 되고, TypeScript 6.0에서 폐기될 예정이에요.node10: 예전엔node로 불렸고, 아쉽게도module이commonjs일 때의 기본값이에요. v12보다 오래된 Node.js 버전을 꽤 잘 모델링하고, 때로는 대부분의 번들러가 모듈 해석을 하는 방식을 그럭저럭 근사하기도 해요.node_modules에서 패키지를 찾고, 디렉터리의index.js파일을 로드하고, 상대 모듈 지정자에서.js확장자를 생략하는 걸 지원해요. 그런데 Node.js v12가 ES 모듈에 대해 다른 모듈 해석 규칙을 도입했기 때문에, 현대 Node.js 버전에 대해서는 아주 나쁜 모델이에요. 새 프로젝트에는 쓰면 안 돼요.node16:--module node16,--module node18의 짝이고 그module설정에서 기본으로 설정돼요. Node.js v12 이상은 ESM과 CJS를 둘 다 지원하고, 각각 자기만의 모듈 해석 알고리즘을 써요. Node.js에서 import 문과 동적import()호출의 모듈 지정자는 파일 확장자나/index.js접미사를 생략할 수 없지만,require호출의 모듈 지정자는 생략할 수 있어요. 이 모듈 해석 모드는--module node16/node18이 세운 모듈 형식 감지 규칙에 따라 필요한 곳에서 이 제약을 이해하고 강제해요. (node16과nodenext의 경우module과moduleResolution은 맞물려 있어요. 하나를node16이나nodenext로 두면서 다른 하나를 다른 값으로 두면 에러예요.)nodenext: 현재는node16과 동일하고,--module nodenext의 짝으로 그module설정에서 기본 설정돼요. 새 Node.js 모듈 해석 기능이 추가됨에 따라 지원하게 될 미래 지향적인 모드로 의도된 거예요.bundler: Node.js v12가 npm 패키지 import를 위한 새 모듈 해석 기능(package.json의"exports","imports"필드)을 도입했고, 많은 번들러가 ESM import의 더 엄격한 규칙은 채택하지 않으면서 이 기능들은 채택했어요. 이 모듈 해석 모드는 번들러를 타기팅하는 코드에 기본 알고리즘을 제공해요.package.json의"exports","imports"를 기본으로 지원하지만 무시하도록 설정할 수도 있어요.module을esnext로 설정해야 해요.
TypeScript는 host의 모듈 해석을 흉내 내되, 타입을 얹어요
모듈에 관한 TypeScript의 역할의 세 요소를 기억하시나요?
- 파일을 유효한 출력 모듈 형식으로 컴파일
- 그 출력물 안의 import들이 성공적으로 해석되도록 보장
- import된 이름에 어떤 타입을 할당할지 알기
모듈 해석은 마지막 두 가지를 하는 데 필요해요. 그런데 우리가 대부분 입력 파일에서 작업할 때는 (2)—모듈 해석의 핵심 요소가 출력 파일 안의 import나 require 호출(여기엔 입력 파일과 같은 모듈 지정자가 들어 있어요)이 실제로 런타임에서 동작할지 검증하는 것이라는 점—을 잊기 쉬워요. 파일이 여러 개인 새 예제를 볼게요:
// @Filename: math.ts
export function add(a: number, b: number) {
return a + b;
}
// @Filename: main.ts
import { add } from "./math";
add(1, 2);
"./math"에서의 import를 보면 "이게 어떻게 한 TypeScript 파일이 다른 파일을 참조하는 방식이지. 컴파일러가 이 (확장자 없는) 경로를 따라가서 add에 타입을 할당하겠지"라고 생각하기 쉬워요.
완전히 틀린 말은 아니지만, 실제 상황은 더 깊어요. "./math"의 해석(그리고 결과적으로 add의 타입)은 출력 파일에 대해 런타임에서 실제로 일어나는 일을 반영해야 해요. 이 과정을 더 탄탄하게 생각하는 방법은 이런 모양이에요:
이 모델이 보여주는 건, TypeScript에게 모듈 해석은 대체로 출력 파일 사이에서 host의 모듈 해석 알고리즘을 정확히 모델링하는 일이고, 타입 정보를 찾으려고 약간의 재매핑(remapping)을 적용하는 것뿐이라는 점이에요. 단순한 모델의 렌즈로는 직관에 어긋나 보이지만, 탄탄한 모델로 보면 완벽히 말이 되는 또 다른 예를 볼게요:
// @moduleResolution: node16
// @rootDir: src
// @outDir: dist
// @Filename: src/math.mts
export function add(a: number, b: number) {
return a + b;
}
// @Filename: src/main.mts
import { add } from "./math.mjs";
add(1, 2);
Node.js의 ESM import 선언은 상대 경로에 파일 확장자를 포함하도록 요구하는 엄격한 모듈 해석 알고리즘을 써요. 입력 파일만 생각하면 "./math.mjs"가 math.mts로 해석되는 게 조금 이상해 보여요. outDir로 컴파일 출력을 다른 디렉터리에 두고 있으니, math.mjs는 main.mts 옆에 아예 존재하지조차 않잖아요! 왜 이게 해석되어야 하죠? 새 정신 모델로 보면 전혀 문제가 없어요.
이 정신 모델을 이해한다고 해서 입력 파일에서 출력 확장자가 보이는 이상함이 바로 사라지지는 않아요. 그리고 ""./math.mjs"는 입력 파일 math.mts를 가리켜. 출력 확장자를 써야 하지만, 내가 .mjs라고 쓰면 컴파일러가 .mts를 찾아봐야 한다는 걸 알지" 같은 지름길로 생각하는 게 자연스러워요. 이 지름길은 사실 컴파일러가 내부적으로 동작하는 방식이기도 해요. 하지만 더 탄탄한 정신 모델이 TypeScript에서 모듈 해석이 이렇게 동작하는 이유를 설명해줘요: 출력 파일의 모듈 지정자가 입력 파일의 모듈 지정자와 같을 수밖에 없다는 제약 안에서, 출력 파일 검증과 타입 할당이라는 두 목표를 달성하는 유일한 과정이 바로 이거거든요.
선언 파일의 역할
앞선 예제에서 모듈 해석의 "재매핑" 부분이 입력 파일과 출력 파일 사이에서 동작하는 걸 봤어요. 그런데 라이브러리 코드를 import하면 어떻게 될까요? 라이브러리가 TypeScript로 작성됐더라도 소스 코드를 공개하지 않았을 수 있어요. 라이브러리의 JavaScript 파일을 TypeScript 파일로 다시 매핑할 수 없다면, 우리의 import가 런타임에서 동작한다는 걸 검증할 수는 있지만, 타입을 할당한다는 두 번째 목표는 어떻게 달성할까요?
여기서 선언 파일(.d.ts, .d.mts 등)이 등장해요. 선언 파일이 어떻게 해석되는지 이해하는 가장 좋은 방법은 그것들이 어디서 오는지 이해하는 거예요. 입력 파일에 tsc --declaration을 실행하면 JavaScript 출력 파일 하나와 선언 출력 파일 하나를 얻어요:
이 관계 때문에 컴파일러는 어디서든 선언 파일을 보면, 그 선언 파일의 타입 정보가 완벽하게 기술하는 대응 JavaScript 파일이 존재한다고 가정해요. 성능 이유로, 모든 모듈 해석 모드에서 컴파일러는 항상 TypeScript 파일과 선언 파일을 먼저 찾고, 찾으면 대응하는 JavaScript 파일을 계속 찾지 않아요. TypeScript 입력 파일을 찾으면 컴파일 후 JavaScript 파일이 생길 거란 걸 알고, 선언 파일을 찾으면 (아마 누군가 다른 사람의) 컴파일이 이미 일어나서 선언 파일과 동시에 JavaScript 파일을 만들었다는 걸 알아요.
선언 파일은 컴파일러에게 JavaScript 파일이 존재한다는 것뿐 아니라, 그 이름과 확장자가 무엇인지도 알려줘요:
마지막 행이 표현하는 건, JS가 아닌 파일을 allowArbitraryExtensions 컴파일러 옵션으로 타입을 지정할 수 있다는 거예요. 이건 모듈 시스템이 JS가 아닌 파일을 JavaScript 객체로 import하는 것을 지원하는 경우를 위한 거예요. 예를 들어 styles.css 파일은 styles.d.css.ts라는 선언 파일로 표현할 수 있어요.
"잠깐만! 선언 파일은 tsc가 만든 게 아니라 손으로 쓰는 것도 엄청 많잖아. DefinitelyTyped라는 거 들어봤죠?"라고 반문할 수도 있어요. 맞아요—선언 파일을 손으로 쓰거나, 외부 빌드 도구의 출력을 나타내도록 이동·복사·이름을 바꾸는 것은 위험하고 실수하기 쉬운 일이에요. DefinitelyTyped 기여자와, JavaScript와 선언 파일을 둘 다 만들기 위해 tsc를 쓰지 않는 타입 있는 라이브러리 작성자는, 모든 JavaScript 파일이 같은 이름과 맞는 확장자의 형제 선언 파일을 갖도록 해야 해요. 이 구조에서 벗어나면 최종 사용자에게 TypeScript 오류의 false-positive가 생길 수 있어요. npm 패키지 @arethetypeswrong/cli가 이런 오류를 게시 전에 잡아내고 설명해주는 데 도움이 돼요.
번들러, TypeScript 런타임, Node.js 로더를 위한 모듈 해석
지금까지 입력 파일과 출력 파일의 구분을 정말 강조했어요. 상대 모듈 지정자에 파일 확장자를 쓸 때 TypeScript가 보통 출력 파일 확장자를 쓰게 한다는 걸 기억해보세요:
// @Filename: src/math.ts
export function add(a: number, b: number) {
return a + b;
}
// @Filename: src/main.ts
import { add } from "./math.ts";
// ^^^^^^^^^^^
// An import path can only end with a '.ts' extension when 'allowImportingTsExtensions' is enabled.
이 제약이 적용되는 이유는 TypeScript가 확장자를 .js로 다시 쓰지 않기 때문이에요. 그래서 "./math.ts"가 출력 JS 파일에 나타나면, 그 import는 런타임에서 다른 JS 파일로 해석되지 않을 거예요. TypeScript는 안전하지 않은 출력 JS 파일이 만들어지는 걸 정말 막고 싶어해요. 그런데 출력 JS 파일이 아예 없다면 어떨까요? 이런 상황 중 하나라면요:
- 이 코드를 번들링 중이고, 번들러가 TypeScript 파일을 인메모리에서 transpile하도록 설정되어 있으며, 결국 여러분이 작성한 import를 전부 소비해서 지우고 번들을 만들 거예요.
- 이 코드를 Node, Deno, Bun 같은 TypeScript 런타임에서 직접 실행 중이에요.
- Node용
ts-node,tsx, 또는 다른 transpiling 로더를 쓰고 있어요.
이런 경우 noEmit(또는 emitDeclarationOnly)과 allowImportingTsExtensions를 켜서 안전하지 않은 JavaScript 파일을 내보내는 걸 끄고 .ts 확장자 import에 대한 에러를 잠잠하게 만들 수 있어요.
allowImportingTsExtensions를 쓰든 말든, 모듈 해석 host에 가장 적절한 moduleResolution 설정을 고르는 건 여전히 중요해요. 번들러와 Bun 런타임에는 bundler가 그 설정이에요. 이 모듈 해석기들은 Node.js에서 영감을 받았지만, Node.js가 import에 적용하는 확장자 탐색을 끄는 엄격한 ESM 해석 알고리즘은 채택하지 않았어요. bundler 모듈 해석 설정은 이를 반영해서, node16—nodenext처럼 package.json의 "exports" 지원을 켜면서도 확장자 없는 import를 항상 허용해요. 더 자세한 지침은 Choosing compiler options를 봐요.
라이브러리를 위한 모듈 해석
앱을 컴파일할 때는 모듈 해석 host가 누구인지에 따라 TypeScript 프로젝트의 moduleResolution 옵션을 골라요. 라이브러리를 컴파일할 때는 출력 코드가 어디서 돌지 모르지만, 가능한 한 많은 곳에서 돌길 바라요. "module": "node18"(그리고 여기서 암시되는 "moduleResolution": "node16")를 쓰는 것이 출력 JavaScript의 모듈 지정자 호환성을 최대화하는 데 가장 좋은 선택이에요. Node.js의 더 엄격한 import 모듈 해석 규칙을 따르도록 강제하기 때문이에요. 라이브러리를 "moduleResolution": "bundler"(혹은 더 나쁘게 "node10")로 컴파일하면 무슨 일이 생길지 볼게요:
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에서 동작하는 모듈 코드는 다른 런타임과 번들러에서도 동작해요.
물론 이 지침은 라이브러리가 tsc의 출력을 배포하는 경우에만 적용돼요. 라이브러리가 배포 전에 번들링된다면 "moduleResolution": "bundler"가 허용될 수 있어요. 라이브러리의 최종 빌드를 만들기 위해 모듈 형식이나 모듈 지정자를 바꾸는 어떤 빌드 도구든, 제품의 모듈 코드의 안전성과 호환성을 보장할 책임을 진다고 볼 수 있어요. tsc는 런타임에 어떤 모듈 코드가 존재할지 알 수 없으니 더 이상 그 일에 기여할 수 없어요.
- [^1]: Node.js v20.19.0 이상에서는 ES 모듈의
require가 허용되지만, 해석된 모듈과 그 최상위 import들이 최상위await를 쓰지 않을 때만 그래요. TypeScript는 선언 파일만으로는 대응하는 JavaScript 파일에 최상위await가 있는지 알 수 없기 때문에 이 규칙을 강제하려고 하지 않아요.↩