Please enable JavaScript to view the comments powered by Disqus.

create-react-app에서 snowpack으로의 마이그레이션

최근 실무에서 Webpack 기반의 create-react-app(이하 CRA)으로 구성된 환경으로 개발을 진행하고 있다. CRA는 간편하게 앱을 부트스트랩핑해서 개발을 시작할 수 있다는 장점이 있지만 커스터마이징에 한계가 있다. 그래서 CRA 커스터마이징을 위한 react-app-rewired 같은 모듈도 존재한다. CRA로 만든 앱을 eject 했을 때 숨겨져있던 다양하고 복잡한 설정 파일을 눈으로 본 사람이라면 저런 패키지에 대한 수요가 왜 생겼는지 공감할 것이다.

커스터마이징의 자유도가 낮은 것도 있지만, Webpack은 앱의 덩치가 커지면 커질수록 빌드 속도가 느려진다. 파일을 하나만 변경해도 번들 파일을 새로 만들기 때문이다. 그래서 더 빠른 빌드 시스템으로의 마이그레이션을 계속 생각해오고 있었다.

자바스크립트 번들러에는 과거에 grunt, gulp 같은 도구도 있었지만 그 후 등장한 Webpack이 사실상 왕좌의 자리를 차지하고 있다. 다운로드 수로 보자면 경쟁자인 rollup은 절반에도 미치지 못하며 이 글에서 살펴볼 snowpack은 다운로드 수 차트에서만 보자면 바닥을 기고 있다. 차트에는 없지만 parcel도 일간 다운로드 수가 10만에 미치지 못한다.

