8분
ESM + TypeScript
들어가면서
자바스크립트의 모듈 시스템은 역사적으로 복잡하고 많은 변화가 있었습니다. 여기서는 모듈 시스템에 대해서 깊게 다루진 않겠지만 다른 분이 쓴 좋은 글을 공유합니다.
ES6와 함께 도입된 ES Module 시스템은 현재 우리가 가장 많이 사용하는 import
, export
구문을 사용합니다.
그에 비해 CommonJS 모듈 시스템은 require
, module.exports
구문을 사용합니다.
Node.js는 CommonJS 모듈 시스템을 선택하였습니다.
개발자가 Node.js에서 import
, export
와 같은 ES Module 구문으로 모듈을 작성하고 실행하면 에러가 발생합니다.
babel과 같은 트랜스파일러를 통해서 ES Module 구문을 CommonJS 구문으로 변환해야 합니다.
TypeScript도 별도의 설정 없이는 디폴트로 CommonJS 모듈 시스템으로 컴파일합니다.
Node.js도 12버전부터 ES Module 시스템을 지원하기 시작했습니다. 현재 LTS버전에서는 CJS 모듈 시스템과 ESM 모듈 시스템이 공존하고 있습니다.
CommonJS와 ES Module 간의 호출
최근 chalk 라이브러리를 사용한 적이 있었는데 이 라이브러리는 v5 부터는 ES Module로 작성된 package만 제공합니다.
그래서 CommonJS 모듈 시스템에서 require()
를 통해 이 라이브러리를 호출할 수 없습니다.
그래서 먼저 궁금했던 CJS와 ESM 사이의 호환성에 대해 알아보았습니다.
got 라이브러리 메인테이너인 sindresorhus가 작성한 ESM 가이드 파일은 ESM을 다룰 때 다양한 케이스를 설명하고 있습니다. 이 글에서도 위 gist 문서를 참고하였습니다.
CJS(CommonJS)에서 ESM(ES Module) 호출
CJS에서는 기본적으로 ESM 모듈을 호출할 수 없습니다.
우리는 이를 해결하기 위해서 어떤 방법이 있을까요?
- CJS를 사용하지 않고 ESM으로 사용. (권장)
package를 import하기 위해서require
대신에import
ESM 모듈 시스템을 사용하는 것이 가장 좋습니다. 그러기 위해서는package.json
파일에"type": "module"
을 추가해야 합니다. 또는.mjs
확장자를 사용하는 방법이 있지만 프로젝트에 ESM을 사용하기 위해서는package.json
에"type": "module"
을 추가하는 것이 좋습니다. - package가 비동기로 사용된다면, CJS에서 await import(...)를 사용할 수 있습니다.
- 본인 프로젝트를 ESM으로 옮기기 전까지 해당 package 버전을 업데이트하지 않고 유지.
오랫동안 사랑받는 Node.js의 여러 라이브러리는 기본적으로 CJS를 제공하였기에 기존 버전을 사용하는 것도 하나의 방법입니다. 그러나 추천하진 않습니다.
결론적으로, CJS에서 ESM으로 옮기는 것이 강력하게 추천됩니다.
ESM에서 CJS 호출
ESM에서는 CJS 모듈을 호출할 수 있습니다.
TypeScript에서 ESM 사용하기
자바스크립트와 Node.js에서 ESM을 사용하는 것은 복잡하고 헷갈리며, TypeScript와 번들러를 함께 사용하면 더욱 복잡해집니다.
그러나 Node.js가 ESM을 공식 지원하기 시작하면서 TypeScript도 이를 지원하게 되었습니다.
TypeScript 4.7 버전 이상에서는 공식 문서에서 ESM 사용에 대한 가이드를 제공합니다.
ESM 설정하기
Node.js 프로젝트에서 ESM을 사용하려면, package.json
에 "type": "module"
을 추가해야 합니다.
TypeScript에서는 ESM 지원을 위해 tsconfig.json
에 module
과 moduleResolution
의 새로운 설정인 node16
과 nodenext
를 사용합니다.
주의 사항
ESM 사용 시 고려해야 할 몇 가지 사항이 있습니다:
import/export
구문은 top-levelawait
를 사용할 수 있습니다- 상대 경로로 import할 경우에
.js
확장자를 명시해야 합니다. TypeScript 파일도 마찬가지로.js
확장자로 명시해야 합니다. (이러한 컨벤션이 조금 더 복잡하게 만드는게 아닌가 싶기도 합니다만...)
- CJS 모듈 시스템에서만 사용가능했던
require
구문은 ESM에서는 사용할 수 없습니다. 또한__dirname
과__filename
도 사용할 수 없습니다. 대신node:url
과node:path
를 이용해 사용할 수 있습니다. node:fs
와 같이 node: 프로토콜을 사용해 Node.js 모듈을 사용하는 것을 권장합니다.(이 이슈에서node:
프로토콜에 대한 몇 가지 장점을 설명합니다.)
TypeScript 5.0과 함께 --moduleResolution
옵션에 bundler
항목이 추가되었습니다. Vite, esbuild,
swc, Webpack, Parcel과 같은 최신 번들러를 사용하는 경우 이 새로운 옵션인 bundler
옵션이 적합할
것입니다.
여기서
더 자세하게 확인하세요.
Package.json
Node.js의 package.json 시스템은 복잡해 보일 수 있지만, 이 글을 통해 이해를 돕고자 합니다. 이 글에서는 Node.js 패키지의 엔트리 포인트를 지정하는 방법을 소개합니다.
해당 문서를 찾아보면 아래와 같은 내용을 볼 수 있습니다.
main
: 모든 Node.js 버전에서 엔트리 포인트로 사용되지만, 단일 엔트리 포인트만 지정할 수 있어 제한적입니다.exports
: main의 한계를 극복하고 다양한 엔트리 포인트를 지정할 수 있는 현대적인 방법입니다. exports 맵을 사용해 다양한 엔트리 포인트를 설정할 수 있습니다.
또는 다음과 같은 형태로 사용할 수 있습니다.
main
필드 대신 exports
필드를 사용하는 것이 권장되며, 하위 호환성을 위해 두 필드를 함께 사용하는 것도 가능합니다.
다양한 케이스가 문서에 있어서 참고하시면 도움이 될 것 같습니다.
Conditional Exports
Node.js는 exports
필드에서 조건부로 export하여 해당 프로젝트에서 모듈 시스템에 따라 import할 수 있도록 지원합니다.
exports
필드를 사용하면 프로젝트에 따라 적절한 모듈 시스템을 사용할 수 있습니다. 추가로, 비슷한 방식으로 동작하는 imports 필드도 참고하시면 도움이 될 것입니다.
ESM을 위한 VSCode 설정
최신 버전 VSCode, TypeScript를 사용하고 있다면 별다른 설정없이 ESM 프로젝트에서는
.js
확장자를 붙여줍니다.
최근 VSCode와 TypeScript버전에서는 별다른 설정을 하지 않아도 tsconfig.json
의 moduleResolution
에 따라 VSCode가 자동으로 .js
와 같은 확장자를 알아서 붙여줍니다.
자동으로 .js
확장자가 붙지 않는 경우
만약 자동으로 .js
확장자가 붙지 않는 경우에는, VSCode 설정을 조정해주면 됩니다.
아래와 같이 .vscode/settings.json
파일에 설정을 추가하면 확장자를 자동으로 붙여줍니다.
예제: 직접 라이브러리를 만들어보기
이 예제에서는 CJS와 ESM을 모두 제공하는 라이브러리를 생성하고, 이를 사용하는 CJS 및 ESM 프로젝트를 만들어봅니다. Rollup 번들러를 사용하며, 다음과 같은 버전의 도구들을 사용합니다.
- Node.js: v16.17.0
- TypeScript: v4.8.3
- yarn: v3.2.3
1. 초기 설정
먼저 Node.js와 TypeScript 설정을 진행합니다.
빌드 폴더는 lib
로 지정하고, 소스 코드는 src
폴더에 작성합니다.
가이드에 따라 package.json
과 tsconfig.json
을 설정합니다.
"type": "module"
을 선언하여 해당 프로젝트가 ESM을 사용하도록exports
필드를 사용하여 conditional export가 가능하도록main
필드를 사용하여 오래된 Node 버전을 가진 프로젝트를 위한 fallback- TypeSciprt를 위해
exports
필드와types
필드 적용
TypeScript 설정을 위한 tsconfig.json
을 다음과 같이 작성합니다.
TypeScript ESM 가이드 문서에 따라
module
필드와moduleResolution
필드를NodeNext
로 설정- declaration을 위해
outDir
필드와declaration
필드를 설
Rollup을 사용하여 CJS파일과 ESM파일을 생성하기 위해 빌드를 진행합니다.
declaration 파일은 번들러를 통하지 않고 tsc
사용하여 생성합니다.
2. 빌드
간단한 라이브러리 코드를 작성한 뒤 빌드합니다.
빌드 결과는 다음과 같습니다.
위 빌드 결과를 보면 CJS는 exports.getSlug
, ESM은 export { getSlug }
로 export되는 것을 확인할 수 있습니다.
3. 라이브러리 사용
CJS 프로젝트와 ESM 프로젝트를 각각 생성하고 라이브러리를 사용해봅니다.
CJS 프로젝트
package.json
과 tsconfig.json
을 다음과 같이 설정합니다.
package.json
에서 type
필드를 선언하지않으면 기본적으로 CJS로 인식합니다.
- 모노레포로 구성된 내부 프로젝트를 의존하고 있는 모듈을 참조, 빌드하기 위해
composite
옵션을 사용 module
과moduleResolution
옵션을node
로 설정하여 CJS로 인식하고 빌드
여기서는 따로 번들러를 쓰지 않고 tsc
로 빌드합니다.
require()
를 이용해 import하는 CJS로 빌드된 라이브러리를 사용하는 것을 확인할 수 있습니다.
ESM 프로젝트
src/index.ts
코드는 동일하게 작성되어있습니다.
package.json
과 tsconfig.json
을 다음과 같이 설정합니다.
- CJS 프로젝트와 마찬가지로
composite
옵션을 사용 module
과moduleResolution
옵션을NodeNext
로 설정하여 ESM으로 인식하고 빌드
마찬가지로 여기서도 tsc
로 빌드하여 실행합니다.
빌드 결과물을 확인해보면 import
를 이용해 import하는 ESM으로 빌드된 라이브러리를 사용하는 것을 확인할 수 있습니다.
마무리하며
이 글에서는 CommonJS 모듈 시스템과 ES Module 시스템에 대한 이해를 돕기 위해 간단한 예시를 통해 설명했습니다.
ERR_REQUIRE_ESM
와 같은 에러를 해결하는 데 어려움을 겪은 경험을 바탕으로, 이 문제를 근본적으로 이해하고자 노력했습니다.
이 과정에서 chalk
라이브러리를 시작점으로, ESM과 CJS 간의 차이와 각 시스템의 동작 원리를 더 깊이 있게 이해할 수 있었습니다.
최근에는 ESM만 지원하는 라이브러리가 점점 늘고 있는 추세입니다. 따라서 ESM에 대한 이해가 점점 더 중요해질 것으로 보입니다.
Node.js 커뮤니티에서도 ESM이 점차 자리잡으면서, 복잡하거나 번거로운 부분들이 개선될 것으로 기대할 수 있습니다. 이 글을 통해 ESM과 CJS의 차이점을 이해하는 데 도움이 되길 바랍니다.
Reference
- https://nodejs.org/api/modules.html
- https://nodejs.org/api/packages.html
- https://www.typescriptlang.org/docs/handbook/esm-node.html
- https://2ality.com/2021/06/typescript-esm-nodejs.html
- https://gist.github.com/sindresorhus/a39789f98801d908bbc7ff3ecc99d99c
마지막 업데이트
3/26/2023