본문 바로가기
Developer/Javascript 해부학

Javascript Module System

by 해적거북 2023. 1. 23.
728x90

목차

  1. 들어가기
  2. 모듈
    1. 불편한 코드
    2. 정리된 코드
    3. 핵심 기능
  3. 알아두면 좋은 것
    1. CommonJS와 ECMAScript Module의 배경이야기
    2. mjs와 type="module"
  4. 코드를 보기 전에
    1. CommonJS Module, ECMAScript Module을 구분하기
  5. CommonJS Module
    1. 특징
    2. cjs require cjs
    3. cjs import mjs
  6. ECMAScript Module
    1. 특징
    2. mjs import mjs
    3. mjs require cjs
    4. mjs import cjs
  7. ECMAScript Module은 어떻게 동작되는가
    1. Construction (구성)
    2. Instantiation (인스턴스화)
    3. Evaluation (평가)
  8. 나가기
  9. 참고

들어가기

개발 분야로 입문했던 당시, Express를 이용한 작은 프로젝트에서 코드를 작성하는데 다음과 같은 오류가 생겼었다.

Cannot use import statment outside a module

다른 javascript 파일 가져올때 import export로 가져오는거 아닌가..? 왜 이러지?

오류메시지를 구글링하며 해결책을 찾아보니 어느 환경에서 동작하는 코드인지에 따라 다르게 작성해야 된다는 것을 알게 되었다. 이후 여러 아티클과 공식문서를 살펴보며 이해한 지식들을 정리하고자 글을 쓰게 되었다.


모듈

불편한 코드

spaghetti_code

먼저 에러가 나타난 애플리케이션이 있다고 상상해보자. 에러가 발생한 javascript 파일을 열어보니 모든 코드와 로직이 뒤죽박죽 작성되어 있다. 에러 메시지를 통해 어디서 문제가 발생하는지 찾았고 원인을 알아내기 위해 코드를 쫒아가본다. 디버깅을 하면서 마침내 찾아 고쳤지만 사이드 이펙트가 나올까봐 안심이 되지 않는다.

정리된 코드

clean_code

문제를 해결해도 안심이 되지 않는 이유는 무엇일까? 원인을 알아보기 위해 비슷한 실생활 예시를 생각해보자. 하나의 서랍장에 분류없이 물건이 담겨져있다면 물건을 찾기 어려워지는데 왜 그럴까? 바로 정리가 되어있지 않기 때문이다. 이와 마찬가지로 하나의 파일에 모든 코드가 담겨져있다면 작은 에러라도 찾기 어려우며 수정하더라도 이로 인해 파생되는 영향을 쉽게 알기 어렵다. 따라서 관련된 코드끼리 묶어두고 묶음끼리는 서로 간섭하지 않도록 해야한다. 즉, 하나의 긴 코드를 분리하는 정리가 필요하다.

긴 코드를 관련된 코드끼리 잘게 쪼개어 여러 코드 조각으로 정리를 하게 되는데 이때 코드의 조각을 모듈이라고 한다.

핵심 기능

module_scope

모듈의 핵심 기능은 독립적인 스코프(모듈 레벨 스코프)를 가진다는 점이다. 독립적이라는 것은 서로 다른 모듈 사이의 영향이 없다는 것을 의미한다. 대표적인 이점은 오염되지 않은 전역 변수를 만들 수 있다는 것이다. 예를 들어, A, B모듈에 공통적으로 있는 count 전역변수가 있다면, 서로 구분되어 한 모듈 내에서 동작이 다른 모듈의 count 전역 변수에 영향을 주지 않는다.


알아두면 좋은 것

CommonJS와 ECMAScript Module의 배경이야기

주관적인 의견이지만 Javascript의 모듈 시스템을 알기 전에 배경 이야기를 알면 좋다고 생각한다. 이에 대해 굉장히 재미있게 작성한 아티클을 보는 것을 추천한다.

mjs와 type="module"

모듈과 관련된 지식들을 찾다보면 <script type="module" ... ></script>을 종종 볼 수 있다. 이는 HTML파일에서 불러올 Javascript 파일이 ECMAScript Module이라고 브라우저에게 알려주는 것에 불과하다. 해당 Javascript 모듈을 ECMAScript Module으로 변경하는 것이 아님에 주의해야 한다.


코드를 보기 전에

CommonJS Module, ECMAScript Module을 구분하기

