모듈 - 이론

모듈 - 이론 (Modules - Theory)

TypeScript의 모듈 관련 설정은 처음 보면 왜 이렇게 복잡한지 궁금해지기 쉬워요. 그 이유를 이해하려면 먼저 JavaScript의 모듈이 어떻게 동작하는지, 그리고 컴파일러가 "호스트(host)"인 런타임이나 번들러의 규칙을 어떻게 따라가려는지 알아야 합니다. 이 문서는 그 이론적 배경을 차근차근 설명해 드릴게요.

출처: TypeScript 핸드북

JavaScript의 스크립트와 모듈

JavaScript 초창기, 언어가 브라우저에서만 돌던 시절에는 모듈이 없었지만, HTML에 여러 script 태그를 써서 웹페이지용 JavaScript를 여러 파일로 나눌 수는 있었어요:

<html>
  <head>
    <script src="a.js"></script>
    <script src="b.js"></script>
  </head>
  <body></body>
</html>

이 방식은 특히 웹페이지가 커지고 복잡해질수록 단점이 있었어요. 특히 같은 페이지에 로드된 모든 스크립트는 같은 스코프("전역 스코프"라고 부르는 바로 그 스코프)를 공유해서, 스크립트들이 서로의 변수와 함수를 덮어쓰지 않도록 아주 조심해야 했습니다.

파일에 자기만의 스코프를 주면서도, 다른 파일이 쓸 수 있게 코드 조각을 제공하는 방법을 가진 어떤 시스템이든 "모듈 시스템"이라고 부를 수 있어요. (모듈 시스템의 각 파일을 "모듈"이라고 부른다는 건 당연해 보이지만, 이 용어는 흔히 모듈 시스템 바깥, 전역 스코프에서 실행되는 스크립트 파일과 대비해서 쓰입니다.)

모듈 시스템은 여러 가지 있고 TypeScript는 여러 개를 방출(emit)을 지원하지만, 이 문서에서는 오늘날 가장 중요한 두 시스템에 집중할 거예요: ECMAScript 모듈(ESM)과 CommonJS(CJS).

ECMAScript 모듈(ESM)은 언어에 내장된 모듈 시스템으로, 현대 브라우저와 Node.js v12 이상에서 지원됩니다. 전용 importexport 문법을 사용해요:

// 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에 처음 포함됐던 모듈 시스템이에요. Node.js에서 여전히 ESM과 함께 지원됩니다. exportsrequire라는 평범한 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의 타입(문자열 인자 하나를 받는 함수인지)을 알아야 하는데, 그러면 꽤 많은 추가 질문이 열려요:

  1. 모듈 시스템이 이 TypeScript 파일을 직접 로드할까요, 아니면 내가(또는 다른 컴파일러가) 이 TypeScript 파일에서 생성한 JavaScript 파일을 로드할까요?
  2. 로드할 파일 이름과 디스크상 위치를 고려할 때, 모듈 시스템은 어떤 종류 의 모듈을 기대할까요?
  3. 출력 JavaScript가 방출된다면, 이 파일에 있는 모듈 문법은 출력 코드에서 어떻게 변환될까요?
  4. 모듈 시스템은 "greetings"가 지정한 모듈을 어디에서 찾으려 할까요? 조회가 성공할까요?
  5. 그 조회로 해석된 파일은 어떤 종류의 모듈일까요?
  6. 모듈 시스템은 (2)에서 감지된 모듈 종류가 (3)에서 정해진 문법으로 (5)에서 감지된 모듈 종류를 참조하는 것을 허용할까요?
  7. "greetings" 모듈을 분석한 뒤, sayHello에 바인딩되는 것은 그 모듈의 어떤 조각일까요?

이 모든 질문은 호스트 의 특성에 달려 있다는 점을 주목하세요. 호스트는 출력 JavaScript(경우에 따라 원시 TypeScript)를 최종적으로 소비해서 모듈 로딩 동작을 지시하는 시스템으로, 보통 런타임(Node.js)이나 번들러(Webpack)입니다.

