트리 쉐이킹 되는 UI 라이브러리 만들기 ㄱ부터 ㅎ까지


웹 페이지를 만들 때 풍부한 기능을 제공하기 위해서 잘 만들어진 UI라이브러리를 사용한다. 필자는 TOAST UI의 라이브러리 중 하나를 개발하고 있고 그동안 쌓은 경험과 노하우를 대방출하고자 한다. 이 글은 UI 라이브러리를 만들기 위한 방법을 소개하기 위해 작성하였고, 라이브러리의 목표와 기능, 사용한 기술 스택 그리고 웹팩 설정까지 바로 적용할 수 있는 실용적인 내용으로 구성하였다. UI 라이브러리를 만드는 방법과 노하우 A to Z를 알고 싶다면 노트북을 준비하고 차근차근 따라해보자.

image

동기

필자는 TOAST UI Calendar v2를 만들고 싶었다. 왜냐하면 TOAST UI Grid가 내부 구조를 획기적으로 개선하며 나에게 영감과 동기를 부여해주었기 때문이다. Grid는 대용량 데이터를 렌더링하기 때문에 성능이 매우 중요한 UI 라이브러리이며, preact을 도입하고 자체 리액티브 시스템을 개발하면서 획기적인 변화를 일으켰다. 고맙게도 이 모든 과정을 나는 옆에서 지켜봐왔고, 잘 정리된 위클리 문서를 공유해 주었기 때문에 나 또한 용기를 얻고 시작하게 되었다. Grid 팀에게 감사를 전한다.

최신 UI 라이브러리의 목표와 기능, 그리고 기술 스택

TOAST UI Grid에서 얻은 몇 가지 장점을 포함하여 내가 생각한 개선된 UI 라이브러리는 몇 가지 목표와 기능을 가진다.

라이브러리의 목표

기술적으로 사용자 관점과 개발자 관점에서 목표는 다음과 같다.

  1. 라이브러리 사용자에게는 라이브러리에서 필요한 기능만 사용할 수 있어서 웹 페이지 크기를 줄일 수 있다.
  2. 라이브러리 개발자에게는 편리한 개발 환경을 구축하여 생산성을 높이고, 성능이 우수하고 사용하기 편리한 라이브러리를 만든다.

라이브러리의 주요 기능

두 가지 목표와 더불어 라이브러리가 지원하는 주요 기능은 다음과 같다.

  1. 트리 쉐이킹 지원
  2. 가상돔으로 렌더링 최적화
  3. 서버 사이드 렌더링 지원

lodash나 moment는 기능이 뛰어나고 좋은 라이브러리이다. 그러나 사용하지 않는 기능까지 모두 번들될 경우 라이브러리의 크기가 커지기 때문에, 크기를 줄이기 위한 최적화 기법이 제공되고 있다. 트리 쉐이킹도 그 중 하나이며 트리 쉐이킹을 지원하는 라이브러리를 만들어서 웹 페이지의 크기를 줄일 수 있도록 할 것이다.

주요 기술 스택

TOAST UI의 라이브러리는 모두 웹팩을 사용하여 번들링 된다. 웹팩 이외에 도입하기로 한 기술 스택을 살펴보자. TOAST UI Calendar v1와 비교하여 변화된 기술도입이다.

타입스크립트 도입 우선 협업자와 논의해 본 결과 타입스크립트를 사용하기로 결정했다. 타입언어가 필자에게 주는 번거로움과 답답한 느낌이 있지만, 나중에 유지 보수를 쉽게하고 에러 발생을 줄일 수 있다는 장점을 수용하기로 한 것이다.

가상돔(preact) 도입 기존에는 템플릿 엔진으로 handlebars를 사용하였다. 렌더링할 때마다 전체 DOM이 항상 업데이트 되어야 하므로 불필요한 렌더링을 줄이기 위해서 가상돔 도입을 결정하였다.

ES6 모듈 도입 ES6 모듈로 개발하는 것은 더 이상 놀라운 일은 아니다. 하지만 트리 쉐이킹을 하기 위해서는 ES6 모듈 사용이 필수이며, 트리 쉐이킹이 지원되는 UI라이브러리를 개발하는 것은 또 다른 도전이다. 이 부분이 궁금하다면 다음 주제인 "주요 기술 스택을 채택한 이유"에서 보다 자세히 알아볼수 있다.

서버 사이드 렌더링 지원 기술 스택이라고 보기는 다소 무리가 있지만, preact를 도입하면서 가상돔을 html 문자열로 변환하는 것이 쉬워졌다.

sass와 postcss 도입 기존에는 stylus를 사용하였다. css를 잘 구조화 시킬 수 있는 sass를 사용하고, css 클래스에 라이브러리 고유의 선택자를 부여하기 위해서 postcss로 변환하도록 한다.

다음은 트리 쉐이킹을 지원하는 UI 라이브러리를 만들기 위해서 겪은 경험과 기술 스택을 채택한 이유를 보다 자세히 설명한다. 설명보다 바로 개발 환경 설정을 하고 싶다면 이 주제는 건너띄고, 다음 주제 "따라해 보는 실전 - 멋진 개발 환경 만들기"으로 넘어간다.

주요 기술 스택을 채택한 이유

