도구 생태계의 성숙과 Node.js의 require(ESM) 도입을 배경으로, 듀얼 포맷의 복잡성을 줄이고 ESM 전용으로 전환할 것을 제안한다. 듀얼 포맷의 문제점, ESM-only로 전환하기 좋은 시점과 기준, 그리고 전환을 돕는 Node Modules Inspector를 소개한다.
3년 전, 나는 하나의 패키지에 ESM과 CJS를 함께 실어 배포하는 법에 대해 글을 썼다. 사용자 마이그레이션을 돕고 두 세계의 장점을 모두 취하자는 취지에서 듀얼 CJS/ESM 포맷을 옹호했었다. 그때만 해도 ESM 전용을 공격적으로 밀어붙이는 것에 전적으로 동의하진 않았다. 생태계가 아직 준비되지 않았다고 봤고, 특히 그 움직임이 주로 로우레벨 라이브러리에서 시작되었기 때문이다. 하지만 시간이 지나면서 도구와 생태계가 발전함에 따라, 나의 관점도 점점 ESM 전용을 채택하는 쪽으로 기울었다.
2025년 현재, 2015년에 ESM이 처음 소개된 지 10년이 지났다. 현대적 도구와 라이브러리는 점점 ESM을 기본 모듈 형식으로 채택하고 있다. WOOORM의 스크립트에 따르면, 2021년에 npm에서 ESM을 포함해 배포된 패키지는 **7.8%**였으나 2024년 말에는 25.8%까지 도달했다. 여전히 상당수 패키지가 CJS를 사용하지만, 추세는 분명 ESM 쪽으로 좋은 전환을 보여준다.
npm-esm-vs-cjs 스크립트로 생성한 ESM 채택 추이. 마지막 업데이트: 2024-11-27
이 글에서는 현재 생태계의 상태와 왜 이제 ESM 전용으로 넘어갈 때라고 믿는지에 대한 생각을 공유하려 한다.
현대 프런트엔드 빌드 도구로 Vite가 부상하면서, Nuxt, SvelteKit, Astro, SolidStart, Remix, Storybook, Redwood 등 많은 메타 프레임워크가 오늘날 Vite 위에 구축되고 있으며, ESM을 일급 시민으로 취급하고 있다.
보완적으로, 테스트 라이브러리 Vitest는 첫날부터 ESM을 염두에 두고 설계되었고, 강력한 모듈 모킹 기능과 효율적인 미세 단위 캐시를 지원한다.
tsx와 jiti 같은 CLI 도구는 추가 설정 없이 TypeScript와 ESM 코드를 실행할 수 있는 매끄러운 경험을 제공한다. 이는 개발 과정을 단순화하고, 프로젝트를 ESM으로 설정하는 데 따르는 오버헤드를 줄여준다.
다른 도구들도 마찬가지다. 예를 들어 ESLint는 최근 v9.0에서 새로운 플랫 구성 시스템을 도입해, CJS 프로젝트에서도 eslint.config.mjs로 네이티브 ESM을 지원한다.
2021년으로 거슬러 올라가면, SINDRESORHUS가 find-up, execa 같은 자신의 패키지들을 ESM 전용으로 마이그레이션하기 시작했을 때, 그것은 과감한 결정이었다. 나는 이 움직임을 보텀업 접근으로 본다. 비교적 로우레벨 패키지들이었고, 그 의존자들 중 다수가 아직 ESM을 쓸 준비가 되어 있지 않았기 때문이다. 그래서 그 의존자들이 해당 패키지의 구버전에 묶여 생태계가 분절될까 걱정했다. (지금 와서 보면, 과정이 아주 매끄럽진 않았더라도 고품질의 ESM 패키지를 꽤 많이 가져온 결정이었다는 점을 높이 평가한다.)
ESM 또는 듀얼 포맷 패키지가 CJS 패키지에 의존하는 것은 상대적으로 쉽지만, 그 반대는 쉽지 않다. 부드러운 채택이라는 측면에서, 나는 톱다운 접근이 생태계를 앞으로 밀어붙이는 데 더 효과적이라고 믿는다. 상위 레벨의 프레임워크와 도구가 톱다운으로 ESM을 지원하면서, 이제 ESM 전용 패키지를 사용하는 데 큰 장애물이 없다. 남은 과제는 주로 패키지 작성자들이 자신의 코드를 ESM 형식으로 마이그레이션하고 배포하는 일에 있다.
Node.js에서 ESM 모듈을 require()할 수 있는 기능은 JOYEECHEUNG이 주도했으며, 놀라운 이정표다. 이 기능 덕분에 패키지를 ESM 전용으로 배포하더라도 CJS 코드베이스에서 최소한의 수정만으로 소비할 수 있다. 이는 동적 import()로 ESM을 불러올 때 발생하는 비동기 전염(Async Infection) 문제(일명 Red Functions)를 피하는 데 도움을 준다. 이 문제는 경우에 따라 마이그레이션 및 적응이 매우 어렵거나 심지어 불가능할 수 있다.
이 기능은 최근 플래그가 해제되었고 Node.js v22에 백포트되었다(곧 v20에도 적용). 즉, 이미 많은 개발자가 사용할 수 있다는 뜻이다. 톱다운/보텀업 비유로 보자면, 이 기능 덕분에 ESM → CJS → ESM → CJS 같은 임포트 체인이 매끄럽게 작동하므로 **미들아웃(middle-out)**에서부터도 ESM 마이그레이션을 시작할 수 있게 되었다.
이 경우 CJS와 ESM 간 상호운용성 문제를 풀기 위해, Node.js는 또 ESM에서 CJS 호환 내보내기를 제공하는 새로운 문법 export { Foo as 'module.exports' }를 도입했다(해당 PR). 이를 통해 패키지 작성자는 ESM 전용으로 배포하면서도 CJS 소비자를 지원할 수 있고, (필요한 Node.js 버전이 바뀌는 점만 제외하면) 심지어 브레이킹 체인지 없이도 가능하다.
이 기능의 진척 상황과 논의에 대해 더 알고 싶다면 이 이슈를 따라가 보자.
듀얼 CJS/ESM 패키지는 과도기적 매커니즘으로 꽤 유용했지만, 고유한 문제도 안고 있다. 두 형식을 동시에 유지하는 일은 특히 복잡한 코드베이스에선 번거롭고 오류가 생기기 쉽다. 듀얼 포맷을 유지할 때 발생하는 몇 가지 이슈는 다음과 같다.
근본적으로 CJS와 ESM은 설계 철학이 다른 모듈 시스템이다. Node.js가 ESM에서 CJS 모듈을 임포트하고, CJS에서 ESM을 동적으로 임포트하며, 심지어 ESM을 require()할 수 있게 만들었지만, 여전히 까다로운 케이스들이 많아 상호운용성 문제가 발생한다.
핵심 차이 중 하나는 CJS는 보통 단일 module.exports 객체를 사용하는 반면, ESM은 기본(default)과 명명(named) 내보내기를 모두 지원한다는 점이다. ESM으로 코드를 작성한 뒤 CJS로 트랜스파일할 때, 내보내기 처리—특히 함수나 클래스처럼 객체가 아닌 값을 내보낼 때—가 매우 까다롭다. 타입을 올바르게 유지하려면 .d.mts와 .d.cts 선언 파일 같은 추가 복잡성도 필요하다. 등등…
이 문제를 더 깊이 설명하려다 보니, 사실 여러분이 아예 이런 문제로 고생하지 않길 바라게 된다. 정말 너무 복잡하고 피곤하다. 여러분이 단지 패키지 사용자라면, 이런 부분은 패키지 작성자에게 맡겨두고 신경 쓰지 않아도 됐으면 한다. 내가 생태계 전체가 ESM으로 전환하길 주장하는 이유 중 하나가 바로 이것이다. 이런 문제를 뒤로하고 모두가 불필요한 번거로움에서 벗어나길 바란다.
패키지가 CJS와 ESM 두 형식을 모두 제공하면 의존성 해석이 복잡해질 수 있다. 예컨대 어떤 패키지가 ESM 전용으로만 배포되는 다른 패키지에 의존한다면, 소비자는 ESM 버전이 사용되도록 보장해야 한다. 특히 추이적(transitive) 의존성을 다룰 때 버전 충돌과 의존성 해석 문제가 발생하기 쉽다.
또 싱글턴 패턴으로 설계된 패키지의 경우, 같은 패키지가 중복으로 포함되어 예기치 않은 동작을 일으킬 수도 있다.
듀얼 포맷을 배포한다는 건 사실상 패키지 크기를 두 배로 키운다는 뜻이다. CJS와 ESM 번들을 모두 포함해야 하기 때문이다. 개별 패키지에서 몇 KB쯤은 별것 아니어도, 수백 개의 의존성을 가진 프로젝트에선 오버헤드가 빠르게 누적되어 악명 높은 node_modules 비대화를 초래한다. 따라서 패키지 작성자는 자신의 패키지 크기에 주의를 기울여야 한다. 특히 CJS에 대한 강한 요구가 없다면 ESM 전용으로 가는 것이 좋은 최적화 방법이다.
이 글은 듀얼 포맷 배포의 가치를 깎아내리려는 의도가 아니다. 대신, 현재 생태계의 상태를 평가하고 ESM 전용으로 전환할 때 얻을 수 있는 이점을 고려해 보길 권하고자 한다.
ESM 전용으로 전환을 결정할 때 고려할 요소는 다음과 같다.
나는 모든 신규 패키지를 ESM 전용으로 배포하길 강력히 권한다. 레거시 의존성을 고려할 필요가 없기 때문이다. 새로 도입하는 사용자들은 이미 현대적이고 ESM 준비가 된 스택을 쓰는 경우가 많아, ESM 전용이라는 점이 채택에 큰 영향을 주지 않을 것이다. 또한 단일 모듈 시스템만 유지하면 개발이 단순해지고, 유지보수 오버헤드가 줄며, 향후 생태계 발전의 이점도 온전히 누릴 수 있다.
주로 브라우저를 대상으로 하는 패키지라면 ESM 전용으로 배포하는 것이 지극히 합리적이다. 대부분의 경우 브라우저 패키지는 번들러를 거치며, ESM은 정적 분석과 트리 셰이킹에서 큰 장점을 제공한다. 이는 더 작고 최적화된 번들을 만들어, 최종 사용자에게 로딩 성능 향상과 대역폭 절감으로 이어진다.
독립형 CLI 도구의 경우, 최종 사용자 입장에서는 ESM인지 CJS인지 차이를 느끼기 어렵다. 하지만 ESM을 사용하면 의존성들도 ESM을 채택하기 쉬워져, 톱다운 접근에서 생태계의 전환을 촉진할 수 있다.
패키지가 최신(evergreen) Node.js 버전을 대상으로 한다면, 특히 최근 도입된 require(ESM) 지원 덕분에 ESM 전용을 진지하게 고려할 때다.
이미 일정한 사용자가 있는 패키지라면, 의존자들의 상황과 요구를 이해하는 것이 중요하다. 예를 들어 ESLint v9를 요구하는 ESLint 플러그인/유틸의 경우, ESLint v9의 새 구성 시스템은 CJS 프로젝트에서도 ESM을 네이티브로 지원하므로, ESM 전용으로 가는 데 걸림돌이 없다.
물론 프로젝트마다 고려할 요소가 다르다. 하지만 전반적으로, 더 많은 패키지가 ESM 전용으로 옮겨갈 준비가 생태계에 되어 있다고 믿는다. 지금이 전환의 이점과 잠재적 과제를 평가하기 좋은 때다.
ESM으로의 전환은 생태계 전체의 협업과 노력이 필요한 점진적 과정이다. 우리는 좋은 흐름으로 나아가고 있다고 믿는다.
ESM 채택의 투명성과 가시성을 높이기 위해, 나는 최근 패키지 의존성을 분석하는 시각화 도구 Node Modules Inspector를 만들었다. 이 도구는 의존성들의 ESM 채택 상태를 파악하고, ESM으로 마이그레이션할 때의 잠재적 이슈를 식별하는 데 도움을 준다.
다음은 도구의 인상을 빠르게 전해 줄 스크린샷 몇 장이다.

Node Modules Inspector - 개요

Node Modules Inspector - 의존성 그래프

Node Modules Inspector - ESM 채택, 중복 패키지 등 리포트
이 도구는 아직 초기 단계지만, 패키지 작성자와 유지관리자가 의존성의 ESM 채택 상황을 추적하고, ESM 전용으로 전환하는 데 필요한 의사결정을 내리는 데 유용한 자료가 되길 바란다.
사용법과 프로젝트 점검 방법은 저장소 node-modules-inspector를 확인하자.
내가 관리하는 패키지들도 점진적으로 ESM 전용으로 전환하고, 우리가 의존하는 패키지들을 더 면밀히 살펴볼 계획이다. 또한 Node Modules Inspector에 대해 더 유용한 인사이트를 제공하고 최적의 경로를 찾는 데 도움이 되도록 흥미로운 아이디어가 많이 있다.
더 이식성 높고, 탄탄하며, 최적화된 JavaScript/TypeScript 생태계를 기대한다.
이 글이 ESM 전용으로 옮겨 가는 이점과 현재 생태계의 상태를 이해하는 데 도움이 되었기를 바란다. 의견이나 질문이 있다면 아래 링크로 언제든 연락해 주시길. 읽어줘서 고맙다!