[JavaScript] CommonJS, ES Module 이해하기

2023. 2. 16. 16:43FE/JavaScript

모듈 시스템 도입 이전에는 파일 의존성에 맞게 script 태그 순서가 중요했다.

모든 파일들이 전역 상태로 공유 되었기 때문에 script 순서와 이미 정의된 변수를 덮어 씌우지 않도록 신경 써야 했다.

 

자바스크립트 애플리케이션은 점점 복잡해졌고 전역 상태가 오염되는 것을 피하기 위해 모듈이 해결책이 되었다.

 

Module

애플리케이션 개발할 때 크기가 커지면 파일을 여러 개로 분리하여 관리한다.

분리된 파일을 모듈이라 부르는데 대개 클래스나 특정 목적을 가진 여러 개의 함수로 구성된 라이브러리로 구성된다.

자바스크립트 크기가 커지고 기능도 복잡해지면서 모듈 시스템이 등장하게 되었다.

 

모듈 시스템 덕분에 import, export 관계를 명시하여 파일간 의존성을 파악하기 쉬워졌다.

 

Module Pattern

모듈 패턴으로 함수 내 변수나 메소드를 외부에서 선택적으로 접근 가능하도록 할 수 있다.

이렇게 클로저를 통해 모듈 패턴을 구현할 수 있다.

const re = new RegExp(/^[a-zA-Z]*$/);

var User = (function (re) {
  var userName = "";

  function isValidName(name) {
    return re.test(name);
  }

  function getName() {
    return userName;
  }

  function setName(name) {
    if (isValidName(name)) {
      userName = name;
    } else {
      throw new Error("Name is invalid");
    }
  }

  return {
    setName: setName,
    getName: getName,
  };
})(re);

User.setName("muckma");
console.log(User.getName()); // muckma

 

CommonJS

Node.js 서버를 위해 만들어진 모듈 시스템이다

Node.js는 각 파일을 개별 모듈로 취급한다.

모듈 패턴과 같은 방식으로 모든 모듈은 클로저를 활용한다.

 

node package manager는 CommonJS 형식으로 만들어져 있다.

 

CommonJS에서는 두 개의 개체로 구성되는데 exports 객체와 require 함수다.

 

exports object

외부에 데이터를 공개하는데 사용되는 객체다.

exports.name = "muckma";

 

require function

모듈 지정자로 다른 CJS모듈에서 해당 스코프로 가져오는 함수다.

module.exports 객체를 반환한다.

const { name } = require("./name.js");
console.log(name); // muckam

 

require 함수를 호출하면 어떻게 되는지?

require 함수 인자로 전달받은 모듈 지정자를 Node.js가 해석한다.

이는 로컬 파일, JSON 파일, node_modules에 있는 모듈일 것이다.

Node.js는 module.paths에 정의된 경로에서 모듈을 찾는다.

 

모듈 지정자가 디렉토리면서 package.json 파일이 있으면 로더는 package.json을 파싱하고 main field에 있는 파일을 로드한다. main field는 패키지 폴더의 루트라 생각하면 되고 기본적으로 index.js로 설정되어 있다.

참고로 모듈이 브라우저에서 사용하려면 브라우저 필드를 사용하면 된다.

package.json 파일이 없다면 Node.js에서 index.js 파일이 entry point로 사용될 것이다.

require.resolve로 파일 로드 없이 존재 여부를 확인할 수 있다.

 

로딩 단계에서 로드할 파일 유형을 결정한다. JS 파일이면 Node.js는 그 파일을 CommonJS 모듈로 간주할 것이다.

 

래핑 단계에서 해당 코드는 자신만의 스코프를 가지게 된다.

평가를 위해 로드된 JS 문자열을 런타임에 전달하기 전에 래퍼 함수에 래핑이 된다.

 

exports, require, module, __filename, __dirname은 래핑 함수의 인자이다.

function (exports, require, module, __filename, __dirname) {
  const num = 1;
  module.exports.num = num;
}

 

노드에서 module.exports는 export하기 위한 특별한 객체이고, exports는 module.exports를 참조하는 포인터다.

const name = "muckma";
exports.name = name;

console.log(module.exports); // { name : "muckma" }

exports = {};

console.log(module.exports); // { name : "muckma" }

따라서 exports가 재할당되면 module.exports 객체 참조가 끊긴다.

 

이렇듯 export하기 위한 프로퍼티를 추가할 때 module.exports.name 사용하기보다 exports.name 을 사용하면 더 짧게 코드를 작성할 수 있겠다.

 

CommonJS 모듈은 동기적으로 동작한다.

즉, 모듈이 순서대로 로드되고 실행된다.

실행 전에는 어떤 모듈을 export할 지 알 수 없기 때문에 클라이언트 측에 적합하지 않다.

 

평가 단계에서 자바스크립트 런타임에 로드된 코드가 평가된다.

 

평가된 모듈은 캐싱되어 동일한 모듈이 require 되었을 시 테이블에 대응하는 module.exports 객체를 반환하도록 한다.

 

require 함수를 호출하면 resolving → loading → wrapping → evaluation → caching 단계를 거친다.

 

ES module

자바스크립트 공식 표준 모듈 시스템이다.

 

import문과 export문을 사용한다.

export const name = "muckma";
import { name } from "./name.js";

 

모듈 지정자로 문자열 리터럴만 허용하고 pre-runtime에 바인딩이 된다.

따라서 CJS import에서는 구조 분해 할당이 되지만 ES module에서는 안된다.

 

