자바스크립트의 모듈 시스템

2024년 3월 1일

자바스크립트는 원래 브라우저를 간단히 조작하기 위해 만들어진 언어였다.

그렇기에 원래는 하나의 js 파일에서도 충분히 코드를 작성할수 있었다.

하지만 시간이 흐르면서 웹 시장의 규모가 커지고 발전할수록, 동시에 브라우저 내에서도 JS가 차지하는 비중도 자연스레 증가했다.

JS 코드의 양이 증가하면서 자연스럽게 하나의 js 파일이 아니라 여러개의 js 파일로 분리해서 관리하게 되었다.

main.js
 ┃ ┣ feature1.js
 ┃ ┗ feature2.js

이런식으로 분리된 하나의 js 파일들을 모듈이라고 부른다.

그리고 이러한 모듈들을 불러오는 방법을 모듈 시스템이라고 한다.

import feature1 from "./feature1.js";
import feature2 from "./feature2.js";

// ...

우리는 html 내에서 script 태그를 사용하여 자바스크립트 파일을 불러온다.

<!doctype html>
<html lang="en">
  <head>
    <title>Document</title>
    <script src="./feature1.js"></script>
    <script src="./feature2.js"></script>
  </head>
  <body></body>
</html>

하지만 모듈 시스템이 등장하기 이전의 문제점은, 각각 js 파일을 따로 불러와서 사용해주더라도 그 파일들은 결국 전역 스코프를 공유하게 된다는 점이다.

예시를 들어보자.

- feature1.js

var targetNumber = 20;

console.log("feature1.js에서 쓰인 targetNumber는 : ", targetNumber);

- feature2.js

var targetNumber = 10;

console.log("feature2.js에서 쓰인 targetNumber는 : ", targetNumber);

- index.html

<script src="./feature1.js"></script>
<script>
  console.log("targetNumber 는 : ", targetNumber);
</script>
<script src="./feature2.js"></script>
<script>
  console.log("targetNumber 는 : ", targetNumber);
</script>

여기서 순서대로 feature1.js => => index.html 에 내장된 script => feature2.js => index.html에 내장된 script 순으로 차례대로 실행 시키는 것을 확인할 수 있다.

또한 feature1에서 선언했던 targetNumber는 feature2.js 를 만나기전까지는 처음에 선언했던 20이지만, feature2.js 를 만난 후에는 10으로 덮어 씌워지는 것을 확인할 수 있다.

이는 각각의 js 파일들이 언뜻 분리가 되어있는 것처럼 보일지라도, 결국 같은 스코프를 공유하기 때문에 하나의 js파일에서 실행되는 것과 마찬가지이다.

그렇기 때문에, 어플리케이션의 규모가 커지면서 각각 다른 파일에서 같은 변수를 사용하게 된다면 의도하지 않은 결과를 도출시킨다.

만약 각각의 파일들을 독립적으로 관리해주고 싶다면 어떻게 해야할까 ?

<title>Document</title>
<script type="module" src="./feature1.js"></script>
<script>
  console.log("targetNumber 는 : ", targetNumber);
</script>
<script type="module" src="./feature2.js"></script>
<script>
  console.log("targetNumber 는 : ", targetNumber);
</script>

각각 script 태그에 type="module" 을 선언해주니, 출력 값의 결과는 이 전과 확연히 달라졌다.

feature1.js 와 feature2.js 에서의 targetNumber는 각각 파일에서 선언한 값 그대로 출력이되고, index.html에서는 해당 targetNumber이 정의가 되지 않았다는 에러 메세지가 발생한다.

이렇게만 보더라도, module로 선언해줌으로써 각각의 파일들이 독립적인 스코프를 갖게 된것을 확인할 수 있다.

module로 선언해준다면 feature1.js에서 생성한 변수들은 feature1.js 에서만, feature2.js 에서 선언한 변수들은 feature2.js 에서만 사용할수 있다.

또한 콘솔의 출력 순서도 변화한것을 확인할 수 있다.

기본적으로 html은 위에서부터 차례대로 파싱을 하다가 script 태그를 만나면 해당 script를 즉시 로딩하고 실행한 후에, 파싱을 재개한다.

하지만 모듈을 사용한다면 기본적으로 HTML 문서의 파싱이 완료 된 후에 실행이 되기 때문에, 초기 로딩 성능을 최적화 할수 있다.

- feature1.js

var targetNumber = 20;

export default targetNumber;

- featue2.js

import targetNumber from "./feature1.js";

console.log("feature1에서 불러온 targetNumber 는 : ", targetNumber); // 20의 값이 출력 된다.

feature2.js가 feature1.js 내부에서 선언한 값이 필요하다고 가정할때, 이런식으로 import 구문을 사용한 모듈시스템을 통해 값을 가져와서 목적에 맞게 사용할 수 있다.

