ESM 전용으로 전환
3년 전, 저는 단일 패키지에서 ESM과 CJS를 함께 제공하는 것에 대한 글을 작성했습니다. 그 글에서는 사용자들의 원활한 마이그레이션을 위해 CJS/ESM 듀얼 포맷을 지지했으며, 두 가지 장점을 모두 활용하고자 했습니다. 당시 ESM 전용으로 공격적으로 전환하는 것에 완전히 동의하지 않았는데, 이는 생태계가 아직 준비되지 않았다고 판단했기 때문이었습니다. 특히 하위 레벨 라이브러리들이 이런 움직임을 주도하고 있었기 때문이었습니다. 시간이 지나면서 도구와 생태계가 발전함에 따라, 저의 관점은 점차 ESM 전용 방식을 채택하도록 변화하게 되었습니다.
ESM은 2025년 기준으로 2015년에 처음 도입된 이후로 10년이 지났습니다. 현대의 도구들과 라이브러리들은 점점 더 ESM을 주요 모듈 형식으로 채택하고 있습니다. WOOORM의 스크립트에 따르면, 2021년 npm에서 ESM을 제공하는 패키지는 7.8%였으며, 2024년 말에는 25.8%에 도달했습니다. 상당수의 패키지가 여전히 CJS를 사용하고 있지만, 이러한 추세는 ESM으로의 전환이 순조롭게 진행되고 있음을 보여주고 있습니다.
npm-esm-vs-cjs
스크립트로 생성되었으며, 2024-11-27에 마지막으로 업데이트되었습니다.이 글에서는 현재 생태계의 상태와 ESM 전용으로 전환해야 하는 이유에 대한 제 생각을 공유하고자 합니다.
도구들이 준비되었습니다
현대적인 도구들
Vite가 인기 있는 현대적 프런트엔드 빌드 도구로 부상하면서, Nuxt, SvelteKit, Astro, SolidStart, Remix, Storybook, Redwood 등 많은 메타 프레임워크들이 현재 Vite를 기반으로 구축되어 ESM을 최우선으로 지원하고 있습니다.
여기에 더해, 처음부터 ESM을 위해 설계된 Vitest라는 테스팅 라이브러리도 있으며, 이는 강력한 모듈 모킹 기능과 효율적인 세분화된 캐싱 지원을 제공합니다.
tsx와 jiti 같은 CLI 도구들은 추가 설정 없이도 타입스크립트와 ESM 코드를 실행할 수 있는 원활한 경험을 제공합니다. 이는 개발 프로세스를 단순화하고 ESM을 사용하기 위한 프로젝트 설정에 관련된 부담을 줄여줍니다.
추가로, ESLint와 같은 다른 도구들도 최근 v9.0에서 eslint.config.mjs
를 통해 CJS 프로젝트에서도 네이티브 ESM 지원을 가능하게 하는 새로운 플랫 설정 시스템을 도입했습니다.
하향식 & 상향식
2021년에 SINDRESORHUS가 find-up
과 execa
와 같은 자신의 모든 패키지를 ESM 전용으로 마이그레이션 하기 시작했을 때, 이는 대담한 움직임이었습니다. 이것은 상대적으로 로우레벨 패키지들이 많았고 이에 의존하는 많은 패키지들이 아직 ESM에 준비가 되어있지 않은 상황에서의 상향식(bottom-up) 접근이었다고 생각합니다. 저는 이러한 움직임이 의존성이 있는 패키지들을 구버전에 머무르게 강요하여 생태계가 분열될 수 있다고 우려했습니다. (오늘날에 와서는, 그 과정이 매우 순조롭지는 않았지만, 이 움직임이 우리에게 많은 고품질 ESM 패키지를 가져다준 것에 감사하고 있습니다).
ESM이나 듀얼 포맷 패키지가 CJS 패키지에 의존하는 것이 그 반대의 경우보다 훨씬 쉽습니다. 원활한 채택 측면에서, 저는 하향식(top-down) 접근이 생태계를 발전시키는 데 더 효과적이라고 믿습니다. 상위 레벨의 프레임워크와 도구들의 하향식 지원으로, ESM 전용 패키지를 사용하는 것은 더 이상 큰 장애물이 아닙니다. ESM 채택과 관련된 남은 과제들은 주로 패키지 작성자들이 그들의 코드를 ESM 형식으로 마이그레이션하고 배포하는 것에 있습니다.
Node.js에서 ESM 가져오기
JOYEECHEUNG가 시작한 Node.js에서 ESM 모듈을 require()할 수 있는 기능은 놀라운 이정표가 되었습니다. 이 기능은 패키지가 ESM 전용으로 배포되더라도 최소한의 수정으로 CJS 코드베이스에서 사용할 수 있게 해 줍니다. 이는 다이내믹 import()
ESM으로 인해 발생하는 비동기 영향 전파 문제(일명 Red Functions)을 피할 수 있게 해줘, 마이그레이션이 어렵거나 일부 경우 거의 불가능한 상황을 방지하는 데 도움이 됩니다.
이 기능은 최근에 정식 기능으로 전환되면서되어 Node.js v22(그리고 곧 v20)에 백포트되었습니다. 즉, 이미 많은 개발자가 사용할 수 있다는 것을 의미합니다. 하향식 또는 상향식 관점에서 보면, 이 기능은 실제로 ESM → CJS → ESM → CJS
와 같은 가져오기 체인이 원활하게 작동하도록 하여 중간에서부터(middle-out) ESM 마이그레이션을 시작할 수 있게 합니다.
이 기능의 진행 상황과 논의에 대한 자세한 내용은 이 이슈를 확인해 주세요.
듀얼 포맷의 문제점
듀얼 CJS/ESM 패키지가 매우 유용한 전환 메커니즘이었지만, 이들은 자체적인 여러 과제를 가지고 있습니다. 두 가지 별도의 포맷을 유지하는 것은 특히 복잡한 코드베이스를 다룰 때 번거롭고 오류가 발생하기 쉽습니다. 아래에서 듀얼 포맷을 유지할 때 발생하는 몇 가지 문제를 살펴보겠습니다.
상호 운용성 문제
근본적으로 CJS와 ESM은 서로 다른 설계 철학을 가진 다른 모듈 시스템입니다. Node.js가 ESM에서 CJS 모듈을 가져오고, CJS에서 ESM을 동적으로 가져오며, 심지어 ESM 모듈을 require()
할 수 있게 만들었지만, 여전히 상호운용성 문제를 일으킬 수 있는 까다로운 경우가 많이 있습니다.
한 가지 주요 차이점은 CJS가 일반적으로 단일 module.exports
객체를 사용하는 반면, ESM은 기본 내보내기와 명명된 내보내기를 모두 지원한다는 것입니다. ESM으로 코드를 작성하고 CJS로 트랜스파일할 때, 특히 내보내는 값이 함수나 클래스와 같은 비객체인 경우 내보내기를 처리하는 것이 특히 어려울 수 있습니다. 또한 타입을 올바르게 만들기 위해서는 .d.mts
와 .d.cts
선언 파일로 인한 추가적인 복잡성도 도입해야 합니다.
이 문제를 더 깊이 설명하려고 하다 보니, 사실 여러분이 이런 문제로 고민할 필요조차 없었으면 좋겠다는 생각이 들었습니다. 솔직히 너무 복잡하고 좌절스럽습니다. 만약 여러분이 단순히 패키지 사용자라면, 패키지 작성자들이 이 문제를 고민하도록 두세요. 이런 부분이 제가 전체 생태계가 ESM으로 전환하기를 지지하는 이유 중 하나입니다. 이러한 불필요한 번거로움에서 모든 사람을 해방시키고 이러한 문제를 뒤로하기 위해서입니다.
의존성 해결
패키지가 CJS와 ESM 포맷을 모두 가지고 있을 때, 의존성 해결이 복잡해질 수 있습니다. 예를 들어, 어떤 패키지가 ESM만 제공하는 다른 패키지에 의존한다면, 소비자는 ESM 버전이 사용되도록 보장해야 합니다. 이는 특히 전이 의존성을 다룰 때 버전 충돌과 의존성 해결 문제로 이어질 수 있습니다.
또한 싱글톤 패턴으로 사용되도록 설계된 패키지의 경우, 동일한 패키지의 여러 복사본이 도입되어 예상치 못한 동작이 발생할 수 있습니다.
패키지 크기
듀얼 포맷을 제공하는 것은 본질적으로 CJS와 ESM 번들을 모두 포함해야 하므로 패키지 크기가 두 배가 됩니다. 단일 패키지에서 몇 킬로바이트의 추가는 중요하지 않아 보일 수 있지만, 수백 개의 의존성을 가진 프로젝트에서는 오버헤드가 빠르게 증가하여 악명 높은 node_modules 비대화로 이어질 수 있습니다. 따라서 패키지 작성자는 패키지 크기에 주의를 기울여야 합니다. 특히 패키지가 CJS에 대한 강력한 요구사항이 없다면, ESM 전용으로 전환하는 것이 이를 최적화하는 방법입니다.
언제 ESM 전용으로 전환해야 할까요?
이 글은 듀얼 포맷 배포의 가치를 축소하려는 의도가 아닙니다. 대신, 현재 생태계의 상태와 ESM 전용으로 전환할 때의 잠재적 이점을 평가하도록 권장하고자 합니다.
ESM 전용으로 이동할지 결정할 때 고려해야 할 몇 가지 요소가 있습니다.
새로운 패키지
모든 새로운 패키지는 고려해야 할 레거시 의존성이 없으므로 ESM 전용으로 출시할 것을 강력히 권장합니다. 새로운 사용자들은 이미 현대적인 ESM 지원 스택을 사용하고 있을 가능성이 높기 때문에, ESM 전용이라고 해서 채택에 영향을 미치지 않을 것입니다. 또한, 단일 모듈 시스템을 유지하는 것은 개발을 단순화하고, 유지보수 부담을 줄이며, 향후 생태계의 발전으로부터 이점을 얻을 수 있도록 보장합니다.
브라우저 대상 패키지
패키지가 주로 브라우저를 대상으로 하는 경우, ESM 전용으로 제공하는 것이 매우 합리적입니다. 대부분의 경우 브라우저 패키지는 번들러를 거치게 되는데, ESM은 정적 분석과 트리 쉐이킹에서 상당한 이점을 제공합니다. 이렇게 만들어진 더 작고 최적화된 번들로 인해 최종 사용자들의 로딩 속도가 향상되고 대역폭 사용량도 줄어듭니다.
독립형 CLI
독립형 CLI 도구의 경우, 최종 사용자에게는 그것이 ESM이든 CJS든 차이가 없습니다. 하지만 ESM을 사용하면 의존성들도 ESM이 될 수 있어, 하향식 접근 방식으로 생태계의 ESM 전환을 촉진할 수 있습니다.
Node.js 지원
패키지가 최신 Node.js 버전을 대상으로 하는 경우, 특히 최근의 require(ESM) 지원과 함께 ESM 전용을 고려하기에 좋은 시기입니다.
사용자 파악하기
패키지가 이미 특정 사용자를 보유하고 있다면, 의존성이 있는 패키지들의 상태와 요구사항을 이해하는 것이 중요합니다. 예를 들어, ESLint v9가 필요한 ESLint 플러그인/유틸의 경우, ESLint v9의 새로운 설정 시스템이 CJS 프로젝트에서도 ESM을 기본적으로 지원하므로 ESM 전용이 되는 데 장애물이 없습니다.
물론 프로젝트마다 고려해야 할 요소가 다르지만, 전반적으로 더 많은 패키지가 ESM 전용으로 전환할 준비가 되었다고 생각하며, 전환의 이점과 잠재적인 과제들을 평가하기에 좋은 시기입니다.
우리는 어디까지 왔을까요?
ESM으로의 전환은 전체 생태계의 협력과 노력이 필요한 점진적인 과정입니다. 저는 우리가 좋은 방향으로 나아가고 있다고 믿습니다. ESM 채택의 투명성과 가시성을 개선하기 위해, 저는 최근 패키지의 의존성을 분석하기 위한 시각화 도구인 Node Modules Inspector를 만들었습니다. 이 도구는 여러분의 의존성들의 ESM 채택 상태에 대한 통찰력을 제공하고 ESM으로 마이그레이션 할 때 발생할 수 있는 잠재적 문제들을 식별하는 데 도움을 줍니다.
다음은 도구를 빠르게 이해하기 위한 몇 가지 스크린숏입니다.
이 도구는 아직 초기 단계에 있지만, 패키지 작성자와 유지 관리자들이 그들의 의존성의 ESM 채택 진행 상황을 추적하고 ESM 전용으로의 전환에 대한 정보에 입각한 결정을 내리는 데 유용한 리소스가 되기를 희망합니다.
도구의 사용 방법과 프로젝트 검사 방법에 대해 더 자세히 알아보시려면, node-modules-inspector 저장소를 확인해 주시기를 바랍니다.
앞으로 나아가기
저는 제가 유지보수하는 패키지들을 점진적으로 ESM 전용으로 전환하고 우리가 의존하는 의존성들을 더 자세히 살펴볼 계획입니다. 또한 Node Modules Inspector에 대해서도 더 유용한 통찰력을 제공하고 최선의 방법을 찾는데 도움이 되는 흥미로운 아이디어들이 많이 있습니다.
저는 더 이식성이 높고, 탄력적이며, 최적화된 자바스크립트/타입스크립트 생태계를 기대합니다.
이 글이 ESM 전용으로의 전환의 이점과 현재 생태계의 상태에 대해 어느 정도 이해를 도왔기를 바랍니다. 어떤 생각이나 질문이 있으시다면, 아래 링크를 통해 연락해 주시기 바랍니다. 읽어주셔서 감사합니다!