먼저 어떤 모듈이 CommonJS Module인지, ECMAScript Module인지 구분할 수 있어야 한다. 우리는 import를 쓰는지 require를 쓰는지 등과 같이 코드를 통해 쉽게 짐작할 수 있다. 하지만 항상 정확하진 않으며 좀 더 확실한 구분점이 있다. 그리고 해당 모듈을 가져오는 Loader도 이러한 기준을 사용한다.

js 파일 확장자를 기준으로 구분할 수 있으며, 총 3가지의 확장자가 있다.

  1. js : 해당 파일의 상위로 가장 가까운 package.json의 type field를 보고 알 수 있다. type field는 commonjs(기본값), module이 있으며, commonjs는 CommonJS Module로 해석되고 module은 ECMAScript Module로 해석된다.
  2. cjs : 항상 CommonJS Module로 해석된다.
  3. mjs : 항상 ECMAScript Module로 해석된다.

아래 예시 코드에서 확장자만으로 구분할 수 있도록 cjs, mjs 확장자를 이용하였다. 또한 예시를 나열하는 기준을 어떠한 모듈에서 가져오는가로 정하였다. 그리고 가져오는 export하는 모듈의 코드는 동일하여 아래와 같이 작성하였다.

  • CommonJS Module (default export)
// utils/calculation.cjs

console.log("calculation.cjs >>>");

// default export
// module.exports에는 빈 객체로 초기화되어있었는데 이를 덮어쓰게 됩니다.
module.exports = function (a, b) {
  return a + b;
};

// 기존의 할당된 module.export 값을 에러없이 덮어쓰게 됩니다.
module.exports = function (a, b) {
  return a - b;
};

console.log("calculation.cjs <<<");
  • CommonJS Module (named export)
// utils/calculation.cjs

console.log("calculation.cjs >>>");

// named export
// 초기화된 객체가 할당된 module.exports에 프로퍼티로 추가하여 내보낼 수 있습니다.
// { plus: function , minus: function }
// 작성법은 두 가지로 작성가능합니다.
module.exports.plus = function (a, b) {
  return a + b;
};

// exports는 module.exports를 가리키기 때문에 module.exports에 프로퍼티로 추가됩니다.
exports.minus = function (a, b) {
  return a - b;
};

console.log("calculation.cjs <<<");
  • ECMAScript Module (default export)
// utils/calculation.mjs

console.log("calculation.mjs >>>");

// default export
// export default는 한 모듈에서 1번만 작성할 수 있습니다.
export default function (a, b) {
  return a + b;
}

// 하나의 모듈에서 default export와 named export를 모두 작성할 수 있습니다.
export const minus = function (a, b) {
  return a - b;
};

console.log("calculation.mjs <<<");
  • ECMAScript Module (named export)
// utils/calculation.mjs

console.log("calculation.mjs >>>");

// named export
export const plus = (a, b) => {
  return a + b;
};

export const minus = function (a, b) {
  return a - b;
};

console.log("calculation.mjs <<<");

CommonJS Module (named export)에서 export하는 2개의 코드가 다른 것을 볼 수 있다. 이는 exports shortcut이며 동일한 export 객체를 가리킨다.

추가적으로 각 Module의 특징을 설명할 때 Top-Level Await 키워드가 등장한다. 이는 최상위 레벨에서 비동기 함수없이도 await 키워드를 사용할 수 있는 기능임을 참고하면 된다.


CommonJS Module

특징

  • Node.JS에서 CommonJS Module을 채택하여 사용하고 있다.
  • require이라는 코드를 만나면 모듈 로더가 동기적으로 실행된다.
  • require, module.exports를 동적으로 실행할 수 있다. 따라서 빌드타임에서 정적 분석을 하기 어렵고, 런타임 환경에서만 모듈 관계를 파악할 수 있다.
  • CommonJS Module은 Top-Level Await를 지원하지 않기 때문에 require 문법으로 ESM을 가져올 수 없다.

cjs require cjs

  • default export
// index.cjs

console.log("index.cjs >>>");

// 하나의 값(객체, 함수 등)으로 받아야 합니다.
const calculation = require("./utils/calculation.cjs");

console.log(calculation);
console.log(calculation(2, 3));

console.log("index.cjs <<<");
# cjs require cjs (default export) 출력결과

