보통 프론트엔드(웹 브라우저)개발을 할 때는 ES6문법을 기반으로 사용한다. 그렇기 때문에 CommonJS에 대해서는 관심을 안갖게 되곤 하는데, 왜 웹 브라우저에서는 CommonJS를 안쓰는지 (단순 레거시이기 때문인지) 차이점을 알아보자
우선 차이점을 알기전에 CommonJS와 ESModule의 탄생 배경에 대해 알 필요가 있다.
웹 브라우저 밖의 JS를 위한 모듈 생태계 규칙을 설립하기 위한 프로젝트로 Mozilla의 개발자 (Kevin Dangoor)에 의해 만들어졌다. 처음의 이름은 ServerJS라고 명명하며 단순히 브라우저에서만 사용하지 않고 API의 더 넓은 적용 가능성을 보여주기 위해 생성된 프로젝트다.
그렇기에 CommonJS는 원래의 이름 ServerJS에 맞게 서버 측 앱개발을 지원하는 모듈 포맷팅 시스템이 되었고, 추후 Node.JS의 모듈관리 방식에 큰 영향을 미치게 되었다.
ES 모듈은 이제는 Node.js의 기반이 되면서도 브라우저에서도 많이 사용하는 문법이여서 ESModule이 더이상 논란의 여지가 없음을 알지만 어떻게 동작하는지 이해하는 사람은 거의 없다.
JS 개발은 변수 관리에 관한 개발들을 많이 한다. 변수에 값을 할당하거나, 숫자 추가, 결합 후 다른 변수에 저장이 하는 역할의 전부이다.
그러기 때문에 한번에 몇가지 변수만 생각하는 작업은 쉽고, JS에서는 실행 범위 scope라는 개념까지 있기 때문에 그 범위가 동작하는 방식으로 인해 함수는 다른 함수에 정의된 변수에 access할 수 없다라는 장점 또한 가지고 있다.
이렇기 때문에 하나의 기능에서는 그 기능에만 생각할 수 있지만, 다른 함수간의 변수를 공유하기 어렵다라는 단점이 있어 일반적인 방법으로는 공유를 위해 전역 변수에 값을 넣음으로써 공유하지만 당연히 전역 변수를 통해서 여러 소스코드 간 변수를 공유하는 것은 자원 관점에서는 좋은 방법은 아니다.
그렇기 때문에 Module이라는 개념이 나와 CommonJS든 ESM이든 모듈이라는 개념을 사용하게 된다.
전역 변수가 아닌 특정 JS에서 코드를 만들고 require or import를 사용해 공유함으로써 script 순서가 아닌 서로간의 참조를 통해 코드의 일부를 먼저 가져오고 그 코드를 사용해 굳이 전역변수가 아니여도 사용이 가능하게끔 만들었다.
이 모듈은 명시적인 관계이기 때문에 다른 모듈을 제거하게 되면 제거된 모듈을 감지할 수 있고, export, import가 되기 때문에 독립적인 코드들로 분리가 매우 쉬워져 유지보수하기도 편하고 합치기도 편해질 수 있다.
모듈을 이용해 개발할 때는 종속성 그래프를 사용하게 되고 ESModule은 import문에서 종속성 간의 연결을 관리하기에 import 문은 브라우저나 노드에서 로드해야하는 코드가 무엇인지를 알고 사용이 가능한 진입점이 될 수 있다.
하지만 파일 자체는 브라우저에서 사용할 수 있는 것이 아니기 때문에 모듈 레코드라는 데이터 구조로 변환하기 위해 모든 파일의 구문을 분석해야만 실제로 파일에서 무슨 일이 일어나는 지 검증이 가능하다.
그 후 모듈 레코드를 인스턴스로 변환하는 작업을 거친다. 인스턴스는 코드와 상태 두가지를 결합하는 작업을 거치게 된다.
따라서 모듈 인스턴스는 코드와 상태를 결합하게 된다.
이렇게 된다면 모듈을 사용할 때 필요한 것은 각 모듈에 대한 모듈 인스턴스를 필요로 하게 된다. 이 모듈 로딩 프로세스는 진입점 파일에서 모듈 인스턴스의 전체 그래프를 갖는 것으로 진행된다.
ES 모듈은 3가지 단계로 나뉘어서 진행되게 된다.
ESModule은 위와 같은 과정을 거치다보니 비동기식으로 라고 말하곤 한다. 간단하게 판단해보면 구성, 인스턴스화 그리고 평가 이 3가지 과정으로 나뉘어있고, 독립적으로 수행될 수 있기 때문이다.
그말은 이 명세가 CommonJS에는 없는 종류의 비동기를 도입하는 것을 말한다. CJS는 모듈과 그 아래의 의존성이 로드되고, 인스턴스화되어 한꺼번에 모든 평가가 이루어지기 때문이다.
그렇다고 ESM에서 단계 자체가 반드시 비동기는 아니다. 무엇을 불러오는가에 따라 sync하게 수행할 수도 있는데, 모든 것이 ESM에 의해 제어되는 것은 아니기 때문이다. 실제로 작업은 2가지 방식으로 나뉘어 다른 명세로 이루어져 있다.
ESM 명세는 모듈 레코드에 파일을 구문 분석하는 방법과 인스턴스화의 방법 그리고 평가하는 방법 까지는 알려주지만 처음에 어떻게 얻는지는 말하고 있지 않는다.
파일은 Loader로 불러오게 되는데 다른 명세로 구성되어있고, 브라우저의 경우 HTML 명세를 따르고 그 외에 사용하는 플랫폼에 따라 다른 로더를 가질 수 있다.
구성 단계에서는 각 모듈에 대해 총 3가지의 일이 발생하게 된다.
로더는 파일을 찾아 다운로드하고 먼저 진입점 파일을 찾아야한다. HTML에서는 스크립트 태그를 통해 로더에게 어디서 다운로드할 것 인지 알려준다.
이후 import문을 통해 import 문의 한 부분(모듈 지정자)가 어디서 다음 모듈을 찾을 지 알려준다.
브라우저에서는 URL만을 모듈 지정자로 받아들이고 URL에서 모듈 파일을 로드하는 과정을 거치지만 모든 그래프에서 동시에 발생하지는 않는다. 파일을 구문 분석할 때 까지 모듈이 가져오는 의존성을 알 수 없고, 또한 가져올 때 까지 파일을 구문 분석 하는 것도 불가능하기 때문이다.
즉 하나의 파일을 구문분석 한 후 트리의 의존성을 파악하고 해당 의존성을 찾아 불러오는 과정을 거쳐야한다.
메인 스레드에서 이 파일 각각을 다운로드할 때 까지 대기해야하는 경우 많은 작업이 대기열에 쌓이게 된다. 이 현상으로 인해 브라우저에서 작업하는 경우 다운로드 시간이 가장 긴 이유가 된다.
그러기 때문에 메인 스레드를 차단하게 되는 경우 실제로 애플리케이션에서 모듈들을 사용하기에는 너무 느린 시간이 걸리기에 ESM 명세가 알고리즘을 여러 단계로 나눈 이유기도 하다. 구조 단계를 각 단계로 나누어 인스턴스화 작업을 동기적으로 처리하기 전 브라우저가 파일을 불러오고 모듈 그래프를 구성할 수 있게 된다.
위의 파일 탐색 및 가져오기 과정을 통해 ESM과 CommonJS의 차이점이 부각되게 되는데 CommonJS는 파일 시스템에서 파일을 로드하기 때문에 인터넷을 통해 다운로드하는 것보다 시간은 훨씬 적게들지만 Node에서는 파일을 불러오는 동안 주 스레드를 차단하는 것을 의미하기도 한다. 파일은 이미 로드되어 있으므로 바로 인스턴스화를 진행하고 평가하면 된다. (CommonJS에서는 분리된 단계는 아니다)
이 말은 즉슨 모듈 인스턴스를 반환하기 전 전체 트리를 순회하고 로드, 인스턴스 화 및 모든 의존성 평가를 하는 것을 의미하게 된다.
보통 commonJS에서 모듈을 가져오는 경우 require()문법을 변수에 할당하는 방식으로 사용한다.
// main.js
let item = require('test');
item.test('hi!');
// test.js
exports.test = function (testString) {
~~~~~
}
위에 방식을 활용하게 된다면 변수에서 외부 모듈을 불러오는 과정이 있기에 저 모듈을 가져오는 작업을 수행하게 될 때 변수에 값이 있음을 의미한다. (즉 값을 가져오는데 시간이 걸리게 된다)
반면에 ESM을 사용하게 되는 경우 평가를 하기 전 (마지막 단계 즉 변수에 값을 할당하는 단계) 전체 모듈 그래프를 미리 작성하게 된다. 변수에 값이 없기 때문에 모듈 지정자에 변수를 넣을 수가 없게 된다.
그렇기 때문에 ESM 문법에서는 다음과 같이 코드를 작성이 불가능하다.
require(`${path}/counter.js`).count; // 가능하다. 이미 path라는 변수를 가져온 상태로 진행하기 때문
import {count} from `${path}/counter.js`; // 불가능하다. path라는 변수를 지정하기 이전에 모듈 그래프를 그려야하는데 path라는 변수에 값이 할당되지 않았기 때문
이렇게만 보면 common-js에서 더 편의성이 있다고 볼 수 있다. 때로는 동적 할당이 필요한 경우도 있기 때문인데, ESM에서도 이 부분을 인지하고 대응해 동적 import라는 제안점을 주고 대응하게 해준다.
import(`${path}/foo.js`);
이 방식을 사용하게 된다면 import()를 통해 불러온 파일은 별개의 그래프 진입점으로 취급되기에 동적으로 import한 모듈은 새로운 그래프를 시작하고 별개로 처리되게 된다.
다만 주의해서 생각해야하는 부분은 동적 import의 그래프와 메인 그래프의 모두 있는 모듈은 모듈 인스턴스를 공유한다 라는 것이다. 이것은 로더가 모듈 인스턴스를 캐시하기 때문이고, 특정 전역 스코프의 각 모듈에는 하나의 인스턴스만 존재한다는 것을 의미한다.
그래도 캐싱 작업을 통해 엔진의 작업을 줄여줄 수 있다. 여러 모듈이 하나의 모듈에 의존하고 있어도 모듈 파일은 한번만 불러오기 때문에 캐싱을 하는 것이고 필요시 계속해서 호출하지 않아도 되기에 엄청난 이점이 된다.
이후 로더는 모듈맵을 이용해 캐시를 관리하고 전역으로 별도의 모듈 맵에서 모듈을 관리하는데, URL에서 가져올 때 모듈 맵에 넣고 파일을 가져오는 중인지 아닌지를 나타내면서 요청을 보내두고 다음 파일을 가져오는 방식으로 Map을 구성한다. 다른 모듈이 같은 파일에 의존하는 경우 모듈 맵에서 URL을 검색해서 가져오기에 불러오는 중이라면 다음 URL로 넘어가고 아니라면 붙이는 작업을 진행할 수 있다.
commonJS와 ESM의 차이는 Loader의 단계 중 2번째 파일을 가져오는 곳에서 차이가 발생했기에 파일을 가져오는 이후의 작업들에 대해 마저 알아보자
파일을 불러오는 작업은 끝났기에 그 다음으로는 모듈 레코드로 해석하는 작업을 해야한다. 이것을 통해 브라우저가 모듈의 다른 부분이 무엇인지 이해하게 해준다. 그리고 모듈 레코드가 만들어지고 나면 그 레코드는 모듈 맵에 추가되어 필요할 때 마다 로더가 모듈맵에서 가져올 수 있는 역할을 수행하게 된다.
이렇게 가져온 모듈들에는 큰 특징이 존재하는데 코드 상단에 use strict가 있는 것처럼 구문 분석이 된다라는 점이다. 하지만 조금 다르게 동작하는데 await문의 경우 모듈의 최상위 레벨의 코드에 예약어이며 이 키워드가 가진 값은 undefined가 된다.
이런 분석 방식을 parse goal(목표 분석)이라고도 부르는데 같은 파일이지만 다른 목표를 가지고 있고 다른 결과를 도출하기 때문에 이렇게 불리곤 한다. 어떤 목표인지를 명시하기 위해서는 script 태그 안에 type="module"이라는 옵션을 넣어 모듈화된 JS인지 아닌지를 구분시켜줄 수 있다.
node.js에서는 HTML 태그를 사용하지 않기에 .mjs와 .cjs를 사용해 esm인지 common-js인지 구분해준다. (https://nodejs.org/api/esm.html#import-specifiers node.js 22.3 기준)
참고글
https://hacks.mozilla.org/2018/03/es-modules-a-cartoon-deep-dive/