ES Modules와 Node.js: 쉽지 않은 선택

Yosuke Furukawa는 Node.js 핵심 기여자이고
일본 Node.js 커뮤니티에서 가장 열정 있는 사람 중 하나입니다.

최근 Yosuke가 Node.js에서 ES Modules 지원에 대해 직면한 고민에 대해서
일본어로 글
작성했습니다. Node.js에서 ES Modules에 대한 의사결정에 포함된 복잡한 요소들을 설명하는 간결한
정보가 부족했으므로 Yosuke의 글을 영어로 번역해도 되는지 문의했습니다. Yosuke와 함께 글을 번역하고
현재 상황을 반영해서 문서를 갱신했습니다. 이 문서에서 유용한 정보를 얻기를 바랍니다.


ECMAScript 2015(ES2015, 이전에는 ES6)는 거의 1년 전에 발표되었습니다. Node.js v6는
ES2015 문법과 기능의 93%를 지원하고 대부분의 현대 브라우저는 90% 이상을 지원합니다. 하지만
ES Modules를 지원하는 JavaScript 런타임은 현재 없습니다.
(kangax의 호환성 표에는
아직 ES Modules 항목이 없습니다.)

ECMAScript 2015에 ES Modules가 정의되어 있지만 ECMAScript는 모듈을 런타임에 추가하는 방법을
결정하는 “Loader” 명세는 정의하지 않았습니다. Loader 명세는
WHATWG에서 정의하고 있지만, 아직 완료되지 않았습니다.

WHATWG Loader 명세는 로드맵의
마일스톤 0에서
다음 요소를 정의해야 합니다.

  • 이름 처리 (상대적, 절대적 URL과 경로)
  • Fetch 통합
  • script 태그를 설명하는 방법: <script type="module">
  • 메모이제이션 / 캐싱

Module 스크립트 태그는 정의되었지만, 다른 요소는
아직 논의 중입니다.
GitHub에서
이 논의의 상황을 볼 수 있습니다. 일부 브라우저는 구현하기 시작했지만, 대부분은 Loader 명세가
완료되기를 기다리고 있습니다.

왜 Node.js에서 ES Modules가 필요한가?

Node.js가 등장했을 때 ES Modules 제안은 없었습니다. Node.js는
CommonJS 모듈을 사용하기로 했습니다.
CommonJS 조직이 활발하게 진행되지 않는 상황에서 Node.js와 npm이 매우 큰 JavaScript 생태계를
만들려고 CommonJS 명세를 발전시켰습니다. Browserify와 더 최근에는
webpack이 CommonJS의 Node 버전을 브라우저로 가져옴으로써
모듈 문제를 잘 해결했습니다. 그 결과 Node/npm JavaScript 모듈 생태계는 서버와 클라이언트 모두에
적용되었고 빠르게 성장하고 있습니다.

하지만 지금과 같이 큰 생태계에서 표준 ES Modules와 CommonJS 형식의 모듈 간의 상호운용성을 어떻게
다루어야 할까요? ES Modules 명세 작업이 시작된 이후 이 질문에 대해 많은 논의가 이뤄졌습니다.

Browserify와 webpack은 현재 브라우저와 서버에서 JavaScript 개발을 쉽게 하는 다리 역할을 하고
있고 어느 정도는 통합되었습니다. 상호운용성을 잃어버린다면 기존의 생태계와 새로운 표준사이의 마찰이
증가할 것입니다. 프론트엔드 개발자가 기본으로 ES Modules를 선택하고 서버 사이드 엔지니어가 Node의
CommonJS를 계속 사용한다면 그 차이는 더 넓어질 것입니다.

Node.js의 상호운용성 제안