타입스크립트는 협업자의 합의로 도입을 결정하였다. 만약 여러분이 타입스크립트를 도입하지 않고 트리 쉐이킹 되는 UI 라이브러리를 만들고 싶다면, 바벨과 preact, jsx를 사용하여 동일한 목적을 달성할 수 있다. 그럼 선택한 기술을 도입한 이유를 자바스크립트 측면에서 자세히 살펴보자.

트리 쉐이킹 지원

TOAST UI Calendar는 일간, 주간, 월간 등 몇 가지 보기 타입을 지원하는데 특정 타입의 보기만 쓰는 사용자들도 있다. 사용하지 않는 보기 타입 때문에 전체 라이브러리를 모두 포함해야 하는 것이 마음에 들지 않는다. 월간보기만 사용할 경우 주간보기 소스코드는 포함될 필요가 없다. 트리 쉐이킹이 될 수 있도록 코드를 구성한다. 그리고 사용자에게 번들되는 자바스크립트에는 포함되지 않도록 하여 크기를 줄이고 싶은 것이 목적이다.

라이브러리 사용자가 웹팩이나 롤업 등 번들러로 트리 쉐이킹을 할 경우 라이브러리는 ES6 모듈로 개발되어야 한다. ES6 모듈은 importexport를 사용한다. 번들러는 모듈에서 export한 기능 중에 실제 사용한 기능만 남기고 나머지 코드는 번들에서 제외시킨다. 그러나 말은 간단하지만 ES6 모듈로 구성된 UI 라이브러리를 제공하기 위해서 몇 가지를 고려해야 한다.

모듈 사이드 이펙트가 발생하지 않아야 트리 쉐이킹이 가능하다.

모듈에서 export한 기능 중에서 사용하지 않는 기능을 번들에서 제거하기 위해서는 해당 기능이 정말로 사용되지 않는지 번들러가 판단해야 한다. 예를 들어 export한 함수 A가 B를 참조하는 경우, 라이브러리 사용자가 함수 A만 사용했다고 하더라도 함수 B는 번들에 반드시 포함해야 한다. 이를 사이드 이펙트가 발생했다고 하며 명시적으로 사용한 함수가 아니더라도 다른 함수가 포함될 수 있음을 의미한다. 번들러는 이와 유사한 경우를 모두 고려해서 함수들이 서로 참조하는지 여부를 판단해야 한다.이는 판단하기 어려운 문제이며 번들 성능을 떨어뜨릴 수 있다.

이를 해결하기 위해서 라이브러리 작성자가 사이드 이펙트가 발생하지 않도록 개발했음을 보장하는 플래그를 설정한다. 웹팩은 이를 믿고 사용하지 않는 모듈을 번들에서 제외시킨다. package.json에서 ”sideEffects”: false를 설정한다.

트랜스파일된 이후에는 순수하게 자바스크립트로만 동작할 수 있어야 한다.

이게 무슨 말인가 싶지만, 웹팩은 자바스크립트를 번들할 때 로더와 플러그인 등 각종 도구를 사용한다. 로더를 필요로 하는 자바스크립트는 자바크립트 VM에서 바로 실행될 수 없는 자바스크립트이며, 웹팩이 전처리를 해야하기 때문에 그냥은 실행될 수 없다. 로더를 통해 트랜스파일 및 번들된 후에야 순수하게 자바스크립트로만 실행될 수 있는 상태가 된다. 우리의 라이브러리도 트랜스파일이 끝난 이후에는 자바스크립트로만 실행되어야 한다.

그렇다면 결론은 트리 쉐이킹되는 ES6 모듈을 만들어 내려면 개발할 때도 웹팩을 사용하는 도구는 사용할 수 없다는 것이다.

왜냐하면 웹팩이 자바스크립트 모듈을 웹팩의 모듈로 변환하기 때문이다. UMD로 번들할 경우 __webpack_require__와 같은 코드를 사용한 모듈로 변경되면서 모든 모듈이 하나 혹은 여러 개의 번들 파일로 모인다. 그러므로 라이브러리 사용자가 라이브러리를 사용할 때는 ES6 모듈이 아닌 형태다. 더 이상 ES6 모듈이 아니므로 트리 쉐이킹이 가능하지 않다. 또한 모듈이 하나의 번들 파일에 모이게 될 경우 사이드 이펙트 발생이 매우 많아지므로 트리 쉐이킹이 실제로 거의 이루어지지 않을 가능성이 높다.

그렇다면 롤업은 어떨까? 롤업은 ES6 라이브러리를 만들 수 있게 해주는 번들러이다. 웹팩처럼 자체 모듈 기법을 사용하지 않고 ES6 모듈 형태를 유지해준다. 롤업으로 번들할 경우 모든 ES6 모듈이 하나의 파일로 번들된다. 그러나 라이브러리 사용자가 웹팩을 사용해서 트리 쉐이킹을 시도했을 때 사이드 이펙트로 인하여 트리 쉐이킹이 잘 되지 않는다.

웹팩 로더의 편리한 기능들은 그럼 모두 버리란 것인가? TOAST UI Calendar v1은 템플릿 엔진으로 handlebars를 사용한다. html을 직관적으로 작성할 수 있기 때문에 편리하게 사용했지만, 역시 웹팩 로더를 사용하기 때문에 다른 대안을 찾아야 했다. 오직 트랜스파일 도구만 사용할 수 있다. 그래서 내가 찾은 대안이 바로 가상돔을 지원하는 preactjsx사용이다. 바벨타입스크립트jsxh함수로 변환할 수 있어서 자바스크립트를 트랜스파일하면 순수한 자바스크립트를 만들어낸다.

