본문 바로가기

FrontEnd

[번역]모듈 번들러는 무엇이며 어떻게 동작하는가?

반응형

해당 글의 번역입니다 : https://lihautan.com/what-is-module-bundler-and-how-does-it-work/

 

What is module bundler and how does it work? | Tan Li Hau

What is module bundler and how does it work? August 30, 2019 (Last updated August 30, 2019) Series: Write a module bundler JavaScriptmodule bundlerdev toolwebpack What is a Module Bundler? How do we bundle? The "webpack way" The "rollup way" Summary Refere

lihautan.com

아래 글을 읽기 전에 이해하고 오면 도움이 됩니다.

2022.11.27 - [웹성능최적화] - Javascript ES module과 순환 참조 해결하기

 

Javascript ES module과 순환 참조 해결하기

TL;DR esm을 통해, 효과적인 변수 관리 모듈 정보 수집과 실행 단계를 분리하여 로더가 비동기 import, 코드 transform등 다양한 것을 쉽게 처리할 수 있게 되었다. esm, cjs를 쓸지 결정한 뒤 코드를 해석

itchallenger.tistory.com

모듈 번들러가 뭔가요?

모듈 번들러는 JavaScript 모듈(JavaScript modules)을 브라우저에서 실행할 수 있는 단일 JavaScript 파일로 묶는 데 사용되는 도구입니다.
webpackrollupfuseboxparcel 등이 그 예시입니다.

다음과 같은 이유로 모듈 번들러가 필요합니다.

 

예를 들어 여러 JavaScript 파일로 구성된 웹 애플리케이션을 구축한다고 가정해 보겠습니다.
스크립트 태그를 통해 JavaScript 파일을 html에 추가합니다.
<html>
  <script src="/src/foo.js"></script>
  <script src="/src/bar.js"></script>
  <script src="/src/baz.js"></script>
  <script src="/src/qux.js"></script>
  <script src="/src/quux.js"></script>
</html>

각 파일에는 별도의 http 요청이 필요합니다.
이는 애플리케이션을 시작하기 위한 5번의 왕복 요청입니다.
5개의 파일을 모두 하나로 결합할 수 있다면 더 좋을 것입니다.

<html>
  <script src="/dist/bundle.js"></script>
</html>
그렇다면 dist/bundle.js는 어떻게 생성할까요?
이 과정에서 몇 가지 문제가 발생합니다.
 
  • 포함할 "파일"의 순서를 어떻게 유지하나요?
    • "파일" 사이의 일종의 종속성 순서가 있으면 좋겠어요
  • "파일" 간의 이름 충돌을 어떻게 방지하죠?
  • 번들 내에서 사용되지 않은 "파일"을 어떻게 확인하죠?
각 파일 간의 관계를 알면 이러한 모든 문제를 해결할 수 있습니다.
 
  • 어떤 파일이 다른 파일에 의존하나요?
  • 파일에서 노출되는 인터페이스는 무엇인가요?
  • 어떤 노출된 인터페이스가 다른 사람에 의해 사용되고 있나요?

 

이러한 정보는 주어진 문제를 각각 해결할 수 있습니다.
따라서 우리에게 필요한 것은 파일 간의 관계를 설명하는 선언적 방법이며,
이를 위해 JavaScript Module System이 탄생했습니다.

 

CommonJS 또는 ES6 Modules은 의존하는 파일과 파일에서 사용하는 인터페이스를 지정하는 방법을 제공합니다.
// CommonJS
const foo = require('./foo');
module.exports = bar;

// ES Modules
import foo from './foo';
export default bar;

코드를 어떻게 번들링하죠?

webpackrollup에 의해 생성된 번들을 면밀히 살펴보면,
가장 인기 있는 2개의 번들러가 번들링에서 완전히 다른 접근 방식을 취한다는 것을 알 수 있습니다.
"웹팩 방식"과 "롤업 방식"이라는 용어를 사용하겠습니다.

 

3개의 파일 circle.js, square.js 및 app.js가 있다고 가정해 보겠습니다.

 

circle.js
const PI = 3.141;
export default function area(radius) {
  return PI * radius * radius;
}

 

square.js
export default function area(side) {
  return side * side;
}

 

app.js
import squareArea from './square';
import circleArea from './circle';
console.log('Area of square: ', squareArea(5));
console.log('Area of circle', circleArea(5));

 

웹팩 방식

webpack-bundle.js

쉬운 설명을 위해 약간의 수정을 했습니다.

