어느날 기묘한 메세지를 받았다. 웹 페이지의 공유하기 버튼이 동작하지 않는 이슈가 있는데 내가 작업한 스크립트의 파싱 에러 상황에서만 정상 동작한다는 내용이었다.
기획팀에서 퍼블리싱 툴을 사용해 수동 배포하는 페이지였고, 공유하기는 툴에서 추가하는 기능이라 기획자님은 그쪽 이슈로 짐작하고 계셨던 상황이다. 알고보니 내 스크립트의 변수 스코프가 원인이었다.
ESM으로 번들링한 스크립트 소스 태그에 type="module"
속성을 빠뜨려 전역 스코프를 오염시킨 것이다. 모듈에 대해 알고있다 생각했는데 기본적인 부분을 놓쳐 많은 반성을 했다. 그런 의미에서 이 글에서는 JS 모듈에 대해 톺아보려 한다.
모듈은 어플리케이션을 구성하는 코드 조각이다.
코드를 조각으로 나누면 역할 분리와 재사용이 가능해 생산성과 유지보수성이 향상된다. 모듈이 기능하기 위해서는 다음의 조건들이 필요하다.
독립적인 파일 스코프를 가질 것
필요한 자산을 선택적으로 공개할 수 있는 문법을 가질 것 (export)
공개된 자산을 스코프 내로 불러들여 사용할 수 있는 문법을 가질 것 (import)
JS는 웹 페이지의 간단한 기능을 처리하기 위해 만들어진 언어로 초기에는 모듈 표준이 없었다. 모든 스크립트가 전역 스코프를 공유하는 끔찍한 환경이라는 의미다. 서론의 이슈가 발생한 것처럼 현재까지도 모듈 스크립트가 아니라면 그렇다.
웹 페이지 규모와 스크립트의 역할이 커질수록 스코프 오염 가능성과 의존성 관리의 제약은 치명적이다.
이에 브라우저 외 서버 환경에서도 JS를 범용적으로 사용하고자 하는 움직임과 함께 CommonJS, AMD 등 다양한 모듈 시스템이 등장했다. 그리고 ES6에서는 공식 모듈 표준인 ES Module이 추가되었다.
ESM은 표준임에도 브라우저와 Node.js 환경 모두에서 기본값이 아니다. 따라서 모듈 스크립트를 사용하기 위해서는 <script>
태그 또는 package.json
파일에 type module
을 명시해야 한다. .mjs
확장자를 사용하면 더 좋다.
ESM이 기본값이 되지 못한 이유는 모듈 스크립트가 일반 스크립트와 다른 제약을 가지기 때문이다.
엄격 모드로 실행된다.
선언하지 않은 변수를 사용(암묵적 전역 객체 프로퍼티 등록)하거나, 함수의 매개변수 이름이 중복되면 명시적으로 에러가 발생한다.
격리된 스코프를 가진다.
모듈 내부에서 정의한 변수나 함수는 다른 스크립트에서 접근할 수 없다.
여러곳에서 사용되더라도 한 번만 평가된다.
import.meta 프로퍼티를 가진다.
모듈 레벨 스코프의 최상위 this는 undefined이다.
브라우저 환경에서의 모듈 스크립트는 몇 가지 추가적인 특징을 가진다.
로컬 파일 시스템이 아닌 HTTP/HTTPS 프로토콜을 통해서만 동작한다.
다른 origin에서 불러오려면 CORS 헤더가 필요하다.
모듈 스크립트는 defer 속성이 기본값으로, 항상 지연 실행된다.
로드가 완료되더라도 HTML 문서의 다운로드와 파싱이 끝난 뒤 DOMContentLoaded 이벤트에 실행된다.
모듈을 지원하지 않는 브라우저에서만 실행되는 nomodule 속성을 사용할 수 있다.
현재 주류로 자리한 모듈 시스템은 Node.js 표준 모듈 시스템으로 채택된 CommonJS(CJS)와 표준 ES Module(ESM)이다. 처음 배울 때는 문법만 다른줄 알았는데 두 시스템은 동작 자체가 달라 호환되지 않는다.
CJS | ESM |
---|---|
require(), module.exports | import, export |
ESM을 require() "할 수는" 있지만 권장되지 않음 - require() 구문이 ESM과 호환 X - ESM에서는 top level await를 사용할 수 있지만 CJS는 지원 X | CJS 모듈을 import 할 수 있지만 default import만 가능 |
CJS는 서버 환경을 위해 만들어졌다. 따라서 의존성이 로컬에 존재한다고 전제하고 모듈을 동기적으로 불러온다. 동기 실행이기 때문에 CJS에서는 require
경로에 변수를 사용하거나 조건부로 require
를 호출하는 것이 가능하다.
require(`${path}/index.js`);
let module;
if (condition) {
module = require('a');
} else {
module = require('b');
}
이런 특징 때문에 정적 분석이 어려워 트리 쉐이킹이 되지 않고, 네트워크 환경에서도 모듈 하나에 대한 I/O와 실행이 모두 끝나야 module.exports
값이 반환된다.
반면 ESM은 브라우저 환경에서의 비동기 실행을 전제로 고안되었다. 로드가 끝난 스크립트라도 바로 실행하지 않고 다음의 과정을 통해 의존성 그래프를 먼저 만든다.
스크립트에서 import
, export
구문을 찾아 파싱한다.
그 뒤 가져온 스크립트를 비동기로 다운로드해 또 다시 의존성 구문을 파싱하고, 더 이상 import 대상이 없을 때까지 의존성 그래프를 만든다.
이 과정을 통해 의존성을 포함한 모든 스크립트들이 준비되면 실행된다.
ESM은 의존성 스크립트들을 병렬로 로드하고, 의존성 그래프 전체를 그린 뒤 순차적으로 실행한다.
스크립트를 다운로드하는 네트워크 I/O가 스크립트를 실행하는 메인 스레드를 블로킹하지 않도록 하기 위해서이다. 이 정적 분석을 위해 모듈 시스템 레벨에서 조건부 import를 허용하지 않는다.
그렇다면
React.lazy
등 런타임에 로드 여부가 결정되는 동적 import는 어떻게 구현되었고 어떻게 처리될까?
이는 ECMAScript의 동적 import 문법인 import(module)
표현식을 사용한다. 모듈 내용을 포함하는 객체를 담은 Promise를 반환하며 await 키워드와 함께 사용할 수 있다. 헷갈릴 수 있지만 모듈 시스템과 별개인 표준 문법이기 때문에 모든 JS 스크립트에서 사용이 가능하다.
ESM에서 import(module)
구문을 사용한 모듈 엔트리에 대해서는 아래처럼 별도의 의존성 그래프가 그려지게 된다.
이렇게만 살펴봐도 클라이언트 환경에는 ESM이 적합해보인다. ES6 미지원 환경이 많았던 때에는 UMD 형식 번들이 표준처럼 사용되었다고 한다. 현재는 대부분의 브라우저에서 ES6를 사용할 수 있기 때문에 서비스 지원 환경에 따라 프로덕션에 ESM 번들을 사용해도 될 것 같다. (번들 결과물과는 별개로 트리 쉐이킹 측면에서 의존성은 ESM을 사용하는 것이 좋다.)
두서는 다소 없지만 모듈과 모듈 시스템에 대한 내용을 정리해봤다.
평소 CJS, ESM을 생각하며 개발하지는 않지만 npm 패키지 배포나 번들링 최적화 작업을 위해서는 모듈 시스템에 대한 이해가 도움이 된다. 특히 프론트엔드 어플리케이션에서도 서버 사이드의 역할이 늘어나다 보니 의존성 관리에 브라우저와 서버의 모듈 시스템을 동시에 고려할 필요성도 생겨났다.
이래저래 개발 환경을 벗어난 내 코드가 어떤 환경에 놓이는지 알고있는건 필요한 일이라는 생각이 든다.