Webpack 같은 모듈 번들러를 만들어 보자

안녕하세요, LINE에서 프런트엔드 개발을 담당하고 있는 전현제입니다. 이번 포스팅에서는 JavaScript 생태계의 구성 요소 중 하나인 모듈 번들러(module bundler)에 대해 소개하려 합니다. 먼저 모듈 번들러의 개념을 간단하게 소개한 후 모듈 번들러가 실제로 어떻게 작동하는지에 대해 직접 모듈 번들러를 만들어 본 경험담을 함께 나누고자 합니다.

모듈 번들러란?

모듈 시스템이란 규모가 큰 코드 베이스를 모듈이라는 단위로 분할할 수 있게 만드는 구조를 말합니다. 자바스크립트에는 오랫동안 모듈 시스템이 없는 상태였는데, 2009년 Node.js가 CommonJS라는 모듈 시스템을 사용하기 시작한 이후 모듈화된 코딩을 하는 것이 보편화되었습니다. 또, ECMAScript 2015에서 자바스크립트 언어 사양에 신규 모듈 시스템인 ES Module이 포함되어 이제는 모듈 없이 자바스크립트로 코딩하기 힘들어졌다고 할 수 있을 정도입니다.

// ES Module의 예
import "otherModule";
import { someFunc } from "otherModule";
export const x = 10;

하지만, 자바스크립트 사양이 변경된 후 브라우저에서 변경된 사양을 지원하기까지는 시간차가 있습니다. 아직 모든 브라우저에서 ES Module을 사용할 수 있는 것은 아닙니다. 그렇기 때문에 브라우저에서 모듈을 사용하려면 모듈의 의존 관계를 미리 해결한 후 브라우저에서 인식할 수 있는 자바스크립트 코드로 변환해 두어야 합니다. 이 변환 작업을 수행하는 것이 이번에 소개할 모듈 번들러입니다.

모듈 번들러의 역할

앞서 설명한 바와 같이 모듈 번들러는 모듈의 의존 관계를 분석하여 브라우저가 인식할 수 있는 자바스크립트 코드로 변환합니다. 더 구체적으로 설명하자면 어떤 모듈을 읽어들인 다음, 브라우저에서도 인식할 수 있는 일반적인 함수로 변환합니다. 그리고 이 함수의 인수로 다른 모듈을 임포트(import)하기 위한 함수와 모듈이 익스포트(export)하는 값을 저장하기 위한 객체를 전달합니다.

예를 들어 아래와 같은 모듈이 있다고 가정해 봅시다.

import { x } from "otherModule";

export const y = x + 10;

위 모듈을 브라우저가 인식할 수 있도록 모듈 번들러는 위 코드를 다음과 같은 함수로 변환시킵니다.

function (require, module, exports) {
  var { x } = require("otherModule");
  exports.y = x + 10;
}

이미 눈치 채신 분도 계시겠지만 함수 내용은 CommonJS 형식으로 되어 있습니다. ES Module을 CommonJS로 변환하는 도구가 많이 있는데 그런 도구를 그대로 사용할 수 있는 것은 장점입니다. 위와 같이 코드를 함수의 형식으로 변환시킨 다음, 변환된 모듈 함수들이 모듈 간에 서로 참조할 수 있도록 하나의 파일로 합칩니다. 여기까지가 모듈 번들링이라는 과정이며, 모듈 번들러가 수행하는 주된 작업입니다.

모듈 번들러 직접 만들어 보기

What I cannot create, I do not understand
Richard Feynman

이제 모듈 번들러의 원리에 대해서는 어느 정도 이해가 되셨으리라 생각합니다. 하지만 직접 만들지 못한다면 완벽하게 이해했다고 할 수 없지요. 그래서 Tinypack이라는 간단한 모듈 번들러를 직접 만들어 보았습니다. 사실 Tinypack은 TypeScript의 번들러인데, TypeScript는 자바스크립트의 모듈 시스템을 그대로 사용하고 있기 때문에 번들러로서 수행하는 작업은 동일합니다. 그럼 단계별로 설명드리겠습니다.

시멘틱 분석

모듈을 변환하기 전에 데이터형을 검사하는 작업을 수행합니다. 이는 Tinypack이 TypeScript 번들러이기 때문에 필요한 작업일 뿐, 자바스크립트의 번들러는 기본적으로 수행할 필요가 없습니다. 데이터형을 검사할 때는 TypeScript Compiler AP를 사용합니다.

let errors = ts.getPreEmitDiagnostics(
  ts.createProgram([entry], { ... })
)

소스 코드를 모듈 객체로 변환하기