가상돔으로 렌더링 최적화

preact는 이미 유명하고 기능이 검증된 도구이지만, FE개발랩에서 아직 도입된 사례가 없어서 snabbdom을 고려한 적이 있었다. 하지만 앞서 언급했듯이 Grid에서 preact를 도입하여 우수함을 입증한 상태라 필자도 도입하기로 결정했다.

가상돔의 장점은 불필요한 렌더링을 줄여서 렌더링을 최적화할 수 있게 하는 것이다. 그리고 다른 큰 장점은 트리 쉐이킹과 관련이 있다. 별도의 템플릿 엔진이 필요 없이 jsx를 사용할 수 있다. 그러므로 웹팩 로더를 사용하지 않아도 된다. 바벨과 타입스크립트가 preact를 지원하고 있기 때문에 jsx가 트랜스파일을 거쳐서 자바스크립트 함수 h()로 변환된다. 웹팩 로더를 사용하지 않기 때문에 ES6 모듈 형태를 유지한다. 또한 따로 템플릿 엔진을 사용하지 않아도 되는 큰 장점이 있다.

서버 사이드 렌더링 지원

TOAST UI Calendar v1은 서버 사이드 렌더링을 고려하여 개발되지 않았다. 깃헙 이슈 게시판에 가끔 서버 사이드 렌더링을 지원하는지 문의가 들어온다. 그 때마다 아쉬운 표정으로 댓글을 달며 키보드를 누르곤 했다. v1에도 서버 사이드 렌더링을 고려해서 추가 개발할 수 있을 것이다. 하지만 진짜 DOM을 기반으로 동작하고 있기 때문에 node에서 실행하는 것이 쉽지 않을 것이다.

앞서 preact를 사용하기로 결정했기 때문에 가상돔을 HTML 문자열로 만들어 주는 서버 사이드 렌더링은 그저 밥 숟가락 하나 더 얻기만 하는 되는 일이다.

따라해 보는 실전 - 멋진 개발 환경 만들기

이제부터는 실전이다. 읽고 따라하면 앞서 말한 목표와 기능을 제공하는 UI 라이브러리를 만들 수 있다. 또한 FE개발랩에서 유용하게 사용 중이며, 사내 가이드로 제시하고 있는 기술 스택과 개발 환경을 쉽게 사용해 볼 수 있다.

최종 결과물의 모습 살펴보기

먼저 번들 및 트랜스파일된 파일의 최종 모습이다. v1처럼 여전히 단일 번들 파일도 제공하고 ES6 모듈도 제공한다.

ES5 단일 번들 파일의 목록이다.

  • dist/tui-calendar.js
  • dist/tui-calendar.css
  • dist/tui-calendar.min.js
  • dist/tui-calendar.min.css

트리 쉐이킹 되는 ES6 모듈dist/esm폴더에 다음 그림의 예와 같이 생성된다(프로토타이핑 중인 v2의 모습이고 이 아티클에서는 간단한 클래스 몇 개만 만들어 본다). image (63)

주요 기술과 개발 환경의 목록을 살펴보면서 항목별로 설치 및 설정을 진행해 보자. 번들러는 웹팩 v4를 사용한다.

주요 개발 환경 설명

  • 기본 설정
  • 정적 분석 툴 적용
  • 📑 문서화 도구 설정 @toastui/doc로 API 문서 만들기
  • 타입스크립트 설정
  • css 설정
  • 개발 서버 설정
  • 번들 및 배포 설정
  • preact로 UI 라이브러리 만들기
  • 라이브러리를 사용해서 기능 테스트하기

기본 설정

프로젝트 폴더 esm-ui-library를 생성하고 패키지를 초기화한다.

mkdir esm-ui-library
cd esm-ui-library
npm init // And Be The Yes Man

소스 폴더는 다음과 같은 구조이다.

  • dist
  • src

    • images
    • sass
    • ts

웹팩 기본 패키지를 설치한다.

npm i --save-dev webpack webpack-cli

타입스크립트와 타입스크립트 로더를 설치한다.

npm i --save-dev typescript ts-loader

웹팩 설정 파일을 생성한다. webpack.config.js

const path = require('path');
const webpack = require('webpack');
const pkg = require('./package.json');

const isProduction = process.env.NODE_ENV === 'production';
const FILENAME = pkg.name + (isProduction ? '.min' : '');

const config = {
  entry: './src/ts/index.ts',
  output: {
    path: path.join(__dirname, 'dist'),
    filename: FILENAME + '.js'
  },
  module: {
    rules: [
      {
        test: /\.tsx?$/,
        use: 'ts-loader'
      }
    ]
  },
};

module.exports = config;

tsconfig.json 파일을 생성한다. 내용은 비어도 상관없지만 파일이 없으면 에러가 발생한다.

{}

엔트리 파일을 생성한다. src/ts/index.ts

export default {};

여기까지만 해도 빌드는 성공적으로 수행된다.

npx webpack --mode development

            Asset     Size  Chunks             Chunk Names
esm-ui-library.js  4.4 KiB    main  [emitted]  main
Entrypoint main = esm-ui-library.js
[./src/ts/index.ts] 66 bytes {main} [built]