ECMAScript 명세는 ESM import와 export가 서로 어떻게 연결되는지는 정의하지만, (4)의 파일 조회인 모듈 해석(module resolution) 이 어떻게 일어나는지는 지정하지 않고, CommonJS 같은 다른 모듈 시스템에 대해서는 아무것도 말하지 않아요. 그래서 런타임과 번들러, 특히 ESM과 CJS를 모두 지원하려는 것들은 자기만의 규칙을 설계할 자유가 큽니다. 결과적으로 TypeScript가 위 질문들에 답하는 방식은 코드가 어디서 돌아가려는지에 따라 크게 달라져요. 단 하나의 정답은 없으므로, 컴파일러는 설정 옵션을 통해 규칙을 전달받아야 합니다.

또 하나 기억해야 할 핵심 아이디어는, TypeScript는 거의 항상 이 질문들을 입력 TypeScript(또는 JavaScript!) 파일이 아니라 출력 JavaScript 파일의 관점에서 생각한다는 점입니다. 오늘날 일부 런타임과 번들러는 TypeScript 파일을 직접 로드하는 걸 지원하는데, 그런 경우 입력과 출력 파일을 따로 생각하는 건 말이 안 돼요. 이 문서의 대부분은 TypeScript 파일이 JavaScript 파일로 컴파일되고, 그 JS 파일이 런타임 모듈 시스템에 로드되는 경우를 다룹니다. 이 경우를 살펴보는 것은 컴파일러의 옵션과 동작을 이해하는 데 필수적이에요. 여기서 시작해서 esbuild, Bun, 그리고 다른 TypeScript 우선 런타임·번들러를 생각할 때 단순화하는 편이 더 쉽습니다. 그래서 지금은 모듈에 관한 TypeScript의 역할을 출력 파일 관점에서 이렇게 요약할 수 있어요:

호스트의 규칙을 충분히 이해해서

  1. 파일을 유효한 출력 모듈 형식으로 컴파일하고,
  2. 출력물 안의 import가 성공적으로 해석되도록 보장하며,
  3. import된 이름에 어떤 타입을 부여할지 알기.

호스트는 누구인가?

계속하기 전에 호스트 라는 용어에 대해 같은 이해를 하고 있는지 확인하는 게 좋아요. 앞서 이를 "출력 코드를 최종적으로 소비해서 모듈 로딩 동작을 지시하는 시스템"으로 정의했죠. 다시 말해, TypeScript 바깥에 있으면서 TypeScript의 모듈 분석이 모델링하려고 하는 시스템입니다:

  • 출력 코드(tsc나 서드파티 트랜스파일러가 만든)가 Node.js 같은 런타임에서 직접 실행된다면, 그 런타임이 호스트예요.
  • 런타임이 TypeScript 파일을 직접 소비해서 "출력 코드"가 없다면, 그 런타임이 여전히 호스트예요.
  • 번들러가 TypeScript 입력이나 출력을 소비해서 번들을 만든다면, 그 번들러가 호스트예요. 원래 import/require 집합을 보고 그것들이 참조하는 파일을 조회해서, 원래 import/require가 인식할 수 없게 지워지거나 변형된 새 파일(또는 파일 집합)을 만들었기 때문이죠. (그 번들 자체가 모듈로 구성되어 있을 수 있고, 그걸 실행하는 런타임이 또 다른 호스트가 되겠지만, TypeScript는 번들러 이후에 일어나는 일에 대해서는 모르는 게 없어요.)
  • 다른 트랜스파일러, 최적화기, 포매터가 TypeScript의 출력물에 대해 실행된다면, 그것이 보는 import와 export를 그대로 두는 한, TypeScript가 신경 쓰는 호스트 는 아니에요.
  • 웹 브라우저에서 모듈을 로드할 때, TypeScript가 모델링해야 할 동작은 실제로 웹 서버와 브라우저에서 돌아가는 모듈 시스템 사이에 나뉘어 있어요. 브라우저의 JavaScript 엔진(또는 RequireJS 같은 스크립트 기반 모듈 로딩 프레임워크)이 어떤 모듈 형식을 받아들일지 제어하고, 웹 서버는 한 모듈이 다른 모듈을 로드하라는 요청을 만들 때 어떤 파일을 보낼지 정합니다.
  • TypeScript 컴파일러 자체는 호스트가 아니에요. 다른 호스트를 모델링하려는 것 외에 모듈과 관련된 어떤 동작도 제공하지 않기 때문입니다.

모듈 출력 형식