Bradley Farias(혹은 Bradley Meck)
CommonJS와 ES Modules의 상호운용성에 대한 제안을 작성했습니다. 이 제안은
Node.js EP(Enhancement Proposal) 형식으로 작성되어
풀 리퀘스트가 등록되어 많은 논의가 이뤄져서
제안을 다듬는 데 도움이 되었습니다. 이 EP는 머지되었지만 아직 DRAFT 상태로 남아있어서 Node.js에서
ES Modules를 구현하려는 명백한 의도보다는 선호를 나타낸다고 할 수 있습니다.
https://github.com/nodejs/node-eps/blob/master/002-es6-modules.md에서
제안을 볼 수 있습니다.

이 제안을 개발하는 동안 이뤄진 논의와 선택사항은 최초 풀 리퀘스트의 댓글에서 주로 볼 수 있지만,
부분적인 요약은
Node.js 위키에서
볼 수 있습니다.

Node.js의 가장 큰 도전은 해당 파일이 CommonJS 형식인지 ES Module인지를 알려주는
<script type="module"> 태그 같은 좋은 방법이 없다는 것입니다. 안타깝게도 Modules 명세에서
구별방법에 모호함이 있으므로 파일을 파싱하는 것만으로 해당 파일이 어떤 형식인지 모든 상황에서 알아낼
수가 없습니다. Node.js가 파일을 CommonJS(“Script”)로 로드할지 ES Module로 로드할지 결정하기
위한 어떤 신호가 필요하다는 것은 확실합니다.

의사 결정 과정에 적용된 다음과 같은 제약사항이 있습니다.

  • "상용문식의 세금"은 피합니다.(예시: "use module")
  • Modules와 Scripts를 다르게 파싱할 수 있는 이중 파싱은 피합니다.
  • JavaScript가 아닌 도구에서 이 결정을 하기 너무 어렵게 만들지 않습니다.
    (예시: Sprockets나 Bash 스크립트같은 툴 체인 작성)
  • 사용자에게 인지할만한 성능비용을 부과하지 않습니다. (예시: 큰 파일의 이중 파싱)
  • 모호함을 없앱니다.
  • 가능하면 자급자족합니다.
  • 가능하면 ES Modules가 가장 뛰어난 형식이 될 미래에 유물이 남지 않게 합니다.

가능한 선택사항을 고려할 때 이 제약사항 중 일부는 충돌하기 때문에 진행할 방향을 찾기 위해서
절충할 필요가 있습니다.

Node.js EP에서 선택한 방법과 Node.js CTC가 현재 받아들인 방법은 파일 확장자 .mjs
ES Modules를 구별하는 것입니다.(대안이었던 .es, .jsm은 여러 가지 이유로 제외되었습니다.)

파일확장자로 구별하는 방법은 JavaScript 파일에 작성된 내용을 결정하는 간단한 방법을 제공합니다.
파일의 확장자가 .mjs이면 파일은 ES Module로 로드될 것이고 .js 파일은 CommonJS를 통해
Script로 로드될 것입니다.

논의의 현재 상황

내부의 협업 과정에서 여러 가지 대안을 고려했지만 받아들여진 Bradley의 EP는 EP 과정 밖에서
가장 뛰어난 제안으로 받아들여졌습니다.
"In Defense of .js"라는
이름의 제안으로 넘어가서 이 제안은 새로 만든 파일 확장자 대신 package.json을 사용합니다.
이전에도 이 방법에 대해 논의하기는 했지만, 이 제안에는 몇 가지 흥미로운 부분이 추가되어 있습니다.