이런식으로 모듈 시스템을 활용함으로써 각각의 기능들이 독립적인 스코프를 가짐과 동시에 무결성, 재사용성을 보장해줄수 있다.


위에서 feature2.js 에서 feature1.js의 값을 가져올때, import 구문을 사용한 모듈시스템을 이용해서 feature1 모듈을 가져왔는데, 이처럼 이 모듈 시스템도 여러개의 종류가 존재한다.

크게 AMD, CommonJS, UMD, ES Module로 나누어볼수 있지만 대표적인 모듈 시스템은 CommonJSES Module이 꼽힌다.

- ES Module(ESM)

ES6(ES2015)에서부터 도입된 자바스크립트 모듈 시스템이다.

기본적으로 script 태그에 type="module"을 속성을 추가하면, 해당 자바스크립트 파일은 모듈로써 작동한다.

ESM 시스템에서 모듈을 외부에서 사용할수 있도록 내보낼때는 exportdefault export 와 같은 키워드를 사용하며, 외부에서 모듈을 불러올 때는 import를 사용하여 불러올 수 있다.

export의 키워드는 여러개의 함수나 값들을 내보낼 수 있고, default export 키워드를 사용하게 된다면 모듈당 하나의 기본 함수나 값을 내보낼 수 있다.

위에서 예로 든 코드가 ESM 시스템을 사용한 것이다.

- calculate.js

export const sum = (num1, num2) => {
  return num1 + num2;
};

export const subtract = (num1, num2) => {
  return num1 - num2;
};

export const multiply = (num1, num2) => {
  return num1 * num2;
};

export const divide = (num1, num2) => {
  return num1 / num2;
};

- index.js

import { sum, subtract, multiply, divide } from "./calculate.js";

console.log("sum 함수 실행 값: ", sum(1, 2)); // 3

console.log("subtract 함수 실행 값: ", subtract(2, 1)); // 1

console.log("multiply 함수 실행 값 :", multiply(2, 4)); // 8

console.log("divide 함수 실행 값 :", divide(4, 2)); // 2

이런식으로 여러개의 필요한 함수들을 import 구문을 통해 가져올 수 있다.

혹은 * as 별칭 방법을 통하여 가져올수도 있다.

import * as calculate from "./calculate.js";

console.log("sum 함수 실행 값: ", calculate.sum(1, 2));

console.log("subtract 함수 실행 값: ", calculate.subtract(2, 1));

console.log("multiply 함수 실행 값 :", calculate.multiply(2, 4));

console.log("divide 함수 실행 값 :", calculate.divide(4, 2));

export default를 사용하여 모듈을 내보냈다면, import 해오는 곳에서 이름을 마음대로 지정해도 상관 없다.

- calculate.js

const sum = (num1, num2) => {
  return num1 + num2;
};

export default sum;

- index.js

import sumSum from "./calculate.js";

console.log(sumSum(1, 2));

이처럼 sum이라는 이름을 가진 함수로 내보냈지만, import 해오는 곳에선 sumSum이라는 네이밍으로 함수를 실행시킬수 있다.

- CommonJS

CommonJS 같은 경우 Node.js 환경에서 자바스크립트 모듈을 사용하기 위해 만들어진 모듈 시스템이다.

모듈을 내보낼때는 exports, module.exports 와 같은 키워드를 사용하고, 모듈을 불러올때는 require 키워드를 사용한다.

Node.js 에서는 기본적으로 CommonJS 모듈 시스템을 채택하여 사용했지만, 13.2버전부터는 ESM 시스템을 정식적으로 지원하기 시작했기때문에 Node.js에서 또한 ESM의 사용이 가능하다. 해당 package.json 에서 type ="module" 선언을 해주면 된다.

- calculate.js

exports.sum = (num1, num2) => {
  return num1 + num2;
};

exports.subtract = (num1, num2) => {
  return num1 - num2;
};

exports.multiply = (num1, num2) => {
  return num1 * num2;
};

exports.divide = (num1, num2) => {
  return num1 / num2;
};

- index.js

const { sum } = require("./calculate");

console.log(sum(1, 2));
node index.js

이 CommonJS 방식은 Node.js 환경에서 모듈을 사용하기 위해 고안된것이기 때문에, ESM과는 다르게 브라우저 화면상에선 require is not defined 에러가 발생한다.

ESM에서 export default 를 통해 하나의 모듈 그 자체를 내보내주었던 것처럼, CommonJS에서도 마찬가지로 module.exports 키워드를 통해 필요한 기능들을 묶어서 한번에 내보내줄수 있다.

- calculate.js

const sum = (num1, num2) => {
  return num1 + num2;
};

const subtract = (num1, num2) => {
  return num1 - num2;
};
const multiply = (num1, num2) => {
  return num1 * num2;
};

const divide = (num1, num2) => {
  return num1 / num2;
};

module.exports = {
  sum,
  subtract,
  multiply,
  divide,
};

- index.js

const calculate = require("./calculate");

