모듈 시스템의 역사, 그리고 ESM

yumyum·2022년 10월 18일
29
post-thumbnail
  • 자바스크립트의 모듈 시스템 역사를 살펴봅니다.

이 글을 통해 얻을 수 있는 것

  • 모듈 시스템의 역사를 이해하게 됩니다.
  • commonjs가 무엇인지 이해하게 됩니다.
  • AMD가 무엇인지 이해하게 됩니다.
  • ESM이 무엇인지 이해하게 됩니다.

Commonjs, 그 이전의 이야기

항상 어떤 기술이 탄생할 때는 문제점이 발견되기 마련입니다. 당연히 commonjs도 js에서 문제점이 있었기 때문에 만들어졌겠죠? 어떤 문제가 있었을까요?

서버사이드에 대한 수요 :

javascript는 1995년 브랜든 아이크에의해 만들어지고, 브라우저가 성장함에 따라서 그 인기를 같이하고 있었습니다. js는 브라우저를 위해 만들어진 간편한 언어였지만, 이것을 서버사이드에서도 사용하고자 하는 수요는 지속적으로 있어왔습니다. 이런 수요로 인해 제공되고 있던 기술은 Helma, Jaxer같은 것들이 있었습니다. 이렇게 js라는 하나의 언어로 클라이언트와 서버가 코드를 주고받는 것은 소통의 측면에서 엄청난 이점이 있었습니다. 하지만 이렇게 사용되던 라이브러리들 또한 표준이라고 할 수는 없었습니다. 이 시기에 누군가 목소리를 내기 시작합니다.

kevin Dangoor의 initiative :

2009년 1월 29일, Kevin Dangoor라는 사람이 자신의 블로그에 js에도 서버사이드 를 위한 제대로 된 표준이 필요하다는 *initiative를 발표합니다. 이 글에서 먼저 Kevin Dangoor는 python에는 있지만, Js는 없는 아쉬운 점들을 지적합니다. 그 내용은 다음과 같습니다.

*initiative : (목표달성 및 문제해결을 위한 새로운) 계획/행동/프로그램

  1. standard library가 없다.
    • 파일이나 디렉터리를 읽을 수 없다.
  2. standard interface가 없다.
    • 웹서버, 데이터베이스 등등에 연결하거나 쿼리하기 위한 표준 인터페이스가 없다.
  3. package manager가 없다.
    • 패키지를 배포하고, 관리하고, 일제히 설치하는 그런 기능이 존재하지 않는다.
  4. module system이 없다.
    • 다양한 모듈들을 쉽게 load할 수 없고, 네임스페이스를 구분하지 않는다.

그리고 다음과 같은 말을 덧붙입니다.

여러분, 저는 지금 기술적인 문제를 지적하는 것이 아닙니다. 이것은 단지 더 크고 멋진 것을 만들기 위해, 사람들이 함께 모이고 한 발자국 나아가기 위한 결정을 내리는 것에 대한 문제입니다. (...) 지금 이미 많은 자바스크립트 코드가 나와있습니다. 저희 노력이 이 코드들을 더욱 가치있게 만들어줄지 한번 보자구요.

[이미지 : Kevin Dangoor]

그 결과 :

이 글이 올라간지 1주일이 지났습니다. 224명의 멤버가 모였고, 653개의 메시지가 도착했습니다. 어쩌면 Kevin이라는 인물이 이미 모질라에서도 일하고 있고, 파이썬의 유명한 라이브러리를 개발했을 정도로 영향력 있는 인물이어서 그랬는지도 모르겠습니다. 또한 이 시기에 많은 사람들이 이런 시스템을 개발하기 원했겠죠. 이 때 많은 훌륭한 개발자들이 모여들었습니다.

그리고 1달 뒤, 공동으로 이용할 수 있는 module systems의 명세가 작성되었습니다.

전역 변수 관리 문제 :

본격적으로 Commonjs에 대한 설명으로 넘어가기 전, 한 가지만 다루고 넘어가겠습니다. 위에서 4가지 문제점을 언급했습니다. 그 중에서도 많은 블로그를 탐색해보면 공통적으로 다루는 이야기가 있습니다. 전역변수에 대한 문제입니다.