TypeScript(또는 자바스크립트)의 소스 코드를 엔트리부터 순서대로 모듈로 변환합니다. 먼저 엔트리를 처리한 후 그때 발견되는 의존 관계를 차례차례 처리하는 거지요. 모듈을 변환할 때마다 고유한 모듈 ID를 모듈에 부여합니다. 엔트리 파일의 모듈 ID는 0입니다. 모듈 ID를 부여한 엔트리 파일을 변환 큐(queue)에 넣고 변환을 시작합니다.

변환 작업의 첫 번째 순서는 소스 파일을 인식하여 AST(Abstract Syntax Tree)로 파싱하는 것입니다. 이 작업은 AST에서 해당 모듈의 의존 관계를 분석하기 위해 진행합니다. 시멘틱 분석 단계에서 타입 체크할 때와 마찬가지로 파싱할 때도 TypeScript Compiler API를 사용합니다.

let ast = ts.createSourceFile(file, content, ...)

파싱된 AST를 순회하면서 import 선언을 체크합니다. 각 import 선언 경로가 새로 발견된 의존 관계입니다. 의존 관계에 각각 고유한 모듈 ID를 부여하고, 그 경로와 모듈 ID를 의존성 맵에 저장합니다. 그리고 나중에 모듈로 변환되도록 변환 큐에 추가해 둡니다.

source.forEachChild(node => {
  if (node.kind === ts.SyntaxKind.ImportDeclaration) {
    let importDecl = node as ts.ImportDeclaration;

    // module specifier should be a string literal
    let moduleSpecifier = importDecl.moduleSpecifier.getText(source);
    let dep = JSON.parse(moduleSpecifier) as string;

    ...

    queue.push(depPath);
    deps.set(dep, depID);
  }
});

의존 관계 분석이 완료되면 TypeScript Compiler API를 사용해서 소스 파일을 CommonJS로 트랜스파일(transpile, transcompile)합니다.

let transpiled = ts.transpileModule(content, {
    compilerOptions: {
      module: ts.ModuleKind.CommonJS,
      ...
    }
  }).outputText;

여기까지 진행하면 모듈 ID와 의존성 맵, 트랜스파일한 결과를 얻을 수 있습니다. 이 세 항목을 객체로 반환하면 파일 하나에 대한 모듈 변환이 완료됩니다.

return { id, deps, transpiled };

이와 같은 변환 작업을 변환 큐가 모두 비워질 때까지 반복하여 의존성 그래프 안에 있는 모든 파일을 변환합니다.

코드 생성/번들링

앞 과정에서 만든 모듈 객체들을 합쳐 브라우저에서 실행될 번들 파일을 생성합니다. 각 모듈 객체의 내용을 다시 한번 살펴 보면 다음과 같습니다.

  • 모듈 ID
  • 의존성 맵(모듈 이름: 모듈 ID)
  • Transpile된 코드

위 정보를 가지고 각 모듈의 코드를 생성합니다. 아래는 예시입니다.

var modules = {
  0: [ // ← 모듈 ID
    function(require, module, exports) {
      ... // ← 트랜스파일 된 코드
    },
    {
      "otherModule": 1, // ← 의존성 맵
      ...
    }
  ],
  1: ...
}

모듈 번들러의 역할에서 소개했던 함수와 비슷한 함수가 나왔네요. 의존성 맵으로부터 의존 관계 객체도 생성되었습니다. 이는 함수 본문에 있는 모듈 경로를 실제 모듈 ID에 매칭하기 위해서 사용합니다.

다음 작업으로, 생성된 모듈 코드를 실행하는 함수를 다음과 같이 생성합니다. 의존 관계 객체(mod[1])를 사용한 require() 함수가 보입니다. 이를 함수 본문(mod[0])에 전달하는 부분도 구현되어 있네요. 함수를 실행하면 module.exports에 모듈이 익스포트한 값이 들어가므로 그 값을 그대로 반환합니다.

function executeModule(id) {
  var mod = modules[id];
  var localRequire = function (path) {
    return executeModule(mod[1][path]);
  };

  var module = { exports: {} };
  mod[0](localRequire, module, module.exports);
  return module.exports;
}

마지막으로 엔트리 모듈을 실행하는 코드를 추가하면 끝입니다.

executeModule(0); // 0는 엔트리 모듈을 의미함

코드 생성까지 마치면 모듈 번들러의 기본적인 구현이 완료됩니다.

번들러 성능 향상을 위한 추가 기능

여기까지 구현하면 모듈 번들러가 작동되어 코드를 브라우저에서 실행할 수 있게 됩니다. 하지만, webpack처럼 우수한 성능을 가진 모듈 번들러는 앞서 설명한 내용 외에도 다양한 기능이 추가되어 있습니다. 그 중 일부 기능들을 Tinypack에도 구현해 보았습니다.

중복 모듈 방지하기