console.log(calculate.sum(1, 2));

console.log(calculate.subtract(2, 1));

console.log(calculate.multiply(2, 4));

console.log(calculate.divide(4, 2));

- 그렇다면 CommonJS와 ESM 의 차이점은 무엇인가 ?

일단 Node.js 에서도 ESM 시스템을 정식적으로 지원하기 시작했다는 것은, ESMCommonJS보다 어떠한 이점이 있기 때문이라고 추측해볼 수 있다.

기본적으로 CommonJS 방식은 트리쉐이킹을 지원하지 않는다.

트리쉐이킹이란 ? 트리쉐이킹이란, 말 그대로 해석하자면 나무를 흔들어서 시든 잎파리들을 제거하는 행위이다. 이는 어플리케이션 내에서 최종 번들 크기를 줄이기 위해서 사용되는 최적화 기법중 하나이다. 실제로 개발 단계에서 많은 라이브러리나 패키지를 불러와서 코드를 작성하다가, 배포를 하게 됐을때 실제로 해당 기능을 사용하지 않게 되는 경우도 존재한다. 트리쉐이킹을 통해서 해당 의존성들을 제거해주어서 최종 번들에 포함되지 않도록 해주고 성능을 향상 시킬수 있다.

❓ 그렇다면 왜 CommonJS는 트리쉐이킹을 지원하지 않는것일까 ❓

트리 쉐이킹이 적용되기 위해서는 모듈의 트리 구조를 정적 분석을 통해서 먼저 파악하는 과정이 필요하다.

먼저 모듈들이 어떤 의존성들을 갖고 있는지에 대해 파악하는 과정을 거친 후에, 그 후 사용하지 않는 모듈들을 최종 번들에서 제거해주며 성능을 향상 시킨다.

기본적으로 ESM은 정적 구조를 가지고 있기때문에 먼저 모듈을을 선언하는 과정을 거친다.

그 후 webpack이나 rollup.js 같은 빌드 도구가 빌드를 하기전에 트리쉐이킹을 적용시킨다.

하지만 CommonJS 는 ESM과 달리 동적 구조를 가지고 있다.

이 동적 구조라는것은 먼저 모듈들이 선언이 되어 트리 구조를 만들어내는 것이 아니라 런타임에, 즉 해당 모듈이 실행될 당시에 로드 되는 것을 의미한다.

이러한 방식은 코드가 실행되기 전까지, 실제로 어떤 모듈이 실제로 필요하고 사용되는 것인지에 대해서 파악이 어렵기 때문에 정적 분석을 통한 트리쉐이킹은 어렵다.


❓ 그러면 왜 CommonJS는 동적 구조를 갖게 된 것일까 ❓

위에서 기술했다시피, CommonJS 방식은 Node.js 환경에서 모듈 시스템을 사용하기 위해 고안된 것이다.

서버 어플리케이션 내에서는 실행 하는 과정에서 다양한 모듈이 필요하게 될 수 있다.

예를 들어 사용자가 특정한 버튼을 눌러야지만 제공할수 있는 모듈이 있다면, 이는 사용자가 버튼을 누르기 전까지는 필요가 없어진다.

CommonJS의 동적 구조는 이러한 요구 사항에 효과적으로 대응할 수 있다.

또한 서버는 해당 서버가 실행이 될때의 시작 시간도 매우 중요한 성능 지표 중 하나이다.

어플리케이션이 시작 되기전에 모든 모듈을을 미리 로드 해놓는 것은 초기 실행 시간을 늦출수 있는 문제를 야기할 수 있기 때문에, 이러한 동적 구조를 갖게 되었다.

하지만 시간이 흐르며 웹 생태계가 발전함에 따라서, 성능 최적화와 모듈 관리의 중요성 또한 커졌다.

그렇기 때문에 결과적으로 ESM의 정적 모듈 시스템이 더 선호되기 시작했고, Node.js 도 ESM 방식을 정식으로 채택하게 됐다.


- 그렇다면 defer 선언과 type="module" 의 차이점은 무엇일까 ?

type="module"을 선언한 모듈 스크립트는 기본적으로 defer속성을 내포한다.

하지만 defer 같은 경우에 해당 스크립트는 모듈이라는 타입으로 취급되지 않기 때문에 importexport 같은 구문을 사용할 수 없다.

<script defer>
  import number from "./test.js";
  //Uncaught SyntaxError: Cannot use import statement outside a module
  console.log(number);
</script>
```

또한 기본적으로 모듈 시스템은 엄격 모드(strict mode)에서 실행 되므로, 더 안전한 상태를 보장받은 상황에서 체계적인 코드를 작성할 수 있다.

단순히 스크립트의 로딩을 지연시키려면 defer를, ES6 모듈이 필요하다면 typoe="module"을 선언해주면 된다.

하지만 둘다 기본적으로 DOMContentLoaded 이벤트가 발생하기 전에 실행 된다.