개발을 하다보면 캡슐화와 의존성에 대한 이야기를 들어본 적이 있을 것입니다. 각각 다른 조각의 소프트웨어(모듈)는 고립된 영역 안에서 개발되어야 합니다. 그렇게 만들어진 조각들이 서로 협력을 하면서 더 큰 소프트웨어를 만들게 됩니다. 이때 중요한 것은 모듈 간에 충돌이 생겨선 안된다는 것입니다. 아마도 충돌되지 않게 하는 것이나, 전역변수 관리하는게 대수냐 라고 생각할 수도 있겠습니다. 하지만, 캡슐화가 제대로 이루어지지 않았다면 어플리케이션의 크기가 커질 수록 충돌은 피하기가 정말 어려울 것입니다. 각 모듈을 캡슐화해주는 것은 관리포인트를 줄이고, 안정적으로 어플리케이션을 관리하기 위해서 너무나 필수적인 일이었습니다.

또한 의존성을 제대로 실행하기 위해서는 의존성을 올바른 순서에 위치하게 만들었어야 했습니다. 만약 그 순서가 지켜지지 않는다면 제대로 실행되지 않았을 것입니다. 여기 예시가 있습니다.

<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="utf-8">
        <title>useTodo.js Todos</title>
        <link rel="stylesheet" href="todos.css"/>
    </head>

    <body>
        <script src="../../test/vendor/json2.js"></script>
        <script src="../../test/vendor/jquery.js"></script>
        <script src="../../test/vendor/underscore.js"></script>
        <script src="../../useTodo.js"></script>
        <script src="../useTodo.sessionStorage.js"></script>
        <script src="todos.js"></script>
    </body>

    <!-- (...) -->

</html>

todos.js안에서 useTodo의 코드를 사용하기 위해서는 useTodo.js는 반드시 todos.js이전에 불러 온 상태여야 합니다. 이런 식으로 script를 가져오는 순서를 섬세하게 관리해주지 않는 이상, 어플리케이션은 망가질 수도 있습니다. 크기가 커질수록 이런 순서를 관리하는 일은 정말 복잡한 일이 될 것입니다. 반드시 고쳐져야만하는 문제였겠죠?

참고자료 :
CommonJS effort sets JavaScript on path for world domination
What Server Side JavaScript needs


CommonJs :

이렇게 kevin의 initiative로 인해서 Commonjs가 만들어지게 되었습니다.(commonjs는 하나의 워킹 그룹입니다.) 이 장에서는 그렇게 만들어진 Commonjs에 무엇이 추가되었는지, 이것은 실제로 어떻게 사용되는지, 문제점은 무엇이 있는지를 알아보겠습니다.

왜 common인가 :

commonjs에 의해 module systems이 만들어졌습니다. 이를 통해 브라우저 뿐만이 아니라, 서버, 데스크탑 어플리케이션, 보안 샌드박스 등등(common)에도 이용 가능하게 되었다고 해서 common이라는 이름이 붙여졌습니다. commonjs의 페이지에 들어가면 이런 글이 대문짝에 걸려있습니다.

javascript : not just for browsers any more!

모든 곳(common)에서 자바스크립트를 사용하겠다는 의지가 엿보입니다.


무엇이 달라졌나?

commonjs로 넘어오면서 그들은 많은 기능들을 표준화하고자 했습니다. 다음이 그 리스트들입니다.

  • Modules
  • Binary strings and buffers
  • Charset encodings
  • Binary, buffered, and textual input and output (io) streams
  • System process arguments, environment, and streams
  • File system interface
  • Socket streams
  • Unit test assertions, running, and reporting
  • Web server gateway interface, JSGI
  • Local and remote packages and package management

정말 많은 리스트들이 있습니다만, 사실 이 중 오늘 저희가 다뤄볼 내용은 별로 없습니다. 여기선 그냥 이런 리스트들이 있다는 것만 소개하고 넘어가겠습니다. 만약 더 궁금하시면 이 자료를 읽어보시면 좋을 것 같습니다. 저희가 집중해 볼 것은 바로 모듈화입니다.

모듈화 :

commonjs로 넘어오면서 생긴 변화 중 저희가 가장 주목할만한 부분은 바로 모듈화입니다. 이 모듈화는 아래와 같이 세 부분으로 이루어집니다.

  • 스코프 : 모든 모듈은 자신만의 독립적인 실행 영역이 있어야 한다.
  • 정의 : 모듈 정의는 exports 객체를 이용한다.
  • 사용 : 모듈 사용은 require 함수를 이용한다.

