Parcel 리서치

2017년 12월 17일

들어가며

parcel star history

최근 프론트엔드를 개발하면서 코드와 의존성을 하나로 묶어주는 반들러(Bundler)는 떼어놓고 생각할 수 없을 정도로 기본적인 도구이다. 오래전부터 사용되어온 Browserify나 Bundler의 대명사 같은 Webpack뿐 아니라 Rollup을 본지도 얼마 되지 않은 것 같은데, 또다시 주목받는 Bundler가 나타났다. 상단의 이미지와 같이 2주 정도 만에 Github에서 Star의 개수가 만 개가량 증가한 것을 보면 Parcel[파설, /parsəl/]에 대한 사람들의 관심이 어느 정도인지 알 수 있다.

bundler star history

이미 많이 알려진 다른 반들러들과 비교해봐도 그 기울기가 예사롭지 않고, Webpack을 제외하면 그 수도 다른 번들러에 밀리지 않는다.

이 글에서는 Parcel 홈페이지를 참고하여 작성되었으며, 홈페이지는 한글 번역도 제공되니 바로 들어가서 보는 것도 좋을 것 같다.

목적

Webpack의 관심받고 있을 때 나타난 Rollup처럼 Parcel 또한 자신의 저장소에 자신을 왜 사용해야하는지 적어두었다. Parcel은 그 이유를 개발자 경험에서 찾고 있다. 아래는 Parcel이 찾고자하는 세 가지 개발자 경험이다.

  • 많은 반들러들에서 지루하고 오랜 시간이 필요한 500줄 이상의 설정은 보기 드문 일이 아니다. Parcel은 설정이 필요하지 않도록 설계되었다.
  • 많은 파일과 의존성을 가진 대형 프로그램에서는 기존 번들러들은 초기 빌드속도가 느리다. Parcel은 병렬로 컴파일하여 초기 빌드의 속도가 크게 향상되었고, 또한 파일 시스템 캐시를 가지고 있어 빠른 시작을 할 수 있다.
  • 기존 번들러들은 문자열을 받아와 구문을 분석하고 변형하여 코드 생성하는 과정을 반복하는 비효율적인 문자열 로더와 변형을 중심으로 만들어졌다. Parcel은 한 번의 구문 분석으로 생성된 AST들을 통해 변형하며, 파일당 한 번의 코드 생성을 한다.

특징

불꽃 튀게 빠른 번들

Parcel은 워커 프로세스를 이용하여 멀티코어 컴파일을 가능케 하고, 심지어 재시작 후라 해도 빠른 리빌드를 할 수 있도록 파일시스템 캐시를 갖고 있다.

모든 애셋 번들

Parcel은 플러그인 없이 JS, CSS, HTML, 파일 애셋, 그 외 많은 것들에 대한 지원을 즉시 제공한다.

자동 변환

필요하다면 Babel, PostCSS, PostHTML을 사용하는 코드는 자동으로 변환되며, 심지어 node_modules까지도 변환된다.

설정 없는 코드 분할

Parcel은 동적 import()문을 사용해서 출력 번들을 분할 할 수 있다. 이를 통해 초기 로드시 필요한 것들만 로드할 수 있다.

핫 모듈 리플레이스먼트

Parcel은 설정 없이 자동으로 개발중의 변화를 갱신하여 브라우저에 나타낸다.

친절한 에러 로그

Parcel은 오류 발생시 도움이 되도록 문제를 정확히 집어주는 구문 강조 코드 프레임을 출력한다.

예제

Hello World

우선 Parcel로 Hello World를 찍는 에제를 보면 아래와 같은 절차로 수행된다.

  • parcel 설치 및 package.json 생성
npm install -g parcel-bundler
npm init -y
  • index.htmlindex.js 파일 생성
<!-- index.html -->
<html>
  <body>
    <script src="./index.js"></script>
  </body>
</html>
// index.js
console.log('hello world');
  • parcel 실행
parcel index.html

만약 Webpack이었다면 아래와 같은 설정 파일이 하나 더 있어야 했을 것이고, CSS를 import해 사용하거나 할 때마다 설정 파일에 설정을 추가해야 했을 것이다.

