React 디자인 시스템 버그 때려잡기 #1
이 포스트는 짧은 호흡으로 핵심만 남겨서 쓰려고 합니다. 디자인 시스템을 만들면서 생긴 어려움이나 트러블슈팅 위주로 작성했습니다. 이 글은 ‘React 디자인 시스템 버그 때려잡기’의 첫 번째 글로 조만간 디자인에 대한 고민을 담은 두 번째 게시글을 작성하겠습니다.
제 인생 2번째 디자인 시스템 개발입니다. 그동안 기술 스택이 변해서 다시 디자인 시스템을 만들고 있습니다. 확장 가능하게 설계하려고 노력하고 있습니다. 는 희망사항이고요. 확장성까지 고려하면 좋겠지만 단일 컴포넌트를 만드는데 있어 반복되는 시간을 줄일 수만 있어도 목적은 달성했다고 보고 있습니다. (그동안 얼마나 많은 Modal을 만들었는데)
(개판직전) 코드가 궁금하신 분들은 아래 깃헙으로 방문해주세요. 참고로 MIT 라이선스로 공개했으니 편하게 가져다 쓰셔도 됩니다.
https://github.com/DipokalLab/devent-design-system-v2
스택 선정
React/TypeScript를 기본 뼈대로,
EmotionCSS를 스타일링 도구로 사용했고,
Vite를 번들링 도구로 사용했습니다.
간단하죠. 사실 이때까지만 해도 몰랐습니다. 트러블슈팅 지옥에 빠질줄은. 원래 Vite 자리에 들어갈 녀석은 Webpack 이었거든요.
1. Webpack의 실업급여
웬만한 컴포넌트 개발을 끝내고서 npm 배포를 위해 번들링할 차례였습니다. 늘 하던대로 Webpack 설정 파일을 세팅하고 번들링을 하려던 찰나 문서화에 필요한 Storybook 코드와 충돌이 생겼습니다. Storybook 파일을 제외하려면 exclude에 지정해두어야 하는데 Webpack에서 해당 파일을 exclude 하는 방법을 못찾았습니다. 깃허브 이슈에 등록되어있긴 한데 명확한 답이 나와있지 않았습니다.
그래서 하는 수 없이 Storybook을 포함한 채로 번들링을 시도했습니다. 잘 되나 싶더니 이번엔 Storybook의 타입설정을 문제삼게 됩니다. 또 새로운 tsconfig 파일을 만들어주어 번들링으로 지정하던 찰나 지원이 안되는 문제가 발생합니다. Webpack에서 지정된 tsconfig를 사용할 수 없었습니다.
결국 기존 tsconfig를 쓰기로 하고 타입은 어찌저찌 우회한 다음에 빌드에 성공했습니다. 그리고 나서 모순에 빠졌습니다.
package.json 에 “main”: “dist/index.cjs.js”, 를 지정해 배포해주었으나 문제가 발생했습니다. Es module은 맞는데, d.ts가 지정되어있지 않다는 문제였습니다. 역시나 tsconfig를 수정해야 해결되는 문제이나 그러려면 빌드용 tsconfig를 webpack에 적용해주어야 하고 이미 여기에서 문제가 해결되지 않았는데 다시 이 문제를 붙들고 있을 수는 없었습니다.

TypeScript d.ts 파일이 필요한데 이게 정의되지 않았습니다. Webpack의 경우 이러한 형태의 번들링을 편하게 할 수 없었고 결국 vite로 전환하게 됩니다. (sourceMap이 해결방법이었으나 이미 늦었습니다)

공식 문서를 보고 10분안애 해결해버렸습니다. 평소에 Webpack을 쓰던 습관이 남아있어 다른 번들러를 쓸 생각조차 못했는데, 반성했습니다. 그동안 빠르다고 해서 써보고는 싶었지만 안정성이 떨어진다는 소리가 있어 쉽게 적용하지 못했는데, 이번 기회에 적용하게 되서 뿌듯합니다. 아마 그래도 Webpack을 쓰긴 하겠지만, 이러한 경우처럼 문제에 봉착하거나 대안이 없을때 vite를 적용해보는것도 나쁘지는 않겠다는 생각이 들었습니다.
사실 이 문제를 가지고 몇 일 동안 고생했으면 기억에 잘 남았을텐데, 3시간도 안걸려 해결했습니다.
2. 날아갈 정도로 가볍게…?

