번들링, 트랜스파일링 전략

김윤진·2022년 10월 16일
2

우아한테크코스

목록 보기
4/8

레벨로그 프로젝트

webpack

webpack으로 3개의 환경을 나누어서 번들링을 진행한다.
webpack 폴더 구조를 보면 다음과 같다.

  • webpack
    • webpack.common.js
    • webpack.js
    • webpack.dev.js
    • webpack.prod.js

webpack.common.js : 공통된 webpack 설정
webpack.js : 로컬 환경
webpack.dev.js : development 환경
webpack.prod.js : production 환경
이 중복되는 webpack 설정을 common 파일에 작성해놓는다. 그리고 webpack-merge라는 모듈을 통해 common 파일의 webpack 설정이 기재되어 있는 객체와 local, development, production 파일의 webpack 설정이 기재되어 있는 객체를 병합한다. 이로 인해 중복되는 코드를 줄일 수 있다.
loader를 통해 정적 리소스를 트랜스파일링 수 있는데 개발 환경에 따라 loader를 다르게 갈 필요가 있다. 왜냐하면 loader에 따라 번들링 속도, 번들 결과물의 용량 차이가 상이하기 때문이다.
프로젝트에서 사용한 loader는 babel-loaderts-loader 이다.


local 환경은 babel-loader + ReactRefreshWebpackPlugin

로컬 환경에서는 babel-loader + ReactRefreshWebpackPlugin을 쓰는 것이 적합하다고 생각했다. 왜냐하면 제 아무리 esbuild-loader가 빠르다고 한들 코드에 수정이 생기면 esbuild-loader는 전체 파일을 재빌드한다. 그러나 ReactRefreshWebpackPlugin 플러그인은 수정이 생긴 파일, 그 파일만 재빌드를 하기 때문에 더 빠르다. 그런데 빠른게 문제가 아니다.
esbuild-loader는 코드에 수정이 생기면 화면이 깜박인다. 그러나 babel-loader + ReactRefreshWebpackPlugin는 화면이 깜박이는 것이 아닌 수정이 생긴 부분만 바뀌기에 개발하는 입장에서는 babel-loader + ReactRefreshWebpackPlugin가 편할 수 밖에 없다.


development, production 환경은 ts-loader ??

babel-loaderts-loader보다 빠르지만 막상 번들링을 하면 production모드에서는 그렇게 큰 차이는 없다.
development-mode, ts-loader : webpack 5.74.0 compiled successfully in 6474 ms
development-mode, babel-loader : webpack 5.74.0 compiled successfully in 3524 ms
production-mode, ts-loader : webpack 5.74.0 compiled with 2 warnings in 13581 ms
production-mode, babel-loader : webpack 5.74.0 compiled with 2 warnings in 11011 ms
이는 우리의 프로젝트 크기가 작은 편이기 때문에 차이가 근소한 것이다. 그리고 ts-loader는 번들링할 때 tsconfig.js을 설정에 따라 타입 체크를 하기 때문에 babel-loader보다 느린 것이다.
여기서 의문이 생길 수 있다. 번들링할 때 타입체크를 할 필요가 있을까? 왜냐하면 IDE에서 타입 체크를 해주지만 현재 IDE에 보이는 파일에 대해서만 타입 체킹을 해주기 때문에 안전하지 않다.

  • 타입이 잘못된 경우이지만 해당 파일이 IDE에 띄워져있지 않기 때문에 에러가 발생하지 않는다.
  • babel-loader로 번들링한 경우
에러가 발생하지 않고 번들링 되는 것을 확인할 수 있다.
  • ts-loader로 번들링한 경우
에러가 발생해서 번들링이 되지 않는 것을 확인할 수 있다.

그렇기에 안정성이 중요한 development, production 환경에서 ts-loader는 필수라고 생각한다.

나중에 프로젝트 크기가 커진다면 어떻게 해야할까? 현재 프로젝트 크기가 작은데도 development 환경의 번들링 시간은 두배가 차이난다. 몇초 차이 안 나네 라고 생각할 수 있지만 CI와 nginx를 생각해보면 그렇지 않다.
Github Action을 통해 CI를 진행중이다. CI에서 빌드 테스트를 진행하는데 빌드 시간이 길어진다고 생각하면 매번 PR을 merge할 때마다 그 만큼 병목이 걸린다는 것이다.
그리고 nginx가 실행되는 환경, ec2에서의 메모리는 한정적이다. 현재 나의 컴퓨터보다 성능이 좋지 않은 것이다. 그러므로 ts-loader를 쓰는 것에 대해 고민을 해봐야한다. 이에 대해 ts-loader의 대책이 존재한다.
바로 ts-loader에서 타입 체킹을 분리하는 것이다. ForkTsCheckerWebpackPlugin를 통해 ts-loader 타입 체킹을 플러그인에서 체크한다. 그리고 ts-loader에서는 트랜스파일링만 하도록 하는 설정을 추가해야 한다.

