2022년에 React 컴포넌트 라이브러리 만들기

@solved-ac/ui-react를 만들기 위한 여정

TL;DR:

  • create-react-library는 쓰지 마세요.
  • peerDependencies에 추가하는 라이브러리는 devDependencies에도 추가하세요.
  • styled-components 기반 라이브러리에서 SSR 이슈가 발생한다면 이 글을 참고하세요.

저는 다음 달이면 3년차가 되는 프론트엔드 개발자입니다. 하나 고백하자면, 안타깝게도 저에게는 프론트엔드 사수가 있었던 적이 없습니다. 여태까지 독학한 React 지식으로 얼렁뚱땅 일해왔다고 할 수 있습니다. 여태까지는 잘 먹혔습니다.

근데 이제 파트장입니다. 야 이거 큰일 났다. 면접도 내가 봐야 되고 신규입사자 교육도 내가 해야 되는데 나는 아는 게 하나도 없네…

그래서 인터넷의 힘을 빌리기로 합니다.

작성했던 코드를 잘 짰던 못 짰던 일단 올리고 보는 겁니다. 이렇게 하면 사수 분들이 마구마구 생기겠지? 회사 코드를 올릴 수는 없고, 마침 개인적으로 컴포넌트 재사용에 대한 니즈가 있던 solved.ac 코드를 정리해서 올려보기로 합니다.

첫 삽 뜨기

모르는 게 있을 때 취해야 하는 참된 개발자의 자세, 바로 구글 켜기입니다.

구글에 creating a react component library를 검색한 결과

1시간 정도 구글링해본 결과 아래 옵션들로 정리할 수 있었습니다.

Bit은 좋아 보이지만 나중에 뭔가 하려면 돈을 내야 될 것 같은 분위기를 느껴서 제외했습니다. 그냥 rollup을 직접 쓰는 것과 create-react-library의 도움을 받는 것 중에서 고민하다가 create-react-library를 골랐습니다. 간단해 보여서였습니다.

프로젝트를 만들고 기존에 쓰던 테마 정의와 함께 Button 컴포넌트를 옮겨왔습니다.

어 그런데 뭔가 이상합니다.

이 왜 any?

분명히 테마도 타입 정의가 잘 되어 있고 styled-components도 잘 임포트되어 있는데 테마 속성들이 전부 any로 뜹니다. 심지어는 styled component prop도 any가 뜹니다. 타입 추론이 없는데 어떻게 개발을 합니까? 이건 천재지변입니다.

styled-componentspeerDependencies에만 있고 devDependencies에는 없었음을 확인하고 고치는 데는 의외로 많은 시간이 걸렸습니다.

dependencies, devDependencies, peerDependencies

TL;DR: 라이브러리를 개발할 때 peerDependencies에 뭔가를 추가하려면 devDependencies에도 똑같은 패키지를 추가해야 합니다.

너무 기니까 dependencies를 줄여서 deps라고 부르도록 합시다.

depsdevDeps는 패키지를 빌드했을 때 프로덕션 번들에 포함되는지 아닌지의 차이가 있습니다. devDeps에는 주로 @types/*라던가 Prettier, Babel 플러그인과 같이 개발 과정이나 빌드 등을 도와주는 패키지들이 들어갑니다. 이미 완성된 코드에다 ESLint를 돌릴 이유는 없으니까요.

하지만 depsdevDeps는 사실 일반적인 프론트엔드 프로젝트에서는 별 상관이 없습니다. 이는 webpack의 번들 방식 때문인데, webpack은 entryPoint부터 시작해서 import들을 따라가면서 패키지들을 필요에 따라 넣기 때문입니다. create-react-app@types/* 같은 의존 패키지들을 전부 devDeps가 아니라 deps에 때려박아도 별 일 없는 이유이기도 합니다. 개인적으로는 싫지만…

peerDependencies

라이브러리를 만들면 아무도 의존하지 않는 패키지 – 예를 들면 프론트엔드 앱 – 를 만들 때는 볼 수 없었던 peerDeps와 마주하게 됩니다. peerDeps에 의존성을 추가하면 내 패키지에서 의존성을 관리하는 대신 내 패키지를 의존하는 패키지에서 의존성을 대신 관리하게 됩니다.

말이 조금 헷갈리는데, 예를 들어 내 프로젝트가 라이브러리 A, B, C를 쓰는데, 세 라이브러리 모두가 D라는 패키지에 의존한다고 합시다.

  • 세 라이브러리에서 D를 deps로 두는 경우에는 node_modules에 A > D, B > D, C > D 모두가 들어가게 됩니다.
  • D를 peerDeps로 두는 경우에는 node_modules에 A, B, C, D가 따로따로 들어가고, 내 프로젝트 단에서 A, B, C 각각이 내 프로젝트에서 직접 가져온 D에 의존할 수 있도록 해 줍니다.

요약하면, peerDeps는 의존성 트리 최적화를 위해 내 패키지를 쓸 패키지들에게 ‘이거 대신 설치해 주세요’라고 설명하는 것과 같습니다. 이건 ‘내 패키지 자체에서는 이 의존성을 굳이 쓰지 않겠어요’라는 말과 같은 말입니다. 다 좋은데 그러면 내가 내 패키지는 어떻게 개발하죠?

빌드된 모습

결과적으로는 peerDepsdeps 모두에 의존성이 들어가야 합니다. 이렇게 하면 빌드된 index.js에서 peerDepsrequire를 사용하도록 바뀌고 나머지는 잘 번들됩니다. 이 require는 로컬 환경에서는 deps에 의해 설치된 패키지를, 피의존 환경에서는 이 패키지의 peerDeps에 의해 설치된 패키지를 활용할 것입니다.

알고 나면 어렵지 않은 이유지만, 라이브러리를 만드는 입장에서 peerDeps를 설명해 둔 리소스가 현저히 적어서 알기까지 너무 오래 걸렸습니다. 이건 험난한 여정의 시작일 뿐이라는 걸 당시의 저는 몰랐습니다.

인터넷에 올리기 부끄럽지 않은 코드 짜기

const ButtonContainer = styled.button<ButtonContainerProps>`
  display: inline-block;
  vertical-align: middle;
  text-align: center;
  background: ${({ backgroundColor }) => backgroundColor}; // XXX
  /* ... */
