JavaScript Module, ESM과 CJS

woolee의 기록보관소·2022년 11월 27일
10

개발환경

목록 보기
13/17

module?

프로그래밍 관점에서 보자면,
"모듈(module)"은 역사적으로 본체로부터 독립된 하위 단위(독립적인 특성을 갖는 기능 단위의 부품)라는 큰 틀의 개념을 따르고 있지만, 본체와 모듈 간 가지고 있던 문제들을 해결해 나가는 과정에서 발전해왔다. 클래스, 라이브러리, 객체화, 캡슐화 등 모두 이러한 움직임의 일환일 것이다.

자바스크립트도 마찬가지였다. 초기 자바스크립트는 무거운 자바스크립트가 필요하지 않았지만 jquery의 등장과 함께 어플리케이션의 규모가 커지면서 문제가 발생했다.

자바스크립트로 코딩을 한다는 건 변수를 관리한다는 것 그 자체와 같다고 한다. 변수를 선언하고, 값을 할당하고, 연산한 값을 재할당한다든지. 변수의 개수가 적다면 문제가 되지 않지만, 자바스크립트 코드가 길어지면 이 변수들을 관리하기가 어렵다. 관련 있는 변수와 함수들을 따로 보관해야 했기에 모듈 개념이 필요했지만 자바스크립트는 그런 기능이 없었다. 이를 위해, 과거에는 IIFE, closure, scope 개념으로 모듈을 흉내를 냈었다.

하지만 Client-side JavaScript은 스크립트 태그를 사용해 외부 스크립트 파일을 가져오더라도 파일마다 독립적인 scope를 갖지 않고 전역 scope로 합쳐지므로 scope가 더러워지는 문제가 있었다. 자바스크립트를 브라우저 전용에 국한하지 않고 실행하기 위해선 파일 단위의 모듈화가 절실했고, 이런 상황에서 등장한 게 바로 CommonJSAMD(Asynchronous Module Definition)이다. CommonJS는 현재 사실상 js의 표준 모듈 시스템으로 불리고 있으며, Node.js는 이러한 CommonJS를 채택하고 있다.

물론 엄밀히 말하자면 CommonJS와 AMD는 js에서 공식적으로 지원하는 표준 모듈 시스템은 아니며 비공식적으로 만들어진 시스템이다. 그럼에도 이들은 자바스크립트의 모듈화 작업의 선두주자이다. 자바스크립트는 ES6 이후에야 공식적으로 문법 수준에서 모듈을 지원하기 시작했다. 이제 자바스크립트의 ESM(ES Modules)가 공식적이며 표준화된 모듈화 시스템이 되었다.

  • 그럼에도 여전히 node.js가 CJS(Common JS)를 채택하고 있으므로 CJS와 ESM은 잘 알아두어야 한다.

하지만 모든 브라우저에서 ES6 모듈을 지원하지는 않는다. 또한 ES6 모듈을 사용하려면 파일을 load할 때 ES 모듈 명세가 아닌 HTML 명세를 따르므로 브라우저와 무관하게 모듈을 사용하고 싶을 수 있다.

CommonsJS나 AMD, UMD 각각의 스타일에 맞게 개별적인 모듈 시스템을 사용해 파일 별로 모듈을 관리할 수 있지만, 이를 손쉽게 할 수 있도록 도와주는 모듈 번들러들의 도움을 받는 게 더 효율적일 것이다.

Browserify 또는 webpack과 같은 모듈 번들러들을 사용하면 보다 손쉽게 파일 별로 모듈을 관리할 수 있다.

ES Module 동작 과정

모듈을 사용해서 개발할 때 의존성 그래프를 생성한다. 서로 다른 파일 간 의존성 연결은 우리가 작성한 import 문에 의해 수행된다.

브라우저 또는 Node가 import 문을 통해 어떤 코드를 불러와야 할지 판단한다. 우리가 HTML 명세에 따라 지정한 파일은 그래프의 진입점(entry point)이 된다. 이후에는 import 문을 따라가면 되는 것이다.

예를 들어
(index.html)

<script type="module" src="app.js"></script>

(app.js)

import * as abc from "abc.js"
abc.func(10) // 100

(abc.js)

export function func(param) {
  return param * 10
}

참고로, Node는 HTML 태그를 사용하지 않아서 type 속성을 사용할 수 없다. 커뮤니티에서는 이를 해결하기 위한 방법 중 하나로 .mjs 확장자를 고안했다.

브라우저가 파일 자체를 사용할 수는 없다. 때문에 모듈 레코드(module record)라는 데이터 구조로 변환해야 하고, 이렇게 하려면 해당 파일들을 전부 구문분석해야 한다. (구성)

구문분석을 한 다음, 모듈 레코드를 모듈 인스턴스로 변환해야 한다. 모듈 인스턴스는 코드(명령어 집합)와 상태(모든 변수값) 이 2가지를 결합한다. 코드는 기본적으로 명령어의 집합일 뿐, 그 자체로는 아무 것도 할 수 없으며 이런 명령들이 쓰일 상태 값이 필요하기 때문이다. (인스턴스화)

인스턴스로 모두 변환하면 그때부터 코드를 실행해 값을 채워넣는 것이다. (평가)

이런 방식으로 각 모듈들에 대한 모듈 인스턴스가 필요하며, 모듈을 불러오는 과정은 entry 파일이 모듈 인스턴스의 전체 그래프를 그리는 과정이라고 할 수 있다.

