TypeScript에서 ESM으로 프로젝트를 동작시키기

eora21·2024년 9월 28일
2

ESM 환경에서만 동작하는 라이브러리(lowdb)를 사용하기 위해 겪었던 문제와 해결 과정을 기록했습니다.

ESM이란?

ESM(ECMAScript Modules)은 js에서 모듈을 정의하고 사용하는 방식입니다.
다른 방식으로는 CJS(CommonJS)가 있습니다.

두 방식의 차이는 무엇인가요?

내보내기, 가져오기 문법의 차이

CJS

module.exports = { test };
const { test } = require("./test");

ESM

export test;
import { test } from "./test";

표준

CJS는 Node에서 도입한 모듈 시스템이지만, ESM은 JavaScript에서의 표준입니다.
Node는 ES6(ES2015)부터 ESM을 도입했습니다. 즉, ES6 이상은 CommonJS, ESM 둘 다 사용 가능합니다.

모듈 로딩

CJS는 동기적으로 모듈을 로드하지만, ESM은 비동기적으로 로드합니다.
또한 ESM은 모듈 간 순환참조가 제한됩니다.

트리 쉐이킹

실행 과정에서 사용하지 않는 코드들을 메모리에 올리지 않는 방식을 뜻합니다. 나무를 흔들 때 죽은 잎사귀들이 떨어지는 모습에 착안했다고 합니다.
CJS는 가져오기 과정에서 내보내기로 설정된 객체에 대해 클로저로 캐싱해두는데, 이로 인해 실제 사용되지 않는 코드라고 해도 메모리에 올라가게 됩니다.

module.exports = { test };

해당 코드를 다시 보면, 중괄호로 묶인 하나의 Object를 반환하고 있습니다.

module.exports = { test, test2, test3 };

만약 이렇게 총 3개의 객체를 묶어 하나의 Object로 반환한다면 Node는 export된 Object 자체를 캐싱해둡니다. 따라서 test2, test3가 실질적으로 사용되지 않더라도 메모리에 올라가게 됩니다(더 자세한 내용은 해당 블로그 글을 참조해보시면 좋을 것 같습니다).
반면 ESM은 트리 쉐이킹을 통해 사용되지 않을 객체들은 메모리에 올리지 않습니다. 이는 분석시점의 차이 덕분입니다.

분석시점

CJS는 런타임에 모듈 내용을 확인합니다. 하지만 EMS는 컴파일 시점에 필요한 객체와 필요하지 않은 객체를 살펴보고, 필요치 않은 객체를 배제합니다.

굳이 ESM으로 실행시켜야 하는 이유가 있나요?

(ESM의 장점이 많긴 하지만) CJS로도 프로젝트를 구성할 수 있습니다. 모종의 이유로 CJS를 선호하는 프로젝트나 기업도 존재하리라 생각합니다.

다만, ESM만 지원하는 라이브러리가 있다면 이야기는 달라집니다.
lowdb는 간단한 파일을 마치 DB가 관리하듯 조작해주는 라이브러리입니다. CJS를 지원하지 않으므로 ESM에서 동작하도록 변경해줘야 합니다.

js라면 그냥 전체적으로 ESM 변경 후 동작시키면 되겠지만, ts를 사용중이시라면 '실제 코드로 작성한 모듈 시스템'과 'js로 컴파일된 후 동작되는 모듈 시스템'이 다를 수 있습니다.

타입스크립트 컴파일러(TSC)는 .ts 파일을 .js로 변환합니다.
해당 과정에서 tsconfig.json 파일의 module을 통해 모듈 시스템을 지정합니다.
이 때 값이 'CommonJS'로 지정되어 있었다면 컴파일된 .js는 CJS 기반 모듈 내보내기/가져오기 코드로 구성됩니다.

ts-node 통해 실행하기

위에서 설명했듯 .ts 파일을 실행하려면 TSC를 통해 .js 파일로 컴파일한 후 해당 .js 파일을 실행시켜야 합니다.
스크립트를 간단하게 유지하기 위해 ts-node를 사용하겠습니다.

tsconfig.json 설정

해당 파일은 프로젝트 설정에 따라 변경되어야 하므로 참고만 하시기 바랍니다.