`

이건 안 좋은 코드의 예입니다. solved.ac에서는 버튼에 색상을 그렇게 많이 집어넣거나 색상에 애니메이션을 줄 일이 없었기 때문에 기존에는 이렇게 구현했지만, styled-components는 모든 경우의 수마다 CSS 클래스를 하나씩 만들 거고 자칫 다이나믹할 수 있는 값을 이런 식으로 구현하면 퍼포먼스 이슈가 생길 것은 안 봐도 비디오, 웰 노운 팩트입니다.

따라서 CSS 변수를 사용하기로 합니다. 스타일드 컴포넌트 안에서는 색상 등을 var(--solvedac-button-background-color) 등으로 정의하고, 컴포넌트에 인라인 스타일로 --solvedac-button-background-color: #17ce3a와 같은 식으로 넣어주면 됩니다. 이걸 잘 쓰기 위해 타입스크립트의 도움을 받고 싶습니다. 예를 들어 아래와 같은 코드를 작성하면…

const [vars, v] = cssVariables(
  [
    'backgroundColor',
    'hoverBackgroundColor',
    'textColor',
    'hoverTextColor',
    'hoverShadow',
    'activeShadow',
  ],
  'button'
)

…스타일드 컴포넌트에서는 이렇게 가져다 쓰고…

const ButtonContainer = styled.button<ButtonContainerProps>`
  display: inline-block;
  vertical-align: middle;
  text-align: center;
  background: ${v.backgroundColor}; // Does not trigger class name generation which is good
  /* ... */
`

…인라인 스타일은 이렇게 넣을 수 있게 말이죠.

<ButtonContainer
  disabled={disabled}
  circle={circle}
  fullWidth={fullWidth}
  style={{
    [vars.backgroundColor]: computedBackgroundColor,
    [vars.hoverBackgroundColor]: computedHoverColor,
    [vars.textColor]:
      computedBackgroundColor &&
      readableColor(computedBackgroundColor, solvedTheme),
    /* ... */
    ...style,
  }}
  {...rest}
>
  {children}
</ButtonContainer>

이게 전부 타입 추론이 되게 하기 위해서 열심히 타입스크립트 매드무비를 찍습니다. readonly를 사용하면 string 배열을 tuple 취급하게 할 수 있습니다. 여기서 [...T]는 TS 4.0 기능입니다.

export const cssVariables = <T extends Array<string>>(
  names: readonly [...T],
  prefix: string
): [
  { [key in T[number]]: `--solvedac-${string}` },
  { [key in T[number]]: `var(--solvedac-${string})` }
] => {
  const vars = Object.fromEntries(
    names.map((name) => [
      name,
      `--solvedac-${prefix}-${name
        .replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`)
        .replace(/^-/, '')}`,
    ])
  ) as { [key in T[number]]: `--solvedac-${string}` }

  const v = Object.fromEntries(
    Object.entries(vars).map(([k, v]) => [k, `var(${v})`])
  ) as { [key in T[number]]: `var(--solvedac-${string})` }

  return [vars, v]
}

이렇게 하면 에디터가 타입 추론을 잘 해 줍니다. 그런데 갑자기 빌드가 되지 않습니다. 이번엔 왜일까요?

CSSProperties와 다시 만나는 declaration merging

리액트는 기본적으로 인라인 스타일에 --solvedac-button-background-color 같은 걸 허용하지 않습니다. CSSProperties의 키가 아니기 때문입니다. 리액트의 index.d.ts에는 이런 주석이 달려 있습니다.