라이브러리 관련 설정

webpack.config.js의 output을 수정하여 모듈 타입, 네임스페이스 등 라이브러리 관련 설정을 한다.

  output: {
    library: ['tui', 'Calendar'],  // 라이브러리 네임스페이스 설정
    libraryTarget: 'umd',          // 라이브러리 타겟 설정
    libraryExport: 'default',     // 엔트리 포인트의 default export를 네임스페이스에 설정하는 옵션
    ...
  }

libraryExport는 commonjs로 모듈을 작성할 경우는 설정할 필요없다. 하지만 ES6 모듈로 default export한 경우는 이 값을 반드시 설정해야 한다. 만약 하지 않으면 네임스페이스는 다음과 같이 접근해야 하므로 불편하다.

설정 전

const calendar = new tui.Calendar.default();

설정 후

const calendar = new tui.Calendar();

모듈 resolve 및 alias 설정

webpack.config.js에 resolve를 추가하여 모듈 resolution을 설정한다. 다른 모듈을 import할 때 상대경로를 사용하면 상대적인 폴더 경로를 모두 파악해야 하므로 불편하다. alias를 추가한다.

  resolve: {
    extensions: ['.ts', '.tsx'],
    alias: {
      '@src': path.resolve(__dirname, './src/ts/')
    }
  }

설정 전

import Month from '../../view/month';

설정 후

import Month from '@src/view/month';

번들 파일 배너 설정

ES5 단일 번들 파일에 배너를 설정하여 버전과 빌드 날짜, 작성자, 라이선스를 명시한다.

webpack.config.js에 webpack.BannerPlugin 플러그인을 추가한다.

const BANNER = [
  'TOAST UI Calendar 2nd Edition',
  '@version ' + pkg.version + ' | ' + new Date().toDateString(),
  '@author ' + pkg.author,
  '@license ' + pkg.license
].join('\n');

