번들러란 무엇이며, 어떻게 동작하는 걸까?

RookieAND·2023년 10월 9일
5

Solve My Question

목록 보기
22/27
post-thumbnail

📖 Introduction

최근 사내 스터디 에서 직접 Bundler 를 제작해보는 소중한 기회를 얻었다.

그간 Javascript 계열에서 개발을 진행하면서 번들러라는 존재를 숨쉬듯 사용해왔으나, 정작 이 친구들이 어떤 원리를 기반으로 동작하는지는 부끄럽게도 알지 못했다. 하지만 최근 사내에서 이야기를 나누었던 분께서 이번 기회에 번들러를 한번 제작해보자는 의견을 주셨고, 무척이나 좋은 기회를 거절할 수 없어 바로 수락을 하였다.

3주간 열심히 맨땅에서 헤딩하듯이 번들러를 제작해보면서, 내가 그동안 알지 못했던 번들러의 심오한 원리와 구현 과정을 그 편린이나마 파악할 수 있었던 좋은 시간을 보내었다. 오늘은 그 중에서도 번들러에 대한 이야기를 짤막하게 진행할 것이다.

다음 글에는 의존성 트리를 실제로 어떻게 구축했는지에 대한 내용과, 트랜스파일링 및 Module Template 구현 과정에 대한 이야기를 적을 예정이다.

✒️ 번들러란 무엇인가?

  • 심플하게 설명하자면, Javascript 모듈을 단일 파일로 변환해주는 도구를 Bundler 라고 한다.
  • 현재는 vite, webpack, rollup 같은 상용화된 번들러를 프로덕션 레벨에서 사용 중이다.

✒️ 번들러가 필요한 이유

  1. 파일 간의 중복된 식별자 사용으로 인한 모듈 간 충돌 가능성 존재

    • 과거 Javascript 는 모듈 시스템을 지원하지 않았기 때문에, 각 파일을 브라우저에서 받아 실행하는 과정에서 코드 간의 충돌 문제가 발생하였음.
    • 브라우저는 <script> 태그를 통해 js 파일을 받는데, Parser의 구조 상 나중에 받은 js 파일의 컨텍스트가 이전에 받았던 js 파일의 컨텍스트를 Override 하기 때문에 문제가 발생한다.
    <head>
        <script src="./source/hello.js"></script>
        <script src="./source/world.js"></script>
    </head>
    <body>
        <h1>Hello, Webpack</h1>
        <div id="root"></div>
        <script type="module">
            document.querySelector("#root").innerHTML = word;
        </script>
    </body>
    • 상단의 HTML 에서는 script 태그를 통해 hello.jsworld.js 파일을 불러온다. 이때 hello.jsworld.js 에서 같은 식별자인 word 를 사용한다고 가정해보자.
    • 첫 번째로 읽힌 hello.js 내에 선언된 변수 word 의 값을 먼저 불러온다.
    • 이후 두 번째로 읽힌 world.js 에서 선언된 변수 word 의 값으로 덮어씌워진다.
    • 지금은 두 개의 파일을 예시로 들었지만, 대규모 애플리케이션의 경우 수백 개의 모듈로 구성되어 있기 때문에 더 많은 충돌이 발생할 가능성이 존재한다.
  2. 다량의 모듈 파일을 전송함으로서 생기는 딜레이 문제

    • 브라우저에서 웹 애플리케이션을 구축하기 위해 요청을 보내면, 서버에서는 이를 구성하기 위해 필요한 여러 파일을 전송한다.
    • 이때 구성에 필요한 파일의 갯수가 많아지면 그만큼 요청의 수도 비례하여 증가하므로, 응답 시간이 길어진다.
    • 브라우저의 경우 먼저 요청된 파일부터 순차적으로 인계 받아 애플리케이션을 구성하기 때문에 만약 구축에 필요한 파일이 나중에 인계된다면 더욱 딜레이가 커진다.
    • 그렇다고 해서 하나의 파일에 모든 코드를 넣을 경우, 공통된 컨텍스트 내에서 개발을 진행해야 하기 때문에 식별자 관리 및 코드 컨벤션을 맞추기가 까다로워진다.