어떤 프로젝트에서든 모듈에 대한 첫 질문은 호스트가 어떤 종류의 모듈을 기대하는지라서, TypeScript가 각 파일의 출력 형식을 거기에 맞출 수 있어요. 때때로 호스트는 한 종류의 모듈만 지원 합니다. 예를 들어 브라우저의 ESM, Node.js v11 이하의 CJS 같은 거죠. Node.js v12 이상은 CJS와 ES 모듈을 모두 받아들이지만, 파일 확장자와 package.json 파일로 각 파일이 어떤 형식이어야 하는지 결정하고, 파일 내용이 기대 형식과 맞지 않으면 오류를 던져요.

module 컴파일러 옵션은 이 정보를 컴파일러에 제공합니다. 주 목적은 컴파일 중 방출되는 모든 JavaScript의 모듈 형식을 제어하는 것이지만, 각 파일의 모듈 종류를 어떻게 감지할지, 서로 다른 모듈 종류가 서로를 어떻게 import할 수 있는지, import.meta나 최상위 await 같은 기능이 가능한지에 대해 컴파일러에 알려주는 역할도 해요. 그래서 TypeScript 프로젝트가 noEmit을 쓰더라도, module에 올바른 설정을 고르는 것은 여전히 중요합니다. 앞서 확립했듯 컴파일러는 import를 타입체크(그리고 IntelliSense 제공)하려면 모듈 시스템에 대한 정확한 이해가 필요해요. 프로젝트에 맞는 올바른 module 설정을 고르는 안내는 컴파일러 옵션 고르기를 참고하세요.

사용 가능한 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 언어 명세를 반영 (언어에 importexport를 처음 도입한 버전).
  • es2020: es2015import.metaexport * as ns from "mod" 지원을 추가.
  • es2022: es2020에 최상위 await 지원을 추가.
  • esnext: 현재는 es2022와 동일하지만, 최신 ECMAScript 명세와 향후 명세 버전에 포함될 것으로 예상되는 모듈 관련 Stage 3+ 제안을 반영하는 이동 대상.
  • commonjs, system, amd, umd: 각각 이름 붙은 모듈 시스템으로 모든 것을 방출하고, 모든 것이 그 모듈 시스템으로 성공적으로 import될 수 있다고 가정. 더 이상 새 프로젝트에 권장되지 않으며 이 문서에서 자세히 다루지 않아요.

Node.js의 모듈 형식 감지·상호 운용 규칙 때문에, Node.js에서 돌아가는 프로젝트는 tsc가 방출하는 모든 파일이 각각 ESM 거나 CJS라도 moduleesnextcommonjs로 지정하는 것이 올바르지 않아요. Node.js에서 실행하려는 프로젝트의 올바른 module 설정은 오직 node16nodenext뿐입니다. 전부-ESM Node.js 프로젝트의 방출된 JavaScript가 esnextnodenext 컴파일 사이에서 동일해 보일 수는 있지만, 타입체크는 달라질 수 있어요. 자세한 내용은 nodenext에 대한 reference 섹션을 참고하세요.

모듈 형식 감지

Node.js는 ES 모듈과 CJS 모듈을 모두 이해하지만, 각 파일의 형식은 파일 확장자와 해당 파일의 디렉터리와 모든 조상 디렉터리 검색에서 가장 먼저 발견되는 package.json 파일의 type 필드에 의해 결정됩니다:

  • .mjs.cjs 파일은 각각 항상 ES 모듈과 CJS 모듈로 해석됩니다.
  • .js 파일은 가장 가까운 package.json 파일이 값이 "module"type 필드를 포함하면 ES 모듈로 해석됩니다. package.json 파일이 없거나 type 필드가 없거나 다른 값을 가지면 .js 파일은 CJS 모듈로 해석돼요.

이 규칙에 따라 파일이 ES 모듈로 결정되면, Node.js는 평가 중에 CommonJS modulerequire 객체를 파일의 스코프에 주입하지 않아서, 그걸 쓰려는 파일은 크래시가 납니다. 반대로 파일이 CJS 모듈로 결정되면, 파일 안의 importexport 선언은 문법 오류 크래시를 일으켜요.

module 컴파일러 옵션이 node16, node18, nodenext로 설정되면 TypeScript는 프로젝트의 입력 파일에 같은 알고리즘을 적용해 각 출력 파일의 모듈 종류를 결정합니다. --module nodenext를 쓰는 예시 프로젝트에서 모듈 형식이 어떻게 감지되는지 볼게요:

