Skip to main content

Command Palette

Search for a command to run...

JavaScript Module System

다른거 공부하다가 모듈이라는 돌부리에 걸려 넘어져서 괘씸한 마음에 삽으로 파봤다.

Updated
9 min read
JavaScript Module System
L

직면하는 모든 문제에 유치한 것은 없으며, 의미 없는 삽질 또한 없다고 믿습니다.

✋🏻 들어가며,

얼마 전, 애플리케이션이 만들어지고 배포가 되고 사용이 되는 일련의 과정들에 대해 공부를 해보고자 그림을 그려가며 공부를 하고 있었는데, '번들러'라는 용어가 나왔고, '모듈화된 js 파일을 하나로 합친다.'라는 문장과 눈이 마주쳤다.

React 로 개발을 하다보면 무수히 많은 함수 컴포넌트들을 작성하기도 하고, 작은 함수 유틸도 만들기도 하는데 머릿속에 어렴풋이 이런 조각조각들이 모여서 하나의 큰 애플리케이션을 만든다는 것에 대해서는 알겠는데 내가 정확하게 알고있지 않다는 생각이 들어 이 참에 공부를 해봤다.

공부한걸 정리하기에 앞서, 관련 자료들을 잘 정리해주신 분들께 감사하다고 말씀드리고 싶다. 하나 하나 공부하고 찾아볼 수 있는 길잡이가 되어주셔서 늘 감사합니다.

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

초기 자바스크립트 프로그램은 규모가 크지 않아서 대부분의 스크립트들이 독립적으로 수행되었다고 한다. 하지만 지금 우리가 사용하고 있는 것들을 생각하면 그 규모가 어마어마하게 커졌다. 이렇게 시간이 지남에 따라 어플리케이션 규모가 커지며 스크립트 파일들이 나뉘었고, 필요한 것들만 가져올 수 있는 모듈 분할에 대한 필요성이 생겼다. 그렇게 모듈 시스템이 나타났다.

모듈 시스템이란 플러그인 파일이나 잘게 쪼개져있는 JS 코드 조각들을 재사용하기 위해서 각각의 파일을 등록하고, 등록된 파일을 JS에서 불러와 사용할 수 있게 해주는 프로그램을 말한다. 여기서 “모듈”이란 어플리케이션을 구성하는 부품, 즉 재활용 가능한 코드 단위라고 할 수 있다.

모듈 : 재활용 가능한 + 코드 단위
→ 어디에서 사용되어도 동작의 일관성이 보장됨
→ 하나의 어플리케이션이 N개의 모듈 집합으로 구성됨

자바스크립트는 아래와 같은 모듈 시스템을 사용할 수 있는데, 각각이 어떤 것이고 어떤 특징이 있는지 살펴보자.

  1. CommonJS

  2. AMD

  3. UMD

  4. ESM


1️⃣ CJS, CommonJS

CommonJS 는 정적 바인딩, 동기(synchronous) import 를 큰 특징으로 가지는 모듈 시스템이다.

정적 바인딩
: require 를 통해 가져온 값의 복사본을 제공함. 즉 module.exports 를 수행한 곳에서 값의 변경이 있어도 최초 require 이후에는 변경된 값을 사용할 수 없음.

CJS 의 "동기 import" 라는 특징 덕분에 이는 서버 사이드에서 사용하기 용이하다. CommonJs라는 이름도 원래는 ServerJS였다고 하니..! Node.js가 CommonJS 를 채택해서 사용하고 있다.

그럼 CJS를 사용하는 예제 코드를 확인해볼까?

var lib = require('package/lib');

function hello() {
    lib.log('hello world!');
};

exports.hello = hello;

위 코드와 같이 require 를 사용하면 package/lib 이라는 모듈을 변수에 담아서 사용할 수 있다. 그리고 위와 같이 만든 함수를 다른 파일에서 사용할 수 있도록 다른 모듈로 추출될 수도 있다.