use: [
  {
    loader: 'ts-loader',
    options: {
      transpileOnly: true,
    },
  },
],
//....
plugins: [new ForkTsCheckerWebpackPlugin()],

이렇게 플러그인을 추가하면 번들링할 때 타입 체킹은 물론 번들링 시간도 babel-loader만큼으로 줄어드는 것을 확인할 수 있다.

development-mode, ts-loader, without ForkTsCheckerWebpackPlugin : webpack 5.74.0 compiled successfully in 6487 ms
development-mode, ts-loader with ForkTsCheckerWebpackPlugin : webpack 5.74.0 compiled successfully in 4755 ms
production-mode, ts-loader without ForkTsCheckerWebpackPlugin : webpack 5.74.0 compiled with 2 warnings in 13979 ms
production-mode, ts-loader with ForkTsCheckerWebpackPlugin : webpack 5.74.0 compiled with 2 warnings in 10970 ms


development, production 환경은 esbuild-loader + ForkTsCheckerWebpackPlugin

그리고 esbuild-loader라는 로더를 통해 빌드하는 시간을 더욱 줄일 수 있다. esbuild-loader는 GO 언어로 만들어져서 자바스크립트와는 언어적인 차이 때문에 빠르다.

development-mode, ts-loader : webpack 5.74.0 compiled successfully in 4755 ms
development-mode, esbuild-loader : webpack 5.74.0 compiled successfully in 4262 ms
production-mode, ts-loader : webpack 5.74.0 compiled with 2 warnings in 10970 ms
production-mode, esbuild-loader : webpack 5.74.0 compiled with 2 warnings in 9072 ms

production 모드에서 빌드 시간이 1초 정도 감소한 것을 확인할 수 있다. 여기서 빌드 속도에 대한 차이를 1초가 감소된 것보다는 10퍼센트가 감소되었다는 것으로 보는 것이 좋다. 그리고 esbuild-loader 또한 트랜스파일링시 타입 체크를 하지 않기 때문에 ForkTsCheckerWebpackPlugin 플러그인과 함께 쓰는 것이 안전하다.
빠른 빌드 시간과 타입 검사를 위해 esbuild-loader + ForkTsCheckerWebpackPlugin 을 쓰는 것이 좋다.


babel

로컬 환경에서 babel-loader를 사용하기 때문에 babel에 있어 추가 설정이 필요하다.
babel도 webpack과 마찬가지로 config 파일을 설정을 기반으로 트랜스파일을 진행한다. babel은 자바스크립트 컴파일러기 때문에 React(JSX 구문), Typescript는 preset을 통하여 트랜스파일링이 가능하게 해준다.

module.exports = {
  presets: [
    '@babel/env',
    '@babel/preset-typescript',
    ['@babel/preset-react', { runtime: 'automatic' }],
  ],
};

ESM

현재 개발환경에서는 import/export문을 쓰고 있다. ESM 방식의 모듈 시스템을 따르고 있는 것이다. 이런 ESM 방식의 모듈 시스템이 나오기 전에는 CommonJS라는 모듈 시스템을 사용하고 있었다.
항상 그렇듯이 어떠한 문제를 해결하기 위해 새로운 기술, 개념이 나오는 것이다. CommmonJS도 마찬가지다. CommonJS도 문제를 해결하기 위해 나온 기술이다. CommonJS가 나오기 이전 모듈 시스템이 없는 자바스크립트는 전역에 스크립트를 정의해서 라이브러리를 사용했다. 전역에 모든 라이브러리를 정의하기 때문에 다양한 문제가 발생한다. 전역 변수를 참조해야 하기에 변수명이 겹칠 수 있다. 그리고 라이브러리의 전체 파일을 하나의 스크립트 태그에서 가져오는 문제가 있다.(필요한 함수는 하나인데 전체 파일을 가져온다는 것이다)
이러한 문제를 해결하기 위해 나온 것이 CommonJS라는 모듈 시스템이다. CommonJS의 require함수를 통해 라이브러리를 정의할 수 있게 되어 코드적으로 깔끔해졌다.

  • before CommonJS
<script crossorigin src="https://unpkg.com/react@18/umd/react.development.js"></script>
  • after CommonJS
const react = require('react');

이 덕분에 파일 단위의 개발이 가능해졌다.
하지만 CommonJS는 문제점이 있다.

  • CommonJS는 언어 표준이 아니다.
    • 언어표준이 아니므로 node 환경을 제외한 런타임 환경(브라우저, 디노)에서 사용할 수 없다.
  • 정적 분석이 어렵다.
    • require는 함수이다. 함수이기 때문에 동적으로 변할 수 있다는 것이다. 조건문 안에서 require함수를 실행할 수도 있기에 컴파일 시간에 어떤 코드가 어떤 코드를 참조하고 있는지 분석하기가 어려진다.(import/export문은 키워드이기 때문에 조건적으로 호출할 수 없다)
  • require는 함수다
    • require는 함수이기 때문에 재정의가 가능하다. 전역에 선언된 require함수를 라이브러리에서 재정의한다면 require함수 통해 다른 모듈을 가져올 때 문제가 생긴다.

