라이브러리 또는 어플리케이션을 빌드하려면 우리가 의식했든, 의식하지 못했든 아래와 같은 질문들을 거쳐야 합니다.
모던 프론트엔드 환경에서 CRA, Next.js, vite 같은 도구들을 사용하다 보면 대체로 이런 질문들을 크게 고민할 필요 없이 번들러에 위임할 수 있습니다.
하지만 어플리케이션 특성 또는 나의 개발환경에 맞게 번들러 (또는 트랜스파일러) 의 옵션들을 직접 조작하려면 이런 질문들에 대한 답을 직접 찾아가야 하는 상황을 금새 마주치게 됩니다.
d.ts
파일만 제공해주세요.저는 라이브러리를 만들면서 모듈 시스템과 번들러, tsconfig
에 대한 이해 없이 “JS로 뱉기만 하면 어디서든 돌아가는 거 아니야?” 라는 패기로 tsc
만 사용하여 CJS 형태로 빌드했고, 여러 가지 문제들을 마주쳤습니다.
vite 에서 CJS 모듈을 로드하면 HMR이 지원되지 않아서, 수정사항을 확인하려면 개발 서버를 완전히 껐다 켜야 하는 문제가 특히 치명적이었습니다.
이로 인해 개발 효율이 상당히 떨어지는 바람에 지금은 외부 번들러를 사용해 ESM 형태로 다시 빌드하기 위한 작업을 하고 있습니다. 그 과정에서 모듈 시스템, 번들러, 트랜스파일러 (tsc
와 tsconfig
) 에 대해 학습한 내용을 정리해보았습니다.
tsconfig
옵션들을 알 수 있습니다.module
, moduleResolution
값이 번들링 혹은 트랜스파일 결과물에 미치는 영향을 알 수 있습니다.module
, moduleResolution
값을 알 수 있습니다.번들러는 위와 같은 문제들을 해결합니다.
번들러는 잘게 쪼개진 모듈들을 더 큰 단위의 자바스크립트 번들로 연결합니다. 단순히 여러 모듈을 한 파일로 합치는 것뿐만 아니라, 모듈을 resolve 하는 과정에서 브라우저가 해석 불가능한 모듈 (CommonJS) 들을 변환하고 복잡한 의존성들을 해결하는 등의 작업까지 수행해 줍니다.
Node.js 에서 일반적으로 사용하는 모듈 시스템으로, v12
이전까지는 오직 CJS 방식만 사용할 수 있었습니다.
// 가져오기
let { A, B } = require('index.js');
// 내보내기
const A = 20;
const B = 30;
module.exports = {
A,
B
}
require
함수를 사용해서 모듈을 동적으로 불러올 수 있기 때문에 더 유연할 수 있지만, 이러한 특성으로 인해 모듈의 로드 여부가 런타임에서 결정된다는 문제가 발생합니다. 따라서 번들러를 통한 의존성 해결 및 tree shaking 이 어려워집니다.
ESM 즉 ECMAScript modules 는 import
, export
구문을 사용하여 모듈을 로드합니다.
// 가져오기
import { A, B } from 'index.js'
// 내보내기
export const A = 20;
export const B = 30;
프론트엔드 웹 어플리케이션은 대부분 ESM 형태로 모듈을 사용합니다. 번들러에서 tree shaking 을 통한 경량화가 가능해지며, 애초에 대부분의 브라우저는 자체적으로 CommonJS 모듈을 지원하지 않습니다.
ESM이 무조건 더 효율적이라면, 항상 ESM 을 사용하면 좋지 않을까요?
프론트엔드 어플리케이션은 브라우저 상에서 작동하는 경우가 대부분이므로 ESM 형태로 개발하는 데 큰 문제가 없습니다. vite 처럼 ESM 모듈에 최적화된 번들러도 있습니다. 하지만 자바스크립트 라이브러리를 개발하는 경우에는 조금 다르게 생각해야 합니다.
만약 내가 배포한 기능을 Node.js 어플리케이션에서 사용해야 한다면 어떨까요? 최근에는 프론트엔드 어플리케이션에서도 SSR을 활용하기 위해 node 환경에서 실행 가능한 코드를 작성하는 경우가 많아지고 있습니다.
어떤 기능을 서버 측 코드에서 실행하기 위해 CJS 모듈을 지원해달라는 요청이 들어오면 대응해야 합니다. 따라서 미리 CJS와 ESM 을 모두 지원하면 좋겠네요.
이제부터는 개발을 마친 라이브러리를 NPM
에 배포한다는 가정하에 생각해 보겠습니다.
개발자들은 코드 내부에서 모듈을 불러오거나 내보낼 때 import
/export
구문을 쓰는지, require
/module.export
구문을 쓰는지에 따라 이 코드가 CJS 방식을 따르는지 ESM 방식을 따르는지 알아낼 수 있습니다.
그렇다면 개발을 마치고 빌드 결과물(artifact, 아티팩트)을 CJS 방식으로 내보낼지, ESM 방식으로 내보낼지는 어떻게 결정할 수 있을까요? CJS 형태로 작성된 코드는 무조건 CJS 형태로 빌드하는 것일까요? ESM 형태로 작성한 코드를 CJS 환경에서도 사용 가능한 형태로 빌드하려면 어떻게 해야 할까요?
라이브러리를 실행시키는 주체를 지금부터 host
라고 부르겠습니다 (문서). 그리고 host
를 Node.js 런타임과 브라우저 (의 JS 엔진) 으로 나누어 생각해 보겠습니다.
우선 번들링 없이 Node.js 환경에서 바로 라이브러리에 접근하는 경우를 가정했습니다.
v11
및 그 이전 버전: 오직 CJS 형태로 작성된 모듈만 지원import
, export
구문 (ESM 모듈)을 만나면 파싱 단계에서 에러 발생v12
및 그 이후 버전: CJS 와 ESM 모듈을 모두 지원package.json
과 파일 확장자
를 참고 (문서)위에 적힌 대로, Node.js v12 이상부터는 두 가지의 모듈 시스템을 모두 지원합니다.
.mjs
또는 .mts
확장자로 작성된 모듈은 무조건 ESM 모듈로 인식됩니다..cjs
또는 .cts
확장자로 작성된 모듈은 무조건 CJS 모듈로 인식됩니다.package.json
type: module
이 명시된 경우, 모든 .js
또는 .ts
파일은 ESM 모듈로 인식됩니다.type
필드에 아무 값도 없거나 module
이 아닌 다른 값인 경우 모든 .js
또는 .ts
파일은 CJS 형태로 인식됩니다.만일 Node.js v12 환경에서 .mts
파일 내부에 require
구문이 사용되었다면 런타임 에러가 발생할 것입니다. 반대로 type
필드에 아무 값도 없는데 .ts
파일에서 import
구문을 사용한다면 마찬가지로 에러가 발생할 것입니다.
동일한 라이브러리를 브라우저에서 사용하려면 어떻게 해야 할까요? 앞서 대부분의 브라우저는 자체 CJS 모듈 지원 기능이 없다고 했으므로, Node.js 환경을 위해 빌드한 CJS 형태의 모듈을 그대로 사용할 수는 없습니다.
모던 프론트엔드 환경에서는 대부분 번들러 내지는 트랜스파일러를 거친 아티팩트를 실행시키고 있기 때문에, 번들러가 모듈을 어떤 형태로 해석하고 내보내야 하는지 config 를 통해 알려주어야 합니다.
우리가 흔히 작성하는 버튼 컴포넌트를 예시로 한 번 들어보겠습니다.
// Button.tsx
import { BaseButton } from '@/BaseButton'
const Button = () => {
const handleClick = () => {
console.log('I am happy')
}
return <BaseButton onClick={handleClick}>누르면 행복해져요</BaseButton>
}
export default Button
여기서 번들러가 해야 하는 일들을 짚어 보면 다음과 같습니다.
우와! 여기서 우리는 자연스럽게 트랜스파일러에 대한 이야기로 넘어갈 수 있습니다. 왜냐하면, .tsx
파일을 브라우저에서 바로 실행시킬 수는 없기 때문입니다.
트랜스파일러는 타입스크립트 / jsx(tsx) / ES6+ 구문들을 브라우저에서 호환되는 형태의 자바스크립트 파일(browser-compatible JavaScript)로 변환하는 작업을 수행합니다.
대부분의 번들러들은 트랜스파일러와 결합하여 실행됩니다. rollup 의 @rollup/plugin-babel
, webpack 에서 사용하는 babel-loader
, vite 의 @vitejs/plugin-react-swc
등등 각 번들러들은 트랜스파일러를 플러그인 또는 패키지 형태로 결합 가능하도록 개발되어 있습니다.
우리가 타입스크립트 프로젝트를 초기화할 때 사용하는 tsc
역시 트랜스파일러의 일종입니다.
트랜스파일러는 위와 같은 문제들을 해결합니다.
여기서는 여러 트랜스파일러 중 tsc
를 예시로 들어 설명하겠습니다. 모든 프론트엔드 환경에서는 라이브러리 차원에서 타입스크립트를 지원하는 것이 중요한데요. 타입스크립트 대응을 위해 타입을 검사하고 d.ts
파일을 생성하는 데 tsc
를 사용해야 하기 때문입니다.
tsc
자체로도 tsx → js 변환이 가능하기 때문에, 트랜스파일 속도가 중요하거나 한 번에 여러 개의 트랜스파일 버전을 만들어내야 하는 등 외부 트랜스파일러들의 장점이 꼭 필요한 상황이 아니라면 tsc
만을 사용해서 전체 트랜스파일을 수행할 수도 있습니다.
tsc
도 ES6 → ES5 변환이 가능합니다. 그러나 babel 과 같은 polyfill 추가 기능이 없습니다. (참고)tsc
는 타입을 검사하고 트랜스파일을 수행하기 위해 tsconfig.json
파일을 참조합니다. 이제부터는 먼저 이야기했던 모듈의 형태를 해석하는 법으로부터 이어지는 내용이 될 것입니다.
tsc
는 번들러도 아니면서 트랜스파일 결과물을 어떤 형태로 내보내야 하는지를 물어봅니다. 사실 당연합니다. 타입스크립트 코드가 Node.js 환경에서 실행될 수도, 브라우저에서 실행될 수도 있으니까요.
모듈을 해석하고 변환하는 데 있어 주요한 tsconfig 의 옵션을 세 가지 짚어 보겠습니다.
compilerOptions.target
: tsc 에 의해 변환된 파일이 실행될 대상 환경을 의미합니다.es2015
로 설정했다면, 기존 코드의 모든 화살표 함수들은 function
으로 변환될 것입니다.compilerOptions.module
: tsc 에 의해 변환된 파일의 모듈 형태를 의미합니다.node16
, nodeNext
: 최신 노드 환경을 기준으로 파일들의 모듈 형태를 결정합니다.package.json
설정과 파일 확장자를 기준으로 결정합니다.es*
: 무조건 ESM 형태로 모듈을 변환합니다.compilerOptions.moduleResolution
: 모듈의 경로를 해석하는 방식을 결정합니다.이 중 module
과 moduleResolution
에 대해 조금 더 알아보려 합니다. compilerOption
의 많은 옵션들은 module
과 moduleResolution
값에 의존하여 결정됩니다.
tsc
를 사용해서 아티팩트를 빌드할 때 모듈 형태를 결정하는 옵션입니다. 위에 적힌 대로 node*
인지 es*
인지에 따라 각기 다른 형태로 모듈을 변환합니다.
2024년 10월 기준으로, 우리가 사용할 수 있는 module 옵션은 아래와 같습니다.
nodeNext
: 최신 Node.js 모듈 정책을 따라 빌드합니다. (확장자 → package.json type
필드 확인)node16
: 현재의 nodeNext
와 동일합니다. Node.js v16+ 정책을 반영합니다esnext
: import.meta
, top-level await
등 최신 ESM 정책을 따라 빌드합니다.es*
: 해당 버전의 ESM 정책에 따라 빌드합니다.tsc
를 통해 변환된 .js
파일까지 내보내든, 단지 d.ts
파일만 내보내든 반드시 적절한 module
옵션 값을 설정해 주어야 합니다.
module
옵션의 기본적인 용도는 아티팩트의 모듈 형태를 결정하는 것이지만, 그뿐만 아니라 트랜스파일링 과정에서 각 파일의 모듈 형태를 감지하고, 적절한 형태로 모듈이 import 되어 있는지 검사하기 때문입니다.
Why care about TypeScript’s
module
emit with a bundler or with Bun, where you’re likely also settingnoEmit
?
- TypeScript’s type checking and module resolution behavior are affected by the module format that it would emit.
- Setting
module
gives TypeScript information about how your bundler or runtime will process imports and exports, which ensures that the types you see on imported values accurately reflect what will happen at runtime or after bundling.
타입스크립트는 정확한 타입 체크를 위해, host
(번들러 등) 가 모듈 간 import, export 를 어떤 식으로 처리할 것인지에 대한 정보가 필요합니다. 이 정보 역시 module
옵션의 값으로 판단하는 것입니다.
모듈 형태가 바뀌면, 단순히 import 가 require 로 바뀌는 것뿐만 아니라 여러 영역에서 변화가 발생합니다.
esModuleInterop
allowSyntheticDefaultImports
위의 두 가지 compilerOptions
옵션을 예로 들어 살펴보겠습니다.
// node_modules/moment/index.js
exports = moment
CJS 형태로 내보내진 moment 라는 객체가 있습니다. 이 모듈을 ESM 모듈에서 사용하는 상황을 생각해봅시다.
// src/index.ts
import * as moment from 'moment'
moment();
이렇게 * as {name}
형태로 모듈을 불러오는 것을 namespace import 라고 합니다. 위 코드에는 한 가지 문제가 있는데요, ES6 스펙 상 namespace import 로 불러온 객체는 호출 가능한 객체 (callable) 가 아닌 순수 객체 (plain object) 여야 합니다. 스펙에 어긋나는 import 를 했군요?
// src/index.ts
import moment from 'moment'
moment();
namespace 가 아니라, 이렇게 default import 를 하면 스펙에 맞게 import 할 수 있게 됩니다.
문제는, 사용되는 CJS 모듈 쪽에서 이런 ES6 스펙을 고려해주지 않는다는 것입니다. 위 CJS 코드에는 default export 가 없습니다. 그럼 모든 CJS 모듈 개발자에게 ESM 모듈에서 사용할 수 있도록 default export 를 해달라고 요청해야 할까요?
esModuleInterop
옵션을 사용하면, 자체 helper function을 사용해서 개발자들이 신경쓰지 않아도 ESM 환경에서 CJS 모듈이 호환되도록 (interoperation) 수정을 거치게 됩니다.
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
모듈이 ESM (__esModule
) 이면 해당 모듈을 그대로 리턴하지만, 아닌 경우 default
키에 해당 모듈을 맵핑해서 객체 형태로 돌려주고 있습니다.
그런데 이 옵션 자체는 실제 .js
파일을 CJS 형식으로 내보낼 때만 필요하기 때문에, 단순히 d.ts
파일만 내보내거나 타입 검사만 수행하고 싶은 경우에는 신경쓰지 않아도 될 것만 같습니다. 과연 그럴까요?
개발자가 tsc 를 사용해 직접 작업하지 않더라도, 결국 누군가는 번들링 내지 트랜스파일링을 수행해야 합니다. 번들러를 사용한다면 번들러는 주어진 tsconfig
옵션을 보고 module
값에 따라 CJS 형태로 모듈을 트랜스파일 할 것입니다. 아마도 위의 __importDefault
함수와 동일한 과정을 거치겠죠? (babel 예시)
위의 import interop 과정을 거치면 ESM 모듈 쪽에서는 실제 default export 가 있든 없든 CJS 모듈에 대해 default import 를 할 수 있게 되는데요. 문제는 실제 트랜스파일이 수행되어 import interop 과정을 거치기 전, 타입 검사 시점에서 발생합니다.
import 되는 모듈에는 default export 가 없는데 default import 를 하려 하니 에러를 발생시키기 때문입니다.
따라서 타입 검사 시점에 esModuleInterop
을 예측하기 위해 allowSyntheticDefaultImports
플래그를 true
로 설정해 주어야 합니다.
// 가능
import * as React from "react";
// allowSyntheticDefaultImports: true 일 때 가능
import React from "react";
만약 우리가 module: nodeNext
로 설정했다면, 확장자에 따라 한 라이브러리 안에서 CJS 모듈과 ESM 모듈이 서로 import 할 수 있으므로 위 esModuleInterop
과 allowSyntheticDefaultImports
플래그를 모두 켜 주어야 합니다.
하지만 우리가 module: esnext
등 ESM 환경을 가정한다면 CJS와 ESM이 혼재할 문제가 없어지기 때문에, allowSyntheticDefaultImports
플래그를 켤 필요도 없고 타입 검사 시 이러한 경우를 고려할 필요도 없습니다.
따라서 tsc
는 단지 타입 검사만 수행하더라도, 번들러에 의해 어떤 형태의 모듈로 변환될 것인지 예상할 수 있어야 적절히 검사를 수행할 수 있겠네요.
module
은 우리가 내보낼 아티팩트 (output) 의 모듈 형태를 결정하는 것이라면, moduleResolution
은 현재 소스코드 내부에 import 되어 있는 모듈들의 경로 (module specifier) 를 어떻게 해석할지를 결정하는 옵션입니다.
원문 상 module path 가 아니라 specifier 라고 표현되어 있다는 점도 알아두면 좋겠습니다.
예를 들어, 아래와 같은 소스코드를 트랜스파일한다고 가정해보겠습니다.
import { add } from "./math.mjs";
add(1, 2);
module: esnext
인 경우 ESM 모듈 형태 그대로 내보내질 것입니다.
import { add } from "./math.mjs";
add(1, 2);
module: nodenext
인 경우 CJS 형태로 변환되어 내보내질 것입니다.
const math_1 = require("./math.mjs");
math_1.add(1, 2);
여기서 중요한 것은, 트랜스파일을 거치더라도 모듈의 경로 "./math.mjs"
는 절대 변하지 않는다는 점입니다 (문서). tsc
에는 모듈의 경로를 변경하는 옵션이 없습니다. 따라서, 모듈의 경로는 반드시 코드가 실행될 런타임 또는 번들러의 해석 방식에 맞추어 작성되어야 합니다.
import monkey from "🐒";
위는 공식문서의 예제입니다. 말도 안 되는 파일 경로 같지만, 이걸 "src/monkey.js"
로 해석하는 런타임 또는 번들러가 세상 어딘가에 존재할지도 모르는 일입니다. 따라서 moduleResolution
은 라이브러리 개발자가 아니라 라이브러리를 실행시킬 엔드 유저의 관점에서 설정되어야 합니다.
2024년 10월 기준으로, 우리가 사용할 수 있는 moduleResolution
옵션은 세 가지입니다.
nodeNext
: 최신 Node.js 정책을 기준으로 모듈 경로를 해석합니다.module
도 반드시 nodeNext
로 설정되어야 합니다. (node16
으로 설정하면 안됩니다.)node16
: 현재의 nodeNext
와 동일합니다. Node.js v16+ 정책을 반영합니다.module
도 반드시 node16
로 설정되어야 합니다. (nodeNext
로 설정하면 안됩니다.)bundler
: nodeNext
와 비슷하지만, import
방식의 모듈 경로에 대해 (1) 상대 경로의 파일 확장자 생략 및 (2) 디렉토리 모듈 (index.js) 을 허용합니다.nodeNext
의 경우, import
방식으로 모듈의 상대 경로를 지정할 경우 반드시 파일 확장자를 명시해야 합니다 (node 공식문서) (관련 이슈).import
방식에서는 디렉토리 모듈 (index.js) 을 사용할 수 없습니다. (SO question)bundler
옵션이 추가되었습니다.module: esnext
사용이 강제됩니다.타입스크립트 공식문서에서는 현재 개발 중인 프로젝트가 어플리케이션인지/라이브러리인지, 번들러를 사용하는지/사용하지 않는지에 따라 각기 다른 설정을 제안하고 있습니다. (이걸 조금 더 일찍 알았더라면…)
어플리케이션 개발 - 번들러 사용 시 (브라우저 사이드 앱, ESM 형태의 모듈 필요)
module
: esnext
esnext
를 사용하지 않고 nodeNext
+ type: “module”
또는 .mts
방식을 사용해도 ESM 모듈을 만들어낼 수 있지만, 번들러에 따라 해석의 차이가 발생할 수 있다고 합니다.This creates an inconsistency between how some imports behave in Node and how they behave in bundlers. Consequently, esbuild and Webpack adopt Node’s behavior and always synthesize default exports in files that Node would recognize as ESM—that is, files with an
.mjs
extension or"type": "module"
inpackage.json
.
moduleResolution
: bundler
package.json
의 exports
, imports
필드 설정값을 기반으로 모듈 경로를 해석합니다.어플리케이션 개발 - 번들러 없음
module
: nodenext
package.json
에 “type: module”
을 명시하고, import
시 파일 확장자를 밝혀 적어야 합니다. (이게 귀찮아서 그냥 CJS로 내보낸다면 저 같은 일을 겪을 수 있습니다.ㅠㅠ)moduleResolution
: nodeNext
module: nodenext
인 경우 반드시 moduleResolution
도 nodeNext
여야 합니다.
module
:esnext
를 쓰면 안되나요?
- 이 경우
moduleResolution
에 들어올 수 있는 값은node10
과bundler
뿐입니다.- Node.js v12 부터 ESM 을 해석하는 정책이 달라졌습니다. 따라서 모던 프로젝트에서는
node10
옵션이 권장되지 않습니다.- 번들러를 쓰지 않으므로
bundler
옵션은 안 되겠군요
어플리케이션 개발 - node.js 환경
module
: nodenext
moduleResolution
: nodeNext
라이브러리 개발 - 번들러 없음
module
: node16
nodeNext
보다는 구체적인 node 버전을 고정하는 것이 더 안전합니다.moduleResolution
: node16
module: node16
인 경우 반드시 moduleResolution
도 node16
여야 합니다.라이브러리 컴파일과 어플리케이션 컴파일의 차이
- 라이브러리 컴파일은 어플리케이션 컴파일과 다른 관점에서 보아야 합니다. 어플리케이션은 실행될 런타임 (대체로 브라우저 or Node.js 중 1) 또는 번들러만 고려하면 됩니다. 즉, 대체로 어떻게 실행되는지 예상 가능한 환경입니다.
- 반면, 라이브러리는 라이브러리 사용자의 컴파일 과정에 포함됩니다. 즉 런타임이 아니라, 빌드 과정에서 해석되어야 합니다. 사용자가 이 라이브러리를 CJS 모듈에 포함시켜 컴파일할지, EJS 모듈에 포함시켜 컴파일할지 알 수 없습니다. 따라서 최대한 엄격한 환경을 가정하는 것이 좋습니다.
라이브러리 개발 - 번들러를 사용하여 빌드
module
: esnext
moduleResolution
: bundler
번들러로 CJS 모듈을 만들고 싶을 수도 있는데 왜
esnext
가 권장되나요?
- 이는
moduleResolution
을bundler
로 설정하기 위함입니다. (Optionbundler
can only be used whenmodule
is set topreserve
or toes2015
or later)- 라이브러리를 번들러로 미리 컴파일하는 경우 개발자가 직접 작성한 소스 코드 (first-party) 들은 소스코드 그대로 파일에 포함되지만, 외부 의존성 (third-party) 들은 소스코드 자체가 포함되는 것이 아니라 import 구문 그대로 포함됩니다.
- 즉, 외부 의존성들은 번들러가 아니라 라이브러리 사용자의 컴파일 시스템 (end-user environment) 에 의해 해석될 것입니다. 이 때
moduleResolution: nodeNext
로 설정하면, 외부 의존성들의 import 까지 지나치게 엄격하게 검사할 수 있습니다.- 어차피 번들러가 아니라 라이브러리 사용자에 의해 resolve 될 의존성들은 Node.js 스펙과 맞지 않는 방식으로 import 되어 있어도 번들러에 따라 허용될 수 있도록
moduleResolution: bundler
로 설정합니다.
지금 내가 개발 중인 프로젝트가 어떤 tsconfig
옵션을 사용 중인지, 빌드 결과물에 어떤 영향을 미치는지 짚고 넘어가보면 좋겠습니다 ~!
References