⇒ ESM은 이렇게 3단계로 나뉘어 진행된다.

  1. (구성) : entry 파일에서 시작해 import 문을 찾아가면서 해당 모듈 파일들을 모듈 레코드로 구문분석한다.
  • 파일을 불러오는 건 로더(loader)인데, 이 로더는 ES 모듈 명세가 아니라 다른 명세로 구성되어 있다. 브라우저의 경우, HTML 명세를 따른다. 그러나 사용 중인 플랫폼에 따라 다른 로더를 가질 수 있다.
  • 로더는 모듈이 정확히 어떻게 불러와지는지도 제어한다. ES 모듈 메서드라고 불린다.
  1. (인스턴스화) : export된 값들을 메모리에 공간에 배치하기 위해 메모리 공간을 찾고, 가리키도록 한다. (메모리 공간에 실제 값들을 채워넣지는 않는다)
  2. (평가) : 코드를 실행해 메모리에 변수값들을 채워 넣는다. JS 엔진은 함수 외부 코드인 최상위 레벨 코드를 실행해 이를 수행한다. 다만 모듈은 한 번만 평가하도록 되어 있다. 평가 도중 모듈이 서버에 무언가를 요청하거나 할 때 부작용이 생길 수 있기 때문이다.

CJS와 ESM의 차이

ES modules: 만화로 보는 심층 탐구

  1. CJS는 동기적으로, ESM은 비동기적으로

CJS에서의 require()는 동기식으로 실행된다. promise나 callback을 반환하지 않는다. 네트워크 혹은 저장매체로부터 읽어오는 즉시 스크립트를 실행한다. 스스로 I/O나 부수효과(side effect)를 실행하고 module.exports에 설정되어 있는 값을 반환한다.

반면 ESM의 모듈 로더는 비동기적으로 실행된다. 먼저 가져온 스크립트를 실행하는 게 아니라 import 구문을 찾아서 스크립트를 파싱한다. 더 이상 import 할 스크립트가 없을 때까지 import를 찾은 뒤에 의존성 그래프를 생성하는 것이다. (깊이우선탐색을 수행한다)

물론 반드시 비동기적으로만 실행되는 건 아니고 무엇을 불러오느냐에 따라 동기식으로도 실행될 수 있다. 단지 모듈화 작업이 구성, 인스턴스화, 평가라는 3단계로 나뉘어져 있고 독립적으로 수행되기 때문에 비동기식으로 실행될 수 있는 것이다.

  1. CJS는 파일을 불러오는 동안 메인 스레드를 차단한다. 반면 ESM은 파일을 불러오는 동안 메인 스레드를 차단하지 않는다.

import 할 스트립트가 없을 때까지 import문을 찾은 뒤 의존성 그래프를 생성한다고 했는데, 메인 스레드가 이들 파일 각각을 다운로드할 때까지 대기해야 한다면 많은 작업이 대기열에 쌓이고 말 것이다. 이게 바로 브라우저에서 작업할 때 다운로드 시간이 긴 이유이기도 하다.
이처럼 메인 스레드를 차단하게 되면 실제로 어플리케이션에서 모듈을 사용하기엔 너무 느릴 것이다. 그래서 ES 모듈 명세는 알고리즘을 여러 단계로 나눠놓았다. 단계를 나누면 인스턴스화 작업을 동기적으로 처리하기 전에, 브라우저 파일을 불러오고 모듈 그래프를 개별적으로 구성할 수 있다.

반면 CJS는 파일 시스템에서 파일을 로드하므로 인터넷을 통해 다운로드 받는 것보다 시간이 훨씬 적게 든다. 그래서 Node는 ESM과 달리, 파일을 불러오는 동안 메인 스레드를 차단한다. 파일은 이미 로드되어 있으므로 인스턴스화와 평가를 바로바로 하면 된다. ESM이 3단계를 분리해놓은 것과 달리 CJS는 한번에 수행한다. CJS는 모듈 인스턴스화를 반환하기 전에 전체 트리를 순회하고 로드, 인스턴스화, 모든 의존성 평가를 수행한다.

ESM은 (평가)하기 전에 미리 전체 모듈 그래프를 작성해야 한다. 즉, 변수에 아직 값이 채워지지 않았으므로 모듈 지정자에 변수를 넣을 수 없다.

하지만 때로는 모듈 경로에 변수를 사용해야 유용한 때가 있기 마련이다. 이때 ESM에서는 동적 import를 사용하면 모듈 경로에 변수를 사용할 수 있다. import(\${path}/foo.js)

동적 import는 별개의 entry 파일로 취급되어 새로운 그래프를 그린다.

  1. CJS와 달리 ESM은 라이브 바인딩을 사용한다.

ESM에서 깊이우선탐색 방법으로 모듈 인스턴스화 과정이 진행될 때, 엔진은 모듈 하위의 모든 export(모듈이 의존하는 모든 export) 연결을 마무리짓는다. 즉, export들을 먼저 연결해서 import들이 모두 각각의 export들에 연결되는 것을 보장한다. 그래서 export 파일에서 값이 변경되면 ESM은 바로 반영된다.

반면 CJS는 전체 export 객체가 내보낼 때 복사한다. 즉, export하는 값들은 사본이다. 그래서 나중에 export하는 모듈의 해당 값이 변경되어도 그 모듈을 import하는 모듈은 해당 변경사항을 알 수 없다.

이는 순환 참조(A,B 모듈이 서로를 참조) 시에도 CJS와 ESM이 다르게 동작하도록 한다. 순환 참조시 CJS는 빈 객체를 반환하지만 ESM은 ReferenceError를 반환한다.

참고

모듈(프로그래밍)
ES modules: 만화로 보는 심층 탐구
모듈화와 npm(node package manager)
JavaScript 표준을 위한 움직임: CommonJS와 AMD

CommonJS와 ES Modules은 왜 함께 할 수 없는가?
Node Modules at War: Why CommonJS and ES Modules Can’t Get Along

The difference between commonjs and esm
ES modules: A cartoon deep-dive

profile
https://medium.com/@wooleejaan

0개의 댓글