입력 파일 이름 내용 출력 파일 이름 모듈 종류 이유
/package.json {}
/main.mts /main.mjs ESM 파일 확장자
/utils.cts /utils.cjs CJS 파일 확장자
/example.ts /example.js CJS package.json"type": "module"가 없음
/node_modules/pkg/package.json { "type": "module" }
/node_modules/pkg/index.d.ts ESM package.json"type": "module"가 있음
/node_modules/pkg/index.d.cts CJS 파일 확장자

입력 파일 확장자가 .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 확장자와 /node_modules/pkg/package.json"type": "module" 필드 때문에 그 파일을 ES 모듈로 해석해야 해요. 선언 파일은 나중 섹션에서 더 자세히 다룹니다.)

입력 파일의 감지된 모듈 형식은 TypeScript가 각 출력 파일에서 Node.js가 기대하는 출력 문법을 방출하도록 보장하는 데 쓰입니다. TypeScript가 /example.jsimportexport 문으로 방출하면 Node.js는 그 파일을 파싱할 때 크래시가 나요. TypeScript가 /main.mjsrequire 호출로 방출하면 Node.js는 평가 중에 크래시가 납니다. 방출 외에도 모듈 형식은 타입체크와 모듈 해석 규칙을 결정하는 데 쓰이며, 이는 다음 섹션에서 다룰게요.

TypeScript 5.6부터 다른 --module 모드(esnext, commonjs 등)도 형식별 파일 확장자(.mts, .cts)를 방출 형식에 대한 파일 수준 재정의로 존중합니다. 예를 들어 main.mts라는 파일은 --modulecommonjs로 설정되어 있어도 main.mjs에 ESM 문법을 방출해요.

--module node16, --module node18, --module nodenext에서 TypeScript의 동작은 전적으로 Node.js의 동작에서 비롯된다는 점을 다시 말해 두고 싶어요. TypeScript의 목표는 잠재적 런타임 오류를 컴파일 타임에 잡는 것이므로, 런타임에 일어날 일에 대한 매우 정확한 모델이 필요합니다. 이 다소 복잡한 모듈 종류 감지 규칙은 Node.js에서 돌아갈 코드를 검사할 때 필요 하지만, Node.js가 아닌 호스트에 적용하면 지나치게 엄격하거나 그냥 잘못될 수 있어요.

입력 모듈 문법

소스 입력 파일에서 보이는 입력 모듈 문법은 JS 파일로 방출되는 출력 모듈 문법과 다소 분리되어 있다는 점이 중요해요. 즉, ESM import가 있는 파일:

import { sayHello } from "greetings";
sayHello("world");

module 컴파일러 옵션(및 해당 시 모듈 형식 감지 규칙)에 따라 ESM 형식 그대로 방출되거나 CommonJS로 방출될 수 있어요:

Object.defineProperty(exports, "__esModule", { value: true });
const greetings_1 = require("greetings");
(0, greetings_1.sayHello)("world");

일반적으로 이는 입력 파일의 내용만 봐서는 ES 모듈인지 CJS 모듈인지 판단할 수 없다는 뜻입니다.

오늘날 대부분의 TypeScript 파일은 출력 형식과 관계없이 ESM 문법(importexport 문)으로 작성됩니다. 이는 ESM이 널리 지원되기까지 걸어온 긴 길의 유산이 크게 작용한 결과예요. ECMAScript 모듈은 2015년에 표준화되었고, 2017년에는 대부분의 브라우저에서 지원됐으며, 2019년에 Node.js v12에 들어왔습니다. 이 기간 대부분 ESM이 JavaScript 모듈의 미래라는 것은 분명했지만, 이를 소비할 수 있는 런타임은 거의 없었어요. Babel 같은 도구는 JavaScript가 ESM으로 작성된 뒤 Node.js나 브라우저에서 쓸 수 있는 다른 모듈 형식으로 다운레벨되는 것을 가능하게 했습니다. TypeScript도 그 뒤를 따랐고, 1.5 릴리스에서 ES 모듈 문법 지원을 추가하면서 원조 CommonJS식 import fs = require("fs") 문법 사용을 부드럽게 권장하지 않았어요.