In Defense of .js 는 어떤 형식의 파일을 로드할지 결정하기 위해 requireimport
동일하게 적용되는 규칙을 제안합니다.

  • package.json"main" 필드는 있지만 "module" 필드가 없다면 패키지의
    모든 파일은 CommonJS로 로드합니다.
  • package.json"module" 필드는 있지만 "main" 필드가 없다면 패키지의
    모든 파일은 ES Modules로 로드합니다.
  • package.json"main""module" 필드가 둘 다 없다면 패키지의 파일은 CommonJS로
    로드할지 ES Modules로 로드할지를 index.js가 있는지 module.js가 있는지에 따릅니다.
  • package.json"main""module" 필드가 둘 다 있는 경우 "module" 필드에서
    어떤 경우에 ES Modules로 로드할지를 설명하지 않았다면 패키지의 파일은 CommonJS로 로드합니다.
    여기서는 디렉터리를 지정할 수도 있습니다.
  • package.json이 존재하지 않는다면(예: require('c:/foo')) 기본적으로 CommonJS로
    로드합니다.
  • package.json에 특수한 필드인 "modules.root" 필드가 있다면 지정한 디렉터리의 파일은
    ES Modules로 로드합니다. 추가로 패키지 내에서 상대적으로 로드된 파일은(예시:
    require('lodash/array')) 해당 디렉터리 내에서 로드합니다.

위의 예제는 패키지의 하위호환성을 유지하는 방법을 보여줍니다. Node.js의 구 버전에서
require('foo/bar')는 패키지의 루트에서 CommonJS bar.js를 찾습니다. 하지만 새로운
버전의 Node.js는 "modules.root": "lib"에서 'foo/bar' 로딩이 lib/bar.js에서
ES Module을 찾도록 지시합니다.

CommonJS와 ES Modules 모두 지원하기

Node.js EP와 In Defense of .js 를 포함한 대부분의 제안에서 Node.js의 구 버전과 신버전을
지원하는 패키지가 트랜스파일 메커니즘을 사용할 것이라고 가정하고 있습니다. .mjs 방법에서
ES Modules은 원래의 파일과 함께 .js 파일로 트랜스파일되고 Node.js의 다른 버전은 적합한 파일을
사용할 것입니다. In Defense of .js 에서 ES Modules는 "modules.root"에서 지정한
하위 디렉터리에 있고 이는 부모 디렉터리에서 CommonJS 형식으로 트랜스파일 될 것입니다. 게다가
package.json"main""module" 진입점을 모두 가질 것입니다.

쉽지 않은 선택

In Defense of .js 는 CommonJS에서 ES Modules로 갈아타야 한다는 시각을 가지고 있고 그
시점에 더 우선순위를 두고 있습니다. 반면에 Node.js EP는 호환성과 상호운용성에 더 우선순위를 두고
있습니다.

최근 Bradley가 이 어려운 선택에 대해서, 또 왜 파일 확장자가 앞으로를 위해서도 적절한 방법인지를 설명하는
글을 작성했습니다.
이 글에서 파일이 ES Modules인지 아닌지를 결정하려고 파일을 파싱하는 것이 불가능한 이유를 자세하게
설명했습니다. .js 파일의 내용이 어떤 형식인지를 결정하는 외부 서술자(예: package.json)가
가지는 어려음도 설명했습니다.

보편적인 .js 파일 확장자를 버리기를 고민하는 것은 슬픈 일이지만 이미 다른 언어는 이 방법을
사용하고 있는 것을 강조할 필요도 없습니다. 예를 들어, Perl은 Perl Script에 .pl을 사용하고
Perl Module에는 .pm을 사용합니다.

참여하기

Node.js CTC가 현재 형식의 EP를 받아들였고 Node.js에서 ES Modules를 구현하는 방법(Node.js에서
조금이라도 구현된다면)으로 선호하고 있지만 논의는 계속되고 있고 아직 변경될 가능성이 있습니다.
Node.js EP 저장소의 이슈 목록에서
이 토픽에 대해서 Node.js 커뮤니티와 토론할 수 있습니다. 고민하는 부분이 이미 논의되었는지를 보려면
첫 리뷰의 댓글을 보기 바랍니다.

Bradley와 the Node.js CTC는 모든 Node.js 사용자가 관심을 가지는 이 결정을 제대로 하기 위해서
고민하고 있습니다. ES Modules를 수용하기 위해 Node.js가 고민하는 선택은 어렵고
가볍게 다룰 수 없습니다.