이제 자바스크립트에도 독립적인 실행영역이 생기게 되었고, 그 실행영역을 밖으로 보내고(exports) 사용하는 방법(require)이 생겼습니다. 코드를 통해서 조금 더 알아보겠습니다.

//math.js
const add = (a,b) => a + b
const subtract = (a,b) => a - b

exports.add = add
exports.subtract = subtract

이렇게 add 함수와 subtract 함수를 exports에 담아 밖으로 내보냅니다. 내보낸 함수를 다른 파일에서 사용해보겠습니다.

//app.js
const math = require('./math.js')

math.add(1,2) // 3
math.subtract(2,1) // 1

이렇게 require를 통해서 가져와 사용할 수 있습니다. 이때 require는 함수라는 사실을 기억해야합니다. 그런 특성 때문에 이 require는 if안에도 들어가 조건부로 다른 모듈의 내용을 가져올 수 있습니다.

if(user.length > 0){
	const math = require('./math.js')
    //... Do something.. 
}

[참고 : JavaScript 표준을 위한 움직임: CommonJS와 AMD]

commonjs 와 nodejs :

초창기에는 nodejs도 commonjs를 표준으로 채택해 사용했습니다. 그러나 추후에는 commonjs의 일부만 채택하고 노선을 달리하게 되었습니다. 그런 이유로 commonjs와 nodejs는 약간의 차이점이 있습니다. 그 중 하나를 소개하자면 module.export입니다.

노드에서 module.export는 하나의 객체입니다. module이라는 객체 안에 default로 exports라는 변수를 가지고 있는 그런 객체입니다. 반면 commonjs에서는 module.exports라는 객체를 가지고 있지않습니다. 그래서 곧장 exports를 사용합니다. 예시를 보면 다음과 같습니다.

//commonjs
exports =  (width) => {
  return {
    area: () => width * width
  };
}

//nodejs
module.exports = (width) => {
  return {
    area: () => width * width
  };
}

commonjs의 문제점 :

commonjs에도 한계점은 존재했습니다.

첫번째, commonjs는 ECMA standands의 지원없이 독립적으로 개발되었다는 점입니다. 이것이 의미하는 바는 언어 자체로는 commonjs를 포함하지 않는다는 것이었습니다. 더구나 브라우저 차원에서 이것을 지원할리도 없었습니다.

두번째, 정적으로 모듈을 분석하고 *트리쉐이킹을 할 방법이 없었습니다. 이것은 종종 번들 크기의 문제로 이어지기도 했습니다.

세번째, 구현의 세부사항들이 로컬 환경에 기반해 있었던 commonjs는 브라우저의 맥락에서는 문제점이 있었습니다. 대표적으로 모듈을 동기적으로 로드한다는 것입니다. 순차적으로 하나씩 모듈을 로드하는 것은 브라우저 환경에선 좋지 못한 선택이었습니다. 각각이 화면에 렌더링되기까지의 작업이 필요했고, 이는 렌더링이나 인터렉션의 블록으로 이어질 수 있었습니다. 때문에 이를 놓고 논의가 이루어졌습니다. 하지만 합의점이 이루어지지 못했고, 그렇게 떨어져 나오게 된 그룹이 바로 AMD입니다.

*트리쉐이킹 : 필요없는 모듈을 제거하는 작업


AMD(Asynchronous Module Definition)

commonjs는 모듈을 로드할 때 동기적으로 로드한다고 했습니다. 이런 문제점을 개선하고자 하는 워킹 그룹이 AMD입니다. 앞서 언급했듯이 commonjs에서 합의점을 찾다가 분리되어 나왔습니다. 이런 AMD가 목표로 하는 것은 브라우저 환경에서 모듈을 로드해야 할 때도 정상적으로 모듈을 사용할 수 있도록 정의하는 것입니다. AMD의 Asynchronous에서도 알 수 있듯이 AMD에선 비동기적으로 모듈을 다루는 것에 대한 표준안을 다룹니다.

commonjs에서 분리되어 나온 그룹이기 때문에, 기본적으로 공통점을 지닌 점이 있습니다. require나 exports같은 문법을 그대로 사용할 수 있습니다. AMD만의 특징이라면 define 함수가 있습니다.

define() :

브라우저에서는 모듈 간 스코프가 따로 존재하지 않습니다. 때문에 파일 간 모듈로 구분해주기 위해서 define함수의 클로저를 활용합니다. define함수의 예시 코드를 보면 다음과 같습니다.

