원문 : https://hacks.mozilla.org/2018/03/es-modules-a-cartoon-deep-dive/
ES 모듈은 자바스크립트의 공식적이며 표준화된 모둘 시스템이 되었다. 표준이 된 지는 얼마 지나지 않았지만 거의 10년간의 표준화 작업을 거쳤다.
이제 기다림은 거의 끝나간다. 5월에 Firefox 60(현재는 베타버전)이 출시되면 모든 브라우저가 ES 모듈을 지원할 것이다. 그리고 Node 모듈 작업 그룹은 현재 ES 모듈 지원을 추가하려고 노력하고 있고, WebAssembly를 위한 ES 모듈 지원또한 진행중이다.
많은 자바스크립트 개발자들은 ES 모듈이 논란의 여지가 있음을 알고 있다. 하지만 실제로 ES 모듈이 어떻게 동작하는지 이해하는 사람은 거의 없다.
그럼 이제 ES 모듈이 어떤 문제를 해결하는지, 다른 모듈 시스템과는 어떻게 다른지 살펴보자.
알다시피, 자바스크립트로 코딩하는 것은 변수를 관리하는 것 그 자체라고 볼 수 있다. 변수에 값을 할당하거나, 변수에 숫자를 더하는 것, 그리고 두 변수를 합쳐서 또 다른 변수에 넣는 것 등이다.
코드의 상당 부분은 변수를 변경하는 부분이 차지하기 때문에, 이런 변수들을 얼마나 잘 관리하는지가 여러분이 얼마나 코드를 잘 작성하는지, 그리고 얼마나 쉽게 유지 보수할 수 있는지에 많은 영향을 미친다.
한 번에 떠올릴 수 있는 몇 개의 변수들만 있다면 개발은 더욱 쉬울 것이다. 자바스크립트에서는 스코프라고 불리는 것을 통해 가능하다.
스코프를 사용하는 것은 좋다. 하나의 함수로 작업할 때 그 함수 하나만 생각하면 된다. 다른 함수가 변수에 어떤 영향을 미치는지 전혀 걱정할 필요가 없다.
하지만 다른 함수들 간에 변수를 공유하는 것을 어렵게 만든다는 단점을 가지고 있다.
만약 변수를 스코프 외부에서 공유하고 싶을 때는 어떻게 해야 할까? 일반적으로는 전역 스코프처럼 상위의 스코프에 변수를 배치하는 방법이 있을 것이다.
여러분은 아마 jQuery 시절을 떠올릴 것이다. jQuery 플러그인들이 로드되기 전에 jQuery가 전역 스코프에 존재해야 하는 것처럼 말이다.
이렇게 하면 해결은 된다. 하지만 조금 귀찮은 문제들이 생긴다.
먼저, 모든 스크립트 태그들을 올바른 순서로 정렬해야 한다. 그리고 여러분은 아무도 정렬해 놓은 스크립트 태그의 순서를 망치지 않도록 조심해야 한다.
만약 그 순서를 뒤섞는다면 여러분의 앱은 실행 중에 에러를 던질 것이다. 함수는 전역에 jQuery가 존재할 것으로 예상하였지만, jQuery는 전역에 존재하지 않았고, 그 함수는 에러를 던지고 실행을 멈출 것이다.
이런 점은 유지 보수를 까다롭게 만든다. 오래된 코드나 스크립트 태그를 제거하는 작업을 룰렛 게임을 하는 것처럼 만들 것이다. 여러분은 어떤 부분이 깨질지 모를 것이다. 그런 코드들 사이에서 다른 코드로의 의존성은 암시적이다. 모든 함수에서 전역 변수를 가져올 수 있으므로 어떤 함수가 어떤 스크립트에 의존성이 있는지 알 수 없다.
두 번째 문제는 이러한 변수들이 전역 스코프에 있기 때문에, 전역 스코프 내부 어디서든 해당 변수를 변경할 수 있다는 것이다. 악의적인 코드로 인해 사용자가 의도하지 않은 코드를 만들거나, 악의적이지 않은 코드라도 변수를 훼손할 수 있다.
모듈은 이러한 변수와 함수를 구성하는 더 좋은 방법을 제공한다. 모듈을 사용하면 관련이 있는 변수와 함수를 한데 모을 수 있다.
모듈은 함수와 변수를 모듈 스코프에 넣는다. 모듈 스코프를 통해 모듈의 함수 사이에서 변수를 공유할 수 있다.
하지만 함수 범위와 달리 모듈 범위는 변수를 다른 모듈에서도 사용할 수 있는 방법을 제공한다. 외부에서 어떤 변수나 클래스, 함수를 사용할지 명시적으로 알려줄 수 있다.
앞서 말한 것을 다른 모듈에서 사용할 수 있게 해주는 것이 바로 export다. 한번 export 하면, 다른 모듈에서 명시적으로 그 변수, 클래스, 함수를 의존한다고 할 수 있다.
이런 명시적인 관계로 인해, 한 모듈을 제거하면 어떤 모듈이 손상되는지 알 수 있다.
모듈 간에 변수를 내보내고(export) 가져올 수(import) 있게 되면, 코드들을 각각 독립적으로 작동할 수 있는 단위로 나누기 수월해진다. 그다음 레고 블록을 조립하듯 이런 코드 조각들을 조합해서 같은 모듈들의 모음으로 다양한 종류의 애플리케이션을 만들 수도 있다.
이처럼 모듈은 유용하므로 그동안 자바스크립트에 모듈의 기능을 도입하려는 노력이 여러 번 있었다. 현재 활발하게 사용되는 모듈 시스템은 두 가지가 있다. CommonJS(CJS)는 Node.js가 오래전부터 사용해오던 것이다. ESM(EcmaScript modules)은 자바스크립트 명세에 추가된 최신의 모듈 시스템이다. 브라우저는 이미 ES 모듈을 지원하며 Node.js는 ES 모듈 지원을 추가 중이다.
자 이제부터는 새로운 모듈 시스템이 어떻게 동작하는지 자세히 알아보자.
여러분이 모듈을 사용해서 개발할 때는 의존성 그래프를 작성할 것이다. 서로 다른 의존성 간의 연결은 여러분이 사용하는 import 문이 작성된 코드들에서 생긴다.
이런 import 문은 브라우저 또는 Node가 어떤 코드를 불러와야 하는지 인식하는 데 사용된다. 여러분이 지정한 파일은 그래프의 진입점(entry point)이 된다. 나머지 코드들을 찾으려면 import 문을 따라가면 된다.
하지만 파일 자체는 브라우저가 사용할 수 있는 것이 아니다. 모듈 레코드(Module Record)라고 하는 데이터 구조로 변환하려면 이 파일들을 모두 구문분석해야 한다. 이를 통해 실제로 파일 안에서 어떤 일이 일어나는지 알 수 있다.
그런 다음, 모듈 레코드를 모듈 인스턴스로 변환해야 한다. 인스턴스는 두 가지를 결합한다: 코드와 상태
코드는 기본적으로 명령어의 집합이다. 무언가를 만드는 제조법과 비슷하다. 하지만 그 자체로는 코드를 사용해서 아무것도 할 수 없다. 이런 명령들에 사용할 원료가 필요하다.
상태(state)는 원료를 제공해준다. 상태는 그 시점의 실제 변숫값이다. 물론 변수는 값을 가진 메모리의 공간 이름에 불과하다.
따라서 모듈 인스턴스는 코드(명령어 목록)와 상태(모든 변숫값들)를 결합한다.
각 모듈에 대한 모듈 인스턴스가 필요하다. 모듈을 불러오는 과정은 entry 파일이 모듈 인스턴스의 전체 그래프를 그리는 것으로 진행된다.
ES 모듈의 경우 세 가지 단계로 나뉘어 진행된다.
사람들은 ES 모듈이 비동기식이라고 말한다. 간단히 생각해보면 모듈화 작업은 구성, 인스턴스 화 및 평가 세 단계로 나뉘어있으며 독립적으로 수행될 수 있기 때문이다.
이것은 이 명세가 CommonJS에 없는 종류의 비동기를 도입하는 것을 의미한다. 이후에 자세히 설명하겠지만 CJS에서는 모듈과 그 아래의 의존성이 로드되고, 인스턴스 화 되어 한꺼번에 모든 평가가 이루어진다.
그러나 단계 자체가 반드시 비동기는 아니다. 무엇을 불러오느냐에 따라 동기식으로 수행할 수 있다. 왜냐하면 모든 것이 ES 모듈 명세에 의해 제어되지 않기 때문이다. 실제로 작업은 두 가지로 나뉘어있는데, 서로 다른 명세로 이루어져 있다.
ES 모듈 명세는 모듈 레코드에 파일을 구문분석하는 방법과 인스턴스 화 하는 방법, 그리고 그 모듈을 평가하는 방법을 알려준다. 하지만 파일을 처음에 어떻게 얻는지는 말하고 있지 않다.
파일을 불러오는 것은 로더(loader)이다. 로더는 다른 명세로 구성되어있다. 브라우저의 경우 HTML 명세를 따른다. 그러나 사용 중인 플랫폼에 따라 다른 로더를 가질 수 있다.
로더는 모듈이 정확히 어떻게 불러와 지는지도 제어한다. 이것은 ES 모듈 메소드(ParseModule, Module.Instantiate, Module.Evaluate)라고 불린다. 마치 자바스크립트 엔진의 문자열을 제어하는 인형사와 비슷하다.
이제 각 단계를 자세히 살펴보자.
구성 단계에서 각 모듈에 대해 세 가지 일이 일어난다.
로더는 파일을 찾아서 다운로드한다. 그러기 위해서는 먼저 진입점 파일을 찾아야 한다. HTML에서는 스크립트 태그를 사용해서 로더에게 어디에서 진입점 파일을 찾을 수 있는지 알려준다.
하지만 다음 모듈들의 묶음을 어떻게 찾을 수 있을까?
이제 import 문이 올 차례다. import 문의 한 부분을 모듈 지정자라고 한다. 이것은 로더가 어디서 다음 모듈을 찾아야 할지 알려준다.
모듈 지정자에서 주의해야 할 사항: 가끔 브라우저와 Node 간에 다르게 처리해야 할 때가 있다. 각 호스트는 모듈 지정자 문자열을 해석하는 고유한 방법을 가지고 있다. 이를 위해 플랫폼 간에 다른 모듈 해결(Module Resolution)알고리즘이라는 것을 사용한다. 현재 몇 가지 모듈 지정자는 Node에서는 동작하지만, 브라우저에서는 동작하지 않는다. 이 문제는 지금 해결하려고 노력 중이다
이것이 수정 될 때까지 브라우저는 URL만을 모듈 지정자로 받아들인다. 해당 URL에서 모듈 파일을 로드한다. 하지만 모든 그래프에서 동시에 발생하지는 않는다. 파일을 구문분석할 때까지 모듈이 가져와야 하는 의존성을 알 수 없고, 파일을 가져올 때까지 파일을 구문분석할 수도 없다.
즉, 하나의 파일을 구문분석한 다음 트리의 의존성을 파악하고, 해당 의존성을 찾아 불러와야 한다.
주 스레드가 이들 파일 각각을 다운로드할 때까지 대기해야 한다면 많은 작업이 대기열에 쌓일 것이다.
이것이 바로 브라우저에서 작업할 때 다운로드 시간이 가장 긴 이유이다.
이처럼 주 스레드를 차단하게 된다면 실제로 애플리케이션에서 모듈을 사용하기엔 너무 느릴 것이다. 이것이 ES 모듈 명세가 알고리즘을 여러 단계로 나눠놓은 이유이다. 구조 단계를 각 단계로 나눈다면 인스턴스 화 작업을 동기적으로 처리하기 전에, 브라우저가 파일을 불러오고 모듈 그래프를 구성할 수 있다.
각 단계를 나누는 알고리즘을 사용하는 접근법은 ES 모듈과 CommonJS모듈 간의 주요 차이점 중 하나이다.
CommonJS는 파일 시스템에서 파일을 로드하므로 인터넷을 통해 다운로드 하는 것보다 시간이 훨씬 적게 든다. 이 말은 Node는 파일을 불러오는 동안 주 스레드를 차단한다는 것을 의미한다. 그리고 이 때문에 파일은 이미 로드되어 있으므로 바로 인스턴스 화 하고 평가하면 된다(CommonJS에서는 분리된 단계가 아니다). 이것은 또한 모듈 인스턴스를 반환하기 전에 전체 트리를 순회하고 로드, 인스턴스 화 및 모든 의존성 평가를 한다는 것을 의미한다.
CommonJS 접근법 중 몇 가지는 함축되어 있으며 나중에 더 자세히 설명하겠다. 하지만 이것이 의미하는 한 가지는 CommonJS 모듈을 사용하는 Node에서 모듈 지정자에 변수를 사용할 수 있다는 것이다. 다음 모듈을 보기 전에 현재 모듈의 모든 코드(require
문 까지)가 실행된다. 즉 모듈 resolution을 수행할 때 변수에 값이 있음을 의미한다.
하지만 ES 모듈을 사용하면 평가를 하기 전에 미리 전체 모듈 그래프를 작성해야 한다. 즉, 변수에 아직 값이 없으므로 모듈 지정자에 변수를 넣을 수 없다.
그러나 때로는 모듈 경로에 변수를 사용하는 것이 유용하다. 예를 들면, 코드가 수행 중인 작업이나 실행 중인 환경에 따라 로드할 모듈을 전환할 수 있다.
ES모듈에서 이것을 가능케 하는 동적 import라는 제안이 있다. 이를 사용하면 다음과 같이 사용할 수 있다. import(\
${path}/foo.js)`
이것이 동작하는 방식은 import()를 통해 불러온 파일은 별개의 그래프의 진입점으로 취급된다. 동적으로 import 한 모듈은 새로운 그래프를 시작하고 별개로 처리된다.
한가지 주의해야 할 점은, 이 두 그래프에 모두 있는 모듈은 모듈 인스턴스를 공유한다는 점이다. 이는 로더가 모듈 인스턴스를 캐시 하기 때문이다. 특정 전역 스코프의 각 모듈에는 하나의 모듈 인스턴스만 존재한다.
이것은 엔진의 작업을 줄여준다. 예를 들어, 여러 모듈이 해당 모듈에 의존하고 있어도 모듈 파일은 오로지 한번 불러 들여진다이것은 모듈을 캐시 하는 이유 중 한가지이다. 평가 부문에서 다른 이유를 설명할 것이다.)
로더는 모듈맵을 이용해서 캐시를 관리한다. 각 전역은 별도의 모듈 맵에서 모듈을 관리한다.
로더가 URL에서 가져올 때, 해당 URL을 모듈 맵에 넣고 현재 파일을 가져오는 중임을 나타낸다. 요청을 보내고 나서 다음 파일을 가져오기 시작한다.
다른 모듈이 같은 파일에 의존하면 어떻게 될까? 로더는 모듈 맵에서 각 URL을 검색한다. 만약 불러오는 중임을 확인하면 다음 URL로 넘어갈 것이다.
그러나 모듈 맵은 가져오는 파일을 추적하지 않는다. 다음 장에서 보겠지만, 모듈맵은 모듈에 대한 캐시로도 사용된다.
이제 파일 불러오기는 끝났다. 다음으로는 그걸 모듈 레코드로 해석해야 한다. 이를 통해 브라우저가 모듈의 다른 부분이 무엇인지 이해하게 해준다.
모듈 레코드가 한번 만들어지고 나면, 그 레코드는 모듈맵에 추가된다. 그다음부터는 필요할 때 마다 로더가 모듈맵에서 가져올 수 있다는 것을 의미한다.
실제로는 큰 의미가 있는 사소한 특징이 한가지 있다. 모든 모듈은 코드 상단에 "use strict"
가 있는 것처럼 구문분석 된다. 그리고 조금 다른 점을 가지고 있다. 예를 들어, await
문은 모듈의 최상위 레벨의 코드에 예약어이며, 이 키워드가 가진 값은 undefined
다.
이런 구문분석의 다른 점을 구문분석 목표라고 한다. 같은 파일을 파싱하더라도 다른 목표를 사용하면 결과가 달라진다. 따라서 모듈인지 아닌지에 관계없이 구문분석할 파일의 종류를 구문분석하기 전에 알아야 한다.
브라우저에서는 이를 처리하기 매우 쉽다. 그저 type="module"
을 스크립트 태그 내부에 적어두기만 하면 된다. 이를 통해 브라우저에 이 파일은 모듈로 구문분석 되어야 한다고 알려주는 것이다. 이런 방법으로는 모듈만 가져올 수 있기 때문에 브라우저는 이것이 모듈이라고 알고 수행하게 된다.
그러나 Node에서는 HTML 태그를 사용하지 않으므로 type
속성을 사용할 수 있는 옵션이 없다. 커뮤니티는 이를 해결하기 위한 한 가지 방법으로 .mjs
확장자를 고안했다. "이 확장자를 사용하는 파일은 모듈이다"라고 Node에 알려주는 것이다. 사람들은 이것을 구문분석 목표의 신호라고 말할 것이다. 이런 논의는 아직 진행 중이다. 아직 Node 커뮤니티에서 어떤 신호를 사용할지는 정해지지 않았다.
어느 쪽이든, 로더는 파일을 모듈로 구문 분석할지를 결정한다. 어떤 파일이 모듈이며 import 문이 있다면 모든 파일을 불러와서 구문분석하는 단계를 계속 수행한다.
그렇다면 이제 거의 끝났다. 불러오기 단계가 끝나면, 우리는 진입점 파일부터 시작해서 모듈 레코드의 묶음을 얻게 된다.
다음 단계는 이 모듈을 인스턴스 화 하고 모든 인스턴스를 함께 연결하는 것이다.
앞서 언급했듯이 인스턴스는 코드와 상태를 결합한다. 상태는 메모리에 있으므로 인스턴스 화 단계는 모든 것을 메모리에 연결하는 것이라고 할 수 있다.
먼저, JS 엔진은 모듈 환경 레코드를 생성한다. 이를 통해 모듈 레코드의 변수를 관리한다. 그다음 모든 export에 대해 메모리에 있는 상자를 찾는다. 모듈 환경 레코드는 각 export와 연관된 메모리의 상자를 추적한다.
메모리에 있는 이 상자들은 아직 값을 가지지 않으며, 평가가 끝난 후에 실제 값이 채워진다. 이 규칙에는 한가지 주의사항이 있다. export 된 함수 선언은 이 단계에서 초기화된다. 이를 통해 평가가 더 쉬워진다.
모듈 그래프를 인스턴스 화하기 위해 엔진은 깊이 우선 순회를 수행한다. 이는 그래프의 맨 아래, 즉 다른 것에 의존하지 않는 최하단의 의존성까지 조사한 뒤 해당 export를 설정한다.
엔진은 모듈 하위의 모든 export(모듈이 의존하는 모든 export) 연결을 마무리한다. 그다음 해당 모듈에서 import 한 항목들을 연결하기 위해 수준을 하나 올린다.
한 모듈에 대한 export와 import는 같은 메모리의 주소를 가리키는 점을 주목하자. export들을 먼저 연결해서 import들이 모두 각각의 export들에 연결되는 것을 보장한다.
이것은 CommonJS 모듈과 다르다. CommonJS에서는 전체 export 객체가 내보낼 때 복사된다. 즉, export하는 값(예 : 숫자)은 사본이다.
즉, 나중에 export 하는 모듈이 해당 값을 변경하면, 그 모듈을 import 하는 모듈은 해당 변경 사항을 알 수 없다.
반대로 ES 모듈에서는 라이브 바인딩이라고 하는 것을 사용한다. 두 모듈 모두 메모리의 같은 위치를 가리킨다. 즉, export 한 모듈에서 값을 변경하면 해당 변경 내용이 import 한 모듈에 표시된다.
값을 export 하는 모듈은 언제든지 값을 변경할 수 있지만, import 하는 모듈은 가져온 값을 변경할 수 없다. 만일 모듈이 객체를 가져오는 경우에는 해당 객체에 있는 속성값은 변경할 수 있다.
이처럼 라이브 바인딩을 사용하는 이유는 코드를 실행하지 않고 모든 모듈을 연결할 수 있기 때문이다. 이는 순환 의존성을 가질 때 평가 과정에 도움이 된다. 아래에서 자세히 설명하겠다.
따라서 이 단계가 끝나면 export/import 한 변수에 대한 모든 인스턴스와 메모리 위치가 연결된다.
자 이제 메모리 주소에 각각의 값을 채우기 위해 코드를 평가할 차례이다.
마지막 단계는 이 메모리 상자들을 채우는 것이다. JS 엔진은 함수 외부 코드인 최상위 레벨 코드를 실행하여 이를 수행한다.
메모리의 상자들을 채우는 것 외에도 코드를 평가하면 부작용이 발생할 수 있다. 예를 들면, 평가 도중에 모듈이 서버에 무언가를 요청할 수도 있다.
혹시나 생길 수 있는 부작용 가능성 때문에 모듈은 한 번만 평가하도록 한다. 인스턴스 화에서 생성되는 연결이 여러 번 수행하더라도 정확히 같은 것과는 대조적으로, 평가는 수행한 횟수에 따라 다른 결과를 가질 수 있다.
이것이 모듈 맵을 사용하는 한 가지 이유이다. 모듈 맵은 표준 URL로 모듈을 캐시 하므로 각 모듈에 대해 하나의 모듈 레코드만 있다. 이렇게 하면 각 모듈이 한 번만 실행된다. 인스턴스 화와 마찬가지로 깊이 우선 탐색 순회로 수행된다.
그렇다면 우리가 이전에 이야기했던 순환 문제는 어떨까?
순환이 있는 의존성에서는 그래프에 루프가 생기고 일반적으로는 긴 루프이다. 하지만 문제점을 설명하기 위해 짧은 루프로 만들어진 예제를 보자.
이것이 CommonJS 모듈에서 어떻게 작동하는지 살펴보자. 먼저 main 모듈은 require 문까지 실행한다. 그다음 counter 모듈을 로드한다.
그런 다음 counter 모듈은 export 객체의 message에 접근을 시도한다. 그러나 이것이 아직 main 모듈에서 평가되지 않았으므로, 이것은 undefined를 반환할 것이다. JS 엔진은 지역 변수에 대한 메모리 공간을 할당하고 값을 undefined로 설정한다.
평가는 counter 모듈의 최상위 레벨 코드 끝까지 계속된다. 우리가 확인하고 싶은 것은 마지막에 message에 올바른 값이 저장되는지(main.js가 평가된 후에) 이다. 그러므로 우리는 setTimeout() 코드를 추가한다. 그리고 main.js의 평가는 계속된다.
message 변수가 초기화되어 메모리에 추가된다. 하지만 둘 사이에 아무런 연결이 없으므로 불러온 모듈에서는 undefined 상태 그대로다.
라이브 바인딩을 사용하여 export 된 경우 counter 모듈은 결국 올바른 값을 보게 된다. setTimeout()이 실행되기 전에 main.js의 평가가 완료되어 메모리 상자에 값을 채운다.
ES 모듈이 이렇게 설계된 가장 큰 이유는 순환 의존성을 지원하기 위해서라고 할 수 있다. 이러한 순환 의존성 지원은 앞서 얘기한 3단계 설계를 통해 가능해졌다.
5월 초에 Firefox 60이 출시되면 모든 주요 브라우저가 기본적으로 ES 모듈을 지원하게 된다. Node는 CommonJS와 ES 모듈 간의 호환성 문제를 파악하기 위한 작업 그룹과 함께 차차 지원해 나가고 있다.
이 말은 스크립트 태그에 type="module"
을 사용해서 import/export를 사용할 수 있다는 것이다. 그러나 모듈의 많은 기능이 아직 제공되고 있지 않다. Node.js의 import.meta라고 사용 사례를 지원하는 동적 import 제안은 현재 명세 과정의 stage 3에 있다. 그리고 모듈 해결 제안 또한 브라우저와 Node·js의 차이를 자연스럽게 해결하는 데에 도움이 될 것이다. 그러니 미래에는 모듈을 사용하기에 더욱더 좋아질 것으로 기대할 만하다.