이 "ESM으로 작성하고 무엇이든 출력" 전략의 장점은 TypeScript가 표준 JavaScript 문법을 쓸 수 있다는 것이라서, 신규 사용자에게 친숙한 작성 경험을 제공하고 (이론상) 미래에 ESM 출력을 대상으로 시작하기 쉽게 해줬습니다. 하지만 크게 세 가지 단점이 있었는데, ESM과 CJS 모듈이 Node.js에서 공존하고 상호 운용되도록 허용된 뒤에야 완전히 드러났어요:

  1. Node.js에서 ESM/CJS 상호 운용이 어떻게 동작할지에 대한 초기 가정이 틀린 것으로 드러났고, 오늘날 상호 운용 규칙은 Node.js와 번들러 사이에서 다릅니다. 결과적으로 TypeScript의 모듈 설정 공간이 커졌어요.
  2. 입력 파일의 문법이 전부 ESM처럼 보이면, 작성자나 코드 리뷰어가 파일이 런타임에서 어떤 종류의 모듈인지 놓치기 쉬워요. 그리고 Node.js의 상호 운용 규칙 때문에 각 파일이 어떤 종류의 모듈인지가 아주 중요해졌습니다.
  3. 입력 파일이 ESM으로 작성되면 타입 선언 출력(.d.ts 파일)의 문법도 ESM처럼 보여요. 하지만 대응하는 JavaScript 파일은 어떤 모듈 형식으로도 방출될 수 있기 때문에, TypeScript는 타입 선언의 내용만으로는 파일이 어떤 종류의 모듈인지 알 수 없어요. 그리고 ESM/CJS 상호 운용의 특성상, TypeScript는 올바른 타입을 제공하고 크래시가 날 import를 막으려면 모든 것이 어떤 종류의 모듈인지 반드시 알아야 합니다.

TypeScript 5.0에서는 verbatimModuleSyntax라는 새 컴파일러 옵션이, TypeScript 작성자가 자신의 importexport 문이 정확히 어떻게 방출될지 알 수 있도록 도입되었습니다. 활성화하면 이 플래그는 입력 파일의 import와 export를 방출 전에 가장 적게 변환되는 형태로 작성하도록 요구해요. 그래서 파일이 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년에 표준화된 이후 이 질문들에 자기만의 답을 내놓을 자유가 있었고, 따라서 표준화된 상호 운용 규칙 집합은 존재하지 않아요. 오늘날 대부분의 런타임과 번들러는 크게 세 범주 중 하나에 들어갑니다:

  1. ESM 전용. 일부 런타임(예: 브라우저 엔진)은 실제로 언어의 일부인 것, 즉 ECMAScript 모듈만 지원합니다.
  2. 번들러 스타일. 어떤 주요 JavaScript 엔진도 ES 모듈을 실행하기 전에, Babel은 개발자가 이를 CommonJS로 트랜스파일해서 작성할 수 있게 했습니다. 이렇게 ESM-트랜스파일-투-CJS 파일들이 손으로 작성한 CJS 파일과 상호 작용한 방식은, 번들러와 트랜스파일러의 사실상 표준이 된 관대한 상호 운용 규칙을 암시했어요.
  3. 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 설정은 현재 없습니다.)

[^1]: Node.js v20.19.0 이상에서는 ES 모듈의 require가 허용되지만, 해석된 모듈과 그 최상위 import가 최상위 await를 쓰지 않을 때만 그래요. TypeScript는 이 규칙을 강제하려 하지 않는데, 선언 파일만으로는 대응하는 JavaScript 파일에 최상위 await가 있는지 알 수 없기 때문입니다.

모듈 지정자는 기본적으로 변환되지 않음

module 컴파일러 옵션이 입력 파일의 import와 export를 출력 파일에서 다른 모듈 형식으로 변환할 수는 있지만, 모듈 지정자(import하는 from 문자열, 또는 require에 넘기는 값)는 작성된 그대로 방출됩니다. 예를 들어 입력:

import { add } from "./math.mjs";
add(1, 2);

module 컴파일러 옵션에 따라 다음과 같이 방출될 수 있어요:

import { add } from "./math.mjs";
add(1, 2);

또는:

const math_1 = require("./math.mjs");
math_1.add(1, 2);

