디자인 시스템 개발 환경 구성하기 - 1 (feat. yarn berry + typescript + rollup + react)

2022. 4. 24

현재 사내에서는 디자인 시스템에 대한 관심이 핫하다. 🔥

최근 회사 전반적인 부분에 대한 큰 개편이 이루어졌고 이에 맞춰서 방향성이 나왔는데, 이 방향성에 대해서 앞으로 어떻게 앞으로 개발해야할지 이야기가 나오다가 이전부터 숙원사업이었던 디자인 시스템을 본격적으로 추진하여 앞으로의 개발에 활용하자는 결론이 나왔다.

사실 이전부터 디자인 시스템은 존재했지만 유명무실한 상태였고 특히나 웹 프로젝트에 사용되는 디자인 시스템이 구현되어있지도 않았고 컴포넌트 작업시 디자인 참고 정도로 사용되는 수준이었다.

그래서 이번 기회에 개편 방향성에 맞추어 정식 프로젝트(?)로 삼고 진행하기로 결정되었다.

여러가지 기술적인 부분들을 고민하다가 현재 웹 프로젝트의 용량, 빌드 속도가 굉장히 느리다는 부분을 인지하였고 이 부분에 대한 인사이트를 얻어 여러가지를 찾아본 결과 yarn berry를 활용한 패키지 매니징을 통해 용량 감소, 빌드 개선 등의 여러 경험을 공유한 아티클들을 보았다.

그래서 결론은 yarn berry를 사용하여 현재 웹 프로젝트에서 사용중인 typescript와 react로 디자인 시스템을 구현하기로 하였다.

본격적인 환경 구성을 하기 전 스터디 형태의 환경 구성을 진행했는데 이를 통한 경험을 공유하려 한다.

Yarn Berry를 도입한 이유

일단 앞서 서론에서 간단하게 공유했지만 현재 서비스 중인 웹 프로젝트의 역사가 오래되었고 모든 서비스가 하나의 레파지토리에 들어가 있어서 규모가 매우 비대해진 상태다.

최근 대규모 프로젝트를 진행하며 이전의 레거시를 전부 걷어내지 못한 상황에서 많은 코드들이 작성되었고 이를 유지보수 하면서 걷어내야 했지만 생각만큼 쉽게 걷어내지 못해 꽤나 많은 양의 레거시를 묻어두게 되었다.

물론 이 말고도 여러가지의 이유가 많겠지만 이런 문제들이 하나씩 쌓여 결국 빌드와 CI 시간까지 영향이 갔고 한 번 개발을 완료한 부분을 레파지토리에 올릴 때 대략 10분 내외로 시간이 걸렸다. 😇

그래서 이런 문제들을 해결해줄 수 있는 Yarn Berry에 눈길이 갔고, 앞으로는 새로운 프로젝트부터 기존 프로젝트까지 마이그레이션 해보자는 목표를 가지고 있다.

Yarn Berry Setup

기존에 npm 환경에서 작업을 했다면 yarn을 아예 설치하지 않았거나 설치만 해두고 npm 명령어를 통해 진행했을 것이다.

yarn berry는 yarn을 통해 설정할 수 있기 때문에 yarn을 설치해주어야 한다.

yarn 설치

npm install -g yarn

Yarn Berry 적용

yarn을 설치했다면 yarn berry를 사용하고싶은 프로젝트로 이동하여 적용한다.

yarn set version berry
yarn -v // 3.2.0

여기까지 진행했다면 아마 프로젝트에 .yarn,.yarnrc.yml, .pnp.cjs가 생겼을 것이다.
간단하게 정리하자면 아래와 같다.

  • .yarn : 기존 node_modules에 들어있던 의존성들이 들어갈 폴더와 플러그인 등이 관리가 될 폴더
  • .yarnrc.yml : yarn을 사용할 때 필요한 설정 사항들이 관리되는 파일
  • .pnp.cjs : plug n play 전략을 사용할 때 .yarn/cache에 저장된 의존성의 정보가 저장된 파일 (의존성을 찾을 때 사용된다.)