번들링의 최종 무게는 580kb 입니다. 소스파일은 npmignore에서 제외해두었고 로고와 같은 에셋 파일은 모두 CDN으로 빼놓았습니다. 순수한 컴포넌트만 존재하며 따라서 NPM에 업로드되는 파일을 절약할 수 있었습니다.
이제 간혹 소스 파일도 npm에 업로드 되는 경우가 있습니다. 제대로 ignore되어 있는지 꼭 확인하시고 npm에 code보기에서 불필요한 파일이 업로드되진 않았나 체크해보시면 좋겠습니다.
3. n!/(n-m)! 배포
NPM 배포입니다. 최종 단계이지만 가끔 골머리를 썩곤 합니다.
import “pkgname”이 아닌 import “pkgname/dist/es” 같은 경로로 들어가야 코드를 찾게 됩니다. 비효율적인 임포트가 아닐 수 없습니다. 따라서 package.json에 다음과 같은 엔드포인트를 명시해두어야 합니다.
"main": "dist/index.cjs.js",
"module": "dist/index.es.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"module": "./dist/index.es.js",
"import": "./dist/index.es.js",
"default": "./dist/index.cjs.js"
}
},
이렇게 되면 임포트를 해도 해당 파일로 간주하게 됩니다. 보통 라이브러리를 쓸때처럼 임포트할 수 있게 되는거죠.
NPM 배포에서의 큰 문제는 없었습니다. 단지 npm publish하고 패키지 버전만 명시해두면 알아서 배포됩니다.
4. 이야기책
소프트웨어 개발자는 문서화를 해야합니다. 프론트의 경우 컴포넌트의 메서드를 정리하고 보여주는 문서가 있어야 하고, 백엔드는 API 명세서를 작성하게 됩니다. (다행스럽게도 소프트웨어 개발자는 문과가 아닙니다) 모든 과정이 자동화되어 있고 그냥 파일 딱 만들어주면 알아서 정리됩니다. 특히 이런 문제에 있어 깊-은 빡침을 느낀 개발자들이 문서 자동화 도구를 잘 만들어두었습니다. 더 이상 이런 문제에서 시간낭비할 필요는 없습니다.
storybook은 프론트앤드 개발 문서화에 있어 유명한 도구입니다. 세팅도 간편하고 빌드와 배포도 정말 편합니다.
특히 TypeScript의 경우 컴포넌트를 불러오기만 해도 메서드를 알아서 지정해준다는 점에서 문서화에 크게 신경쓰지 않아도 됩니다.
예제코드를 첨부하는 방법도 간편합니다. *.stories.tsx 혹은 *.stories.ts 파일을 만들어주고 meta. component 에 컴포넌트를 집어넣기만 하면 됩니다. 아래 코드는 처음 init 할때 레퍼런스로 주어집니다.
import React, { useState } from "react";
import type { Meta, StoryObj } from "@storybook/react";
import { fn } from "@storybook/test";
import { Dialog } from "../lib/components/Dialog";
const meta = {
title: "Components/Dialog",
component: Dialog,
parameters: {
layout: "centered",
},
tags: ["autodocs"],
argTypes: {},
args: { onClose: fn() },
} satisfies Meta<typeof Dialog>;
export default meta;
type Story = StoryObj<typeof meta>;
예제 코드를 첨부하는 방식도 간편합니다. Tsx 컴포넌트를 만든 후 export 해주면 됩니다. 아래처럼요.
export function Demo() {
return (
...
);
}
결과는 아래와 같이 사용 가능한 모든 타입이 지정되어서 옵니다. Union 타입의 경우에도 컴포넌트에 지정해두기만 하면 알아서 불러와집니다. 간편하죠.

