본문 바로가기
JavaScript,TypeScript/JavaScript

Module System

by 그냥하는거지뭐~ 2024. 8. 20.
목차
1. JavaScript 모듈이 왜 등장했을까
2. 모듈 시스템의 발전 흐름 (CommonJS > AMD -> UMD -> ESM)
3. 마치면서.. (다음 글 예고)

 

1. JavaScript 모듈이 왜 등장했을까 

모듈이 왜 등장했는지부터 알아보자. 그러려면 초창기 JavaScript가 어떤 식으로 사용되었는지부터 알아야 한다. 초기에는 작은 규모의 스크립트만 작성했기 때문에 전역 스코프가 기본적으로 적용되었다. 모든 변수와 함수는 기본적으로 전역 공간에 저장되었고, 이에 따라 서로 다른 스크립트에서 같은 이름의 변수나 함수를 정의하게 되면, 하나가 다른 것을 덮어쓰는 문제가 발생했다. 그러다 보니 유지보수가 심각하게 어려워졌다. 
 
나도 vanilla js로 개발한 첫 프로젝트에서 같은 문제를 맞닥뜨린 적이 있어서 더 와닿는다. 다음 코드를 보자. 

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Example</title>
</head>
<body>
    <h1>Example!</h1>
    <script src="script1.js"></script>
    <script src="script2.js"></script>
</body>
</html>
// script1.js
var globalVar = "This is from script1";

console.log(globalVar);
// script2.js
var globalVar = "This is from script2";

console.log(globalVar);

 
콘솔에는 뭐가 찍힐까? 브라우저가 HTML 파일을 읽을 때 script tag를 마주치면 해당 스크립트를 다운로드하고 실행한 다음에야 다음 줄을 해석한다. 먼저 script1.js가 로드되면 globalVar가 전역에 선언되고, "This is from script1"이라는 값이 할당된다. 그 다음, script2.js가 로드되면, 동일한 이름의 globalVar 변수가 "This is from script2"로 덮어써지게 된다. 따라서 console을 확인해 보면 "This is from script2"가 찍히게 된다. 
 
아직까지는 코드가 복잡하지 않기 때문에 문제가 되지 않겠지만, script가 몇십개, 몇백개가 되면 스크립트 간에 충돌을 피할 수 없을 것이다. 그래서 사람들은 고민하기 시작했다. 어떻게 이 문제를 해결할 수 있을까?
 

Trial 1. 객체를 이용해보자 (NameSpace Pattern)

이 방법은 객체를 정의하고 그 안에 속성과 메서드를 추가하는 방식이다. 모든 모듈을 전역 객체의 속성으로 정의해서 전역 네임스페이스를 최소화하고, 논리적으로 관련된 기능들을 그룹화할 수 있다. 

var MyApp = MyApp || {}; // 기존 네임스페이스가 있으면 재사용, 없으면 새로운 객체 생성

MyApp.module1 = {
    variable1: "Hello",
    function1: function() {
        console.log(this.variable1);
    }
};

MyApp.module2 = {
    variable2: "World",
    function2: function() {
        console.log(this.variable2);
    }
};

MyApp.module1.function1(); // "Hello"
MyApp.module2.function2(); // "World"

 
이 방식은 전역 스코프를 오염시키지 않고, 모듈 간의 명확한 구분이 가능하다는 장점이 있다. 하지만 네임스페이스 객체를 통해 모든 것에 접근해야 하기 때문에 코드가 길어질 수 있다. 또, MyApp이라는 이름을 다른 스크립트에서 사용할 경우 여전히 충돌할 수 있다는 위험이 있다. 다시 말해, 여전히 전역 객체에 의존하고 있기 때문에 네임스페이스 충돌 가능성이 존재하고, 복잡한 애플리케이션에서는 여전히 의존성 관리가 어렵다는 문제를 해결하지 못했다. 
 
 

Trial 2. 범위를 제한해보자 (IIFE, Immediately Invoked Function Expression)

이 방식은 즉시 실행 함수를 사용한다. 내부 변수와 함수가 전역 스코프에 노출되지 않게 된다. 

(function() {
    var privateVariable = 'I am private';
    function privateFunction() {
        console.log(privateVariable);
    }
    privateFunction(); // "I am private"
})();

하지만, 외부에서 접근할 수 있는 방법이 없다. 
 
 

Trial 3. 밖에서 필요한건 return시키자 (Revealing Module Pattern)

즉시 실행 함수를 사용하여 모듈을 정의하고, 공개하고자 하는 메서드나 속성을 반환하는 구조이다. 

var myModule = (function() {
    var privateVar = 'I am private';
    
    function privateFunction() {
        console.log(privateVar);
    }
    
    function publicFunction() {
        privateFunction();
    }
    
    return {
        publicFunction: publicFunction
    };
})();