const config = {
  ...,
  plugins: [
    new webpack.BannerPlugin({
      banner: BANNER,
      entryOnly: true
    })
  ]

ES6 모듈은 타입스크립트 소스 상단에 주석을 활용하여 작성하면 트랜스파일 이후에도 주석이 남아 있기 때문에 각 소스 파일에 작성한다. 예> src/ts/month.ts

/**
 * @fileoverview Month View Interface
 * @author NHN FE Development Lab <dl_javascript@nhn.com>
 */
export const Month = {};

정적 분석 툴 적용

자바스크립트와 css를 모두 정적 분석할 수 있도록 eslintprettier 그리고 stylelint를 먼저 적용한다. 프로젝트 초기부터 정적 분석을 적용하는 것을 추천한다.

eslint 설치

eslint의 규칙이 잘 정의된 설정을 상속 받으면 편리하다. eslint-config-tui를 활용한다.

npm i --save-dev eslint eslint-loader eslint-config-tui eslint-plugin-react

타입스크립트를 정적 분석해야 하므로 관련 패키지도 같이 설치한다.

npm i --save-dev @typescript-eslint/parser @typescript-eslint/eslint-plugin

prettier 설치

npm i --save-dev prettier eslint-config-prettier eslint-plugin-prettier

stylelint 설치

npm i --save-dev stylelint stylelint-config-recommended stylelint-scss stylelint-webpack-plugin

eslint 설정

타입스크립트와 eslint, prettier까지 모두 적용한 .eslintrc.js 파일을 다음과 같이 작성한다.

module.exports = {
  root: true,
  env: {
    browser: true,
    es6: true,
    node: true
  },
  parser: "@typescript-eslint/parser",
  plugins: ["prettier", "react", "@typescript-eslint"],
  extends: [
    "tui/es6",
    "plugin:@typescript-eslint/recommended",
    "prettier/@typescript-eslint",
    "plugin:react/recommended",
    "plugin:prettier/recommended"
  ],
  parserOptions: {
    parser: "typescript-eslint-parser",
    ecmaVersion: 2018,
    sourceType: "module",
    project: "tsconfig.json"
  },
  settings: {
    react: {
      pragma: "h",
      version: "16.3"
    }
  }
};

@typescript-eslint/parser가 2.0으로 판올림되면서 에디터에서 import구문을 에러로 표시하는 오류가 발생한다. 에디터에만 발생하는 오류로 눈에만 거슬릴 뿐 동작은 잘 된다. 이슈가 등록되어 처리 중이니 패치가 되면 같이 업데이트하자.

prettier 설정

.prettierrc.js 파일을 다음과 같이 작성한다. 규칙은 팀 규칙에 맞게 변경하면 된다. 최근에 팀원 11명이 printWidth를 80, 100, 120 중 어떤 것으로 결정할 것인가를 30분 동안 토론한 기억이 난다. 하하하!

module.exports = {
  printWidth: 100,
  singleQuote: true
};

stylelint 설정

stylelint.config.js 파일을 다음과 같이 작성한다.

module.exports = {
  extends: 'stylelint-config-recommended',
  plugins: ['stylelint-scss']
};

웹팩 설정

자바스크립트 정적 분석을 위해 eslint-loader를 추가한다. use 속성이 배열인 경우 배열의 끝에서부터 로더가 실행되므로 순서에 유의한다. 반대로 할 경우 타입스크립트가 트랜스파일한 결과를 정적 분석하게 되므로 원하지 않는 결과가 나올 것이다.

const StyleLintPlugin = require('stylelint-webpack-plugin');

const config = {
  ...
  module: {
    rules: [
      {
        test: /\.tsx?$/,
        use: ['ts-loader', 'eslint-loader']
      }
    ]
  },
  plugins: [
    ...
    new StyleLintPlugin()
  ]
};

Visual Studio Code 설정

Visual Studio Code에서 정적 분석 결과롤 바로 보기 위해서 설치해야 하는 확장 프로그램 목록이다. 링크를 눌러서 모두 설치한다.

그리고 Visual Studio Code의 설정 폴더를 생성하고 설정 파일( .vscode/settings.json)을 다음과 같이 작성한다.

{
  "eslint.validate": [
    {
      "language": "typescript",
      "autoFix": true
    },
    {
      "language": "typescriptreact",
      "autoFix": true
    }
  ]
}

📑문서화 도구 설정 @toastui/doc로 API 문서 만들기

라이브러리는 API 문서가 아주 중요하다. 어떤 라이브러리를 처음으로 발견했을 때 API 문서가 잘 만들어진 것과 아닌 것의 차이는 매우 크다.

TOAST UI Doc은 최근 발표된 따끈따끈한 문서화 도구로 TOAST UI의 전 제품군에 적용 중이다. JSDoc을 파싱하여 API 문서를 생성하고 예제를 묶어 하나의 문서로 만들어주는 도구이다. 몇 가지 옵션을 설정하고 실행하기만 하면 자바스크립트 라이브러리를 위한 문서를 쉽게 만들 수 있다. 데모를 보면 라이브러리의 API 문서가 왜 필요한지 공감할 것이다.

image

패키지 설치

npm i -g @toast-ui/doc

설정 파일 작성

tuidoc.config.json 파일을 작성한다. 타입스크립트는 아직 지원하지 않으므로 dist/esm 폴더에 생성되는 ES6 모듈을 대상으로 API 문서를 만들어 내면 된다. 깃헙 리포지토리를 설정하면 실제 소스를 바로 볼 수 있는 링크를 제공하는 기능이 편리하다. 예제이므로 이미지, 텍스트 등은 여러분에게 맞게 수정한다.

{
  "header": {
    "logo": {
      "src": "https://uicdn.toast.com/toastui/img/tui-component-bi-white.png",
      "linkUrl": "/"
    },
    "title": {
      "text": "Calendar",
      "linkUrl": "https://github.com/nhn/toast-ui.doc"
    },
    "version": true
  },
  "footer": [
    {
      "title": "NHN",
      "linkUrl": "https://github.com/nhn"
    },
    {
      "title": "FE Development Lab",
      "linkUrl": "https://github.com/nhn/fe.javascript"
    }
  ],
  "main": {
    "filePath": "README.md"
  },
  "api": {
    "filePath": "dist/esm",
    "permalink": {
      "repository": "https://github.com/nhn/toast-ui.doc",
      "ref": "master"
    }
  }
}

JSDoc 추가

다음과 같이 JSDoc을 추가한다.

/**
 * @class Calendar Calendar View
 */
export default class Calendar {}

npm 스크립트로 간편하게 사용한다.

package.json에 스크립트를 추가하고 실행하면 _lastest 폴더에 문서가 생성된다.

{
  "scripts": {
    "doc": "tuidoc"
  }
}

타입스크립트 설정

타입스크립트 패키지는 앞서 설치했고, 타입스크립트 설정 파일을 작성한다. 두 가지 설정 파일을 작성한다. 하나는 ES5로 트랜스파일 되며 단일 번들 파일이 생성되는 것이다. 다른 하나는 ES6로 트랜스파일 되어 트리 쉐이킹 가능한 ES6 모듈로 변환하는 것이다.

ES5 단일 번들 파일

웹팩 설정에서 ts-loader를 추가했기 때문에 웹팩 설정은 이미 완료된 상태이다. 그러면 타입스크립트 설정 파일을 다음과 같이 작성한다.

ES5용 tsconfig.json

{
  "compilerOptions": {
    "noImplicitAny": true,
    "target": "es5",
    "jsx": "react",
    "jsxFactory": "h",
    "esModuleInterop": true,
    "allowSyntheticDefaultImports": true,
    "strict": true,
    "baseUrl": ".",
    "paths": {
      "@src/*": ["src/ts/*"]
    },
    "sourceMap": true
  },
  "include": ["src/ts/**/*.ts", "src/ts/**/*.tsx"],
  "exclude": ["node_modules"]
}

웹팩 설정에서 alias를 추가했지만 Visual Studio Code에서 모듈을 찾을 수 없다고 에러가 표시되는데, 타입스크립트 설정 파일을 추가하면서 baseUrlpaths에도 동일하게 alias를 설정해 주면 비로소 에러 표시가 없어진다. image

라이브러리 엔트리 파일 추가 - package.json 라이브러리의 시작점을 ES5 단일 번들 파일로 지정한다. package.json의 main 속성이다.

{
  "main": "dist/esm-ui-library.js"
}

(트리 쉐이킹 가능한)ES6 모듈

타입스크립트를 ES6 모듈로 트랜스파일 하기 위해서는 웹팩을 사용하지 않고 직접 타입스크립트 트랜스파일을 이용해야 한다. 그 이유는 앞의 "주요 기술 스택을 채택한 이유"에서 설명하였다.

ES6로 트랜스파일하기 위해서 타입스크립트 설정 파일을 하나 더 추가한다.

ES6 모듈용 tsconfig.esm.json

{
  "compilerOptions": {
    "noImplicitAny": true,
    "target": "es6",
    "jsx": "react",
    "jsxFactory": "h",
    "esModuleInterop": true,
    "allowSyntheticDefaultImports": true,
    "moduleResolution": "node",
    "outDir": "dist/esm/",
    "strict": true,
    "baseUrl": ".",
    "paths": {
      "@src/*": ["src/ts/*"]
    }
  },
  "include": ["src/ts/**/*.ts", "src/ts/**/*.tsx"],
  "exclude": ["node_modules"]
}

ES5용과 다른 점이다.

  • 변경: "target": "es6"
  • 추가: "moduleResolution": "node", 라이브러리 사용자가 node 환경에서 개발하거나, node에서 코드가 실행되므로 설정한다.
  • 추가: "outDir": "dist/esm/", ES6 모듈의 생성 경로 지정
  • 제거: sourceMap 제거, ES6 모듈은 번들하지 않아서 필요 없다.

ES6 모듈 경로 설정 - package.json

{
  "module": "dist/esm/",
  "sideEffects": false
}
  • module 속성: ES6 모듈로 사용될 경우 탐색 경로를 설정한다.
  • sideEffects 속성: 트리 쉐이킹에서 사이드 이펙트로 인하여 불필요한 모듈을 제거하지 못하는 경우가 있다. 이 라이브러리의 모듈에 사이드 이펙트가 없다고 보장하는 플래그를 설정해 준다. 라이브러리 작성자가 책임을 지는 것으로 웹팩이 트리 쉐이킹을 보다 명시적으로 수행할 수 있다.

ES6 모듈에서 alias를 상대경로로 변환하기

타입스크립트 트랜스파일을 거친 후라도 @src와 같은 alias는 변하지 않고 그대로 남아 있다.

import Month from '@src/month';
import Week from '@src/week';

라이브러리 사용자가 ES6 모듈을 import할 경우 사용자는 @src를 해석할 수 없으므로 모듈을 찾을 수 없다고 에러가 발생한다. 그러므로 alias는 다시 상대 경로로 변환해 주어야 한다. ttypescript - Transform Typescripttypescript-transform-paths 플러그인을 추가하여 상대 경로로 변환한다.

npm i --save-dev ttypescript typescript-transform-paths

ES6 모듈용 tsconfig.esm.json에 플러그인을 추가한다.

{
  "compilerOptions": {
    ...,
    "plugins": [
      {
        "transform": "typescript-transform-paths"
      }
    ]
  },
  ...
}

설정 전

import Month from '@src/month';
import Week from '@src/week';

설정 후

import Month from './month';
import Week from './week';

css 설정

v1은 stylus를 css 트랜스파일 툴을 사용하고 있어서 stylus-loader를 사용하고 있다. 앞서 자바스크립트는 트리 쉐이킹을 지원하기 위해서 웹팩 로더를 사용할 수 없다고 했다. 하지만 css의 경우는 상황이 다르다. css는 최종 html에 번들 css를 포함시키기만 하면 되므로 어떤 로더와 툴이라도 사용할 수 있다.

웹팩으로 css를 번들하기 위해 css-loader를 포함하여 필요한 패키지를 설치한다.

npm i --save-dev css-loader style-loader mini-css-extract-plugin

css import 방법

먼저, 주의할 점은 css를 자바스크립트 소스에서 import하지 않는 것이다. 웹팩을 사용하여 개발할 경우 흔히 자바스크립트 코드에서 css 파일을 import할 것이다. 이렇게 되면 자바스크립트가 트랜스파일된 이후에 import될 경우, 라이브러리 사용자 쪽에서 css 파일의 import를 같이 처리해 주어야 하는 문제가 생긴다. 그렇지 않으면 css 파일 경로가 맞지 않아서 모듈 가져오기가 실패할 것이다. 그러므로 css 파일의 import는 자바스크립트 소스에서 하지 않고 별도의 엔트리 포인트로 추가한다. 웹팩 엔트리 포인트를 배열 타입으로 추가하면 자바스크립트 소스 내에서 css를 import 하지 않아도 의존성 그래프를 하나 추가하여 번들 과정에 포함시킬 수 있다.

webpack.config.js

module.exports = {
  entry: ['./src/sass/index.scss', './src/ts/index.ts'],
  ...
};

scss 사용

stylus도 훌륭한 도구이지만, TOAST UI Calendar 협업자들은 sass에 익숙하다. 또한 점유율이 좀 더 높은 것도 이유가 되었다.

sasssass-loader를 설치한다.

npm i --save-dev node-sass sass-loader

라이브러리 전용 prefix를 클래스 선택자에 추가하기

TOAST UI Calendar의 css는 tui-full-calendar-라는 prefix를 붙여서 클래스 선택자를 작성한다. 고유한 이름을 부여하여 선택자가 중복되는 것을 방지하기 위함이다. v1은 preprocess-loader를 사용하고 번들 과정에서 stylus의 코드의 문자열이 치환되도록 하였다.

v1의 웹팩 설정을 보면 마지막 단계에서 문자열을 치환한다.

const context = JSON.stringify({CSS_PREFIX: ‘tui-full-calendar-});
...
module: {
  rules: [
    {
      test: /\.styl$/,
      use: [
        `preprocess-loader?${context}`,
        ‘css-loader’,
        ‘stylus-loader’
      ]
    }
  ]
}

stylus 파일에서 {css-prefix} 부분이 tui-full-calendar-로 치환된다.

.{css-prefix}holiday {
  color: red;
  font-size: 15px;
}

하지만 코드가 아름답지 않고 디버깅 시 선택자를 코드에서 찾기가 어려워서 유지 보수가 까다로웠다. postcss를 사용하는 것이 훨씬 더 깔끔하게 코드를 작성할 수 있다.

postcss-loaderpostcss-prefixer를 설치한다.

npm i --save-dev postcss-loader postcss-prefixer

그러면 css가 더 깔끔해진다.

.holiday {
  color: red;
  font-size: 15px;
}

postcss로 변환된 결과를 보면 다음과 같이 prefix가 잘 붙는다.

.tui-full-calendar-holiday {
  color: red;
  font-size: 15px;
}

css에서 사용한 이미지 번들하기

url-loader를 사용하여 css에서 사용한 이미지를 base64 형태로 변환하여 번들에 포함되도록 한다.

npm i --save-dev url-loader

css 번들을 위한 웹팩 설정

웹팩 설정 파일에 css 설정을 module.rules에 추가한다.

const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const postcssPrefixer = require('postcss-prefixer');

...
const isDevServer = process.env.DEV_SERVER === 'true';
...

module: {
  rules: [
    ...
    {
      test: /\.s[ac]ss$/i,
      use: [
        isDevServer ? 'style-loader' : MiniCssExtractPlugin.loader,
        {
          loader: 'css-loader',
          options: {
            sourceMap: true
          }
        },
        {
          loader: 'postcss-loader',
          options: {
            sourceMap: true,
            plugins: [
              postcssPrefixer({
                prefix: 'tui-full-calendar-'
              })
            ]
          }
        },
        {
          loader: 'sass-loader',
          options: {
            sourceMap: true
          }
        }
      ]
    },
    {
      test: /\.(gif|png|jpe?g)$/,
      use: 'url-loader'
    }
  ]
},
plugins: [
  ...,
  new MiniCssExtractPlugin({
    filename: `${FILENAME}.css`
  })
]

개발 서버 설정

webpack-dev-server를 설치하고 html-webpack-plugin을 사용해서 페이지가 뜨는지 확인해 보자.

npm i --save-dev webpack-dev-server html-webpack-plugin

webpack.config.js에 html-webpack-plugin을 추가하고 개발 서버 설정을 추가한다. 이 때 유의할 것은 resolve.extensions'.js' 확장자를 추가해야 한다는 것이다. '.js'를 추가하지 않으면 webpack-dev-server가 로딩하는 js 모듈이 로딩되지 않으므로 서버가 실행이 되지 않는다.

const HtmlWebpackPlugin = require('html-webpack-plugin');

const config = {
  ...,
  resolve: {
    extensions: ['.ts', '.tsx', '.js'], // '.js'를 추가한다.
    ...
  },
  plugins: [
    ...,
    new HtmlWebpackPlugin()
  ],
  devtool: 'source-map',
  devServer: {
    historyApiFallback: false,
    host: '0.0.0.0',
    disableHostCheck: true
  }

화면에 아직 아무 것도 나오지 않겠지만 실행해 보면 자바스크립트와 css가 잘 로딩된 것을 볼 수 있다.

npx webpack-dev-server --mode development
<head>
  <link href="/dist/esm-ui-library.css" rel="stylesheet">
</head>
<body style="">
  <script type="text/javascript" src="/dist/esm-ui-library.js"></script>
</body>

번들 및 배포 설정

이제 실제로 ES5번들 파일과 ES6 모듈 파일이 잘 생성되는지 확인할 차례이다.

종류별 번들과 개발 서버를 띄우는 npm 스크립트를 추가한다. @toastui/doc을 사용한 스크립트("doc")는 ES6 모듈을 빌드한 후 API 문서를 생성하도록 변경되었다.

{
  "scripts": {
    "doc": "npm run build:esm && tuidoc",
    "serve": "DEV_SERVER=true webpack-dev-server --mode development",
    "build:dev": "webpack --mode development",
    "build:prod": "NODE_ENV=production webpack --mode production",
    "build:esm": "ttsc -p tsconfig.esm.json",
    "build": "rm -rf dist && npm run build:dev && npm run build:prod && npm run build:esm"
  }
}

ES5 단일 번들 파일 생성

development와 production 버전을 생성한다. dist 폴더에 파일이 생성된다.

npm run build:dev
npm run build:prod

ES6 모듈 생성

dist/esm 폴더에 파일이 생성된다.

npm run build:esm

생성된 파일의 모습 image (65)

npm에 배포할 파일 선택

npm에 불필요한 파일을 배포할 필요는 없으므로 필요한 파일만 배포할 수 있도록 설정한다.

package.json

{
  "files": [
    "src",
    "dist",
    "index.d.ts"
  ]
}

preact로 UI 라이브러리 만들기

먼저 preact와 서버 사이드 렌더링을 위한 preact-render-to-string를 설치한다.

npm i --save preact preact-render-to-string

간단하게 엔트리 파일과 Month와 Week를 렌더링하는 클래스를 작성한다. renderToString은 preact 컴포넌트를 html 문자열로 바꾸어주는 함수이다.

index.ts

import Month from '@src/month';
import Week from '@src/week';

export default {
  Month,
  Week
};

export { Month, Week };

base.ts

import { render, ComponentChild } from 'preact';
import renderToString from 'preact-render-to-string';

export default abstract class Base {
  private _container: Element;

  private _base?: Element;

  public constructor(container: Element) {
    this._container = container;
  }

  protected abstract getComponent(): JSX.Element;

  public render(): void {
    this._base = render(this.getComponent(), this._container, this._base);
  }

  public renderToString(): string {
    return renderToString.render(this.getComponent());
  }
}

month.tsx

import { h } from 'preact';
import Base from '@src/base';

export default class Month extends Base {
  protected getComponent(): JSX.Element {
    return <h2>Month View</h2>;
  }
}

week.tsx

import { h } from 'preact';
import Base from '@src/base';

export default class Week extends Base {
  protected getComponent(): JSX.Element {
    return <h2>Week View</h2>;
  }
}

라이브러리 사용 테스트

라이브러리를 직접 사용해서 목표대로 잘 동작하는지 테스트 해보자. 이 라이브러리의 목표에 따라 다음의 기능이 잘 동작하는지 확인하는 것이다.

  • 트리 쉐이킹 테스트 - 사용한 모듈만 번들되어 파일 사이즈가 줄어드는지 확인
  • 서버 사이드 렌더링 테스트 - html 문자열 생성

테스트 코드

테스트 코드는 Week와 Month 모듈을 가져와서 렌더링하고, 추가로 Month를 서버 사이드 렌더링으로 html 문자열을 생성한다.

import { Month, Week } from "esm-ui-library";

const week = new Week(document.getElementById("app1"));
week.render();

const month = new Month(document.getElementById("app2"));
month.render();

document.getElementById("ssr").innerHTML = month.renderToString();

실행 화면

실행해 보면 그림처럼 Week와 Month, 그리고 Month의 서버 사이드 렌더링 결과인 html이 잘 렌더링되는 것을 볼 수 있다. image (66)

트리 쉐이킹 테스트

번들 파일의 크기는 12.8 KiB이며, 번들 파일에서 month.tsx와 week.tsx 모듈이 모두 포함된 것을 확인할 수 있다.

      Asset       Size  Chunks             Chunk Names
 index.html  551 bytes          [emitted]
    main.js   12.8 KiB       0  [emitted]  main
main.js.map   49.8 KiB       0  [emitted]  main
new (class extends v {
  getComponent() {
    return Object(o.h)("h2", null, "Week View");
  }
})(document.getElementById("app1")).render();
const m = new (class extends v {
  getComponent() {
    return Object(o.h)("h2", null, "Month View");
  }
})(document.getElementById("app2"));

Month를 사용하지 않도록 소스에서 제거한 후 번들해 보자. 트리 쉐이킹이 잘 동작해서 Month 모듈은 제거되어야 한다.

import { Month, Week } from "esm-ui-library";

const week = new Week(document.getElementById("app1"));
week.render();

// const month = new Month(document.getElementById("app2"));
// month.render();

// document.getElementById("ssr").innerHTML = month.renderToString();

번들 파일의 크기가 12.6KiB로 줄어 들었으며, 번들 파일에서 month.tsx 모듈은 제거되었음을 알 수 있다.

      Asset       Size  Chunks             Chunk Names
 index.html  551 bytes          [emitted]
    main.js   12.6 KiB       0  [emitted]  main
main.js.map   49.4 KiB       0  [emitted]  main
new (class extends v {
  getComponent() {
    return Object(o.h)("h2", null, "Week View");
  }
})(document.getElementById("app1")).render();

맺음말

어느 언어에서나 불필요한 소스를 줄여 최종 실행 파일의 크기를 줄이는 것은 매우 중요하다. 파일 크기의 증가는 대개 비용의 증가로 이어지기 때문이다. 자바스크립트 UI 라이브러리는 많은 기능을 지원할 수록 파일 크기가 커진다. 그러나 사용자는 라이브러리가 제공하는 특정 기능만 쓰고 싶을 수도 있다. 따라서 UI 라이브러리가 웹 페이지를 최적화할 수 있는 방법을 제공한다면 더 멋진 라이브러리가 되지 않을까? 나는 트리 쉐이킹을 방법으로 선택했지만 다른 방법도 있을 것이다.

TOAST UI Calendar는 잘 만들어진 소스를 v1으로 오픈소스화 하면서 많은 사랑을 받았다. 깃헙의 스타 갯수도 7000개가 넘었다. 많은 개발자들이 사랑하는 오픈소스가 되어 참으로 기쁘다. TOAST UI Calendar v2를 기획하고, 기술 스택을 선정하고, 가능성을 확인하는 과정은 참으로 매력적이다. 이제 한발 더 도약하기 위한 시작이다. 이 아티클이 많은 도움이 되면 좋겠다. 전체 소스는 여기에 있다.

혹시 TOAST UI Calendar v2에 바라는 기능이 있다면 깃헙 이슈를 남겨 주시라. 물 들어 올 때 노저어야 한다.