// example.js
define(['lodash', 'yumyum'], function(_, Y) {
  console.log(_); // lodash
  console.log(Y); // yumyum
  return {
    a: _,
    b: Y,
  }
});

보시는바와 같이 첫번째 인자에는 사용할 다른 사람들의 코드를 배열로 넣어줍니다. 그리고 두 번째 인자에는 첫번째 인자에 배열로 넣어준 의존성 코드들을 인자로 받아옵니다.

이 코드를 사용할 때는 다음과 같이 require를 사용합니다.

require(['example'], function (example) {
  console.log(example.a); // lodash
  console.log(example.b); // yumyum
  console.log(lodash); // undefined 또는 에러
});

ESM(ECMAScript Modules)

이렇게 표준을 찾아 헤매던 중 ECMAScript에서 드디어 모듈시스템에 대한 표준을 발표했습니다. 이 표준은 현재 모든 브라우저에서 지원하고 있습니다. 이는 드디어 자바스크립트 자체로 모듈에 대한 문법을 가지게 되었다는 것을 의미합니다. 이 챕터에서는 ESM의 사용법을 소개해보도록 하겠습니다.

import와 export :

드디어 해당 키워드를 공식적으로 사용할 수 있게 되었습니다. 리액트를 사용해보셨다면 자주 보던 키워드일 것 같습니다. export를 사용해 const나 function을 내보낼 수 있습니다. 앞에 prefix로 붙여주기만 하면됩니다.

// math.mjs
export const add = (a,b) => a + b
export function subtract (a,b) { a - b }

이렇게 내보낸 add와 subtract는 import 키워드로 가져와 사용할 수 있습니다.

// app.mjs
import {add, subtract} from "./math.mjs"

add(1,2) // 3 
subtract(2,1) // 1

export default 키워드도 사용할 수 있습니다. 해당 키워드는 한 모듈당 하나만 사용할 수 있습니다.

//math.mjs
export default const add = (a,b) => a + b
export const subtract = (a,b) => a - b

마찬가지로 import를 통해 가져올 수 있습니다. 그런데, default로 내보낸 경우에는 가져오는 곳에서 이름을 자유롭게 지정할 수 있습니다.

//app.js
import addSomething, {subtract} from "./math.mjs"

addSomething(1,2) // 3 
subtract(2,1) // 1 

스코프 구분 :

모듈을 사용하면 더 이상 변수들이 전역에 할당되지 않습니다. 이전에는

const 철수 = 20

와 같이 작성하고 window로 접근하면 값을 얻을 수 있었지만, 모듈로 사용하는 경우에는 undefined를 할당합니다.

// ..js
console.log(window.철수) // 20

// ..mjs 
console.log(window.철수) // undefined

this도 마찬가지입니다. 모듈 class script에서는 전역 공간에서 this를 사용하는 경우 window를 가리키지만, mjs에서 this를 전역공간에 사용하면 undefined를 내뱉습니다.

사용법 :

type = "module" :

브라우저에서 모듈을 사용하기 위해서는 해당 스크립트를 모듈로 여겨달라고 브라우저에게 말해야합니다. type="module"을 명시해주면 됩니다.

<script type="module" src="main.mjs"></script>
<script nomodule src="fallback.js"></script>

만약 브라우저가 module이해한다면 해당 script를 사용하게 될 것입니다. 그러나 지원하지 않는 브라우저의 경우 nomodule이 명시된 script를 사용합니다. 이것은 지원 가능한 문법을 구분하는데에도 유용한 측면이있습니다. module을 지원하는 브라우저라는 것은 그 이전의 문법(arrow function ..etc)도 이해할 수 있다는 것입니다. 때문에 비교적 쉽게 브라우저 호환에 대비할 수 있습니다.

nodejs 프로젝트에서 사용하는 또 다른 방법으로는 package.json에 "type: module" 명시해주는 방법이 있습니다.

{
  "name": "my-library",
  "version": "1.0.0",
  "type": "module",
  // ...
}

이렇게 작성해두면, nodejs는 프로젝트 내에 존재하는 파일들을 esm으로 여길것이고 덕분에 파일명을 .mjs로 변환해주지 않아도 됩니다.

nodejs 프로젝트에서 사용하는 또 다른 방법으로는 바벨 같은 트렌스파일러를 사용하는 방법도 있습니다. 리액트나 뷰 같은 프로젝트들에서도 esm을 사용할 수 있는데, 이는 그 내부적으로 바벨처럼 트렌스파일러를 통해서 import문을 require문으로 바꿔주고 있기 때문에 그렇습니다.