2️⃣ AMD, Asynchronous Module Definition

CommonJS는 모든 파일이 로컬 디스크에 있어서 필요할 때 바로 불러올 수 있는 상황을 전제로 하는데, 이는 즉 동기적인 동작이 가능한 서버사이드 JS 환경을 전제로 한다는 것이다. 하지만 브라우저에서 이 방식은 필요한 모듈이 모두 다운로드 되기 전까지는 아무것도 할 수가 없어 치명적인 단점이 될 수 있다.

AMD는 비동기 상황에서 JS모듈을 사용하는 것에 대해 고민했고, 기존에 CommonJS에서 함께 논의하다 합의점을 찾지 못해 독립했다. 즉 CommonJS는 JS를 브라우저 밖으로 꺼내기 위해 탄생한 그룹인 반면 AMD는 브라우저에 중점을 둔 그룹이다.

아래 코드는 AMD에서 모듈로 추출하는 코드이며,

// 종속성을 갖는 모듈인 'package/lib'를 모듈 선언부의 첫 번째 파라미터에 넣으면,
define(['package/lib'], function (lib) {
                        // 'package/lib'은 콜백 함수의 lib 파라미터 안에 담김
    function hello() {
        lib.log('hello world!');
    }

    return {
        hello: hello
    }
}

위와 같이 선언된 모듈들은 아래와 같이 require 로 사용가능하다.

require(['package/myModule'], function(myModule){
    moModule.hello();
});

**AMD의 주된 특징은 ‘비동기’**이다. 브라우저는 네트워크를 통해 모듈을 내려받기 떄문에 비동기적으로 동작해야하므로 AMD의 비동기적인 특징으로 클라이언트 사이드 개발에 적합하다 볼 수 있겠다.

여기까지 딱 보고나면, 이 모두를 아우르는 뭔가가 나올 때가 되었다는 동물적인 직감이 든다. 개발자들은.. 이렇게 두지 않았을 것이기 때문에.. 이런 생각이 떠오르고 나니 그 다음으로 다뤄지는게 UMD 라는 녀석이다. (양반 아님)

3️⃣ UMD

AMD와 CommonJS 두 그룹으로 나누어지다 보니 서로 호환되지 않는 문제가 발생하게 되어 이를 해결하기 위해 UMD가 등장했다. UMD는 모듈 시스템에 따라 다른 구현을 정의하고 있는 형태에 가깝다.

AMD는 define을 사용하고, CommonJS는 module.exports를 사용하는데, 이 차이를 이용하여 UMD를 만들 수 있다. UMD는 두 부분으로 구성된다.

  • 모듈 로더를 확인하는 즉시 실행 함수(IIFE)
    → 이 함수는 root(전역 범위)와 factory(모듈을 선언하는 함수) 2개의 파라미터를 가짐

  • 모듈을 생성하는 익명 함수
    → 이 함수가 즉시 실행 함수의 2번째 파라미터로 전달됨

아래 코드를 보면, exportsmodule이 존재하면 CommonJS 방식으로, define이 함수이고 define.amd가 존재할 경우 AMD 방식으로 그것도 아니라면 window 객체에 모듈을 내보낸다.

(function (root, factory) {
  if (typeof define === 'function' && define.amd) {
    // AMD
    define(['exports', 'b'], factory);
  } else if (typeof exports === 'object' && typeof exports.nodeName !== 'string') {
    // CommonJS
    factory(exports, require('b'));
  } else {
    // Browser globals
    factory((root.commonJsStrict = {}), root.b);
  }
}(this, function (exports, b) {
  //use b in some fashion.

  // attach properties to the exports object to define
  // the exported module properties.
  exports.action = function () {};
}));

다시 정리하면, UMD의 주된 특징은 여러 모듈 로더에서 사용이 가능한 것이다. 즉 AMD와 CommonJS 모두 사용 가능하다.

4️⃣ ESM, ECMAScript Module

ESM은 언어 자체에 표준으로 탑재된 모듈 시스템으로 ES6에 추가되었다. 우리가 주로 사용하는 방법인데 예제 코드는 아래와 같다.

import lib from 'package/lib';

fuction hello() {
    lib.log('hello world!');
}

export { hello: hello };

ESM은 브라우저에서의 지원을 위해 번들러를 함께 사용한다. 이 친구는 계속해서 조금 더 자세히 알아보도록 하자. (공부하려했던 주요 내용이었으니!)


🤝 ESM, ECMAScript Module

ESM은 ES6 부터 도입된 모듈 시스템으로 export - import 문을 사용해 분리되어 있는 자바스크립트 파일 간의 접근을 가능하게 만들어준다. (다른 프레임워크는 아직 잘 모르는데, React 개발자들은.. 위 아래로 매일 쓰고 있는 그것..^^)

ESM 등장 이전에는 각각의 script 파일을 전역 스코프처럼 사용했는데, HTML 파일에서 보다 위에 있는 script 파일은 전역 스코프처럼 하위의 script 태그에서의 접근 또는 변경이 가능했다. 하지만 이 구조는 문제점이 있었다.

  1. script 파일들의 순서가 중요했고, 순서가 섞이면 에러가 발생

  2. 하위의 script 가 상위 script 상태를 쉽게 변경시키는 전역 오염이 발생

  3. 모든 script 파일에서 전역 스코프에 있는 변수들에 접근할 수 있어서 하나의 script 가 어떤 script 를 의존하고 있는지 파악이 어려움

  4. 위 문제들로 인해 유지보수가 어려움

말이 뭐가 많은데 대충 정리하면 script의 순서가 지켜지지 않는 등의 실수가 생기면서 오류가 발생할 수 있고 상태 변경에 있어서도 오염 가능성이 있고 파악이 점점 어려워진단 것이다. 이러한 문제들로 인해 ESM이 등장했다.

그럼 이제 ESM이 어떻게 돌아가고 있는지 알아보자.

⚙️ ES Module 의 동작 방식

ESM에서 의존성 간의 연결은 import 문이 작성된 코드에서 발생한다. import 문은 브라우저 또는 Node가 어떤 코드를 불러와야하는지 인식하는 것에 사용되는데, import 문에서 지정한 파일이 의존성 그래프의 진입점이 되고 연결되어있는 import 문들을 따라가며 의존성 그래프가 그려진다.

ESM 이 동작하기 위해서는 브라우저가 사용 가능한 Module Record(export, import 정보가 담긴 데이터 구조)로 변환 작업이 필요하다. 그리고 이러한 모듈화 과정은 다음과 같이 세 단계를 거친다.

구성 → 인스턴스화 → 평가

1️⃣ 구성 Construction

구성 단계에서는 모듈이 있는 파일을 어디서 다운로드 할 지 파악하고, 파일을 가져온 뒤 이를 Module Record(export, import 정보가 담긴 데이터 구조)로 구문 분석을 한다.

로더 loader
: 파일을 불러오는 역할을 하며, 플랫폼에 따라 다른 로더를 가질 수도 있고 브라우저의 경우는 HTML 명세를 따른다. 로더는 script 태그에서 진입점 파일을 찾을 수 있는 단서를 얻고 import문은 모듈 지정자를 통해 다음 모듈의 의존성을 파악한다.

2️⃣ 인스턴스화 Instantiation

이렇게 구문 분석이 끝나면 Module Record 를 Module Instance로 변환한다. 이는 import 할 모든 값을 할당할 메모리 공간을 찾는 과정으로, export / import 모두 해당 메모리를 가리키도록 한다.

모듈 인스턴스 Module Instance ’code’와 ‘state’라는 두 가지를 결합한 상태

하나의 모듈에 대한 export / import 는 같은 메모리 주소를 가리키는데, 이것을 라이브 바인딩이라 한다. 이는 import 들이 각각의 export 에 연결되어 있다는 것을 보장함으로써 특정 모듈을 import 하는 또 다른 모듈에서는 가져오고 있는 모듈에서 발생하는 변경사항들을 알 수가 있게 된다.

그럼 import 해온 모듈은 import 해온 위치에서 변경할 수 있을까? 놉.. 변경할 수 없다. 하지만 모듈이 객체를 가져오는 경우라면 해당 객체의 프로퍼티 값 변경은 가능하긴 하다.

3️⃣ 평가 Evaluation

평가 단계는 코드를 실행해 변수의 실제 값으로 메모리 공간을 채우는 과정이다.

👻 ESM의 특징

  1. ESM은 static 함
    : commonjs를 사용하거나, 리액트에서 webpack을 사용하는 경우에는 파일 확장자를 생략하는 경우도 있는데, ESM은 static 하기 때문에 import - export 가 컴파일 시점에 결정되어 파일 경로를 꼭 명시해야 함

  2. 함수, 변수, 클래스를 export 하거나 import 할 수 있음

  3. export문은 최상위 항목이어야 함
    : ESM에서 import 는 함수가 아닌 키워드이므로 변수에 할당하거나 중첩된 구조로 사용 X

  4. 내보내거나 가져올 때는 중괄호 {} 로 묶을 수 있음

  5. script 로 모듈을 선언할 때는 <script>type="module" 을 포함시키면 됨

<script type="module" src="module.mjs"></script>

📘 ES Module 사용 방법

1. 내보내기 - 가져오기

// 1. 개별로 내보내기
export const name = 'square';
export function draw(ctx, length, x, y, color) {
  ctx.fillStyle = color;
  ctx.fillRect(x, y, length, length);

  return {
    length: length,
    x: x,
    y: y,
    color: color
  };
}
// 2. 묶어서 내보내기
export { name, draw, reportArea, reportPerimeter };
// 3. 가져오기
import { name, draw, reportArea, reportPerimeter }
                                            from './modules/square.js';

2. as 키워드와 Renaming

import - export 문에서 중괄호 {} 내부에 as 라는 키워드를 사용해 내보내거나 가져오려고 하는 모듈의 각 요소들에 대한 이름을 변경해줄 수 있는데, 이를 통해서 이름이 겹치는 경우에 생기는 에러를 해결할 수 있다.

export {
  function1 as newFunctionName,
  function2 as anotherNewFunctionName
};
import { function1 as newFunctionName,
         function2 as anotherNewFunctionName } from './modules/module.js';

3. 모듈 한 번에 가져오기 - Module Object

중괄호 {} 를 사용해서 비구조화해서 모듈을 가져오는 경우에는, 코드가 너무 길어져 지저분해질 수 있다. 이 때는 as 를 사용해서 모듈의 기능을 하나의 객체로 묶어 가져올 수 있다.

import * as UI from './style.ts';

const Component = () => {
    return <UI.Layout></UI.Layout>
}

위와 같이 사용할 수 있는데, 이렇게 되면 style.ts 내에서 사용할 수 있는 모든 export 를 가져와 각각을 이 모듈의 프로퍼티처럼 사용할 수 있다.

4. Module 집합

모듈을 모아야 할 때는 여러 개의 서브 모듈들을 하나의 부모 모듈로 결합시킬 수 있다. 아래 예시를 보면, modules 하위의 세 파일에서 각각 모듈을 불러와서 사용하고 있는데 이것들을 하나로 만들어줄 수 있다는 것이다.

// main.js
import { Square } from './modules/square.js';
import { Circle } from './modules/circle.js';
import { Triangle } from './modules/triangle.js';
modules/
  - shapes.js
  - shapes/
    - circle.js
    - square.js
    - triangle.js

위와 같이 구조를 잡아두고, 아래와 같이 circle.js, square.jstriangle.jsshape.js 파일에서 각각 내보내주자. 그러면 이 세 개의 모 듈은 shape.js 만 불러와서 간단하게 사용할 수 있게 된다.

// shape.js
export { Square } from './shapes/square.js';
export { Triangle } from './shapes/triangle.js';
export { Circle } from './shapes/circle.js';
import { Square, Circle, Triangle } from './modules/shapes.js';

5. Dynamic Imports 동적 모듈 로딩

기본적으로 모듈들은 최상위에서 불러오게 되는데, 동적 모듈 로딩은 필요할 때만 모듈을 동적으로 불러올 수 있다. 기억할 키워드는 import() 이다.

import() 를 함수로 호출해서 모듈 경로를 파라미터로 전달한 뒤 모듈 객체를 사용해 promise 를 반환하면 모듈 객체가 가지고 있는 export 에 접근이 가능하다.

import('/modules/myModule.js')
  .then((module) => {
        // Do something with the module.
});

음 뜬금없는 여담인데, 예전에 팀원분께서 옆에 스윽 오셔서 “이렇게 export 하면 너무 귀찮지 않아요? 이름을 매번 지어줘야되서 나는 싫던데..”라고 말씀을 하셨었는데 ‘어.. 원래 이렇게 하는게 아닌가..? 😳’ 하며 머쓱했던 기억이 난다. 아마 아래 내용이 그 때 내가 제대로 알아듣지 못했던 이유가 될 것 같구나. 지금이라도 알아서 다행..!

6. Default export & Named export

  • Named export : export 로 내보내지는 함수, 변수, 클래스 등의 항목이 이름으로 참조됨

  • Default export : 하나의 모듈에 하나만 존재할 수 있으며, import 할 때 해당 모듈이 default 값이 됨. 선언과 분리할 수도 있고, 선언과 동시에 내보낼 수도 있음

    • import 하라 때 어떤 이름으로든 import 가 가능함

    • named export 와 달리 export문과 import문에 중괄호가 없음

    • 함수나 클래스와 다르게 변수는 선언과 동시에 내보내기가 불가능하므로 반드시 선언과 내보내기를 분리하여 작성해야 함

default export 는 아래와 같이 사용할 수 있다.

// 선언과 내보내기 분리
export default randomSquare;

// 선언과 동시에 내보내기
export default function(ctx) {
  ...
}
// 기본형
import {default as randomSquare} from './modules/square.js';

// 단축형
import randomSquare from './modules/square.js';

음 팀원분께서 귀찮다고 느끼셨던건 아마 default export 에서 import를 할 때 이름을 지어줘야하는 점이 귀찮으셨던게 아닐까. 😶 그럼 뭘 쓰는게 더 좋을까.

Airbnb JS Style Guide에서는 하나만 export 하는 모듈이면 default export 를 사용한다고 한다. default를 쓰면 읽기 편하고, 유지보수성이 향상되고, Treeshaking이 가능하고, 개념적 이해 등등이 가능하다는 의견과 함께..? 물론 팀이 정하기 나름이 아니겠나.

👩🏻‍🌾 마무리

간단하다 생각해서 다이빙 뛰었는데, 정말 오랜 시간 공부를 하게 된 것 같다. 그 중에서 핵심만 집어서 정리를 해봤는데, 망각할 내 뇌 녀석을 생각하면 깊은 것들도 그냥 좀 더 정리해둘걸 싶기도 하고.. 나중에 시간이 되면 그 때 조금 더 딥하게 정리해봐야겠다.

그래도 이번에 정리를 하면서, 생각없이 import - export 하던 것들이 왜 그렇게 작성을 해야하고 이것들이 어떤식으로 서로를 참조하고 사용하게 되는지 전반적인 과정을 알게 되어서 좋았다. 이제 오늘 공부한 것들을 기반으로 해서 순환참조와 번들러 관련해서도 조금 살펴봐야겠다.

🙏🏻 참고