둘 중 하나로요. 하지만 모듈 지정자는 어떤 경우든 "./math.mjs"입니다. 기본적으로 모듈 지정자는 코드의 대상 런타임이나 번들러에서 동작하는 방식으로 작성돼야 하며, 그런 출력 기준 지정자를 이해하는 것이 TypeScript의 역할이에요. 모듈 지정자가 참조하는 파일을 찾는 과정을 모듈 해석(module resolution) 이라고 합니다.

TypeScript 5.7은 --rewriteRelativeImportExtensions 옵션을 도입했는데, 이는 .ts, .tsx, .mts, .cts 확장자를 가진 상대 모듈 지정자를 출력 파일에서 JavaScript 대응값으로 변환합니다. 이 옵션은 개발 중에 Node.js에서 직접 실행할 수 있으면서도 배포·프로덕션용 JavaScript 출력으로 컴파일할 수 있는 TypeScript 파일을 만드는 데 유용해요.

이 문서는 --rewriteRelativeImportExtensions 도입 전에 작성됐고, 여기가 제시하는 정신 모델은 호스트 모듈 시스템이 그 입력 파일에 대해 동작하는 것을 모델링하는 데 기반을 두고 있어요. 번들러가 TypeScript 파일에 동작하든, 런타임이 .js 출력에 동작하든 말이죠. --rewriteRelativeImportExtensions와 함께라면 그 정신 모델을 적용하는 방식은 두 번 적용하는 것입니다. TypeScript 입력 파일을 직접 처리하는 런타임이나 번들러에 한 번, 변환된 출력을 처리하는 런타임이나 번들러에 한 번. 이 문서의 대부분은 입력 파일만 또는 출력 파일만 로드된다고 가정하지만, 제시하는 원칙은 둘 다 로드되는 경우로 확장될 수 있어요.

모듈 해석

첫 예시로 돌아가서, 그동안 배운 것을 복습해 볼게요:

import sayHello from "greetings";
sayHello("world");

지금까지 호스트의 모듈 시스템과 TypeScript의 module 컴파일러 옵션이 이 코드에 어떤 영향을 줄 수 있는지 다뤘어요. 입력 문법이 ESM처럼 보이지만 출력 형식은 module 컴파일러 옵션, 잠재적으로 파일 확장자, package.json"type" 필드에 달려 있다는 것을 알았습니다. 또한 sayHello가 무엇에 바인딩되는지, 심지어 import가 허용되는지조차 이 파일과 대상 파일의 모듈 종류에 따라 달라질 수 있다는 것도 알았어요. 하지만 아직 대상 파일을 어떻게 찾는지 에 대해서는 다루지 않았습니다.

모듈 해석은 호스트가 정의

ECMAScript 명세는 importexport 문을 파싱·해석하는 방법은 정의하지만, 모듈 해석은 호스트에 맡겨둡니다. 만약 여러분이 새로운 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이 컴파일러에게 호스트의 기대 모듈 형식을 알려주듯, moduleResolution은 몇 가지 사용자화 옵션과 함께 호스트가 모듈 지정자를 파일로 해석하는 데 쓰는 알고리즘을 지정합니다. 이는 또한 TypeScript가 방출 중에 import 지정자를 수정하지 않는 이유를 설명해 줘요. import 지정자와 디스크상 파일 사이의 관계(존재한다면)는 호스트가 정의하는 것이고, TypeScript는 호스트가 아니기 때문입니다.

사용 가능한 moduleResolution 옵션은:

  • classic: TypeScript의 가장 오래된 모듈 해석 모드로, 안타깝게도 modulecommonjs, node16, nodenext 이외의 값일 때 기본값입니다. 광범위한 RequireJS 설정에 대해 최선의 해석 노력을 제공하려고 만들어진 것으로 보여요. 새 프로젝트(또는 RequireJS나 다른 AMD 모듈 로더를 쓰지 않는 옛 프로젝트)에는 쓰면 안 되며, TypeScript 6.0에서 폐지(deprecation) 예정입니다.
  • node10: 이전에는 node로 알려졌으며, modulecommonjs일 때의 불행한 기본값입니다. Node.js v12 이전 버전의 꽤 좋은 모델이고, 때로는 대부분 번들러가 모듈 해석을 하는 방식의 그럴듯한 근사치이기도 해요. 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에서 modulemoduleResolution은 함께 가는데, 하나를 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"를 지원하지만, 무시하도록 구성할 수도 있어요. moduleesnext로 설정해야 합니다.