myModule.publicFunction(); // "I am private"

이를 통해 내부 구현을 숨기고, 필요한 부분만 외부에 노출하여 모듈화를 구현했고, 코드 구조가 명확해졌다는 장점이 있다. 하지만 여전히 의존성 관리가 어렵고, 모듈의 재사용성이 제한적이다. 
 


2. 모듈 시스템의 발전 흐름 

2.1. CommonJS (2009)

#Node.js의 모듈 시스템
2005년쯤부터 웹 생태계가 엄청난 속도로 발전하기 시작했다. 2005년, AJAX가 Google Maps와 같은 혁신적인 웹 애플리케이션에 사용되면서 비동기 데이터 로딩이 본격적으로 주목받기 시작했고, 2006년에는 JQuery가 등장했다. 그리고 2008년, 구글이 JavaScript 엔진 V8을 공개하면서 JavaScript의 속도가 크게 개선됐다. 이로 인해 서버사이드에서도 JavaScript를 사용할 수 있으면 좋겠다는 의견이 많아지기 시작했다(당시에는 브라우저에서만 동작했음). 그러다 Ryan Dahl이 2009년에 server-side js 환경인 node.js를 만들었다. 이로 인해 서버에서도 JavaScript를 사용할 수 있게 되었는데, 복잡한 애플리케이션 구현을 위해서는 모듈 방식이 필요했고, 그래서 등장한게 CommonJS 표준이다. CommonJS는 지금까지도 node.js에서 사용되는 모듈 시스템이다. (최근에는 ESM도 지원) 
 
CommonJS의 간단한 예시를 보자. Node.js를 한번이라도 공부해봤다면 익숙할 것이다. 

// math.js
function add(a, b) {
    return a + b;
}

function subtract(a, b) {
    return a - b;
}

// 모듈에서 함수들을 외부로 내보냄
module.exports = {
    add: add,
    subtract: subtract
};
// app.js
const math = require('./math');

const sum = math.add(10, 5);
const difference = math.subtract(10, 5);

 
모듈을 내보낼 때는 exports, 불러올 때는 require을 사용한다. 그리고 파일별로 스코프가 분리된다. 
 
 

2.2. AMD (Asynchronous Module Definition) (2009)

#브라우저 최적화 비동기 모듈 시스템
Node.js에서 모듈을 관리하기 위해 개발된 CommonJS를 서버에서만 사용하기에는 너무 아까워서 브라우저에서도 사용하고자 하는 요구가 늘어났다. 브라우저에서 JavaScript 애플리케이션이 복잡해짐에 따라 모듈화의 필요성이 커졌기 때문이다. 하지만, CommonJS를 그대로 가져오려면 문제가 있었다. CommonJS의 require 함수는 동기적으로 동작하므로, 브라우저에서 모듈을 순서대로 하나씩 로드해야 한다는 문제였다. 
 
서버에서는 파일 시스템에 직접 접근해서 필요한 모듈을 빠르게 불러올 수 있기 때문에 큰 문제가 되지 않는다. 하지만 브라우저에서는 직접 접근하는게 불가능하고, 모든 모듈이나 라이브러리를 네트워크를 통해 로드해야 한다. 이 경우에 동기 로딩 방식은 네트워크 지연을 발생시키고, 페이지 로딩 속도가 느려질 수 있어서 UX에 치명적이다. 
 
이 문제를 해결하기 위해 CommonJS의 브라우저 지원을 고민하던 일부 구성원들이 독립하여, 브라우저 환경에 최적화된 비동기 모듈 시스템인 AMD(Asynchronous Module Definition)를 개발했다. AMD는 비동기 로딩을 통해 브라우저에서의 성능 문제를 해결하며, 이를 기반으로 여러 라이브러리들이 등장하기 시작했는데, 대표적인 게 RequireJS이다. RequireJS는 브라우저에서 JavaScript 모듈을 효율적으로 관리하고 로드할 수 있게 해주는 강력한 도구로 자리잡았다. 
 
다음과 같이 모듈을 정의하고,

// math.js
define([], function() {
    var add = function(x, y) {
        return x + y;
    };
    
    return {
        add: add
    };
});

 
다른 모듈에서 사용하려면 require을,

// main.js
require(['math'], function(math) {
    var sum = math.add(2, 3);
    console.log(sum); // 5
});

 
모듈 간의 의존성을 관리할 때는 자동으로 관리된다. 

// app.js
define(['math', 'util'], function(math, util) {
    var sum = math.add(2, 3);
    util.log(sum); // util 모듈의 log 함수 사용
});

app.js를 로드할 때, 먼저 math.js와 util.js를 로드하고, 로드가 끝난 후에 app.js의 콜백 함수가 실행된다.
 