export interface CSSProperties extends CSS.Properties<string | number> {
    /**
     * The index signature was removed to enable closed typing for style
     * using CSSType. You're able to use type assertion or module augmentation
     * to add properties or an index signature of your own.
     *
     * For examples and more information, visit:
     * https://github.com/frenic/csstype#what-should-i-do-when-i-get-type-errors
     */
}

직접 인덱스 시그니쳐를 만들고 싶으면 type assert를 하거나 module augmentation을 하라고 합니다.

Type assert는 웬만해서는 쓰기 싫기 때문에 declaration merging을 했습니다. 예전에 테마 타입 정의하는 데 고생한 적이 있어서 비교적 쉽게 해결했습니다. 이렇게 하면 됩니다.

type CustomProp = { [key in `--${string}`]: string }
declare module 'react' {
  // eslint-disable-next-line @typescript-eslint/no-empty-interface
  export interface CSSProperties extends CustomProp {}
}

오래된 react-scripts, 관리가 중단된 microbundle-crl

리액트 프로젝트에서 타입스크립트 관련해서 뭔가 안 된다 싶으면 이 친구입니다. react-scripts는 자기만의 webpack 설정 등을 쓰기로 유명합니다.

create-react-libraryreact-scripts@3.4.1을 설치해 줍니다. TS 4.0을 지원하지 않는 버전입니다. yarn add react-scripts@latest -D로 해결해 줍니다.

이외에도 microbundle-crl은 예전 버전의 Babel을 사용하고 있으며, 2년 전 microbundle 소스의 포크입니다. Unfork 해 줍니다.

Zero configuration을 표방하는 패키지를 종종 보게 되는데, 일반적인 경우에는 좋지만 일반적이지 않은 경우에는 꽤 골치아파지게 됩니다. 여담으로 react-scripts를 쓰면서 webpack 구성을 바꿔야 하는 경우가 있다면, eject하는 대신 react-app-rewired를 사용해 보시기 바랍니다.

이제 모든 게 다 잘 됩니다. 슬슬 solved.ac에 적용해 볼까요?

이상해요

뭔가 이상한 솔브드

사이트에 새 컴포넌트들을 적용하고 띄워 보니 패딩이 사라져 있습니다. 그럴 리가 없는데… 링크를 타고 다른 페이지들을 로드해 보면 정상적으로 보입니다. Prop `className` did not match와 같은 증상이 또 나타났습니다! 이번엔 테마 정의도 declaration merging으로 해 줬고 babel-plugin-styled-components도 잘 설정해 줬는데 대체 왜?

SSR의 악몽

생성되어 있는 componentId

dist/index.js를 확인해 봤을 때 컴포넌트 ID는 빌드 시점에 생성되는 것을 알 수 있습니다. Babel 플러그인이 잘 동작했다는 뜻입니다.

그러면 의심이 가는 부분은 solved.ac 프론트엔드 프로젝트에서 ServerStyleSheetcollectStyles 해 주는 부분입니다. 이 방향으로 검색해 봤더니 FAQ가 하나 나옵니다. yarn link에 대한 내용이지만 이 라이브러리에도 적용할 수 있을 것 같습니다.

styled-components는 싱글톤이고, 각자의 스코프에서 렌더할 컴포넌트들을 전부 관리합니다. 따라서 solved.ac 프론트엔드 프로젝트와 방금 만든 UI 라이브러리에 있는 styled-components는 다른 styled-components라는 뜻입니다. 이걸 강제로 같게 만들어서 한 쪽의 styled-components가 프론트엔드 프로젝트와 UI 라이브러리 모두의 컴포넌트를 관리하게 해 줘야 합니다.

모든 require('styled-components')가 특정 경로의 모듈로 resolve 되도록 모듈 alias를 해 주면 되는데 Next.js + Typescript 환경에서는 비교적 간단하게 해결할 수 있었습니다.

{
  "compilerOptions": {
    "paths": {
      "styled-components": ["./node_modules/styled-components"]
    }
  }
}

이렇게 해 주면 SSR도 잘 적용되는 것을 확인할 수 있습니다.

Update: @solved-ac/ui-react는 이제 emotion을 사용하고 있습니다. emotion은 일부 셀렉터를 제외하고는 SSR 환경에서 별도의 설정 없이 사용할 수 있습니다. (2022/06/20)

사수 무제한 제공 거짓말 사건

컴포넌트를 라이브러리화하고 정상적으로 렌더하기 위한 과정들이었습니다. 모르면 맞아야 하는 도메인 지식들이 너무 많은데, 컴포넌트 라이브러리를 만드는 사람이 많지 않은지 리소스도 상당히 적었고 그로 인해 너무 고생을 많이 했습니다. 나는 있는 코드를 그대로 올리면 누군가 코드 리뷰를 해 주지 않을까 싶었던 것뿐인데!

그래도 이제 잘 동작하니까 계속 여러 컴포넌트들을 정리해서 올려봐야겠어요. 가뜩이나 리소스 없는 분야인 것 같은데 이 글이라도 여러분의 쓸데없는 삽질 예방에 도움이 되었으면 좋겠습니다.