{
  "compilerOptions": {
    // 컴파일 버전 지정
    "target": "ESNext",
    // 모듈 시스템 지정
    "module": "NodeNext",
    // 모듈 해석 방식 지정
    "moduleResolution": "NodeNext",
    // CJS를 ESM처럼 다루게끔 설정
    "esModuleInterop": true,
    // 타입 엄격 체크
    "strict": true,
    // ts 확장자 허용
    "allowImportingTsExtensions": true,
    // 컴파일 시 js 파일 미생성
    "noEmit": true
  }
}

모듈 시스템을 ESNext로 지정하여 ESM이 가능하도록 했습니다.

ts-node-esm, ts-node --esm(Node v20 미만)

package.json에 ts-node를 이용한 스크립트를 만들어줍시다.
Node.js가 20버전 미만이라면 ts-node-esm을 사용할 수 있습니다.

"type": "module",
"scripts": {
  "dev": "ts-node-esm app.ts"
},

혹은 ts-node에 esm 플래그를 줄 수도 있습니다.

"type": "module",
"scripts": {
  "dev": "ts-node --esm app.ts"
},

node --loader ts-node/esm(Node v20 이상)

해당 정보에 도움을 주신 김영관님께 감사드립니다!

node v20 이상에서 기존의 방식인 ts-node로 실행하면 TypeError: Unknown file extension ".ts" for ...에러가 발생할 수 있습니다.

node 20 출시 문서를 보면, 사용자 정의 ESM 로더 후크가 메인 스레드와 격리된 전용 스레드에서 실행된다는 점을 알 수 있습니다. 해당 사항에 의해 기존의 ts-node를 직접 실행하는 것이 아닌 --loader 플래그를 통해 ts-node를 실행시킬 수 있습니다.

"type": "module",
"scripts": {
  "dev": "node --loader ts-node/esm app.ts"
},

위와 같이 작성하고 동작시키면 정상적으로 실행은 되지만

(node:18295) ExperimentalWarning: `--experimental-loader` may be removed in the future; instead use `register()`:
--import 'data:text/javascript,import { register } from "node:module"; import { pathToFileURL } from "node:url"; register("ts-node/esm", pathToFileURL("./"));'
(Use `node --trace-warnings ...` to show where the warning was created)

와 같은 경고 문구가 나옵니다.

경고 무시하기

--loader를 기입했는데 --experimental-loader 설명이 나오는 이유는 18.6.0 업데이트 시 깜빡하고 'experimental'를 빼먹어 '--loader'가 되었다고 하더라구요 ㅎㅎ.. (추후에 --experimental-loader도 추가했다고 합니다)

하지만 해당 옵션의 뜻처럼, 언젠가는 제거될 것이기에 register로 변환하라고 합니다.

경고를 무시하고 실행시키는 방법이 있습니다.

"type": "module",
"scripts": {
  "dev": "node --loader ts-node/esm --no-warnings app.ts"
},

경고가 뜨지는 않겠지만, 추후 문제가 생길 수 있겠습니다. 따라서 권장하는 사항대로 구성해봅시다.

register()로 변환하기

script로 추가하기

스크립트에 직접 해당 내용을 반영해보도록 하겠습니다.

"type": "module",
"scripts": {
  "dev": "node --import 'data:text/javascript,import { register } from \"node:module\"; import { pathToFileURL } from \"node:url\"; register(\"ts-node/esm\", pathToFileURL(\"./\"));' app.ts"
},

실행은 잘 되지만, 해당 스크립트가 굉장히 더러워져서 보기 불편하네요.

파일로 분리하기

ts-node-register를 따로 생성합시다.

import { register } from "node:module";
import { pathToFileURL } from "node:url";

register("ts-node/esm", pathToFileURL("./"));

그 후 위의 파일을 import해줍시다.

"type": "module",
"scripts": {
  "dev": "node --import ./ts-node-register.js app.ts"
},

깔끔하게 잘 동작하는 것을 확인할 수 있습니다.

번외

ESM 설정을 하면 .ts 파일을 못읽는다고 하고, .ts를 적용하려고 검색해보면 CJS로 변경하라는 글들이 보여서 어떻게든 해결해보고자 여기저기 참조한 내용을 정리했습니다.
이 외에도 더 좋은 해결책이 있으시다면, 공유해주시면 감사하겠습니다.

Reference

https://developer.mozilla.org/ko/docs/Glossary/Tree_shaking
https://blog.naver.com/dlaxodud2388/223126024138
https://velog.io/@pengoose_dev/CJS%EC%99%80-ESM

profile
나누며 타오르는 프로그래머, 타프입니다.

0개의 댓글

관련 채용 정보