Seonghyeon

ESM은 어떤 한계를 극복하기 위해 나왔을까?

개발

Rollup을 본격적으로 공부하기에 앞서 지난 글에서는 전역 스코프의 문제, CommonJS, AMD, ESM이 나오기까지 흐름을 알아봤다. 그럼에도 나는 ESM에 대한 이해가 부족하다고 느껴졌고, import/export가 대체 어떤 문제를 해결하기 위해 등장한 것인지 더 자세히 알고 싶어졌다.

즉시 실행 함수(IIFE)의 진짜 한계점은 뭘까

단지 매번 감싸기 번거롭고 코드가 지저분해서?

var 키워드의 전역 스코프의 문제를 해결하기 위해 즉시 실행 함수(IIFE)를 사용해 캡슐화를 했다고 하지만, 이 방식은 구체적으로 어떤 문제가 있었을까?

// a.js
var myModule = (function() {
  var privateVar = "private variable";

  return {
    publicMethod: function() {
      console.log(privateVar);
    }
  };
})();

myModule.publicMethod(); // Output: private variable

1. 결국 모듈은 전역 객체에 등록된다.

기본적으로 이 모듈 자체는 결국 전역객체에 등록되기 때문에, 언제든 아래처럼 덮어씌워질 수 있다는 문제가 생긴다. privateVar 같은 변수들은 모듈 안으로 숨겼지만, 모듈 변수명 자체는 여전히 window에 등록된다.

// b.js
var myModule = null;

2. 여전히 의존성의 순서를 제어할 수 없다. 🌟

IIFE 방식을 사용해도 모듈 간의 관계는 HTML의 <script> 태그가 선언된 순서에 따른다. 프로젝트 규모가 조금만 커져도 복잡해지기 때문에 이러한 방식을 모듈 시스템이라고 정의하기 어렵다. 개발자는 의존성을 암시적으로 생각할 수 밖에 없다.

의존 순서 문제를 해결하기 위한 방법

디펜던시를 관리하는 코드가 브라우저에서 실행되어서 각 파일에 명시된 디펜던시 파일들을 각각 다운로드 받고 실행한다.

말이 너무 어렵다. 쉽게 말하면 더 이상 <script>의 순서가 아닌 코드로 모듈의 의존성을 명시하겠다는 것이다. 주로 Require JS같은 AMD(Asynchronous Module Definition) 라이브러리가 이 방식을 사용했다. Require JS 코드를 살펴보면서 이해해보자.

define은 모듈을 정의하는 함수다.

// mathlib/sum.js
define([], function() {
  return function(a, b, c) {
    return a + b + c;
  };
});

cusotm-module 모듈을 실행하기 위해선 배열 안에 담긴 mathlib/sum 모듈이 필요하다는 뜻이다.

// cusotm-module.js
define(['mathlib/sum'], function(sum) {
  return { sum };
});

콜백 함수의 인자인 sum에 mathlib/sum 모듈의 반환값인 함수가 들어오고, sum이라는 이름으로 사용하려고 한다. 이렇게 외부 모듈에 의존하는 모듈을 정의할 수 있다.

define(['mathlib/sum'], function(sum) {
  
  var total = sum(1, 2, 3);
  
  return { 
    add10ToSum: total + 10
  };
});

AMD가 해결하지 못한 것

AMD(Asynchronous Module Definition)는 이름처럼 모듈을 비동기로 로드해야 한다는 니즈에서 나왔다. Common JS는 서버에서는 사용하기 좋았지만, 브라우저에서 모듈(스크립트 파일)을 서버에서 동기적으로 가져오게 되면 그 시간 동안 사용자는 흰 화면을 보면서 기다려야 하기 때문이다.

<script>는 기본적으로 HTML 파서를 블로킹한다. 스크립트를 다운 받는 동안 화면이 그려지지 않기 때문에 사용자는 흰 화면을 보게 된다. Require JS 라이브러리를 사용하면 HTML에 require.js 스크립트만 박아두고, 나머지 A.js, B.js 같이 필요한 모듈들은 document.createElement('script')를 통해 동적 스크립트로 삽입한다.

여기서 이런 궁금증이 생겼다. async인데 어떻게 의존 순서를 지킬 수 있을까? 동적 스크립트는 기본적으로 async=true로 동작하는데, async의 특징은 먼저 다운로드된 스크립트부터 실행된다는 것이다. 따라서 의존 순서를 제어하지 못한다. 하지만 AMD는 define의 콜백 안에 모듈을 가둬둔다. 그리고 의존성 지도가 모두 완성될 때까지 실행을 미루는 방식을 사용했다. 따라서 스크립트를 요청하면서도 순차적으로 실행하는 의존성을 지킬 수 있었다.

네트워크 Waterfall

AMD처럼 비동기로 스크립트를 로드하면 HTML 자체가 그려지는 건 문제가 없다. 하지만 스크립트가 또 다른 스크립트를 의존해 로드하고, 또 스크립트가 로드해야 할 때까지 기다리고.. 네트워크 Waterfall 현상이 생긴다. 초기 화면이 빠르게 그려지더라도 브라우저가 바빠서 TBT(Total Blocking Time)와 INP(Interaction to next paint) 병목이 생길 수 있다. 코드가 제대로 실행되는데 걸리는 시간은 동기나 비동기나 결국 똑같아진다.

ESM은 어떤 문제를 해결했을까?

ES6의 import를 써도 이 문제는 해결이 안되지 않을까? 어쨌든 스크립트를 로드하고, 그 스크립트가 의존하는 스크립트를 로드하는 게 반복되는데 말이다. 이 문제는 사실 ESM보다는 번들러가 해결한 문제다. 하지만 ESM은 번들러가 더 강력한 최적화를 제공할 수 있도록 설계도를 제공했다.

정적 분석이 가능해졌다

런타임에 의존성을 파악해야 했던 AMD와 달리, ES6의 모듈 시스템은 정적 분석이 가능해졌다. 브라우저가 HTML을 읽다가 <script type="module"> 를 만나면, 바로 실행하는 게 아니라 전체 import 구문들을 먼저 싹 훑는다. import를 파일 최상단에서만 쓸 수 있는 규칙도 이런 점 때문이다. 그리고 필요한 파일들을 병렬로 다운로드한 뒤에, 의존 지도가 완성되면 코드를 한번에 실행한다. 이것은 번들러의 트리 셰이킹과 연결된다.

스코프 문제를 해결하다

앞서 IIFE를 통해 함수 스코프로 제한했지만 결국 전역에 모듈 변수가 선언되어 있어야 하고, 의존 순서도 헷갈렸다. 하지만 ES 모듈은 변수와 함수들을 캡슐화하면서도 exportimport로 전역 변수 없이도 모듈 간 필요한 변수나 함수를 참조할 수 있다.

모듈 간에 변수를 내보내고 가져올 수 있게 되면 코드를 서로 독립적으로 작동하는 작은 단위로 나누는 것이 훨씬 쉬워집니다. 이러한 단위들을 레고 블록처럼 결합하고 재조합하여 동일한 모듈 세트에서 다양한 종류의 애플리케이션을 만들 수 있습니다. - ES modules: A cartoon deep-dive

다음 글에서는 ES 모듈이 어떻게 작동하는지 자세히 알아보고, Vite에서 프로덕션 빌드에 사용하는 Rollup이 어떻게 이런 작은 모듈들을 합치고, 트리쉐이킹하는지 알아보려고 한다.

레퍼런스

https://wikidocs.net/156289 https://hacks.mozilla.org/2018/03/es-modules-a-cartoon-deep-dive/