c.f)
AMD가 등장한 시기는 client side 애플리케이션이 급격하게 복잡해지던 시기와 맞물린다. 이 시기에 등장한 라이브러리와 프레임워크들이 SPA 아키텍처 개념을 본격적으로 도입하기 시작했다. AMD는 이러한 복잡한 SPA를 개발할 때 매우 유용했다. SPA의 규모가 커지면서 모듈화된 코드 구조와 비동기 로딩의 필요성이 증가했고, RequireJS와 같은 도구들이 모듈을 비동기적으로 로드하고, 의존성을 관리하는 데에 사용되었다. 

Backbone.js(2010), Knockout.js(2010), Angular(2010 출시, 2012 본격 사용 시작)

 
 

2.3. UMD (Universal Module Definition) (2009-2010)

#다양한 환경에서의 호환성
CommonJS는 서버에, UMD는 브라우저에 최적화된 방식이어서 다른 프로젝트나 환경에서 모듈 시스템이 호환되지 않는 문제가 발생했다. 예를 들어 브라우저에서 AMD, 서버에서 CommonJS를 사용하는 경우 같은 모듈을 재사용할 수 없거나, 동일한 기능을 하는 코드를 두 번 다른 버전으로 작성해야 하는 비효율적인 상황이 발생했다. 특히 라이브러리나 프레임워크를 만드는 개발자들은 자신의 코드가 다양한 환경에서 사용되어야 했다. 이러한 상황에서, 브라우저와 서버에서 모두 사용할 수 있는 모듈 시스템이 필요하게 되었다. 
 
그래서 등장한게 UMD(Universal Module Definition)이다. UMD는 CommonJS, AMD, 전역 변수를 모두 지원하는 방식으로 설계되었다. 현재 환경이 CommonJS인지, AMD인지, 또는 전역 변수 방식인지를 감지하고, 그에 맞게 모듈을 정의한다. 

(function (root, factory) {
    if (typeof define === 'function' && define.amd) {
        // AMD 환경 (예: RequireJS)
        define([], factory);
    } else if (typeof module === 'object' && module.exports) {
        // CommonJS 환경 (예: Node.js)
        module.exports = factory();
    } else {
        // 전역 변수로 정의 (예: 브라우저 환경)
        root.MyModule = factory();
    }
}(this, function () {
    // 모듈의 실제 구현
    return {
        sayHello: function() {
            return "Hello, world!";
        }
    };
}));

 
 

2.4. ESM (2015)

#ECMA가 만든 모듈 시스템
JavaScript 생태계가 계속해서 발전하면서, 서로 다른 모듈 시스템들은 개발자들에게 부담이 되었고, 호환성 문제를 포함하여 각종 혼란을 해결하기 위해 JavaScript의 공식 표준화 기구인 ECMA가 나서기 시작했다. 이로 인해 등장한 것이 바로 ESM(ECMAScript Modules)이다. ESM은 2015년에 발표된 ECMAScript 2015(ES6)에서 처음으로 도입되었다. 자바스크립트 언어에 내장된 모듈 시스템으로, 모든 실행 환경(브라우저, Node.js, etc)에서 일관되게 동작하는 표준 모듈 시스템을 제공하는 것을 목표로 했다. 
 
모듈을 내보내는 것은 다음과 같이 export로, 

// math.js
export function add(x, y) {
    return x + y;
}

export const pi = 3.14159;

불러오는 것은 import로

// app.js
import { add, pi } from './math.js';

console.log(add(2, 3)); // 5
console.log(pi);        // 3.14159

프론트엔드 개발자들에게는 굉장히 익숙한 문법이다.
 
ESM의 특징을 간단히 정리해보면 다음과 같다. 1번과 4번은 더 자세히 봐보자. 

1. 정적 모듈 구조 (2.4.1.)
2. 비동기 로딩
3. 모듈 스코프
4. 모든 환경에서 일관되게 작동 (2.4.2.)

 

2.4.1. ESM의 정적 모듈 구조 vs Node.js의 동적 모듈 로딩

ESM정적 모듈 구조를 채택하고 있다. 이 말은 ESM이 모듈을 정적으로 분석하고 로드할 수 있다는 말이다. import와 export는 파일이 실행되기 전에 정적으로 해석되고, 자바스크립트 엔진은 코드를 실행하기 전에 어떤 모듈이 필요하고 어떻게 연결되는지 모든 모듈의 의존성을 파악할 수 있다. 이로 인해 tree-shaking과 같은 최적화 기법이 가능해진다. 
 
tree-shaking의 간단한 예를 살펴보자. 

// utils.js
export function usefulFunction() {
    console.log('This is a useful function');
}

export function unusedFunction() {
    console.log('This function is not used anywhere');
}

// main.js
import { usefulFunction } from './utils.js';