const modules = {
  'circle.js': function(exports, require) {
    const PI = 3.141;
    exports.default = function area(radius) {
      return PI * radius * radius;
    }
  },
  'square.js': function(exports, require) {
    exports.default = function area(side) {
      return side * side;
    }
  },
  'app.js': function(exports, require) {
    const squareArea = require('square.js').default;
    const circleArea = require('circle.js').default;
    console.log('Area of square: ', squareArea(5))
    console.log('Area of circle', circleArea(5))
  }
}

webpackStart({
  modules,
  entry: 'app.js'
});
가장 먼저 눈에 띄는 것은 "모듈 맵"입니다.
 
  • 함수에 의해 래핑된 모듈 자체에 모듈 이름을 매핑하는 딕셔너리 입니다.
  • "모듈 맵"은 레지스트리와 같으며 항목을 추가하여 모듈을 쉽게 등록할 수 있습니다.

 

둘째, 각 모듈은 함수로 래핑됩니다.
  • 이 함수는 모듈 스코프를 시뮬레이션 합니다
  • 함수 자체를 "모듈 팩토리 함수"라고 합니다.
  • 모듈이 인터페이스를 export 하고 다른 모듈에서 require할 수 있도록 몇 가지 매개 변수를 사용합니다.

 

셋째, 응용 프로그램은 모든 것을 함께 붙이는 함수인 webpackStart를 통해 시작됩니다.
  • 종종 "런타임"이라고 하는 함수이며, 번들의 가장 중요한 부분입니다.
  • "모듈 맵"과 엔트리 모듈을 사용하여 애플리케이션을 시작합니다.

 

webpack-bundle.js

쉬운 설명을 위해 약간의 수정을 했습니다.
function webpackStart({ modules, entry }) {
  const moduleCache = {};
  const require = moduleName => {
    // if in cache, return the cached version
    if (moduleCache[moduleName]) {
      return moduleCache[moduleName];
    }
    const exports = {};
    // this will prevent infinite "require" loop
    // from circular dependencies
    moduleCache[moduleName] = exports;

    // "require"-ing the module,
    // exported stuff will assigned to "exports"
    modules[moduleName](exports, require);
    return moduleCache[moduleName];
  };

  // start the program
  require(entry);
}
  • webpackStart는 "require" 함수와 모듈 캐시라는 두 가지를 정의합니다.
  • "require" 함수는 CommonJS의 require와 다릅니다.
    • "require"는 모듈 이름을 가져오고 모듈에서 내보낸 인터페이스를 반환합니다.
    • 예: circle.js의 경우 { default: function area(radius){ ... } }입니다.
  • 내보낸 인터페이스는 모듈 캐시에 캐시됩니다.
    • 동일한 모듈 이름의 "require"를 반복해서 호출하면 "모듈 팩토리 함수"가 한 번만 실행됩니다.
  • "require"가 정의된 상태에서 응용 프로그램을 시작하면 엔트리 모듈을 "require"합니다.

 

주 : 위와 같이 웹팩의 핵심 기능은 번들링임.
로더랑 플러그인은 번들링 이전 파일단위, 이후 전체 결과물에 대한 전처리 후처리만 시행할 뿐임.
웹팩은 js 번들러이기 때문에 js만 이해할 수 있기 때문.

로더랑 플러그인은 번들링 이전 파일단위, 이후 전체 결과물에 대한 전처리 후처리만 시행할 뿐임.

아래 게시물에 로더, 플러그인에 대한 설명이 정리되어 있다.

롤업 방식

이제 webpack 번들이 어떻게 생겼는지 확인했습니다.
"롤업 방식" 번들을 살펴보겠습니다.

 

rollup-bundle.js

더 쉬운 설명을 위해 약간의 수정을 했습니다.

const PI = 3.141;

function circle$area(radius) {
  return PI * radius * radius;
}

function square$area(side) {
  return side * side;
}

console.log('Area of square: ', square$area(5));
console.log('Area of circle', circle$area(5));

 

첫째, 롤업 번들의 주요 차이점은 웹팩 번들에 비해 훨씬 작다는 것입니다.

  • 모듈 맵이 없습니다.
  • 모듈의 함수 래핑이 없습니다.
    • 모듈 내에서 선언된 모든 변수/함수는 이제 전역 범위로 선언됩니다.

개별 모듈 범위에서 선언된 모든 것이 이제 전역 범위로 선언되었는데,
2개의 모듈이 동일한 이름의 변수/함수를 선언하면 어떻하죠?