✒️ 번들러는 어떤 일을 하는가?

  • 기본적으로 번들러는, 웹 애플리케이션을 구성하는 여러 파일을 “하나로 묶는” 기능을 한다.
    • 이 덕에 브라우저에서는 각 파일 별로 요청을 보내지 않고 한번의 요청으로 모든 파일을 받을 수 있다.
  • 각 모듈 간의 의존성 관계를 파악하여 올바른 순서로 모듈이 불러와지고 실행되는 것을 보장해준다.
    • 모듈 간의 의존성 문제 및 전역 스코프 문제를 개발자가 직접 해결하지 않아도 되기 때문에 개발이 편리해진다.
  • 여러 파일을 하나로 합치는 역할 뿐만이 아니라, 트랜스파일러 및 컴파일러의 역할도 겸한다.
    • 일부 번들러 (SWC 등) 의 경우에는 코드의 식별자를 난독화하는 uglify 와, 코드 간의 공백 및 개행문자를 없애 용량을 줄이는 minify 옵션을 지원하기도 한다.
    • 트랜스파일러 (Babel) 로 ES6 기반의 코드를 하위 버전 또는 CJS 기반으로 변환하여 호환성을 지원한다.
  • 번들러를 통해 생성된 결과물을 나누어 필요한 데이터만 사용자에게 우선적으로 제공하는 전략도 사용이 가능하다.
    • ESM Build를 지원하는 경우, 구조 상 사용되지 않는 모듈을 번들링 결과에 포함시키지 않는다.
    • 이를 Tree - Shaking 이라 하며, 번들 사이즈를 줄이는 효과가 있기에 사용자에게 더욱 빠른 로딩을 제공한다.

✒️ 번들러의 동작 방식

  • 번들러는 두 가지 방식을 통해 여러 Javascript 모듈을 하나의 파일로 합치는 작업을 수행한다.
    1. Entry File Path를 기반으로 의존성 관계에 놓인 모듈을 재귀적으로 탐색하고, 각 모듈의 절대 경로를 구하여 의존성 트리 (Dependency Graph) 를 구축한다.
    2. Webpack / Rollup 스타일에 따라 다르지만, 앞선 과정에서 구축한 의존성 트리를 기반으로 각 모듈을 하나의 번들링 파일에 합쳐 이를 반환한다.

✒️ 의존성 트리 구축 과정

  1. 탐색하고자 하는 모듈의 코드를 Parser 를 통해 AST (Abstract Syntax Tree) 로 변환하는 과정을 거친다.

    • 주로 사용되는 AST Parser 의 종류로는 esprima , babel/parser , acorn 가 있다.
  2. 변환된 AST 내에서 ImportDeclaration type 을 가진 노드를 찾고, 내부 속성 중 source.value 값을 통해 import 경로를 찾는다.

    • 상단의 이미지를 보면 source.value 속성에 import 된 경로가 저장되었음을 알 수 있다.

    • 이전에 찾은 모듈의 정보를 별도의 자료구조 (Map 등) 에 보관해두지 않으면, 순환 참조가 걸린 구조에서 무한 루프에 빠진다!

    • 따라서 이전에 탐지한 모듈의 정보를 저장하여, 이후 모듈을 탐색할 때 이전에 찾았던 모듈인지를 한번 검증하는 절차가 필요하다.

  3. 앞선 과정에서 찾아낸 import 경로를 기반으로, 실제 해당 모듈이 위치한 절대 경로를 탐색한다.

  4. 탐색한 절대 경로와 모듈 내부의 코드를 매핑하여 의존성 트리의 노드를 구축한다.

    • 만약 CJS 나 ES5 이하의 polyfill 과정이 필요하다면 별도의 트랜스파일러를 사용하여 모듈 내부의 코드를 변환한 결과를 저장한다.
    • 예시로, babel/core 의 transformFromAstSync 메서드를 사용하여 AST를 코드로 재변환 하는 과정에서babel/preset-env 플러그인으로 CJS + ES5 트랜스파일링을 진행할 수 있다.