앞서 설명한 대로 구현하면 동일한 파일을 다른 모듈에서 임포트하면 같은 파일임에도 불구하고 별개의 모듈로 인식하게 됩니다. 동일한 코드가 중복된다는 문제도 있지만 모듈 컨텍스트(module context)가 공유되지 않는다는 점도 문제가 됩니다. 이러한 문제를 해결하기 위해 파일 경로를 절대 경로로 만들어 모듈 ID와 함께 맵(fileModuleIdMap) 에 저장합니다.

절대 경로 값으로 맵에서 모듈 ID(moduleID)를 조회하고 맵에 조회한 ID가 없을 때만 모듈을 추가합니다.

let depPath = path.resolve(file, dep);
let depID = fileModuleIdMap.get(depPath);
if (depID === undefined) {
  depID = ++moduleID;
  fileModuleIdMap.set(depPath, depID);
  files.push(depPath);
}

Circular dependency 해결하기

Circular dependency란 의존성 그래프가 순환되는 것을 의미하며, cyclic dependency라고도 불립니다. 예를 들어, x 모듈이 y 모듈을 임포트하고 있고, y 모듈이 또 x 모듈을 임포트하고 있는 상황을 생각해 봅시다. 이 상황을 고려하지 않고 처리하면 큐에 파일이 무한정으로 늘어나 변환 처리가 끝없이 진행될 것입니다.

이 문제를 해결하는 방법은 사실 중복 처리 방법과 동일합니다. 이미 처리가 완료된 모듈은 처리하지 않고 모듈 ID만 사용하는 방법이지요. 하지만 추가 조건이 있습니다. 실제 모듈 처리를 하기 전에 해당 모듈의 ID가 정해져야 한다는 점입니다. 위에서 언급한 x, y 예시로 설명하면, x의 의존성 맵을 만들기 위해서는 해당 시점에 y의 모듈 ID를 알고 있어야 합니다. 바로 앞에서 살펴본 코드에서 모듈 ID를 먼저 정해서 맵에 저장해 두는 이유는 이 때문입니다.

단, 이 처리 방법은 의존성의 circular dependency만 해결할 뿐, 실행의 circular dependency까지는 해결하지 못한다는 점에 유의해야 합니다.

npm에서 설치한 모듈 임포트하기

Node.js의 패키지 매니저인 npm은 프런트엔드 자바스크립트용으로도 자주 사용됩니다. 사실 이제는 npm을 사용하는 유저층이 프런트엔드 쪽에 더 많습니다.

The npm Registry now distributes more front-end code than server-side JavaScript and is used to distribute every popular web development framework.
npm blog 발췌

때문에 npm에서 설치한 패키지를 번들링하고 싶은 것은 당연한 일입니다. npm 패키지를 번들링하기 위해 필요한 작업을 알아 보겠습니다.

먼저, 의존성을 해결하려면 Node.js에서 사용하는 규칙을 적용해야 합니다.

  • .으로 시작하면 로컬 모듈
  • 그 외의 경우는 npm에서 설치한 모듈

위 규칙을 코드로 하면 아래와 같습니다.

let depPath: string;
if (dep.startsWith(".")) {
  depPath = localModulePath(dep, file);
} else {
  depPath = npmModulePath(dep, file);
}

또 하나는, npm 모듈의 package.json 파일 내의 module 필드와 main 필드를 확인하는 작업입니다.

let packageJSONPath = resolve(pkgRoot, "package.json");
if (isFile(packageJSONPath)) {
  let main: string =
    require(packageJSONPath).module ||
    require(packageJSONPath).main;
  if (main) {
    return resolve(pkgRoot, main);
  }
}

그 외에 확장자가 없는 파일 처리, index.js로 fallback하는 처리 등이 로컬 모듈과 npm 모듈에 공통으로 들어갑니다.

마치며

모듈 번들링이라는 가장 기본적인 기능을 이해하기 위해 직접 모듈 번들러를 만들어 보는 작업은 매우 재미있는 경험이었습니다. 물론 현재 모듈 번들러가 수행하는 역할은 제가 소개드린 역할 뿐만이 아닙니다. Code splitting, Dead code elimination 등 다양한 기능이 많이 제공됩니다. 여러분도 꼭 한번 만들어 보시면 좋을 것 같습니다.

이번에 소개한 Tinypack은 GitHub에 공개되어 있으니 저장소의 README 파일을 한번 읽어 보시기 바랍니다. (⭐️도 주세요!) 특히 Minipack은 Tinypack을 만들게 된 계기이기도 합니다. 내용이 매우 알차고 코드도 이해하기 쉽게 작성되어 있으니 관심 있으신 분은 읽어 보시면 좋을 듯 합니다.

Related Post