회사에 입사하고 6개월 정도 지났을 때, 프로젝트 세 개를 동시에 운영하고 있었다. 어드민 패널, 유저 대시보드, 랜딩 페이지. 세 프로젝트 모두 React + TypeScript 기반인데, 버튼 스타일이 전부 달랐다. 같은 "확인" 버튼인데 어드민에서는 파란색 라운드, 대시보드에서는 초록색 사각, 랜딩에서는 그라데이션이었다.
디자이너분이 피그마에서 컴포넌트를 정리하면서 "버튼 스타일을 통일하면 좋겠다"고 했다. 팀 리드가 나를 보며 "디자인 시스템 한번 만들어볼래?"라고 했다. 가벼운 톤이었는데, 그게 몇 달짜리 여정이 될 줄은 몰랐다.
처음에는 큰 그림을 그렸다
솔직히 의욕이 넘쳤다. Material UI 같은 거 만들면 되는 거 아닌가. 토큰 시스템 설계하고, 테마 프로바이더 만들고, Storybook으로 문서화하고. 첫 주에 노션에 정리한 계획서가 A4 열 장 분량이었다. 컬러 토큰, 타이포그래피 스케일, 스페이싱 시스템, 브레이크포인트, 아이콘 시스템, 모션 가이드라인까지.
팀 리드가 계획서를 보더니 한마디 했다. "이거 다 하려면 전담 인원 두 명은 필요한데." 우리 팀은 프론트엔드 개발자가 나 포함 두 명이었다. 나머지 한 명은 시니어였는데 당시 대시보드 리뉴얼로 바빠서 손 쓸 여유가 없었다.
현실을 직시해야 했다. 디자인 시스템을 "제대로" 만들려면 팀 규모가 부족하다. 하지만 아무것도 안 하면 세 프로젝트의 UI 파편화는 계속 심해질 거였다.
버튼 하나에서 시작했다
시니어가 조언을 줬다. "일단 Button 하나만 만들어봐. 그것만으로도 꽤 많은 걸 배울 수 있어." 그래서 정말 버튼 하나부터 시작했다.
처음에 만든 Button 컴포넌트는 이랬다.
interface ButtonProps {
variant: 'primary' | 'secondary' | 'ghost';
size: 'sm' | 'md' | 'lg';
children: React.ReactNode;
onClick?: () => void;
disabled?: boolean;
loading?: boolean;
}
export function Button({
variant = 'primary',
size = 'md',
children,
onClick,
disabled = false,
loading = false,
}: ButtonProps) {
return (
<button
className={cn(
baseStyles,
variantStyles[variant],
sizeStyles[size],
disabled && disabledStyles,
)}
onClick={onClick}
disabled={disabled || loading}
>
{loading ? <Spinner size={size} /> : children}
</button>
);
}심플하다. 근데 이 심플한 버튼 하나 만드는 데 일주일이 걸렸다. variant를 몇 개로 할지, size는 세 단계면 충분한지, loading 상태에서 버튼 너비가 변하면 안 되는 건 어떻게 처리할지. 디자이너분과 피그마 앞에 앉아서 하나하나 결정했다.
특히 고민했던 건 as prop이었다. 버튼인데 실제로는 <a> 태그여야 하는 경우가 있다. 링크처럼 생긴 버튼, 버튼처럼 생긴 링크. polymorphic component라는 개념을 이때 처음 알았는데, 타입 추론이 생각보다 까다로웠다.
type ButtonProps<T extends React.ElementType= 'button'> = {
as?: T;
variant: 'primary' | 'secondary' | 'ghost';
size: 'sm' | 'md' | 'lg';
} & Omit<React.ComponentPropsWithoutRef<T>, 'as' | 'variant' | 'size'>;
이 타입 하나 잡는 데 이틀을 썼다. TypeScript 제네릭이랑 React.ComponentPropsWithoutRef의 동작 방식을 제대로 이해하게 된 건 이때가 처음이었다.
Input은 버튼보다 세 배는 복잡했다
Button 다음에 Input을 만들었다. 단순하게 생각하면 <input> 태그에 스타일 입히면 끝인데, 실제로는 그렇지 않았다.
라벨이 필요하다. 에러 메시지 영역이 필요하다. 헬퍼 텍스트가 있을 수도 있다. 왼쪽에 아이콘이 들어갈 수 있고, 오른쪽에 버튼이 들어갈 수도 있다. 비밀번호 필드면 show/hide 토글이 필요하다. disabled 상태, error 상태, focus 상태의 스타일이 전부 다르다.
interface InputProps {
label?: string;
error?: string;
helperText?: string;
leftIcon?: React.ReactNode;
rightElement?: React.ReactNode;
// ... input 기본 props
}여기서 가장 큰 실수를 했다. error prop으로 문자열을 받으면서 동시에 React Hook Form의 register를 spread하면 ref가 꼬이는 문제가 있었다. forwardRef를 빼먹은 거였다. 이거 때문에 프로덕션에서 폼 제출이 안 되는 버그가 생겼고, 핫픽스를 올렸다.
그때 깨달은 거다. 디자인 시스템 컴포넌트는 예쁘게 만드는 게 아니라, 다른 개발자가 실수 없이 쓸 수 있게 만드는 거라는 걸. 내가 유일한 사용자가 아니다. 시니어도 쓰고, 나중에 들어올 신규 입사자도 쓸 거다.
Storybook은 도입했다가 반쪽짜리로 운영했다
컴포넌트를 몇 개 만들고 나서 Storybook을 붙였다. 문서화 없이 디자인 시스템을 운영하면 "이 컴포넌트 어떻게 쓰는 거야?" 질문이 매일 올 게 뻔했으니까.
근데 Storybook 관리가 은근히 손이 많이 간다. 컴포넌트를 수정할 때마다 스토리도 같이 업데이트해야 한다. 처음에는 성실하게 했다. Controls 패널도 달고, 각 variant별로 스토리를 분리하고, 사용 예시도 적었다. 한 달 정도 지나니까 슬슬 밀리기 시작했다. 새로운 prop을 추가하면서 스토리는 안 고치는 일이 생기고, 그러면 문서와 실제 컴포넌트가 안 맞는 상태가 되고.
결국 "최소한 각 컴포넌트의 기본 스토리는 항상 최신 상태로 유지하자"는 규칙만 남겼다. 완벽한 문서화는 포기했다. 현실적으로 우리 팀 규모에서는 그게 최선이었다.
토큰 시스템은 세 번 갈아엎었다
컬러 토큰을 정의하는 과정이 생각보다 험난했다.
첫 번째 시도는 그냥 색상 이름이었다. blue-500, gray-100 같은 식. Tailwind에서 영감을 받았는데, 문제는 "이 파란색이 Primary인지 Info인지 Link인지" 의미를 모른다는 거였다.
두 번째 시도는 시맨틱 토큰만 쓰는 거였다. color-primary, color-error, color-background. 의미는 명확한데, 실제 개발할 때 "이 텍스트는 좀 연한 회색이어야 하는데 토큰이 없네?"라는 상황이 계속 생겼다.
세 번째 시도에서 두 레이어를 합쳤다.
// Primitive tokens - 실제 색상값
const colors = {
blue: { 50: '#eff6ff', 100: '#dbeafe', /* ... */ 600: '#2563eb' },
gray: { 50: '#f9fafb', 100: '#f3f4f6', /* ... */ 900: '#111827' },
};
// Semantic tokens - 의미 기반
const semantic = {
primary: colors.blue[600],
'primary-hover': colors.blue[700],
background: colors.gray[50],
'text-primary': colors.gray[900],
'text-secondary': colors.gray[600],
error: colors.red[500],
border: colors.gray[200],
};이 구조가 되고 나서야 디자이너분과 대화가 수월해졌다. "이 텍스트 색상은 text-secondary로 하면 될 것 같아요"라고 하면 바로 통했다.
모노레포는 안 했다
인터넷 아티클을 보면 디자인 시스템은 모노레포로 별도 패키지를 만들라고 한다. Turborepo나 Nx 써서 packages/design-system으로 분리하고, npm에 퍼블리시하고. 맞는 말이다. 이상적으로는.
우리는 그냥 대시보드 프로젝트의 src/components/ui 폴더에 때려넣었다. 다른 프로젝트에서 필요하면 파일을 복사했다. 더럽다는 거 안다. 하지만 세 프로젝트 간에 디자인 시스템 패키지를 관리하는 오버헤드가, 파일 복사의 불편함보다 컸다. 우리 팀 사이즈에서는.
나중에 프로젝트가 더 늘어나면 그때 패키지로 분리해도 된다고 판단했다. 실제로 이후에 프로젝트가 다섯 개로 늘었을 때 시니어가 "이제 패키지로 빼자"고 했고, 그때서야 Turborepo를 도입했다. 타이밍이 맞았다.
6개월 후
Button, Input, Select, Modal, Toast, Badge, Tag, Tooltip. 8개의 기본 컴포넌트가 만들어졌다. 화려하지 않다. Material UI나 Chakra에 비하면 초라하다. 근데 우리 서비스에 필요한 건 다 있었다.
가장 큰 변화는 개발 속도였다. 새 페이지를 만들 때 버튼 스타일 고민하는 시간이 사라졌다. "여기는 primary md로 넣으면 돼"라고 말하면 끝이었다. 디자이너분도 피그마에서 같은 토큰 이름을 쓰기 시작하면서, 디자인 핸드오프가 훨씬 깔끔해졌다.
실패한 것도 있다. 너무 일찍 추상화한 컴포넌트가 몇 개 있었다. Select를 처음에 Headless UI 스타일로 만들었는데, 써보니까 우리 유스케이스의 90%는 단순 드롭다운이어서 인터페이스가 과하게 복잡했다. 다시 심플하게 고쳤다.
작은 팀에서 디자인 시스템을 만들 수 있냐고 묻는다면, 만들 수 있다. 근데 "디자인 시스템"이라는 거창한 이름보다는, "우리 팀에서 반복되는 UI 패턴을 정리한 것"에 가까운 형태로. 스케일은 팀이 커질 때 같이 키우면 된다.
요즘은 가끔 다른 회사 디자인 시스템 문서를 구경한다. 토스의 TDS나 라인의 컴포넌트 시스템 같은 거. 보면서 "우리도 언젠가 이 정도까지 가려면 뭐가 필요할까" 생각하는데, 그 답은 아직 잘 모르겠다. 아마 팀이 커지면 자연스럽게 필요해지는 것들이겠지. 그때 가서 또 고민하면 되는 거다.