뭐 우리 서비스에서는 ESM의 import/export를 쓰고 있기 때문에 상관없다고 생각할 수 있다. 하지만 우리의 대부분의 코드는 타입스크립트나 바벨에 의해 CommonJS의 require함수로 트랜스파일링이 된다.
webpack 빌드 결과 __webpack_require__require 함수처럼 호출하고 있는 것을 확인할 수 있다.

((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {

"use strict";
eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */   \"default\": () => (__WEBPACK_DEFAULT_EXPORT__)\n/* harmony export */ });\nfunction memoize(fn) {\n  var cache = Object.create(null);\n  return function (arg) {\n    if (cache[arg] === undefined) cache[arg] = fn(arg);\n    return cache[arg];\n  };\n}\n\n/* harmony default export */ const __WEBPACK_DEFAULT_EXPORT__ = (memoize);\n//# sourceURL=[module]\n//# sourceMappingURL=data:application/json;charset=utf-8;base64,
// .....

개발자의 편의를 위해서 개발 환경에서는 import/export문을 사용하고 서비스 환경에서는 require함수로 트랜스파일링이 되도 괜찮지 않을까? 라고 생각랗 수 도 있지만 CommonJS는 위와 같이 여러가지 문제가 있기 때문에 ESM으로 적성된 라이브러리와도 궁합이 굉장히 좋지 않다. 또한 require함수 동작에도 불필요한 동작이 일어난다.

const { Component } from './Component';

Component를 가져오려면 ./Component, ./Component.js, ./Component/index.js 등등 뒤에 붙는 확장자나 path들을 순회하면서 Component를 찾기 때문에 불필요한 비용이 발생한다.
그렇다면 ESM을 쓰기 위해서는 어떻게 해야할까?
다행히 웹팩에서는 ESM을 지원한다. ESM 방식의 모듈 시스템을 따르기 위해서는

  • package.json의 type을 module로 설정해야한다.
  • 파일의 확장자를 붙여줘야한다.
    • 우리 프로젝트는 typescript + react 를 사용하기에 확장자는 tsx를 붙여줘야하는 것 같지만 js 확장자를 붙여야 한다. 왜냐하면 이는 typescript는 ts 확장자인 파일을 컴파일시 js 확장자로 변경되는 것이 코드적으로 변경이 일어난다고 판단하였기에 개발환경에서 jsx나 tsx 라도 js 확장자를 붙여야 한다.

ESM 방식으로 모듈 시스템을 따르는 것은 그렇게 어려보이지 않아 우리 프로젝트에도 도입하기로 했다. 그러나 결국 ESM을 도입하지 못하였다. 왜냐하면 프로젝트의 일부 모듈이 ESM을 지원하지 않았기 때문이다. 특히 webpack cli는 ESM을 지원하지 않는다.

Require stack:
- /Users/yunjin/Desktop/project/test/node_modules/webpack-cli/lib/webpack-cli.js
- /Users/yunjin/Desktop/project/test/node_modules/webpack-cli/lib/bootstrap.js
- /Users/yunjin/Desktop/project/test/node_modules/webpack-cli/bin/cli.js
- /Users/yunjin/Desktop/project/test/node_modules/webpack/bin/webpack.js
    at Function.Module._resolveFilename (node:internal/modules/cjs/loader:933:15)
    at Function.Module._load (node:internal/modules/cjs/loader:778:27)
    at Module.require (node:internal/modules/cjs/loader:1005:19)
    at require (node:internal/modules/cjs/helpers:94:18)
    at WebpackCLI.tryRequireThenImport (/Users/yunjin/Desktop/project/test/node_modules/webpack-cli/lib/webpack-cli.js:204:22)
    at loadConfigByPath (/Users/yunjin/Desktop/project/test/node_modules/webpack-cli/lib/webpack-cli.js:1404:38)
    at /Users/yunjin/Desktop/project/test/node_modules/webpack-cli/lib/webpack-cli.js:1454:88

그렇다고 ESM을 webpack 대신 Vite를 도입할 수도 없는 노릇이다. 왜냐하면 개인 프로젝트가 아닌 팀 프로젝트이다. 눈에 보이지 않고 끝까지 도달해야 알 수 있는 이득을 위해 많은 자원을 투자하기에는 다른 이슈가 더 우선되기 때문이다. ESM의 상태계가 조금 더 성숙해진다면 그 때는 ESM을 도입할 필요가 있다.

0개의 댓글