usefulFunction(); // This is a useful function

 
 
여기서 unusedFunction은 main.js에서 사용되지 않으므로, tree-shaking 과정에서 번들에 포함되지 않을 수 있다. 이렇게 최적화를 할 수 있는건 ESM이 정적 모듈 구조를 가지고 있기 때문이다. 
 
반면, Node.js동적 모듈 로딩 방식을 사용한다. require 함수는 런타임에 모듈을 로드한다. 즉, 코드가 실행될 때 실제로 require가 호출되어야만 어떤 모듈이 로드되는지 결정된다는 뜻이다. require 함수는 함수 호출이기 때문에 조건부 로딩이나 함수 내부에서의 로딩이 가능해서 유연하다는 장점(스크립트 중간에서 require할 수 있음)이 있지만, JavaScript 엔진이 require 호출을 미리 파악할 수 없기 때문에 모듈 의존성을 미리 분석하기는 어렵다. 즉, ESM에서 가능했던 tree-shaking 기법을 적용하기가 어려워서 사용되지 않는 코드도 번들에 포함되어 성능이 저하될 수 있다. 
 
 
 

2.4.2. ESM의 도입 과정

2017년부터 대부분의 최신 브라우저는 ESM을 기본적으로 지원하기 시작했다. HTML <script> 태그에 type="module" 속성을 추가하면, 브라우저는 해당 스크립트를 ESM으로 해석하고 비동기적으로 로드한다.
 
브라우저는 그렇다 쳐도 Node.js는 CommonJS를 잘 사용하고 있었지 않나? 라는 의문이 들 수 있다. 맞다. Node.js는 원래 CommonJS를 모듈 시스템으로 채택했지만, v12.17.0부터 ESM도 지원하게 되었다. Node.js에 ESM이 도입된 이유는 여러 가지가 있지만, 그 중 한가지가 모듈 간 순환 참조 문제이다. 

💡 순환 참조(Circular Dependency)란?
순환 참조는 두 개 이상의 모듈이 서로를 참조할 때 발생한다. 예를 들어, 모듈 A가 모듈 B를 require로 가져오고, 동시에 모듈 B가 모듈 A를 require로 가져오는 경우이다. 

 
CommonJS는 정적 바인딩(Static Binding) 방식을 사용한다. 모듈이 처음 require될 때, 그 시점의 값을 가져와서 캐싱하고 이후 변경 사항은 반영하지 않는다는 의미이다. 순환 참조가 발생하는 경우에는, 한 모듈이 완전히 초기화되기 전에 다른 모듈이 이를 참조하려고 하면, 불완전한 객체를 가져오게 된다. 
 
ESM은 모듈을 Live Binding 방식으로 처리한다. 모듈에서 내보낸 값이 참조할 때마다 최신 상태로 유지되는 것을 의미한다. 즉, ESM에서는 모듈 간에 참조가 발생하더라도, 모듈이 완전히 초기화된 후의 최신 값을 참조할 수 있다. 모듈이 다시 로드되거나 값이 변경되어도 이를 참조하는 다른 모듈에서 최신 값을 사용할 수 있다. 
 
이 외에도 Node.js에서 ESM을 도입하게 된 이유는 순환 참조 뿐만 아니라, 표준화된 모듈 시스템이라는 점, tree-shaking과 같은 최적화가 가능하다는 점, 브라우저와 서버 간의 호환성이 향상된다는 점 등이 있다. 사용 방법은 파일 확장자를 .mjs로 하거나 package.json에 "type": "module"을 추가하여 ESM을 활성화할 수 있다. 
 
 

 


3. 마치면서.. 다음 글 예고

이렇게 모듈 시스템이 도입되면서 보다 구조화되고 모듈화된 방식으로 개발할 수 있게 되었다. 덕분에 복잡한 애플리케이션을 쉽게 구축할 수 있게 되었다. 하지만, 모듈 시스템의 도입으로 인해서 브라우저에서는 수많은 작은 JavaScript 파일을 개별적으로 로드하는 문제가 발생했다. 이 과정에서 네트워크 요청이 급격하게 증가하고, 페이지 로딩 속도가 느려지는 등 성능 문제가 발생하기 시작했다. 
 
이러한 문제를 해결하기 위해 번들러가 등장했다. 간단히 설명해보자면, 번들러는 여러 JavaScript 모듈 파일들을 하나의 파일로 묶어주는 도구이다. 다음 글에서는 번들러의 역할, 역사와 더불어 여러 번들러에 대해 다뤄보겠다. 
 


Reference

- https://deemmun.tistory.com/86
- https://www.youtube.com/watch?v=Mah0QakFaJk
 
 
 

'JavaScript,TypeScript > JavaScript' 카테고리의 다른 글

JavaScript가 비동기를 다루는 방법  (3) 2024.04.05