ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 모듈 프로그래밍의 역사와 ESM
    🍌 자바스크립트 2025. 9. 13. 17:35

    🔍 모듈(Module)?

    프로젝트 규모가 커지면서 Javascript 코드를 여러 파일과 폴더에 나누고 서로가 불러와 사용할 수 있도록 해주는 시스템의 필요성이 커졌습니다.

    JavaScript에서는 각 파일을 서로 다른 모듈로 생각해 각 모듈에서 변수나 함수를 공유하기 위해 이를 내보내고 불러올 수 있어야 합니다.

    사용하고 있는 모듈 시스템과 사용하려는 모듈이 어떤 시스템을 따르고 있는지에 따라 사용 방식이 달라집니다.

     

    👍 모듈 장점

    1️⃣ 유지보수 용이

    • 모듈화가 잘 되어 있다면 의존성을 줄여 기능 개선이나 수정에 용이

     

    2️⃣ 네임스페이스화

    • 전역스코프에 존재하는 변수명이 겹치는 경우가 있을 수 있다.
    • 모듈로 분리하면 모듈만의 네임스페이스를 가지기 때문에 이런 문제를 해결할 수 있습니다.

     

    3️⃣ 재사용성

    • 같은 코드를 반복하지 않고 모듈로 분리해 재활용 가능

     

    🧩 모듈 시스템

     

    ↖️ 초기 Javascript 프로그램

    1️⃣ 전역 변수 (Global Variables)

    자바스크립트 초창기에는 웹 페이지에서 간단한 DOM 조작이나 이벤트 처리 정도만 필요했습니다.

    그래서 모듈 시스템은 없었고 모든 스크립트가 전역 스코프(Global Scope) 에 존재했습니다.

    • <script> 태그를 사용해 불러올 수 있었습니다.
    <!-- index.html -->
    <script src="a.js"></script>
    <script src="b.js"></script>
    <script src="c.js"></script>

    이 방식의 문제점은 전역 스코프를 공유한다는 것입니다.

    • 파일이 여러개로 나누어져 있어도 실제 동작할 때는 하나의 파일안에 있는 것처럼 전역 스코프를 공유

     

    ⚠️ 문제점

    • 이름 충돌(Name Collision): 변수나 함수 이름이 겹치면 덮어써짐.
    • 코드 관리 어려움: 프로젝트가 커지면 어떤 함수가 어디서 정의되었는지 추적하기 힘듬.
    • 의존성 관리 불가: 어떤 코드가 먼저 실행돼야 하는지 수동으로 <script> 순서를 관리해야 함.

     

    🖥️ 예시로 확인해보기

    / a.js
    var number = 1;
    console.log(`a.js : ${number}`); 
    
    // b.js
    var number = 2;
    console.log(`b.js : ${number}`);
    
    // c.js
    var number = 3;
    console.log(`c.js : ${number}`);
    <!-- index.html -->
    <!DOCTYPE html>
    <html lang="en">
      <head>
        <meta charset="UTF-8" />
        <script src="a.js"></script>
        <script src="b.js"></script>
        <script src="c.js"></script>
        <script>
          console.log(`number : ${number}`);
        </script>
      </head>
      <body></body>
    </html>

     

    결과: number : 3 (마지막에 선언된 값으로 덮어써짐)

     

    📖 전역 스코프

    • 어느 곳에서든 해당 변수에 접근할 수 있다

     

    2️⃣ 네임스페이스

    📖 Namespace = Name + Space

    • 이름이 존재하는 범위(공간) 를 뜻합니다.
    • 즉, 이름 충돌을 방지하기 위해 서로 다른 이름의 공간 을 만들어 구분한다는 의미

     

    이처럼 전역 변수의 혼란을 줄이기 위해 하나의 전역 객체만 두고 그 안에 모듈을 넣는 방식 등장.

     

    💡 전역 변수 하나만 만들고 나머지는 그 안에 계층적으로 관리

     

    var MyApp = {};
    MyApp.utils = {};
    MyApp.components = {};
    MyApp.components.menu = {
      init: function() {
        console.log('Menu initialized');
      }
    };
    
    // 사용
    MyApp.components.menu.init();

     

     

    장점 단점
    전역 오염(Global Pollution) 완화 → 충돌 가능성 ↓ 여전히 의존성 관리가 힘듬 → 순서 보장 문제 존재
    모듈을 비슷한 그룹끼리 묶을 수 있음 모듈이 커질수록 전역 객체 하나가 비대해짐
      모듈 간의 캡슐화가 완벽하지 않음 → 내부 상태를 쉽게 변경 가능
      서로 호출할 때 객체의 속성(이름)을 많이 적어야해 불편

     

     

     

    3️⃣ IIFE (Immediately Invoked Function Expression) — 즉시 실행 함수 패턴

    📖 IIFE

    함수 스코프를 활용해 캡슐화와 모듈화를 더 엄격히 하고자 함.

    즉시 실행되는 함수를 이용해 독립적인 스코프를 만들고 필요한 것만 반환. (함수 스코프 이용)

     

    🤖 IIFE 예시

    (function () {
      var privateVar = "외부에서 접근 불가";
      function privateFunction() {
        return "비공개 함수";
      }
    })();

     

    이 방식을 사용하면 함수 스코프 때문에 외부에서 안에 있는 함수에 접근이 되지 않습니다.

    따라서 전역변수의 개수는 0 이 됩니다.

     

    🤔 외부에서 접근할 수 없도록 보호는 했는데 밖에서 쓰고 싶다면 어떻게 해야하지?

    • return
    const MyModule = (function () {
      var privateCounter = 0;
    
      function privateMethod() {
        console.log('Private method called');
      }
    
      return {
        increment: function() {
          privateCounter++;
          privateMethod();
        },
        getCount: function() {
          return privateCounter;
        }
      };
    })();
    
    // 사용 예시
    MyModule.increment(); // "Private method called"
    console.log(MyModule.getCount()); // 1
    // console.log(MyModule.privateCounter); // undefined (접근 불가)

     

    이처럼 하나의 모듈처럼 생각해서 프라이빗, 퍼블릭을 나누기 시작

     

    장점 한계
    캡슐화: 외부에서 내부 변수나 함수에 접근 불가 다른 모듈을 가져오려면 매개변수로 전달해야 함
    전역 오염 방지: 전역 변수 생성을 최소화 비동기 로딩 불가: 모든 코드가 즉시 실행
    충돌 방지: 다른 라이브러리와의 이름 충돌 해결  

     

    🌠 V8 엔진의 등장

    2008년 Google V8 엔진이 등장하고 2009년 Ryan Dahl이 Node.js를 개발했습니다.

     

    🤔 자바스크립트가 클라이언트 사이드뿐만 아니라 서버 사이드에서도 동작할 수 있지 않을까?

    • 이런 복잡한 애플리케이션을 구현하기 위해서는 본격적인 모듈 시스템이 필요할 것이다..!

    서버 사이드 자바스크립트(Node.js)가 등장하며 복잡한 애플리케이션을 구현하기 위해서 모듈 시스템의 필요성이 폭발적으로 증가했습니다.

     

    ⭐ Common JS

    📖 Common JS

    파일 단위로 모듈을 정의하고 require/module.exports로 가져오기 / 내보내기.

     

    🧩 module.exports, exports 객체를 통해 모듈을 내보내고 require 함수를 통해 모듈을 불러오기

    // math.js
    function add(a, b) {
      return a + b;
    }
    
    function multiply(a, b) {
      return a * b;
    }
    
    module.exports = { add, multiply };
    const math = require('./math');
    // const { add, multiply } = require('./math');
    
    console.log(math.add(2, 3)); // 5
    console.log(multiply(4, 5)); // 20

     

    👍 장점

    • 각 파일이 독립적인 모듈 스코프를 가짐
    • require를 통해 의존성 자동 해결
    • 서버 사이드(Node.js)에서 매우 유용
    • 조건부 로딩 가능: if문 안에서도 require 사용 가능

     

    🧩 특징

    • 동기적(Synchronous) 로딩: require는 해당 모듈이 완전히 로드될 때까지 기다림
    • 런타임 의존성 해결: 실행 시점에 의존성을 해결
    • 캐싱: 한 번 로드된 모듈은 캐시되어 재사용

     

    ⚠️ CommonJS 한계

    • 브라우저에서 네이티브 지원 X → 번들러(Browserify, Webpack) 필요
    • require는 동기 방식 → 브라우저 환경에서 네트워크 지연으로 인해 비효율적
    • Tree shaking 어려움: 사용하지 않는 코드 제거가 복잡

     

    📖 네이티브

    프로그래밍에서 네이티브는 기본적으로 제공되는 것, 별도 도구 없이 즉시 사용 가능한 것을 의미

    따라서 “자바스크립트 실행 환경(브라우저 또는 Node.js)이 번들러나 추가 도구 없이 바로 이해하고 실행할 수 있다”를 의미

     

    🌐 AMD (Asynchronous module definition)

     

    CommonJS가 서버 환경에 적합하다면 브라우저 환경에서도 사용하기 위해서는 어떻게 해야 할까? 네트워크를 통해 모듈을 로드해야 하는 브라우저 환경에서는 비동기 로딩이 필요했습니다.

     

    따라서 브라우저 환경을 위한 브라우저 모듈의 표준을 만들고자 했으며 AMD 그룹은 자바스크립트 모듈과 의존성을 비동기적으로 로드하는 방법을 정의하는 개방형 표준을 공개했습니다.

     

    AMD를 구현한 라이브러리 - require.js

    // 모듈 정의
    define(['jquery', 'underscore'], function($, _) {
      function doSomething() {
        // jQuery와 underscore 사용
        return _.map([1,2,3], function(n) { return n * 2; });
      }
    
      return {
        doSomething: doSomething
      };
    });
    
    // 모듈 사용
    require(['myModule'], function(myModule) {
      console.log(myModule.doSomething()); // [2, 4, 6]
    });

     

     

    🤔 Common.js 는 브라우저에서 사용하지 못하나? - browserify

    browserify 라는 라이브러리가 등장

     

    commons.js 를 이용해서 노드에서처럼 개발하면 브라우저에서 동작하도록 번들링을 지원.

    📖 번들링

    • 의존성을 파악해 하나의 파일로 만드는 작업

     

    💡 따라서 최종 번들 파일이 script src에 존재

    browserify main.js -o bundle.js
    
    <script src="bundle.js"></script>

     

    🔄 UMD (Universal Module Definition)

    📖 모듈 방식을 분기처리를 통해 관리

     

    자바스크립트 생태계가 점점 커져 CommonJS 와 AMD 모듈 시스템을 모두 지원해야 하는 상황이 생기게 되었고 (브라우저/서버 런타임을 모두 지원) 이를 위해 모듈 관리 패턴 UMD가 탄생했습니다

     

    (function (root, factory) {
      if (typeof define === 'function' && define.amd) {
        *// AMD*
        define(['jquery'], factory);
      } else if (typeof module === 'object' && module.exports) {
        *// CommonJS*
        module.exports = factory(require('jquery'));
      } else {
        root.MyModule = factory(root.jQuery);
      }
    }(typeof self !== 'undefined' ? self : this, function ($) {
    
      function MyModule() {
        *// 모듈 로직*
      }
    
      return MyModule;
    }));

     

    🚀 ES Modules

    📖 ES Modules

    자바스크립트 언어 차원에서 표준 모듈 시스템을 도입.

    2020년 5월 Node.js (v12.17.0)에서 공식적으로 ESM을 지원하기 시작

     

     

    ECMAScript

    • Javascript를 만든 표준 기구
    • 우리가 모듈을 정의하겠다!! → ESModule 등장

     

    // math.js
    export function add(a, b) {
      return a + b;
    }
    
    export const PI = 3.14159;
    
    // 기본 내보내기
    export default function multiply(a, b) {
      return a * b;
    }
    
    // 여러 개를 한번에 내보내기
    const subtract = (a, b) => a - b;
    const divide = (a, b) => a / b;
    export { subtract, divide };
    // app.js
    import multiply, { add, PI, subtract } from './math.js';
    import * as MathUtils from './math.js'; // 모든 내보내기를 객체로
    
    console.log(add(2, 3)); // 5
    console.log(multiply(4, 5)); // 20 (기본 내보내기)
    console.log(PI); // 3.14159
    console.log(MathUtils.divide(10, 2)); // 5

     

    ⭐ ESModule의 특징 - 정적 구조 (Static Structure)

    *// main.js
    // ✅ 정적 분석 가능 - 최상위에서 import*
    import { utils } from './utils.js';
    
    *// ❌ 동적 import는 ES2020에서 별도 지원*
    if (condition) {
      import('./dynamic.js').then(module => {
        *// 동적 import는 Promise를 반환*
      });
    }
    • main.js가 실행되기 전에 브라우저 또는 번들러가 ./utils.js 모듈을 반드시 로드해야 한다는 사실을 바로 알 수 있습니다.
    • 즉, 분석 시점에서 모듈 의존성 트리를 확정할 수 있습니다.

     

    main.js → utils.js

     

    이처럼 import 문법이 코드 최상위(Top-Level) 에 있어야 하며 조건문이나 함수 내부에 있으면 안 됩니다. → 이렇게 하면 의존성이 코드 분석 단계에서 명확히 드러나기 때문입니다.

     

    📖 정적 분석(Static Analysis)이란?

    정적 분석은 프로그램을 실제로 실행하지 않고 코드 자체만 보고 분석하는 과정을 말합니다.

     

    🏄‍♂️ 이를 통해 빌드 도구(Webpack, Rollup, Vite 등)나 브라우저가 다음과 같은 작업을 합니다.

     

    1️⃣ 의존성 그래프 생성

    • 코드 실행 전에 import 경로를 따라가며 모듈 간 관계를 파악합니다.
    main.js
     ├─ utils.js
     └─ api.js

     

    2️⃣ 트리 쉐이킹(Tree-Shaking)

    • 사용되지 않는 코드(Dead Code)를 실행 전 단계에서 미리 제거할 수 있습니다.
    // utils.js
    export function add() {}
    export function subtract() {} // 사용되지 않음

    → main.js에서 add만 쓰면, subtract는 최종 번들에서 제거됨.

     

    3️⃣ 최적화

    • 브라우저가 어떤 모듈을 먼저 로드해야 할지 미리 결정.
    • 캐싱 전략 수립 가능.

     

    💡 정적 구조가 중요한 이유 정리

     

    🤖 브라우저가 효율적으로 모듈을 로드

    • 브라우저는 페이지 로드 전에 미리 **모든 모듈을 파악(정적 분석)**하고 병렬, 비동기 다운로드나 캐싱 최적화를 할 수 있습니다.
    <script type="module" src="main.js"></script>
    • main.js의 import를 분석하여 의존성을 미리 파악하고 병렬, 비동기로 로드합니다.
      • utils.js
      • api.js
      • constants.js
      • …

     

    👍 번들러가 코드 최적화 가능

     

    Webpack, Rollup 같은 번들러가 동작할 때 import 문법은 정적으로 확정된 모듈 구조 덕분에 사용하지 않는 코드를 미리 제거하고 필요한 모듈만 하나의 파일로 묶을 수 있습니다.

     

    🌐 브라우저에서 네이티브 지원: type=”module”

    *<!-- 전통적인 스크립트 (전역 스코프 공유) -->*
    <script src="legacy.js"></script>
    
    *<!-- ES Module (모듈 스코프) -->*
    <script type="module" src="module.js"></script>
    
    *<!-- 인라인 모듈 -->*
    <script type="module">
      import { myFunction } from './utils.js';
      myFunction();
    </script>

     

     

    ✅ 실제 예시로 비교해보기

     

    📌 이전 전역 스코프 공유하는 방식

    *// a.js*
    var number = 1;
    console.log(`a.js : ${number}`);
    
    *// b.js*
    console.log(`b.js : ${number}`); *// a.js의 number에 접근 가능*
    var number = 2;
    console.log(`b.js : ${number}`);
    <!DOCTYPE html>
    <html lang="en">
      <head>
        <meta charset="UTF-8" />
        <script src="a.js"></script>
        <script src="b.js"></script>
        <script>
          console.log(`global : ${number}`); *// 2 출력*
        </script>
      </head>
      <body></body>
    </html>

     

     

    📌 ES Modules 방식 (모듈 스코프)

    *// a.js*
    const number = 1;
    console.log(`a.js : ${number}`);
    export default number;
    
    *// b.js*  
    import a_number from './a.js';
    console.log(`imported from a: ${a_number}`);
    const number = 2;
    console.log(`b.js : ${number}`);
    export default number;
    <!DOCTYPE html>
    <html lang="en">
      <head>
        <meta charset="UTF-8" />
        <script type="module">
          import a_number from './a.js';
          import b_number from './b.js';
          console.log(`a_number : ${a_number}`); *// 1*
          console.log(`b_number : ${b_number}`); *// 2*
        </script>
      </head>
      <body></body>
    </html>

     

    🆚 ES Modules vs CommonJS 비교

    특징 ES Modules  CommonJS
    문법 import / export require() / module.exports
    로딩 정적분석 + 비동기 동적 + 동기
    Tree Shaking ✅ 지원 ❌ 어려움
    브라우저 지원 ✅ 네이티브 지원 ❌ 번들러 필요
    Top-level await ✅  지원 ❌ 불가능
    조건부 로딩 동적 import 필요 ✅ 쉬움

     

    ⬆️ Top-level await

    📖 Top-level await

    async 함수 내부가 아닌 모듈의 최상위 레벨에서 바로 await 키워드를 사용할 수 있도록 해주는 기능입니다.

     

     

    ↖️ Top-level await 이전 방식

    // ❌ Top-level await 불가능
    const data = await fetch('/api/data'); // SyntaxError
    • 이전까지 await는 반드시 async 함수 안에서만 사용 가능했습니다.

    따라서 모듈 최상위에서 비동기 작업을 하려면 반드시 async 함수로 한 번 감싸야 했어요.

     

    // 기존 해결 방법
    async function init() {
      const data = await fetch('/api/data');
      console.log(data);
    }
    init();

     

     

    ↗️ Top-level await 이후 방식

    ES2022(ES13)부터는 모듈의 최상위에서 바로 await를 사용 가능하게 되었습니다.

    // ✅ Top-level await 가능 (ESM 전용)
    const response = await fetch('/api/data');
    const data = await response.json();
    console.log(data);

     

    즉, async 함수 없이도 최상위에서 await 사용 가능!

     

    🤔 왜 필요한가?

    Top-level await의 가장 큰 목적은 모듈 간의 비동기 초기화를 간단하게 처리하기 위함입니다.

    • ex) 모듈이 로드될 때 외부 API를 호출하거나, DB 연결 같은 비동기 초기화가 필요할 수 있습니다.

     

    // db.js
    export const db = await connectToDatabase(); // DB 연결 완료 후 export
    // main.js
    import { db } from './db.js';
    
    const result = await db.query('SELECT * FROM users');
    console.log(result);
    • db.js에서 await를 사용하면 connectToDatabase()가 완료될 때까지 main.js 실행이 자동으로 대기됩니다.
    • 이렇게 하면 초기화 코드가 매우 간단해지고 모듈의 동기적인 로딩 흐름을 유지할 수 있습니다.

     

    🔍 왜 CommonJS는 불가능할까?

    • CommonJS는 동기적 로드 모델
      • require()는 파일을 읽고, 파싱하고, 실행하는 과정이 모두 동기적으로 진행됩니다.
      • 즉, require()가 끝나기 전까지 다음 코드가 실행되지 않아요.
      • 만약 await를 넣어버리면 require()가 비동기로 바뀌어야 하는데 CJS의 근본적인 설계와 맞지 않음 → 호환성 문제 발생

     

    • 기존 생태계와의 호환성
      • CJS는 오래된 Node.js 패키지들과의 호환성이 중요하기 때문에 기존 동기 모델을 깨지 않기 위해 Top-level await을 도입하지 않음.

     

    🏄‍♂️ ESM에서 Top-level await 동작 원리

    ESM은 기본적으로 비동기 모듈 시스템이기 때문에 모듈 로딩 시 Top-level await가 있으면 해당 모듈이 완전히 초기화될 때까지 기다립니다.

     

    🖥️ 예시

    // data.js
    export const data = await fetch('<https://api.example.com>').then(res => res.json());
    // main.js
    import { data } from './data.js';
    console.log(data); // Top-level await가 끝날 때까지 자동으로 대기
    1. main.js가 data.js를 import
    2. data.js의 await fetch(...) 실행
    3. 완료될 때까지 main.js 실행이 멈춤(대기)
    4. data 값이 준비되면 main.js 실행 재개

     

    즉, ESM의 import는 Promise 기반으로 작동한다고 볼 수 있습니다.

     

    🔗 모듈 간 의존성

    Top-level await이 여러 모듈에 걸쳐있으면 모듈 로딩 순서가 체인처럼 연결됩니다.

     

    // step1.js
    export const step1 = await new Promise(resolve => {
      setTimeout(() => resolve('step1 done'), 1000);
    });
    // step2.js
    import { step1 } from './step1.js';
    export const step2 = step1 + ' -> step2 done';
    // main.js
    import { step2 } from './step2.js';
    console.log(step2);

     

    🏄‍♂️ 실행 순서

    main.js (대기)
      ↓
    step2.js (대기)
      ↓
    step1.js (1초 기다림)
      ↓
    step2 실행 후 main.js 실행

     

    👍 Top-level await의 장단점

    ✅ 장점 1. 비동기 초기화 코드 간결화

    // 기존 방식
    export let config;
    async function loadConfig() {
      config = await fetchConfig();
    }
    await loadConfig(); // 불필요한 함수 래핑
    
    // Top-level await
    export const config = await fetchConfig(); // 깔끔!
    

     

    ✅ 장점 2. 의존성 관리 단순화

    • 여러 모듈이 체인처럼 연결되어 있어도 자동으로 로딩 순서를 맞춰줌.

     

    ✅ 장점 3. ESM의 비동기적 특성과 자연스러움

    • import 자체가 Promise 기반 → Top-level await와 궁합이 좋음.

     

    ❌ 단점 1. 모듈 로딩 속도 저하

    하나의 모듈이 await 때문에 멈추면 그 모듈을 사용하는 다른 모듈들도 전부 기다려야 합니다.

     

    ❌ 단점 2. 병목 현상 가능성

    • 여러 모듈이 Top-level await에 의존할 경우 초기 로딩이 매우 느려질 수 있음.

     

    📌 번들러 등장 - Webpack

    ⚠️ 개발자들이 혼란스러워짐.

    • common.js 에서 esm을 가져올 수 없음
    • esm 에서 common.js 가져올 수 있지만 변경이 필요

     

    이처럼 프로젝트에서 두 모듈 시스템이 섞이면 관리가 매우 어려워졌습니다.

    "어떤 모듈 시스템을 사용할지" + "브라우저 호환성 문제"가 큰 고민거리로 떠올랐습니다.

     

    이 혼란을 해결하기 위해 등장한 도구가 번들러(Bundler) 입니다.

    • Webpack, Rollup, Parcel, Vite 등

     

    📖 번들러 역할

    여러 모듈 시스템(CJS, ESM 등)으로 작성된 파일들을 하나의 파일로 묶고 브라우저에서 실행 가능한 형태로 변환

     

     

    🔍 번들러가 필요한 이유

    1. 브라우저 호환성
      • 모든 브라우저가 ES Modules를 지원하지는 않음
      • 번들러가 ESM 코드를 구형 브라우저에서도 동작 가능한 코드로 변환
    2. 성능 최적화
      • 여러 파일을 하나로 합쳐 HTTP 요청 수 감소
      • 코드 압축 및 난독화
      • Tree shaking으로 불필요한 코드 제거
    3. 다양한 모듈 시스템 통합
      • CommonJS, ESM, AMD 등 다양한 모듈 시스템을 하나의 통합된 모듈 구조로 변환

     

    이후에 번들러에 대해서도 더 자세히 살펴볼 예정입니다…. 👀

     

    '🍌 자바스크립트' 카테고리의 다른 글

    실행 컨텍스트  (0) 2025.09.22
    자바스크립트 비동기와 이벤트 루프  (3) 2025.07.29
    🔍 함수와 클로저  (1) 2025.07.27
    📒 Map, Set  (0) 2024.05.29
    📂 배열  (0) 2024.05.28

    댓글

Designed by Tistory.