// webpack.config.js
module.exports = {
  entry: './index.js',
  output: {
    filename: 'bundle.js',
  },
};

React

기본적인 React를 구성한다면 아래와 같이 간단하게 설정할 수 있다.

npm install --save react
npm install --save react-dom
npm install --save-dev parcel-bundler
npm install --save-dev babel-preset-env
npm install --save-dev babel-preset-react
 // .babelrc
{
  "presets": ["env", "react"]
}
// package.json
"scripts": {
  "start": "parcel index.html"
}

Webpack이었다면 아래와 같은 설정 파일이 있어야할 것이다.

// webpack.config.js
module.exports = {
  entry: './src/index.js',

  output: {
    path: __dirname + '/public/',
    filename: 'bundle.js',
  },

  devServer: {
    inline: true,
    port: 4000,
    contentBase: __dirname + '/public/',
  },

  module: {
    loaders: [
      {
        test: /\.js$/,
        loader: 'babel-loader',
        exclude: /node_modules/,
      },
    ],
  },
};

구성 요소

애셋 유형

Parcel은 각 입력 파일을 Asset이라고 표현하며 애셋 유형은 기본 Asset 클래스를 상속한 클래스로 표현된다. 애셋 유형은 구문 분석, 종속성 분석, 변환과 코드 생성에 필요한 인터페이스를 구현한다. Parcel은 다중 프로세서 코어로 애셋을 병렬 처리 하기 때문에 애셋 유형이 수행할 수 있는 변환은 한번에 하나의 파일 운용만으로 한정 되어 있으며, 여러 파일 변환을 위해 사용자 정의 패키저를 쓸 수 있다.

애셋 인터페이스

const { Asset } = require('parcel-bundler');

class MyAsset extends Asset {
  type = 'foo'; // 주 출력 유형 설정

  parse(code) {
    // AST에 코드 구문 분석
    return ast;
  }

  pretransform() {
    // 옵션. 의존성 수집 이전의 변환.
  }

  collectDependencies() {
    // 의존성 분석.
    this.addDependency('my-dep');
  }

  transform() {
    // 옵션. 의존성 수집 이전의 변환.
  }

  generate() {
    // 코드 생성. 필요하다면 다수의 표현(rendition)을 반환할 수 있음.
    // 결과물은 적절한 패키저로 전달되어 최종 번들을 생성.
    return {
      foo: 'my stuff here', // 메인 출력
      js: 'some javascript', // 필요하다면 JS 번들에 배치할 대체 표현(rendition)
    };
  }
}

애셋 유형 등록하기

const Bundler = require('parcel-bundler');

let bundler = new Bundler('input.js');
bundler.addAssetType('.ext', require.resolve('./MyAsset'));

패키저

Parcel에선 패키저가 다수의 애셋을 하나의 최종 출력 번들로 결합시킨다. 이것은 모든 애셋이 처리되고, 하나의 번들 트리가 만들어 진 후 주 과정 중에 발생한다. 패키저는 출력 파일 유형을 기반으로 등록되고, 해당 출력 타입을 생성한 애셋은 최종 출력 파일의 제품화를 위해 패키저로 보내진다.

패키저 인터페이스

const { Packager } = require('parcel-bundler');

class MyPackager extends Packager {
  async start() {
    // 옵션. 필요하다면 파일 헤더 작성.
    await this.dest.write(header);
  }

  async addAsset(asset) {
    // 필수. 출력 파일에 애셋 작성.
    await this.dest.write(asset.generated.foo);
  }

  async end() {
    // 옵션. 필요하다면 파일 트레일러 작성.
    await this.dest.end(trailer);
  }
}

패키저 등록하기

const Bundler = require('parcel-bundler');

let bundler = new Bundler('input.js');
bundler.addPackager('foo', require.resolve('./MyPackager'));

플러그인

Parcel 플러그인은 초기화 중 Parcel에 의해 자동으로 호출되는 단일 함수를 내보내는(export) 모듈이다. 이 함수는 Bundler 객체를 입력으로 받고 애셋 유형과 패키저 등록과 같은 설정을 수행할 수 있다.

module.exports = function (bundler) {
  bundler.addAssetType('ext', require.resolve('./MyAsset'));
  bundler.addPackager('foo', require.resolve('./MyPackager'));
};