롤업은 이름 충돌이 발생하지 않도록 변수/함수 이름을 바꿉니다.
이 예에서 circle.js와 square.js는 모두 모듈 내에서 function area(){}를 선언했습니다.
번들링 시 충돌을 피하기 위해 두 함수명과 사용처의 함수명이 변경된 것을 볼 수 있습니다.

모듈을 함수로 래핑하지 않는 것의 부작용 중 하나는 eval의 동작입니다. 자세한 설명은 여기(here)를 참조하세요.

둘째, 번들 내 모듈 순서가 중요합니다.

둘째, 번들 내의 모듈 순서가 중요합니다.
circle$area 및 square$area가 console.log 다음에 올 수 있고 여전히 작동한다고 주장할 수 있지만,
PI는 TDZ 때문에 console.log 이전에 선언되어야 합니다.
따라서 종속성 순서로 모듈을 정렬하는 것은 "롤업 방식"에 중요합니다.

대체로 "롤업 방식"이 "웹팩 방식"보다 나은 것 같았습니다.
모듈 로더 에뮬레이션 방식을 제거하여, 더 작은 번들과 더 적은 런타임 오버헤드를 갖습니다.

 

"롤업 방식"의 단점은 뭘까요?

  • 순환 참조를 잘 해결해주지 않습니다.
    • (주 : 이건 다른 모듈 명세도 똑같은 것 같긴 합니다.)
아래 예제를 볼까요?
const circle = require('./circle');

module.exports.PI = 3.141;

console.log(circle(5));
const PI = require('./shape');
const _PI = PI * 1
module.exports = function(radius) {
  return _PI * radius * radius;
}
이 예에서 shape.js는 circle.js에 의존하고 circle.js는 shape.js에 의존합니다.
 
롤업이 출력 번들에서 다른 모듈보다 먼저 올 모듈의 순서를 정렬할 수 있는 방법이 없습니다.
 
circle.js 다음 shape.js 또는 shape.js 다음 circle.js 둘 다 합리적입니다.
 
따라서 다음과 같은 번들링 결과 파일을 얻을 수 있습니다.
// cirlce.js first
const _PI = PI * 1; // throws ReferenceError: PI is not defined
function circle$Area(radius) {
  return _PI * radius * radius;
}

// shape.js later
const PI = 3.141;
console.log(circle$Area(5));​

이에 대한 해결책이 있나요?


짧은 대답은 '아니오'입니다.

 

"간단한" 해결 방법은 순환 참조를 사용하지 않는 것입니다. Rollup은 경고가 발생하면 경고를 표시합니다.
다른 한가지 방법은, 아래 예제의 경우 오류가 발생하는 이유는, 모듈 내에서 즉시 평가되는 명령문이 있다는 것입니다.
_PI의 평가를 게으른 것으로 변경하면:
const PI = require('./shape');
const _PI = () => PI * 1; // to be lazily evaluated
module.exports = function(radius) {
  return _PI() * radius * radius;
}
이제 모듈의 순서는 크게 중요하지 않습니다.
// cirlce.js first
const _PI = () => PI * 1;
function circle$Area(radius) {
  return _PI() * radius * radius;
}

// shape.js later
const PI = 3.141;
console.log(circle$Area(5)); // prints 78.525
_PI가 평가되는 시점에 PI가 이미 정의되어 있기 때문입니다.

요약

  • 모듈 번들러는 여러 JavaScript 모듈을 하나의 JavaScript 파일로 결합하는 데 도움이 됩니다.
  • 각 번들러는 다른 번들링 전략을 취합니다, 우리는 최신 번들러, 웹팩 및 롤업 중 2가지를 살펴보았습니다.
  • "웹팩 방식":
    • 모듈 맵 사용 함수를 사용
    • 각 모듈을 함수로 래핑합니다.
    • 모듈을 함께 붙이는 런타임 코드가 있습니다.
  • "롤업 방식":
    • 평탄화된 작은 번들 크기
      • 평탄화라는건 단 하나의 파일에서 정의한 것처럼 변경한다는 것
    • 모듈을 함수로 래핑하지 않습니다.
    • 모듈의 임포트 순서가 중요합니다, 의존성의 순서를 정렬해야 합니다.
    • 순서 문제 종속성에 따라 정렬 필요 순환 종속성이 작동하지 않을 수 있습니다.
반응형