프로그램 실행 없이 런타임에 미리 어떤 값이 export 되는지 알 수 없기 때문에 export/import 문을 파일 상단에 작성해야 한다.

 

ES module에서 import/export문은 CJS와 달리 정적 분석이 가능하여 코드 실행 없이 의존성 트리를 완성할 수 있다.

또한 ES module에서 번들링 툴, tree-shaking 과 같은 것도 가능하다.

 

ES module은 개별 단계로 처리되기 때문에 본질적으로 비동기이다.

 

construction

모듈 지정자를 해석하고 로더를 통해 파일을 가져오고 모듈 레코드를 만들기 위해 파일을 파싱한다.

모든 imports를 연쇄적으로 찾아 각 파일로부터 모든 모듈 코드를 로드한다.

 

instantiation

정적 모듈 그래프를 만들고 exports/imports를 메모리에 연결한다.

모든 export하는 모듈에 대해 참조값을 메모리에 저장하나 아직 값이 할당되지 않는다.

import/export문이 각 모듈의 의존성을 추적할 수 있도록 연결한다.

이 단계에서 코드는 실행되지 않는다.

 

evaluation

런타임에 코드를 로딩한다.

인스턴스화된 개체들이 실제 값을 가질 수 있도록 entry point부터 코드가 실행된다.

한 번 평가된 module은 module map에 캐싱된다.

 

Node.js에서 ES Module도 지원하는데 파일 단위로 사용하려면 모듈을 .mjs 확장자로 파일을 만들어야 한다.

프로젝트 단위로 ESM을 사용하려면 package.json 파일에 "type":"module" 를 추가해주어야 한다.

브라우저에서는 script 태그에 type 속성을 사용한다.

<script type="module" src="esm-index.js"></script>

MIME type으로 파일이 처리된다.

 

이렇게 구분하는 이유는 ? script와 module 차이 ?

 

모듈은 기본으로 strict mode 활성화 되어 있다. (use strict)

정적 export/import문은 모듈에서만 가능하고 일반 스크립트에서는 안된다.

모듈은 한 번만 평가되지만 일반 스크립트는 DOM에 추가될 때마다 평가된다.

 

웹에 있는 모듈은 추가적인 특징이 있다.

모듈은 lexical top level 스코프를 가진다.

이는 모듈 내에서 전역으로 선언한 코드는 전역 변수를 만들지 않고 window 객체의 프로퍼티가 된다.

그러나 일반 스크립트에서는 전역 변수가 된다.

 

모듈을 CORS로 가져온다.

모든 cross-origin module script는 적절한 헤더와 함께 제공되어야 한다. Access-Control-Allow-Origin: *

 

이런 차이점으로 JS 엔진은 모듈과 일반 스크립트는 다르게 동작할 수 있다.

 

모던 브라우저에서는 스크립트 태그 속성 type을 이해하지만 구 브라우저에서는 이해하지 못한다.

<script type="module" src="index.js"></script> // ES6+ 지원 브라우저
<script nomodule src="fallback.js"></script> // ES5 지원 브라우저

모듈을 지원하는 웹 브라우저에서는 nomodule script는 무시하는 반면

모듈을 지원하지 않는 브라우저에서는 type="module" script를 무시한다.

 

ES 버전에 따라 스크립트 파일을 나누면 더 이상 tranpile 할 필요가 없다.

 

import는 동적이지 않다고 했다.

export 모듈이 렉시컬로 정의되어 있기 때문이다.

즉 파싱을 통해 모듈에서 export 것이 코드 실행 전에 결정된다.

 

그렇지만 동적으로 import 하는 방법이 있다.

조건에 따라 import를 하는 것이 유용할 수 있다.

if (condition) {
  const moduleSpecifier = "./index.js";
  
  import(moduleSpecifier)
    .then(module=>{
      console.log(module.name);
    })
    .catch(err => {
      console.log(err);
    })
}

 

lexical import와 달리 dynamic import는 CJS require과 같이 평가 때 처리된다.

import() 는 프로미스를 반환하므로 async/await를 사용할 수 있다.

함수 같이 생겼지만 Function.prototype를 상속 받지 않는다.

 

ESM과 CJS 간 다른점

 

ESM은 비동기 로딩을 지원한다.

CJS 모듈은 동기적으로 로드되고 일반적으로 파일 시스템으로부터 로드된다.

 

Resolution 알고리즘이 다르다.

package.json에 정의된 모듈명만 모듈 지정자가 가지고 있다.

Node.js의 경우 import 할 때 모듈 지정자를 심플하게 모듈 이름만 쓰는게 허용된다.

Node.js는 node_modules 디렉토리에서 찾아보기 때문.

 

ESM에서는 import 할 때 전체 URL 또는 /, ./ 같은 상대 URL이어야 한다.

Node.js은 CJS를 위한 resolution 알고리즘을 구현하고 있다.

 

모듈을 로딩하는 기본 규칙이 다르다.

ESM은 다른 JS 모듈을 import를 사용해서 로드한다.

ESM은 CJS 모듈을 default import로 로드할 수 있다.

CJS 모듈은 다른 CJS모듈을 require를 사용해서 로드한다.

CJS 모듈은 ESM을 로드 할 수 없다. 

그래서 ESM은 CJS 모듈로 변환되어야 하고 이 경우 예를 들어 바벨을 사용 할 수 있다.

 

의존성 트리 생성되는 시점이 다르다.

CJS 경우 의존성 트리를 탐색하면서 모든 파일을 실행한다.

ESM 경우 런타임 전에 의존성 트리를 완성한 뒤에 코드를 실행한다.

728x90