index.cjs >>>
calculation.cjs >>>
calculation.cjs <<<
[Function (anonymous)]
-1
index.cjs <<<
  • named export
// index.cjs

console.log("index.cjs >>>");

// 구조 분해 할당하여 사용 가능합니다.
const calculation = require("./utils/calculation.cjs");
const { minus } = require("./utils/calculation.cjs");

console.log(calculation);
console.log(calculation.plus(2, 3));
console.log(minus(2, 3));

console.log("index.cjs <<<");
# cjs require cjs (named export) 출력결과

index.cjs >>>
calculation.cjs >>>
calculation.cjs <<<
{ plus: [Function (anonymous)], minus: [Function (anonymous)] }
5
-1
index.cjs <<<

cjs import mjs

  • default export
// index.cjs

console.log("index.cjs >>>");

(async () => {
  // 객체 전체를 받을 수도, 구조 분해 할당으로 받을 수도 있습니다.
  // 구조 분해 할당으로 받을 경우, default export는 새로운 이름으로 정해야 합니다.
  const calculation = await import("./utils/calculation.mjs");
  const { default: plus, minus } = await import("./utils/calculation.mjs");

  console.log(calculation);
  console.log(calculation.default(2, 3));
  console.log(calculation.minus(2, 3));
  console.log(plus(2, 3));
  console.log(minus(2, 3));
})();

console.log("index.cjs <<<");
# cjs import mjs (default export) 출력결과

index.cjs >>>
index.cjs <<<
calculation.mjs >>>
calculation.mjs <<<
[Module: null prototype] {
  default: [Function: default],
  minus: [Function: minus]
}
5
-1
5
-1
  • named export
// index.cjs

console.log("index.cjs >>>");

(async () => {
  // 객체 전체를 받을 수도, 구조 분해 할당으로 받을 수도 있습니다.
  const calculation = await import("./utils/calculation.mjs");
  const { plus, minus } = await import("./utils/calculation.mjs");

  console.log(calculation);
  console.log(calculation.plus(2, 3));
  console.log(calculation.minus(2, 3));
  console.log(plus(2, 3));
  console.log(minus(2, 3));
})();

console.log("index.cjs <<<");
# cjs import mjs (named export) 출력결과

index.cjs >>>
index.cjs <<<
calculation.mjs >>>
calculation.mjs <<<
[Module: null prototype] {
  minus: [Function: minus],
  plus: [Function: plus]
}
5
-1
5
-1

ECMAScript Module

특징

  • ESM은 ECMAScript에서 지원하는 자바스크립트 공식 모듈 시스템이며, 현재 대부분의 브라우저에서 지원함을 확인 할 수 있다.
  • Node.js에서도 지원한다.
  • ECMAScript Module에서는 모듈 로더가 비동기적으로 다운로드한 후 파싱을 거쳐 Module Graph를 빌드한다. 이후 스크립트를 실행한다.
  • 동적으로 import, export 할 수 없다. 또한 export는 최상위 스코프에서만 가능하다. 따라서 빌드타임에서 정적 분석이 가능하여 Tree-Shaking을 할 수 있다.
  • Top-Level Await를 지원한다.

mjs import mjs

  • default export
// index.mjs

console.log("index.mjs >>>");

// import 'default export' + 'named export'
import calculation, { minus } from "./utils/calculation.mjs";

console.log(calculation);
console.log(calculation(2, 3));
console.log(minus(2, 3));

console.log("index.mjs <<<");
# mjs import mjs (default export) 출력결과

calculation.mjs >>>
calculation.mjs <<<
index.mjs >>>
[Function: default]
5
-1
index.mjs <<<
  • named export
// index.mjs

console.log("index.mjs >>>");

// default export가 없기 때문에 구조 분해 할당을 이용해야 합니다.
// * 키워드와 별칭을 이용하여 객체 통째로 받아올 수 있습니다.
import { plus } from "./utils/calculation.mjs";
import * as calculation from "./utils/calculation.mjs";

console.log(calculation);
console.log(plus(2, 3));
console.log(calculation.minus(2, 3));

console.log("index.mjs <<<");
# mjs import mjs (named export) 출력결과

calculation.mjs >>>
calculation.mjs <<<
index.mjs >>>
[Module: null prototype] {
  minus: [Function: minus],
  plus: [Function: plus]
}
5
-1
index.mjs <<<

mjs require cjs