yarn berry의 의존성은 node_modules와는 다르게 zipFS 형태로 관리가 되는데 이로 인해 굳이 node_modules 처럼 디렉토리 구조를 생성하지 않아도 되어 빠르다. 그리고 버전별 하나의 zip을 가지고 있기 때문에 중복이 없으며 용량도 적다.

Zero-Install

우리는 이전까지 새로운 프로젝트를 clone 할 때 npm install을 통해 의존성을 설치하는 과정을 진행했다. 그렇게 오랜 시간이 걸리는건 아니지만 번거롭기도 했고 또한 의존성이 잘못 설치되는 경우도 가끔 있어 불미스러운 일이 발생하기도 했다.

하지만 이를 방지해주는 것이 바로 Zero-Install이다.

위에서 보았듯 zip으로 관리되어 상대적으로 용량이 적어졌다. 그래서 node_modules처럼 ignore 시키는 것이 아닌 있는 그대로를 레파지토리에 올려 clone 받는 개발자로 하여금 굳이 추가 설치를 하지 않고 바로 사용할 수 있도록 하는 것이다.

또한 의존성 관리를 직접 할 수 있게 되어 더욱 엄격한 의존성 관리가 가능하다.

만약 Zero-Install을 사용하고 싶다면 아래 내용을 .gitignore에 추가하면 된다.