출처 - [https://www.npmtrends.com](https://www.npmtrends.com/) 출처 - https://www.npmtrends.com

Snowpack, 더 빠른 프론트엔드 빌드 도구

Snowpack은 Webpack처럼 복잡하고 무거운 빌드 시스템의 대안으로 만들어졌다. 자바스크립트 네이티브 모듈 시스템(ESM, Javascript Modules)을 사용해서 무거운 번들링 작업을 제거하고 프로젝트 크기와 상관없이 빠른 속도를 제공하도록 만들어졌다. 특징은 다음과 같다.

  • 개발서버는 처음에만 의존 모듈 설치 시간을 필요로 하며, 그 다음부터는 50ms 안에 실행된다.
  • 같은 파일을 두번 빌드하지 않는다. 브라우저 안에서 자바스크립트 네이티브 모듈(ESM) 사용으로 가능하다.
  • 소스가 변경되어도 브라우저 새로고침이 필요하지 않다. React, Preact, Svelte에서 HMR+Fast refresh를 지원한다.

    • 코드가 수정되어도 컴포넌트의 상태를 유지한다. 예를 들어 React에서 useState 등으로 설정된 값을 초기화하지 않음으로서 더 빠른 리프레쉬가 가능하다.
  • JSX, 타입스크립트, React, Preact, CSS 모듈을 기본 지원한다.
  • 최적화된 빌드를 지원하며 선호하는 번들러를 사용하기 위한 플러그인을 지원한다
  • Babel, Sass, MDX 등 기능 확장을 위한 빌드 플러그인을 지원한다

ESM을 사용하려면 script 요소에 type="module" 속성을 추가해야 한다. 그러면 인라인 스크립트 안에서도 import 구문을 쓸 수 있게 된다.

IE는 네이티브 모듈을 지원하지 않는다. 하지만 Snowpack의 Webpack 플러그인으로 빌드된 결과에서는 네이티브 모듈을 사용하지 않으므로 상관없다. 다만 IE를 반드시 지원해야 한다면 IE에서 개발 서버를 사용할 수 없으므로 디버깅에 어려움이 있을 것이다.

어떻게 더 빠른가?

webpack, parcel같은 전통적인 빌드 도구는 파일이 하나 바뀔 때마다 어플리케이션의 모든 조각(chunks)들을 다시 번들링하는 과정을 거친다. 하지만 Snowpack은 각각의 소스를 빌드만 할 뿐 번들이 되지 않은 상태로 개발 서버에 호스팅한다. 그리고 파일이 변경되면 그것만 다시 빌드하기 때문에 수정사항이 브라우저에 매우 빠르게 반영될 수 있다.

출처 - [https://www.snowpack.dev](https://www.snowpack.dev) 출처 - https://www.snowpack.dev

물론 배포를 위한 빌드도 제공하며, 배포용 빌드에 webpack을 사용하기 위한 플러그인도 제공한다. 빌드를 위한 복잡한 설정도 필요하지 않다.

개발서버에서는 소스를 Babel이나 Typescript로 변환만 할 뿐 번들은 하지 않은 상태로 제공하는 것, 이것이 개발 서버에서도 번들이 된 소스를 제공하는 다른 도구와 가장 큰 차이다. 그것(unbundled development)의 장점은 다음과 같다.

  • 파일 1개의 빌드 속도는 빠르다
  • 파일 1개의 빌드는 결정론적(deterministic, 언제나 같은 결과를 낳으므로 예측 가능함)이다.
  • 파일 1개의 빌드는 디버그하기 쉽다
  • 프로젝트 사이즈가 개발 속도에 영향을 미치지 않는다
  • 개별 파일이 캐시하기에 더 좋다

물론 빌드된 수백개의 소스 파일을 브라우저에서 처음 불러올 때는 시간이 조금 걸린다. 현재 작업 중인 프로젝트에서는 450여개의 소스 파일이 있는데, 최초 로딩시 6~7초로 아주 오랜 시간은 아니었다. 그 다음부터는 모두 웹 브라우저 캐시에서 불러오므로 로딩 속도가 훨씬 빠르다.

Snowpack에서 NPM 패키지를 사용하는 방식

NPM 패키지는 Common.js 모듈 문법을 사용해서 배포되기 때문에 빌드 프로세스를 거치지 않고서는 웹 브라우저에서 실행할 수 없다. 하지만 Snowpack에서는 패키지를 개발 서버에서 ESM 형태로 사용하기 위해 1개의 파일로 번들링한 후 호스팅한다.

node_modules/react/**/\* -> http://localhost:3000/web_modules/react.js
node_modules/react-dom/**/\* -> http://localhost:3000/web_modules/react-dom.js

React 모듈을 소스에 번들링하지 않아도 개발 서버에서 ESM 형태로 제공하면 컴포넌트 소스에서 import 문을 사용해서 직접 불러올 수 있다. NPM 패키지는 덩치가 크므로 빌드하는 데 시간이 걸리긴 하지만, 처음 1번만 빌드된 후 아래 폴더에 캐시된다.

<project root>/node_modules/.cache/snowpack/development

캐시를 삭제하지 않는 이상 더 이상 빌드하지 않기 때문에 많은 시간이 절약된다. 이렇게 NPM 패키지를 소스에 번들링하지 않고 웹 브라우저가 지원하는 ESM 문법을 사용한다는 이 특징이 Snowpack이 제공하는 번들링 없는 개발의 기반이다.

CRA에서 Snowpack으로의 마이그레이션

snowpack 설정 파일 추가

프로젝트 루트에 snowpack.config.js 파일을 추가한다.

/** @type {import("snowpack").SnowpackUserConfig } */
const path = require("path");

module.exports = {
  mount: {
    public: "/",
    src: "/_dist_",
  },
  plugins: [
    "@snowpack/plugin-react-refresh",
    "@snowpack/plugin-dotenv",
    "@snowpack/plugin-typescript",
    "@snowpack/plugin-webpack",
  ],
  installOptions: {
    polyfillNode: true,
  },
  devOptions: {
    port: 3000,
  },
  buildOptions: {
    sourceMaps: true,
  },
  proxy: {
    /* ... */
  },
  alias: {
    __mocks__: path.join(__dirname, "src/__mocks__"),
    component: path.join(__dirname, "src/component"),
    constants: path.join(__dirname, "src/constants"),
    hook: path.join(__dirname, "src/hook"),
    page: path.join(__dirname, "src/page"),
    store: path.join(__dirname, "src/store"),
    style: path.join(__dirname, "src/style"),
    types: path.join(__dirname, "src/types"),
    styles: path.join(__dirname, "src/styles"),
    utils: path.join(__dirname, "src/utils"),
    vendor: path.join(__dirname, "src/vendor"),
  },
  testOptions: {
    files: ["**/*.@(spec|test).*"],
  },
};

mount

빌드된 소스와 정적인 파일을 호스팅할 개발 서버의 경로를 지정한다. 위처럼 설정하면 소스 저장소의 public/index.html 파일은 개발 서버의 /index.html 경로로, src/index.tsx 파일은 빌드되어 /_dist_/index.js 경로로 접근할 수 있게 된다.

CRA를 사용했다면 index.html 파일에 엔트리 파일을 불러오는 부분이 없을 것이다. 하지만 Snowpack에서는 필요하므로 루트 컴포넌트를 연결할 요소 아래쪽에 아래 코드를 추가해준다.

<html>
  <head>
    ... (생략)
  </head>

  <body>
    <div id="root"></div>
    <script type="module" src="/_dist_/index.js"></script>
  </body>
</html>

plugin

Snowpack은 확장성을 위한 플러그인을 지원한다.

  • @snowpack/plugin-react-refresh

    • HMR 사용을 위한 플러그인
  • @snowpack/plugin-dotenv

    • .env 파일에 있는 환경 변수 파일을 import.meta.env 객체를 통해 접근할 수 있게 한다.
  • @snowpack/plugin-typescript

    • 타입스트립트 소스를 자바스크립트로 변환해준다.
  • @snowpack/plugin-webpack

    • Snowpack은 번들링 기능을 포함하고 있지 않다. 하지만 서비스 배포를 위해선 소스 압축이 필요하므로 플러그인을 통해 webpack의 기능을 활용한다(Webpack의 옵션을 직접 전달할 수도 있지만 기본적인 설정이 되어 있어서 별도로 필요하진 않았다). rollup 플러그인은 현재 개발 중이며, Snowpack 버전 3에는 자체적으로 번들러를 제공할 예정이라고 한다.

installOptions

모듈 설치와 관련된 옵션. polyfillNode 옵션은 소스에서 path같은 Node.js 모듈을 사용할 경우 필요하다.

devOptions

개발 서버 관련 옵션. port에 개발 서버의 포트를 지정한다.

buildOptions

빌드 옵션. sourceMapstrue로 지정하면 소스맵도 함께 생성한다. 배포된 서비스에서 에러가 발생했을 때 Sentry같은 모니터링 서비스에서 에러가 발생한 부분과 원본 소스를 매칭시켜 줄 때 사용한다.

proxy

개발 서버의 프록시를 설정한다. 예를 들어 /api 로 접근하면 https://pokeapi.co/api/v2/로 연결시켜주는 등의 기능을 한다.

alias

아래처럼 소스 파일을 절대 경로로 접근하려고 할 때 필요하다.

import useTypedSelector from "hook/useTypedSelector";

testOptions

테스트 파일의 위치를 지정한다. 테스트 파일 관련 모듈은 개발 서버 실행에만 설치되며, 빌드 작업에는 포함되지 않는다.

환경 변수 설정

CRA에서는 환경 변수를 .env 파일에 추가하면 node.js에서 실행되는 소스와 브라우저에서 실행되는 소스 모두에서 process.env 객체를 통해 접근할 수 있도록 Webpack 설정이 되어 있다. Snowpack에서는 그 방법 대신import.meta.env 객체를 통해 환경 변수를 제공한다.

그리고 변수 이름 앞에 SNOWPACK_PUBLIC_이 붙지 않은 변수는 브라우저 실행되는 소스에서 접근할 수 없도록 되어 있다. 저렇게 구분한 이유는 환경 변수에 서버 접속 시크릿 키 같은 중요한 값이 포함되어 있을 수 있기 때문이다. 그런 값들은 실수로라도 노출되어선 안되기에 보안을 위해 사용자에게 어떤 환경변수가 브라우저에서 노출되는지 확실히 알게 하려는 목적을 가진다.

CRA로 프로젝트가 구성되어 있다면 node.js 환경에서만 사용되는 값을 제외한 모든 환경변수의 이름 앞에 REACT_APP_ 프리픽스 대신SNOWPACK_PUBLIC_을 추가해줘야 한다. 소스는 물론 배포 환경에도 영향을 미칠 수 있는 사항이기에 새로운 프리픽스가 붙은 환경변수를 먼저 추가해준 후, 마이그레이션이 완료되면 필요없는 변수를 지우는 방식을 쓰는 편이 좋을 것이다

Jest 설정

CRA는 Jest 실행 설정을 포함하고 있다. 하지만 Snowpack에는 없으므로 설정 파일을 직접 추가해줘야 한다.

// jest.config.js 파일
const snowpackJestConfig = require("@snowpack/app-scripts-react/jest.config.js")();
const { parsed: env } = require("dotenv").config();

module.exports = {
  ...snowpackJestConfig, // Snowpack에서 제공하는 설정을 가져와서 커스터마이징 하는 방식을 쓴다
  verbose: false,
  preset: "ts-jest", // 타입스트립트를 사용한다면 ts-jest 모듈이 필요하다.
  testEnvironment: "jsdom",
  setupFilesAfterEnv: ["./src/setupTests.ts"],
  testPathIgnorePatterns: [
    "/node_modules/",
    "<rootDir>/build",
    "<rootDir>/cypress",
  ],
  testMatch: ["<rootDir>/src/**/?(*.)+(spec|test).[jt]s?(x)"],
  moduleNameMapper: {
    // Snowpack 설정의 alias를 그대로 가져와서 쓸 수 없어서 jest에서 사용하는 방식으로 설정한다.
    // 키와 밸류는 String.prototype.replace 메소드의 파라미터에 차례대로 들어간다고 보면 된다.
    "^__mocks__(/?.*)$": "<rootDir>/src/__mocks__$1",
    "^component(/?.*)$": "<rootDir>/src/component$1",
    "^constants/(.*)$": "<rootDir>/src/constants/$1",
    "^hook(/?.*)$": "<rootDir>/src/hook$1",
    "^page(/?.*)$": "<rootDir>/src/page$1",
    "^store(/?.*)$": "<rootDir>/src/store$1",
    "^style(/?.*)$": "<rootDir>/src/style$1",
    "^translation(/?.*)$": "<rootDir>/src/translation$1",
    "^types/(.*)$": "<rootDir>/src/types/$1",
    "^utils(/?.*)$": "<rootDir>/src/utils$1",
    "^vendor(/?.*)$": "<rootDir>/src/vendor$1",
  },
};

Storybook 설정

Storybook은 빌드 툴로 Webpack을 내부적으로 사용한다. 실행을 위해서는 .storybook/main.js 파일에서 Webpack 설정을 확장해야 한다.

우선 소스에서 모듈을 절대 경로로 가져오기 위해 alias 설정을 추가해줘야 한다. snowpack.config.js 모듈에 있는 것을 재사용할 수 있다. 다만 snowpack.config.js 파일과 main.js 파일이 다른 디렉토리에 있기 때문에 path.join(__dirname, '경로') 형태로 설정하지 않았다면 오류가 발생할 것이다.

그리고 역시 소스에서 import.meta.env 를 사용하기 위한 설정이 필요하다. webpack.DefinePlugin 으로 __SNOWPACK_ENV__ 전역 변수를 선언한 후, importMetaLoader.js 로더를 추가해서 import.meta.env 코드를 환경 변수를 포함한 객체 형태로 변환해준다.

const path = require("path");
const webpack = require("webpack");
const snowpackConfig = require("../snowpack.config.js");

require("dotenv").config();

module.exports = {
  webpackFinal: async (config) => {
    // Assign aliases from snowpack.config.js
    config.resolve.alias = {
      ...config.resolve.alias,
      ...snowpackConfig.alias,
    };

    // Add __SNOWPACK_ENV__ global
    config.plugins.push(
      new webpack.DefinePlugin({
        __SNOWPACK_ENV__: JSON.stringify(
          // process.env 객체에서 SNOWPACK_PUBLIC_ 프리픽스가 붙은 변수만 필터링
          Object.entries(process.env).reduce(
            (filtered, [key, value]) =>
              /^SNOWPACK_PUBLIC_.+/.test(key)
                ? { ...filtered, [key]: value }
                : filtered,
            {}
          )
        ),
      })
    );

    // Add rules for supporting import.meta
    config.module.rules.push({
      test: /\.[tj]sx?$/,
      loader: [require.resolve("./importMetaLoader.js")],
    });

    return config;
  },
};
// importMetaLoader.js
const path = require("path");
const regex = /import\.meta/g;

/**
 * import.meta 코드 변환을 위한 storybook용 webpack 로더
 * 참조) https://github.com/snowpackjs/snowpack/blob/main/plugins/plugin-webpack/plugins/import-meta-fix.js
 */
module.exports = function (source) {
  let found = false;
  let rewrittenSource = source.replace(regex, () => {
    found = true;
    return `({ env: __SNOWPACK_ENV__ })`;
  });

  if (found) {
    return `${rewrittenSource}`;
  } else {
    return source;
  }
};

위의 로더는 @snowpack/plugin-webpack에 있는 것을 수정한 것이다. import.meta.url을 사용하지 않는다면 위의 간단한 버전을 사용해도 상관없다.

Snowpack 셋업 후 속도 측정

실무에서 작업 중인 프로젝트를 Snowpack으로 마이그레이션 한 후 속도를 측정해 보았다.

개발 서버 실행 속도

최초

20201215 개발 서버 실행 속도 - 최초

2회차

20201215 개발 서버 실행 속도 - 2회차

최초에는 소스 및 NPM 모듈 빌드를 위해 시간이 40초 정도 걸리지만, 그 다음부터는 빌드 시간이 거의 필요없는 수준이다.

개발 서버 리소스 로딩 속도

최초

20201215 로컬서버 페이지 로드 시간 - 최초 로딩 (dependency 설치 후)

소스와 NPM 모듈이 캐시가 되지 않은 상태로 5초 정도가 걸렸다. 스크린샷에 보면 react, react-dom, react-redux 등의 모듈을 브라우저에서 직접 불러오고 있는 것을 확인할 수 있다.

2회차 이상

20201215 로컬서버 페이지 로드 시간 - 새로고침

파일들이 캐시되어 로딩에 대부분 100ms를 넘기지 않음을 확인할 수 있다.

빌드 시간 비교

CRA

20201215 build log - CRA

129초 정도가 걸렸다.

Snowpack

20201215 build log - snowpack

@snowpack/plugin-webpack 플러그인을 사용해서 73초 정도가 걸렸다. CRA에 비하면 1.7배 정도 빠르다. (Snowpack의 번들 파일 용량이 더 큰 것처럼 나오지만, Webpack 결과에는 웹 서버에서 gzip으로 압축 전송을 적용했을 때의 크기를 보여주고 있기 때문에 그렇다.)

테스트 속도 비교

CRA + Jest

20201215 테스트 속도 - CRA

Snowpack + Jest

20201215 테스트 속도 - snowpack

테스트는 Snowpack에서 추천하는 esbuild 기반의 Web test runner를 사용하지 않는 이상 아주 큰 차이가 없을 것으로 보인다.

esbuild는 Go로 만들어졌으며 무거운 번들링 작업을 할때 싱글 스레드인 자바스크립트로는 불가능한 병렬 처리가 구현되어 있다. Snowpack은 v2에서도 개별 파일을 빌드할 때는 esbuild를 사용하고 있다.

결론

Snowpack은 2021년 1월에 배포 예정인 3.0 버전에서 NPM 패키지를 설치하는 대신 Skypack CDN으로 스트리밍하기, esbuild를 사용해서 webpack보다 100배 이상 빠른 번들링 지원, 라우팅등 지금보다 더 다양하고 혁신적인 기능을 선보일 것이라서 기대가 된다.

Snowpack은 많은 사람의 관심에 비해 실제 사용량이 아직은 적다. 하지만 2020년 12월 현재 아직 만 2년도 안된 프로젝트인데다(Webpack은 8년) 라이브러리가 더 성숙해지고 플러그인의 종류도 다양해지면 사용자가 앞으로 더 많아질 것으로 기대된다. 개인적으로는 신규 프로젝트를 진행한다면 빌드 시스템으로 Snowpack을 우선 고려할 것 같다. 하지만 Webpack에 비하면 성숙도가 낮은 도구라서 대규모 상용 서비스에 즉시 도입을 권하기는 애매한 상황이다. 하지만 지금 진행 중이거나, 새로 시작할 프로젝트가 Webpack이나 다른 번들러의 기능에 크게 의존하지 않는다면 사용을 고려해볼만 하다.

참고 자료