// index.mjs

console.log("index.mjs >>>");

import { createRequire } from "module";
const require = createRequire(import.meta.url);

const calculation = require("./utils/calculation.cjs");

console.log(calculation);
console.log(calculation(2, 3));

console.log("index.mjs <<<");
# mjs require cjs (default export) 출력결과

index.mjs >>>
calculation.cjs >>>
calculation.cjs <<<
[Function (anonymous)]
-1
index.mjs <<<
  • named export
// index.mjs

console.log("index.mjs >>>");

import { createRequire } from "module";
const require = createRequire(import.meta.url);
const calculation = require("./utils/calculation.cjs");

console.log(calculation);
console.log(calculation.plus(2, 3));
console.log(calculation.minus(2, 3));

console.log("index.mjs <<<");
# mjs require cjs (named export) 출력결과

index.mjs >>>
calculation.cjs >>>
calculation.cjs <<<
{ plus: [Function (anonymous)], minus: [Function (anonymous)] }
5
-1
index.mjs <<<

mjs import cjs

  • default export
// index.mjs

console.log("index.mjs >>>");

import calculation from "./utils/calculation.cjs";

console.log(calculation);
console.log(calculation(2, 3));

console.log("index.mjs <<<");
# mjs import cjs (default export) 출력결과

calculation.cjs >>>
calculation.cjs <<<
index.mjs >>>
[Function (anonymous)]
-1
index.mjs <<<
  • named export
// index.mjs

console.log("index.mjs >>>");

// 구조 분해 할당으로 가져올 수 없다.
// ESM의 named exports가 파싱 단계에서 평가되는 것과 달리 CJS 스크립트는 스크립트가 실행될 때 named exports를 평가하기 때문이다.
import calculation from "./utils/calculation.cjs";

console.log(calculation);
console.log(calculation.plus(2, 3));
console.log(calculation.minus(2, 3));

console.log("index.mjs <<<");
# mjs import cjs (named export) 출력결과

calculation.cjs >>>
calculation.cjs <<<
index.mjs >>>
{ plus: [Function (anonymous)], minus: [Function (anonymous)] }
5
-1
index.mjs <<<

ECMAScript Module은 어떻게 동작되는가

ESM_process

브라우저와 Node.js는 Javascript파일 그대로 읽어서 실행하는 것처럼 보이지만, 실제로는 각 파일들의 관계를 파악하여 모듈 의존성 그래프(트리)를 만들게 된다. 그 이후, 코드와 메모리 공간을 이용하여 동작한다. 모듈화 작업은 3단계로 나누어 독립적으로 수행될 수 있기 때문에 ECMAScript Module이 비동기식으로 동작한다. 하지만 CommonJS Module에서는 모듈과 그 아래의 의존성이 로드되고, 인스턴스화 되어 한꺼번에 모든 평가가 이루어진다. 이해를 위해 각 단계를 더 자세히 살펴보자.

1. Construction (구성)

: 모든 파일을 확인하여 가져온 후 Module Record를 만든다.
dependencies_construction

먼저 파일을 어디서 가져올 것인지 확인하고 가져와야 한다. 여기서 브라우저와 CommonJS Module(서버)에서 동작하는 것이 다르다. 왜냐하면 ECMAScript Module 명세에는 파일을 가져오는 방법을 제외하고 구문분석, 인스턴스화, 모듈 평가에 대해서만 있기 때문이다.
brower_loader

따라서 브라우저의 파일을 가져오는 방법은 HTML 명세를 따른다.

브라우저에서는 HTML파일의 script태그를 통해서 가져온다. 이때 URL을 이용하여 네트워크를 통해 파일을 가져오게 되는데, 가져온 파일은 구문분석 완료한 후 다음 모듈을 가져온다. 이러한 동작은 브라우저에서 모듈 시스템을 실행하기에 너무 느리므로 ECMAScript Module 명세에서 Module Resolution 알고리즘을 여러 단계로 나누어 해결하였다.
network_latency

반면에 CommonJS Module에서는 파일 시스템을 바탕으로 파일을 가져온다. 이는 네트워크보다 매우 적은 시간이 소요된다. 따라서 전체 모듈을 순회하면서 의존성 평가까지 완료하게 된다.

module_record