# Yarn Zero-install
.yarn/*
!.yarn/cache
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions

하지만 굳이 필요가 없는 상황이라면 아래 내용을 추가하면 된다.

# Yarn Non zero-install
.yarn/*
!.yarn/patches
!.yarn/releases
!.yarn/plugins
!.yarn/sdks
!.yarn/versions
.pnp.*

그런데 여기서 만약 의존성들 중에 설치 스크립트가 존재하는 의존성이라면 Zero-Install이 작동하지 않을 수도 있다.

설치 스크립트가 있는 경우 .yarn/unplugged 경로에 생성되는데 이는 .zip이 아닌 node_modules에서 보던 방식과 동일하다.

그래서 이런 문제를 해결하기 위해 .yarnrc.yml 파일에 enableScripts 속성을 설정해줘야한다.

yarnPath: .yarn/releases/yarn-3.2.0.cjs

enableScripts: false

enableScripts 값을 false로 변경해주면서 의존성에 있는 스크립트를 동작시키지 않으면 cache 디렉토리에 생성되게 된다.

여기까지 진행했다면 yarn berry를 통한 패키지 매니징 준비는 끝났다. 👍

Typescript Setup

yarn berry에서 타입스크립트를 사용하기 위해서는 추가적인 작업이 필요하다.

이전에는 node_modules에 있는 type를 받아왔지만 현재는 zip 형태로 되어있기 때문이다.

그렇기 때문에 vscode 기준에서는 zipFS Plugin을 설치해주어야 정상적으로 zip 형태를 읽어올 수 있다.

tsconfig.json 생성

가장 기본이 되는 tsconfig.json을 생성한다.

tsc -init

이후 파일을 프로젝트 기호에 맞게 수정한다.

typescript 의존성 설치

yarn add -D typescript @types/react @types/react-dom tslib

자 여기까지 설치해도 아직 타입에 관련된건 전부 빨간줄이 뜨고 있을 것이다.

bad

하지만 지극히 정상이다.

에디터 SDK 설치

PnP를 위해서 에디터별로 SDK를 설치해야한다. vscode로 셋업을 진행했기 때문에 해당 명령어는 아래와 같다.

yarn dlx @yarnpkg/sdks vscode

위 명령어를 작성하고 문제가 없다면 .yarn/sdks 디렉토리가 생성 되었을 것이고, 사진과 같은 안내창이 뜬다.

allow typescript version

이는 해당 프로젝트에서 사용할 typescript 버전 허용 여부를 결정하는 부분인데 Allow를 선택하면 방금 추가된 typescript sdk를 사용하게 된다. (Allow 누르지 않으면 큰코 다침.. 🤥)

누르면 지금껏 눈을 힘들게 했던 빨간줄이 사라질 것이다.

cool

Rollup을 사용한 이유

rollup을 사용한 이유는 ES Module 형태로 번들링을 하여 Tree Shaking을 지원하기 위함이다.

만약 디자인 시스템을 사용하는 프로젝트에서 Button 컴포넌트만 사용한다면 나머지 필요하지 않은 코드들은 빌드될 필요가 없다. ❌
그래서 ESM 형태로 바로 번들링이 되는 rollup 사용했다.

Rollup Setup

먼저 Rollup에 관련된 의존성들을 설치한다.

yarn add -D rollup @rollup/plugin-babel @rollup/plugin-commonjs @rollup/plugin-node-resolve @rollup/plugin-typescript @rollup/plugin-url @svgr/rollup rollup-plugin-peer-deps-external @babel/runtime @babel/plugin-transform-runtime @babel/core

뭔가 엄청 많다.. 🤔

그래도 막상 다 쓸모있는 친구들이니 안심하자

디렉토리 구조 생성

현재 구성하고 있는 디자인 시스템 기준은 src 내부에 각각의 컴포넌트들이 있고, 그 내부에 컴포넌트, 테스트, 스토리북 등의 파일들을 위치시킬 예정이다.

구조를 간단하게 보면 대략 아래와 같다.

+-- .yarn
+-- lib [빌드된 결과물]
|   +-- * [commonjs 결과물]
|   +-- esm [ES Module 결과물]
|   |   +-- *
+-- src
|   +-- * [컴포넌트 디렉토리]
|   |   +-- index.spec.tsx
|   |   +-- index.stories.tsx
|   |   +-- index.tsx
|   +-- index.ts [export될 entry point]
+-- tsconfig.json
+-- tsconfig.esm.json
+-- rollup.config.js
+-- babelrc
+-- *

rollup.config.js

설치한 의존성들을 바탕으로 rollup을 본격적으로 설정해보자

import fs from 'fs'
import path from 'path'

import babel from '@rollup/plugin-babel'
import resolve from '@rollup/plugin-node-resolve'
import commonjs from '@rollup/plugin-commonjs'
import url from '@rollup/plugin-url'
import svgr from '@svgr/rollup'
import typescript from '@rollup/plugin-typescript'
import peerDepsExternal from 'rollup-plugin-peer-deps-external'

const extensions = ['.js', '.jsx', '.ts', '.tsx']

// 번들링할 파일을 객체에 담는다.
const inputs = fs.readdirSync('./src', { withFileTypes: true }).reduce((config, f) => {
  if (f.isDirectory() && f.name !== 'stories') {
    const name = f.name
    const dir = `src/${name}`

    const files = fs.readdirSync(dir).reduce((result, file) => {
      if (file.match(/spec|test|stories/i)) {
        return result
      }

      const filename = path.parse(file).name
      result[`${name}/${filename}`] = `${dir}/${file}`

      return result
    }, {})

    return { ...config, ...files }
  }

  return config
}, {})

const input = { ...inputs, index: './src/index.ts' }

export default [
  {
    input,
    // 번들할 포맷과 디렉토리를 특정 파일에 명시한다.
    output: [
      {
        dir: 'lib',
        format: 'cjs',
        exports: 'named',
      },
    ],
    external: [/@babel\/runtime/],
    plugins: [
      // peerDependencies에 있는 의존성들을 빌드에 포함시키지 않는다.
      peerDepsExternal(),
      // babel 설정을 할 수 있도록 도와준다.
      babel({
        extensions,
        babelHelpers: 'runtime',
        skipPreflightCheck: true,
        exclude: /node_modules/,
      }),
      // 외부 라이브러리 사용 및 ts, tsx 지원을 해준다.
      resolve(),
      // Commonjs 형태의 코드를 ES6로 변환하여 빌드에 포함되도록 해준다.
      commonjs(),
      // 타입스크립트를 사용할 수 있게 해준다.
      typescript({
        tsconfig: './tsconfig.json',
        declaration: true,
        sourceMap: false,
      }),
      // data-URI 형태로 svg, png, jpg 파일 등을 불러와서 사용 할 수 있게 해준다.
      url(),
      // SVG를 컴포넌트 형태로 불러와서 사용 할 수 있게 해준다.
      svgr(),
    ],
  },
  {
    input,
    output: [
      {
        dir: 'lib/esm',
        format: 'esm',
        exports: 'named',
      },
    ],
    external: [/@babel\/runtime/],
    plugins: [
      peerDepsExternal(),
      babel({
        extensions,
        babelHelpers: 'runtime',
        skipPreflightCheck: true,
        exclude: /node_modules/,
      }),
      resolve(),
      commonjs(),
      typescript({
        tsconfig: './tsconfig.esm.json',
        declaration: true,
        sourceMap: false,
      }),
      url(),
      svgr(),
    ],
  },
]

번들링은 commonjs, esm 두가지 형태로 제공했다.

이는 package.json에서 main, module 두가지 속성을 활용하여 어떤 부분을 사용할지 명시가 가능하다.

그리고 번들링 명령어를 추가해준다.

// package.json
{
  "name": "design-system",
  "version": "1.0.0",
  "description": "yarn berry design-system project",
  "main": "./lib/index.js",
  "module": "./lib/esm/index.js",
  "scripts": {
    "build": "tsc --emitDeclarationOnly && NODE_ENV=development rollup -c"
  }
  // do something..
}

여기까지 진행했다면 번들링을 할 준비까지 완료가 되었다.

이제 React를 추가하여 번들링을 해보자

React Setup

리액트는 현재 날짜 기준(22.04.24) 18 버전이 정식 배포가 된 상태다.

하지만 테스팅 라이브러리를 18 기준으로 해본 결과 React.createroot 이슈로 인해 테스트가 정상적으로 통과가 되지 않는 것을 보고 17 기준으로 진행했다. (만약 테스트를 도입하지 않는다면 18 버전으로 진행해도 무방하다.)

디자인 시스템은 내부에서 동작하는 것이 아닌 외부에서 동작시킬 프로젝트다.

그래서 외부에서 리액트의 어떤 버전을 사용해야할지 명시한다.

yarn add -P react@16.8.0 react-dom@16.8.0

react와 react-dom을 peerDependencies에 추가한다.

그리고 16.8 이상을 설치할 수 있도록 변경해준다.

// package.json
"peerDependencies": {
    "react": ">=16.8.0",
    "react-dom": ">=16.8.0"
}

자 그러면 리액트로 간단한 컴포넌트를 구현해보자

버튼 컴포넌트 구현

// src/button/index.tsx

import React from 'react'

function Button() {
  return <button>버튼</button>
}

export default Button

3초만에 작성한 버튼 컴포넌트다.

이제 이걸 번들링의 entry point에 추가해준다.

// src/index.ts

export { default as Button } from './Button'

자 진짜 모든 번들링 준비가 끝났다.

이제 대망의 빌드 명령어를 작성하자

yarn build

success bundled

이 아름다운 자태가 보이는가..

정상적으로 버튼 컴포넌트가 commonjs, esm 디렉토리로 분리되어 번들링 되었다. 이제 이 번들링 된 컴포넌트가 밖에서 잘 사용되는지 확인해보자

⚠️ 현재 작업한 디자인 시스템 프로젝트는 로컬에만 존재하기 때문에 이를 테스트 하기 위해서는 심볼릭 링크와 같은 추가 작업이 필요하다. (Git URL과 같은 방법도 있다.)

만약 npm에 배포가 가능하다면 해당 방법으로 해도 무방하다.

이제 사용하고 싶은 프로젝트에 설치 후 불러오자

// 디자인 시스템을 사용할 프로젝트

import React from 'react'
import { Button } from 'design-system'

function ButtonPage() {
  return <Button />
}

export default ButtonPage

만든 버튼 컴포넌트를 불러와서 사용하면?!

success render

만든 버튼이 잘 불러와진다! 🎉

여기까지 해서 기본적인 환경 구성을 진행했다. 위의 기술 스택으로서는 가장 기본적인 틀이라고 생각했고 여기서 추가적으로 세팅할 부분은 프로젝트 의도와 기획에 맞게 진행하여 요리조리 입맛에 맛게 골라넣으면 된다! 👨🏻‍💻

아마 다음 글은 추가적으로 세팅한 styled-components, jest, storybook이 될 것 같다. (아님 말고 😙)