defer 키워드 :

esm에서는 defer 키워드가 default로 적용됩니다. nomodule이라고 명시해 준 경우에 대해서만 선택적으로 defer 키워드를 사용하면 됩니다.

<script type="module" src="main.mjs"></script>
<script nomodule defer src="fallback.js"></script>

스크립트 실행 횟수 :

class script는 매번 다시 평가하지만, module은 단 한번만 평가하기 때문에 단 한번만 실행하게 됩니다.

<script src="classic.js"></script>
<script src="classic.js"></script>
<!-- 2번 실행 -->

<script type="module" src="module.mjs"></script>
<script type="module" src="module.mjs"></script>
<script type="module">import './module.mjs';</script>
<!-- 1번만 실행 -->

ESM은 static하다:

commonjs를 사용하거나, 리액트에서 webpack을 사용하는 경우에는 파일 확장자를 생략하는 경우도 있었습니다. 하지만, esm은 static합니다. static하다는 것이 의미하는 바는 import와 export를 컴파일 시점에 결정하겠다는 것입니다. nodejs나 webpack해서 사용하던 것처럼 실행 중에 그것을 결정하는 것이 아닙니다. esm의 경우엔 문법적으로 static하게 작성하도록 강제하고 있습니다. 때문에 파일 확장자와 경로를 꼭 명시해주어야 합니다.

import {shout} from './lib.mjs';

// 지원안함:
import {shout} from 'jquery';
import {shout} from 'lib.mjs';
import {shout} from 'modules/lib.mjs';

// 지원:
import {shout} from './lib.mjs';
import {shout} from '../lib.mjs';
import {shout} from '/modules/lib.mjs';
import {shout} from 'https://simple.example/modules/lib.mjs';

이것의 또 다른 의미는 반드시 최상단에서 사용해야한다는 것입니다. commonjs같은 경우에는 require문이 함수였기 때문에 if문 안에서도 사용할 수 있었습니다.

if(user.length > 0){
	const math = require('./math.js')
    //... Do something.. 
}

하지만 esm에서는 import가 함수가 아닌 하나의 키워드로 사용되고 있기 때문에 변수에 할당한다던지, if문과 같이 중첩된 구조 안에서 사용되어서는 안됩니다.

dynamic import :

종종 초기 렌더링시 성능을 높이기 위해서 특정 스크립트를 처음에 같이 로드하지 않고, 딱 필요한 순간에만 로드하고 싶을 수도 있습니다. 그럴 때 dynamic import를 사용하면 됩니다. static import의 경우엔 중첩문안에서는 import를 사용할 수 없었지만, dynamic import의 경우엔 사용 가능합니다.

button.addEventListener('click', event => {
    import('./dialogBox.js')
    .then(dialogBox => {
        dialogBox.open();
    })
    .catch(error => {
        /* Error handling */
    })
});

결론 :

자바스크립트는 브라우저만을 지원하는 작은 언어로 시작해 현재까지 이르렀습니다. 언어에 있어서 중요한 모듈 시스템이 자바스크립트 안에선 어떻게 발전되어왔는지 살펴보면서, 자바스크립트 자체의 성장과 현재를 살펴볼 수 있는 시간이었습니다.

글의 첫마디에서 제 글을 읽으면 commonjs가 무엇인지, amd이 무엇인지, esm이 무엇인지, 그리고 js 안에서 모듈시스템이 어떻게 발전해왔는지를 이해할 수 있을 것이라고 약속드렸습니다. 부디 자바스크립트 안에서의 모듈 시스템의 변천사와 현재를 이해하는데에 조금이라도 도움을 얻으셨기를 기대해봅니다. 만약 부족한 점이 느껴지신다면, 제가 참고했던 링크들을 찾아가보시면 더욱 상세한 설명을 확인할 수 있을 것입니다.

그럼 저는 다음 글로 찾아뵙겠습니다👋🏻

ECMAScript modules in browsers
JavaScript modules
JavaScript Modules: A Brief History
Node Modules at War: Why CommonJS and ES Modules Can’t Get Along
JavaScript 표준을 위한 움직임: CommonJS와 AMD
JavaScript Module Systems Showdown: CommonJS vs AMD vs ES2015

profile
맛있는 세상 shdpcks95@gmail.com

1개의 댓글

comment-user-thumbnail
2023년 2월 9일

Useful! google

답글 달기