TypeScript는 호스트의 모듈 해석을 흉내 내되, 타입을 붙여서

모듈에 관한 TypeScript의 역할의 세 구성 요소를 기억하시나요?

  1. 파일을 유효한 출력 모듈 형식으로 컴파일
  2. 출력물 안의 import가 성공적으로 해석되도록 보장
  3. 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에서 모듈 해석의 대부분입니다.

이 모델은 또 다른, 단순 모델의 렌즈로는 직관에 반해 보이지만 견고 모델로는 완벽히 말이 되는 예시를 이해하게 해줘요:

// @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.mjsmain.mts 옆에조차 존재하지 않는데요! 왜 이게 해석되어야 할까요? 새 정신 모델로 보면 전혀 문제가 없습니다. src/main.mts(입력 파일)는 출력 파일 dist/main.mjs로 매핑되고, 이는 모듈 지정자 "./math.mjs"를 통해 dist/math.mjs(다른 출력 파일)로 해석되며, 이는 다시 입력 파일 src/math.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 파일이 존재한다는 것뿐 아니라 그 이름과 확장자가 무엇인지도 알려줍니다:

선언 파일 확장자 JavaScript 파일 확장자 TypeScript 파일 확장자
.d.ts .js .ts
.d.ts .js .tsx
.d.mts .mjs .mts
.d.cts .cjs .cts
.d.*.ts .*

마지막 행은 allowArbitraryExtensions 컴파일러 옵션으로 JS가 아닌 파일에도 타입을 붙일 수 있다는 것을 표현해요. 모듈 시스템이 JS가 아닌 파일을 JavaScript 객체로 import하는 것을 지원하는 경우를 다루기 위해서죠. 예를 들어 styles.css라는 파일은 styles.d.css.ts라는 선언 파일로 표현될 수 있어요.

"잠깐만요! 선언 파일이 아주 많이는 손으로 작성되는데, tsc가 생성한 게 아니잖아요. DefinitelyTyped를 들어본 적 없으세요?"라고 반박할 수 있겠죠. 맞아요. 선언 파일을 손으로 쓰거나, 외부 빌드 도구의 출력을 나타내도록 이동·복사·이름 변경하는 것은 위험하고 오류가 나기 쉬운 모험이에요. DefinitelyTyped 기여자와 tsc로 JavaScript와 선언 파일을 둘 다 생성하지 않는 타입 라이브러리 작성자들은, 모든 JavaScript 파일이 같은 이름과 대응하는 확장자의 형제 선언 파일을 가지도록 보장해야 해요. 이 구조에서 벗어나면 최종 사용자에게 위양성 TypeScript 오류가 생길 수 있어요. 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 파일을 메모리에서 트랜스파일하도록 설정되어 있으며, 결국 여러분이 작성한 모든 import를 소비하고 지워서 번들을 만들 거예요.
  • Node, Deno, Bun 같은 TypeScript 런타임에서 이 코드를 직접 실행 중이에요.
  • Node용 ts-node, tsx 또는 다른 트랜스파일링 로더를 사용 중이에요.

이런 경우 noEmit(또는 emitDeclarationOnly)과 allowImportingTsExtensions를 켜서 안전하지 않은 JavaScript 파일 방출을 비활성화하고 .ts 확장자 import의 오류를 조용하게 할 수 있어요.

allowImportingTsExtensions가 있든 없든, 모듈 해석 호스트에 가장 적절한 moduleResolution 설정을 고르는 것은 여전히 중요해요. 번들러와 Bun 런타임에는 bundler가 그것입니다. 이 모듈 해석기들은 Node.js에서 영감을 받았지만, Node.js가 import에 적용하는 확장자 검색을 비활성화하는 엄격한 ESM 해석 알고리즘은 채택하지 않았어요. bundler 모듈 해석 설정은 이를 반영해서, node16nodenext처럼 package.json "exports" 지원을 켜면서도 확장자 없는 import를 항상 허용합니다. 더 자세한 안내는 컴파일러 옵션 고르기를 참고하세요.

라이브러리를 위한 모듈 해석

앱을 컴파일할 때는 모듈 해석 호스트가 누구인지에 따라 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는 더 이상 그 작업에 기여할 수 없어요. 런타임에 어떤 모듈 코드가 존재할지 알 수 없기 때문입니다.

더 알아보기 (Learn more)