✒️ Webpack / Rollup 의 동작 차이

  • Webpack 방식의 번들러 동작 과정은 아래와 같다.
    • 의존성 트리를 기반으로 각 모듈을 독립적인 함수로 감싸 스코프를 격리시킨 후, 이를 하나의 Module Map 으로 묶어 관리한다.

    • 각 모듈의 절대 경로는 코드를 감산 함수와 매핑되며, 추후 Runtime 과정에서 함수를 통해 실행된다.

      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))
        }
      }
    • 모듈을 실행하는 과정에서 다른 모듈을 실행하는 경우 require 함수를 내부에서 실행하고, 이는 이전에 캐싱된 모듈을 반환할지를 결정하여 순환 참조로 발생하는 문제를 방지한다.

    • 만약 Module Cache 가 없다면 모듈을 참조할 경우 계속해서 서로를 탐색하는 과정을 거치게 되므로 문제가 발생한다.

      function webpackStart({ modules, entry }) {
      	// 이미 실행되어 캐싱된 모듈 목록을 저장하는 Module Cache Object.
        const moduleCache = {};
        const require = moduleName => {
          // 만약 해당 모듈이 이미 캐싱되었다면, 캐싱된 값을 return 한다.
          if (moduleCache[moduleName]) {
            return moduleCache[moduleName];
          }
          const exports = {};
          // 해당 모듈이 아직 실행되지 않았다면, 새롭게 캐싱을 진행한다.
          moduleCache[moduleName] = exports;
      
          // modules 객체에서 특정 경로와 매핑되는 함수를 실행한다.
          modules[moduleName](exports, require);
          return moduleCache[moduleName];
        };
      
        // entry 경로를 기반으로 의존성 관계에 놓인 모든 모듈을 실행한다다.
        require(entry);
      }
      
      webpackStart({
      	modules,
      	entry
      })
  • Rollup 방식의 번들러 동작 과정은 아래와 같다.
    • 각 모듈 내부의 코드를 동일한 컨텍스트 상에 위치시키고 (호이스팅) 이를 기반으로 번들 파일을 생성한다.

    • 동일한 컨텍스트 내에서 코드가 실행되기 때문에, Rollup 에서는 실행 케이스에 따라 identifier 를 동적으로 변경하여 이를 방지하고자 한다.

    • 모듈 간의 올바른 선언 및 실행 순서를 보장해야 하기 때문에 Rollup 에서는 의존성 트리를 기반으로 모듈 간의 선언부 위치를 정렬하는 과정이 매우 중요하다.

      const PI = 3.141;
      
      // 다른 모듈에서 동일한 이름의 식별자를 사용할 경우, Rollup 에서 이를 동적으로 변경한다.
      function circle$area(radius) {
        return PI * radius * radius;
      }
      
      function square$area(side) {
        return side * side;
      }
      
      // square$area 가 먼저 선언된 이후 실행되어야 할 코드이기에 실행 순서를 맞춘다.
      console.log('Area of square: ', square$area(5));
      console.log('Area of circle', circle$area(5));
  • Webpack 방식의 장/단점은 아래와 같다.
    • 각 모듈을 함수로 감싸 스코프를 격리시켰기 때문에, 모듈 간의 식별자 충돌을 완벽하게 방지할 수 있다.
    • Module Cache 를 통해 순환 참조 문제가 발생할 경우 캐싱된 모듈을 반환하기 때문에 제어가 가능하다.
    • 하지만 각 모듈이 런타임 과정에 실행되고 Module Cache 에 추가되기 때문에, 실행 속도가 느리다는 단점이 존재한다. (모듈을 감싸는 함수를 실행하고 평가하는 단계가 필요)
  • Rollup 방식의 장/단점은 아래와 같다.
    • 모든 모듈을 하나의 컨텍스트를 기반으로 실행시키기 때문에 별도의 평가 과정이 필요하지 않아 빠른 실행을 보장한다.
    • dynamic import 를 지원하지 않고, 모듈 간의 순환 참조 문제가 발생할 경우 이를 제어하지 못하고 오류를 발생시킨다.

다음 포스트에서는 직접 구현한 번들러의 구조와 Module Compiler 에 대해 소개할 예정이다.

📒 참고 문서

profile
항상 왜 이걸 써야하는지가 궁금한 사람

0개의 댓글