이제 문서를 배포하기만 하면 됩니다. Vercel과 같은 배포 플랫폼을 사용하시는걸 권장드립니다. Vercel의 경우 빌드 옵션을 따로 지정해줄 필요 없이 옵션이 이미 세팅되어 있습니다. 클릭만 해주면 알아서 배포됩니다.
5. 토스트 떨구기
토스트를 구현하는 방법은 여러가지가 있습니다. 그러나 이는 기존 컴포넌트만을 이용해 구현할 수는 없습니다. 제 경우 React의 hook을 이용했고 ReactDOM.createRoot를 이용해 Toast Container를 구축했습니다. 이때 useContext를 이용해서 상단에 추가해볼까도 생각해봤지만 이 디자인 시스템의 목적이 쉬운 사용이기 때문에 별도의 컴포넌트 없이 hook function만으로 동작하게 만들고 싶었습니다.
이렇게 되면 토스트 띄우기가 console.log 급으로 쉬워집니다. 다음과 같이 구현됩니다. React hook의 적합한 이용 사례라고 볼 수 있겠습니다.
toast.message({
text: "dsdvsd"
})
대략 다음과 같이 구현됩니다. (더 좋은 방법 있으면 댓글로 알려주세요)
function appendToast(id, text) {
const dom = ReactDOM.createRoot(
document.getElementById(id) as HTMLElement as HTMLElement
);
dom.render(<Toast>{text}</Toast>);
}
function useToast() {
const message = ({ text }: { text: string }) => {
if (document.getElementById("toastContainer")) {
const root = document.getElementById("toastContainer") as HTMLElement;
const appendDiv = document.createElement("div");
const id = "toastComponents" + String(Math.random());
appendDiv.id = id;
root.append(appendDiv);
appendToast(id)
return 0;
}
const appendDiv = document.createElement("div");
appendDiv.id = "toastContainer";
appendDiv.style...
const root = document.getElementById("root") as HTMLElement;
root.append(appendDiv);
appendToast("toastContainer", text)
return 0;
};
return { message: message };
}
6. 맥락있는 라디오
Radio는 여러개의 선택지에서 단 하나의 선택지만을 고르는 컴포넌트 입니다.

그렇기에 각각의 컴포넌트 원소가 서로에게 영향을 줍니다. 일반적인 단순 컴포넌트 개발로는 불가능한 구현입니다. html의 input type=‘radio’는 name을 통해 어떤 컴포넌트가 묶여있는지를 판별합니다. 그럼 같은 컴포넌트의 name을 구현하기 위해서는 각 컴포넌트마다 name을 props로 넘기면 되는거 아니냐고 질문하실 수 있습니다. 물론 가능하지만, 복잡합니다. 저는 onChange를 통해 어떤 옵션이 선택되었는지 여부를 확인하고만 싶지 어떤 name을 가져야 하는가는 정의해두고 싶지 않았습니다. DX 적으로 불편한 디자인인거죠.
그래서 React의 useContext를 이용했습니다. 토스트 떨구는 컴포넌트와 마찬가지로 처음 구현해봅니다. 구글 검색을 통해 하위 컴포넌트가 상위 컴포넌트의 값을 props 없이 가져오는 방법을 찾은 끝에 useContext의 리액트 문서가 눈에 들어왔습니다.
기초가 왜 중요한지 다시끔 깨달았습니다. 개발하는데 있어 useContext와 같은 내장 훅을 평소 잘 사용하지 않다보니 팍팍 떠오르지 않았습니다.
컴포넌트 하나하나를 개발하는데 있어 기초적 지식이 중요하다고 느꼈습니다. 이제라도 알았으니 계속 배워 나가야겠습니다.
*
제가 디자인 시스템을 만들면서 마주했던 문제 및 트러블 슈팅에 대해 알아보았습니다. 다음 포스트에서는 디자인에 대한 고민과 토큰 설정에 대해 다뤄보겠습니다. 읽어주셔서 감사합니다!