이 패키지를 parcel-plugin-접두어를 붙여 npm에 발행하면 자동으로 감지되고 로드된다.

예제

parcel-plugin-vue를 살펴 본다면 앞서본 구성 요소들의 이해에 도움이 될 것 같다.

//index.js
module.exports = function (bundler) {
  bundler.addAssetType('vue', require.resolve('./src/VueAsset.js'));
  bundler.addPackager('js', require.resolve('./src/packagers/JSPackager.js'));
};

위 코드를 통해 플러그인 API를 통해 애셋 유형과 패키저를 등록하는 것을 볼 수 있다.

// VueAsset.js
class MyAsset extends JSAsset {
  async parse(code) {
    ownDebugger('parse');

    // parse code to an AST
    this.outputCode = await compilerPromise(this.contents, this.name);
    return await super.parse(this.outputCode);
  }

  collectDependencies() {
    ownDebugger('collectDependencies');

    // analyze dependencies
    this.addDependency('vue');
    this.addDependency('vueify/lib/insert-css');
    this.addDependency('vue-hot-reload-api');
    super.collectDependencies();
  }
}

module.exports = MyAsset;

등록한 애셋 유형에서 구문 분석된 코드를 받아 AST로 만드는 것을 볼 수 있다. 한편으로 의존성 분석을 하는 것도 볼 수 있다.

// JSPackager.js
class JSPackager extends JSPackagerOfficial {
  async start() {
    ownDebugger('start');

    this.first = true;
    this.dedupe = new Map();

    await this.dest.write(prelude + '({');
  }

  async end() {
    ownDebugger('end');

    let entry = [];

    // Add the HMR runtime if needed.
    if (this.options.hmr) {
      // Asset ids normally start at 1, so this should be safe.
      await this.writeModule(0, hmr.replace('{{HMR_PORT}}', this.options.hmrPort));
      entry.push(0);
    }

    // Load the entry module
    if (this.bundle.entryAsset) {
      entry.push(this.bundle.entryAsset.id);
    }

    await this.dest.end('},{},' + JSON.stringify(entry) + ')');
  }
}

module.exports = JSPackager;

등록한 패키저에서 파일 헤더 작성 및 파일 트레일러 작성을 수행하는 것을 볼 수 있다.

동작

애셋 트리 구성

Parcel은 하나의 진입 애셋을 입력으로 받아, 애셋이 분석되어 애셋의 의존 요소가 추출되고, 최종적인 컴파일 형태로 변환되어 애셋 트리를 만든다.

번들 트리 구성

일단 애셋 트리가 만들어지면 애셋은 번들 트리 안에 놓이되며, 진입 애셋을 위한 번들이 만들어지고, 코드 분할을 발생시키는 다이나믹 import()를 위한 하위 번들이 만들진다.

패키징

번들 트리가 만들어지고 나면, 각 번들은 패키저에 의해 특정 유형의 파일로 작성된다.

성능

합리적인 사이즈인 1726개의 module을 포함한 압축되지 않은 6.5M의 앱을 기준으로 4개의 물리 CPU가 있는 2016형 MacBook Pro에서 빌드되었을 때의 성능이다.

Bundler Time
browserify 22.98s
webpack 20.71s
parcel 9.98s
parcel - 캐시 사용 2.64s

마치며

Parcel의 설정이 없고 빠른 성능은 매력적이었다. 특히 설정이 없다는 부분에서 새로운 사이드 프로젝트할 때 마다 새로 설정하거나 복사해 변경하며 버린 시간을 생각하면 특히 매력적이다.

하지만 만약 당장 프로덕션에 적용하는 경우라면 조심스럽다. 저장소를 보면 많이 사용되는 TypeScript, SASS 등의 애셋은 Parcel에 포함되어 있지만, 아직 다른 번들러들에 비하여 플러그인이 적어 e2e 테스트 등은 어떻게 구성해야할지 판단이 서지를 않는다.

엄청난 스타를 받으며 주목을 바탕으로 다른 번들러들이 가진 기능 및 플러그인들이 추가된다면 매력적인 번들러가 되지 않을까 생각한다.

참고

Recently posts
© 2016-2023 smilecat.dev