배경
이전 게시글에서 라이브러리를 tsc만으로 컴파일해서 배포했다.
그랬더니 아래와 같은 일이 발생한다.
CJS방식으로 require했을때도, ESM으로 import했을때도 잘 작동되지 않는다.
왜 이런일이 발생했을까?
모듈 시스템
그전에, 모듈 시스템 중 CJS와 ESM에 대해 살펴보자.
CJS
require와 module.exports를 통해 동작한다. 동기적으로 실행된다.
또한, 메인스레드를 블로킹한다. 즉, 모듈의 코드 실행까지 다음 진행이 차단되는 것이다.
exports되는 항목을 마치 object처럼 취급한다.
아래와 같은 코드가 있다고 하자.
//module.js
let num=20;
function setNum(n){
num=n;
}
function getNum(){
return num;
}
module.exports={
num,
setNum,
getNum
}
//main.js
let {num, setNum} =require("./module.js");
let {num:num2} =require("./module.js);
위코드는 CJS에서 아래코드와 동일하게 동작한다.
let num=20;
function setNum(n){
num=n;
}
function getNum(){
return num;
}
let obj={
num,
setNum,
getNum
}
let {num,setNum}={...obj};
let {num:num2}={...obj};
ESM
import, export를 통해 동작하며, 비동기적으로 실행될 수 있다.
비동기적 실행이란, 가져온 스크립트를 바로 실행하는게 아니라 import구문을 찾아서 스크립트를 파싱한다는 것이다.
ESM은 구성 -> 인스턴스화 -> 평가의 단계를 거쳐 수행된다.
구성
엔트리를 기준으로 import를 쭉 타고 올라가며 트리를 만들어낸다.
그리고 이를 통해 모듈 레코드라는 데이터 구조를 만들어낸다.
요 과정에서 fetch url과 모듈 레코드를 매핑한다.
그래야 한번 fetch가 완료된 모듈을 다시 다운로드 받지 않기 때문이다.
인스턴스화
export되는 값을 저장할 메모리 공간을 찾아놓고, export하는 코드와 import하는 코드가 모두 같은 메모리 공간을 가리키도록 연결한다.
이그림을 가져왔는데 코드와 함께보면 이런식으로 된다는 것이다.
평가
이제 위 그림에서 검정색 부분에 1을 채워 넣는다.
또한, import로 접근한 메모리 값을 바꿀수는 없다.
결론
이 둘이 동작하는 방식은 너무나도 다르다.
둘이 호환될 수 없는 것이다.
왜?
이제 다시 돌아와서 이전에 설정한 것들을 기반으로 안되는 이유를 분석해보자.
package.json
우선, 이전 게시글에서 살펴본 package.json을 다시 보자.
{
"name": "@rapiders/react-hooks", //패키지의 이름
"version": "0.0.9",
"description": "react hooks for fast development",
"main": "dist/index.js", //entry point
"types": "dist/index.d.ts", //entry point의 타입
"homepage": "https://github.com/d0422/react-hooks",
"scripts": {
...
},
"keywords": [
...
],
"author": "d0422 <rlfehd2013@naver.com>",
"license": "MIT",
"peerDependencies": {
...
},
"devDependencies": {
...
}
}
여기 보면 type 필드가 빠져있는 것을 확인할 수 있다.
type 필드가 빠지게 되면 프로젝트는 CJS를 따르게된다.
npm배포된 결과, 즉 다운로드되는 라이브러리도 package.json을 통해 엔트리포인트에 접근하기 때문에 dist 내부의 모듈들은 CJS방식을 따라야한다.
그러나...
dist내부의 index.js를 보면 ESM방식으로 트랜스파일링 됐다는 것을 확인할 수 있다.
이 원인은 ts파일이 ESM방식으로 트랜스파일링됐기 때문이다.
원인 파악
아하...! 그럼 이제 현상을 정리할 수 있다.
- 라이브러리를 CJS 형태로 읽으라고 package.json에 적어둠
- 근데 라이브러리 트랜스파일링을 ESM으로 해둠
- 따라서 CJS로도, ESM으로도 읽을 수 없는 코드가 되어버림
해결을 위한 분석
그렇다면.... 이젠 tsconfig.json을 다시 살펴볼때이다.
tsconfig.json
tsconfig의 module과 moduleResolution이 트랜스파일링시 모듈방식을 결정한다.
{
...
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "Bundler"
}
}
따라서 바로 여기가 문제인 것이다.
여기서 module이 ESNext로 되어있는데, 이래서 ESM방식으로 트랜스파일링됐던 것이다.
따라서 module필드의 값을 CJS, CommonJS로 변경해줘보았다.
CommonJS로 변경해주면 moduleResolution이 불같이 화를 내는 것을 확인할 수있다.
번들러 옵션은 module이 preserve나 es2015이상일때만 쓸수있다고한다.
왜 이런것일까? 이걸 생각하다보니 tsconfig의 module, moduleResolution에 대한 이해도가 굉장히 떨어진다는 것을 알 수 있었다.
module, moduleResolution
tsconfig의 module은 ts파일이 js로 변환될때 어떤 형태로 변환될 것인지를 말한다.
moduleResolution은 모듈 해석으로, 컴파일러가 모듈 이름을 읽을때, 어떻게 모듈을 가져올지를 정해주는 것을 말한다.
module
module이 바로 ESM과 CJS를 결정하는 필드다.
ES6, ES2015, ES2020, ESNest가 ESM방식이고
CommonJS가 CJS이다.
NodeNext/Node16
이중 Node16과 NodeNext가 무엇인지 헷갈렸었는데, 이는 nodejs환경에서 ESM을 지원하기위한 옵션이다.
package.json의 type필드가 무엇이냐에 따라 트랜스파일링방식이 바뀐다.
이러면 import시 확장자가 반드시 붙어야한다.
package.json의 type필드를 바꿔가면서 tsc시켜보면 결과가 다르다는 것을 알 수있다.
왼쪽이 module, 오른쪽이 type필드가 비었을때의 tsc결과이다.
moduleResolution 방식
이러한 방식이 있는 것을 확인할 수 있다.
1. node16, nodenext는 require(CJS), import(ESM)을 모두 지원한다.
2. node10은 이전에는 node라는 이름이었는데, CJS만 지원한다.
3. bundler는 번들러와 함께 사용할때 사용한다. 가져오기시 상대 경로에 파일 확장자를 요구하지 않는다고한다.
4.Classic은 ts1.6버전 이전에 쓰이던거라서 사용하지말라고한다.
따라서 NodeNext를 사용하면 ESM을 지원하게 만들 수 있다.
결론
두가지 방향이 있다.
- CJS로 통일
- ESM으로 통일
package.json이 CJS이므로, CJS형태로 통일해주었다.
{
...
"compilerOptions": {
"module": "CommmonJS",
"moduleResolution": "Node10"
}
}
ESM으로 통일하려면 package.json에 type필드를 module로 변경해주면된다.
CJS와 ESM가 모두 지원되는 라이브러리를 만들 수는 없을까?
그러던 중 토스의 아래 글을 보게되었고, package.json의 exports필드를 사용하면 호환가능한 라이브러리를 만들 수 있다는 것을 알게 되었다.
https://toss.tech/article/commonjs-esm-exports-field
요걸 보고 아이디어를 얻어서 tsconfig를 두개 만들어서 두번 트랜스파일하는 방식을 구성했다.
package.json
type을 module로 바꾸어 ESM을 기본으로 사용하게 했다.
그리고 exports필드를 사용해 dist/esm, dist/cjs에 각각 빌드 결과를 배치시키고 cjs확장자를 통해 require시 CJS가 지원되도록 만들어주었다.
{
"name": "@rapiders/react-hooks",
...
"main": "dist/esm/index.js",
"types": "dist/esm/index.d.ts",
"exports": {
".": {
"require": "./dist/cjs/index.cjs",
"import": "./dist/esm/index.js"
}
},
"scripts": {
"build": "sh build.sh",
...
},
...
"type": "module"
}
tsconfig.json
{
"include": ["src"],
...
"compilerOptions": {
"outDir": "./dist/esm", //esm폴더에 트랜스파일링 결과를 둔다
"module": "ESNext", //ESM
...
}
}
그리고 tsconfig.cjs.json을 만들어서 CJS로 트랜스파일링 할 수 있도록 해주었다.
tsconfig.cjs.json
{
"include": ["src"],
...
"compilerOptions": {
"outDir": "./dist/cjs", //cjs폴더에 트랜스파일링 결과를 둔다
"module": "CommonJS", //CJS
"moduleResolution": "Node10",
}
}
build.sh
빌드 스크립트가 너무 길어지는 것 같아서 빌드용 파일을 따로 분리해주었다.
rm -rf ./dist
mkdir dist
tsc --project tsconfig.cjs.json
tsc --project tsconfig.json
이러면 CJS와 ESM 파일을 모두 지원하는 라이브러리 구성이 된...줄 알았으나....
확장자 문제
이제 다 해결됐나 싶었지만, 여전히 작동이 안된다... 확장자가 없기 때문이다.
그렇다... 생각해보니 번들러 없이는 ESM에서는 확장자 생략이 안되는게 당연하다.
그렇다면 트랜스파일링 결과에 확장자를 달아줄 수는 없을까?
이걸 이해하려면 이 문서를 읽는게 직빵이다.
결론은 안된다다.
이 내용을 번역해서 작성하면 글이 너무 길어지므로, 이 부분은 다음게시글에 작성을 하도록 하겠다.
참고
1. [javascript] 모듈시스템 -CJS, ESM 동작원리 - https://blog.naver.com/pjt3591oo/222834625061
2. 내가 보려고 정리한 tsconfig(2) - https://velog.io/@milkboy2564/%EB%82%B4%EA%B0%80-%EB%B3%B4%EB%A0%A4%EA%B3%A0-%EC%A0%95%EB%A6%AC%ED%95%9C-tsconfig2#module
3. Module - Theory - https://www.typescriptlang.org/docs/handbook/modules/theory.html#module-resolution-for-bundlers-typescript-runtimes-and-nodejs-loaders
4. ES modules:A cartoon deep-dive - https://hacks.mozilla.org/2018/03/es-modules-a-cartoon-deep-dive/
5. CommonJS와 ESM에 모두 대응하는 라이브러리 개발하기: exports field - https://toss.tech/article/commonjs-esm-exports-field