이렇게 가져온 파일들을 Loader가 각각 구문분석하여 Module Record로 만든다. 이때 Loader가 구문분석하기 전에 모듈인지 아닌지를 알아야 한다. 이를 알려주기 위해 브라우저에서 사용할 때는 script 태그에 type="module"을 사용하는것이고, Node.js 환경에서는 mjs 파일 확장자를 이용한다.
type_module

이렇게 만든 각 Module Record는 Module Map에 추가해두고 필요할 때마다 가져와서 사용한다. (캐싱)

2. Instantiation (인스턴스화)

: export한 값을 저장할 메모리를 확보하고 연결한다.
module_instance

이제 각 Module Record를 Module Instance로 변환해야한다. Instance는 코드상태의 결합으로 이루어져있다. 상태는 메모리에 있는 값이므로 인스턴스화는 코드를 통해 상태 변경 가능하도록 연결하는 것이다.

Javascript 엔진이 모듈 환경 레코드를 생성하여 모듈 내의 변수들을 관리한다. 해당 변수들 가운데 export되는 변수들의 메모리와 import하는 모듈 환경 레코드의 변수 메모리에 연결한다. 이때 주목할 부분은 같은 메모리 주소를 통해 연결만 할 뿐, 값을 채워넣지 않는다는 것이다. 이렇게 메모리 주소를 공유하면 export하는 모듈의 값이 변경될 경우 import하는 모듈에서 반영이 된다. 하지만 반대로 반영되지는 않는다.(객체의 속성 값인 경우 가능) 이러한 동작을 라이브 바인딩이라 한다.
live_binding

따라서 ECMAScript Module은 라이브 바인딩을 통해 코드 실행 없이 모든 모듈을 연결할 수 있고, 순환 의존성을 가질 때 평가 과정에도 도움이 받는다.

cjs_export_import

반면에 CommonJS Module에서는 export되는 객체를 복사해서 import된다.

여기서 순환 의존성이 무엇인지 잠깐 알아보자. 순환 의존성은 순환되는 구조로 파일이 의존하는 것이다. 다음과 같이 CommonJS Module로 이루어진 순환 구조가 있다고 하자.
cjs_cycle_dependency_code

main.js에서 require문까지 실행하여 counter.js 파일을 가져오고 평가하기 시작한다. 하지만 main.js의 message값은 평가되지 않았으므로 undefined로 반환된다. 이후 main.js의 message 값이 평가 완료되어도 counter.js의 message에는 반영되지 않는다. 왜냐하면 CommonJS Module에서는 export하는 객체를 복사하기 때문이다.
cjs_cycle_dependency

하지만 ECMAScript Module 경우, 라이브 바인딩을 통해 메모리를 공유하기 때문에 counter.js의 message에는 main.js의 message와 동일한 값을 가져오게 된다.

그리고 모듈 그래프를 깊이 우선 탐색으로 순회하므로 최하단의 모듈부터 탐색한다.

3. Evaluation (평가)

: 코드를 실행하여 메모리에 값을 채운다.
평가 단계에서는 연결된 메모리에 값을 채우는 작업만 한다. 만약 메모리 값을 채우는 작업 외의 코드를 평가하게 된다면 서버에 무언가를 요청하는 것과 같은 Side Effect가 일어날 수 있다. 따라서 모듈은 한번만 평가하며, 이를 위해 Module Map을 이용한 것이다.
module_map

그리고 인스턴스화 단계와 마찬가지로 깊이 우선 탐색으로 평가한다. 이러한 순회 순서가 위에서 작성한 콘솔 출력이 CommonJS Module과 다른 이유를 알 수 있게 해준다. 따라서 ECMAScript Module으로 모듈을 구성하는 경우, calculation 모듈의 콘솔 출력 2개가 완료된 후 index 모듈의 콘솔 출력 2개가 완료된다.


나가기

지금까지 Javascript Module System이 어떠한 이유로 등장하여 발전해왔는지 알게 되었다. 많은 아티클을 읽으며 학습하고 정리하여 글까지 작성하는 경험은 처음이었다. 적지 않은 시간이 걸렸지만, 더 정확하게 학습하는 힘을 기를 수 있는 운동이 되었다. 또한 이러한 학습이 코드를 치면서 프로젝트를 만드는 것과 다른 재미와 성취감이 있었다. 벌써 다음에 다루고 싶은 주제가 여러 개 있어서 어느 것부터 다루면 좋을지 고민이다.


참고

728x90

댓글