프로젝트 개요
레거시 코드베이스를 현대적인 디자인 시스템으로 개편하는 실무형 리팩토링 프로젝트입니다. 정리되지 않은 코드의 문제점을 식별하고, TailwindCSS, shadcn/ui, CVA 등 현대 도구를 활용하여 일관된 디자인 토큰과 컴포넌트 API를 구축했습니다.
개편 목표
디자인 시스템
- TailwindCSS 기반 일관된 디자인 토큰 정의
- 하드코딩 제거, 재사용 가능한 스타일 시스템 구축
- dark mode, 반응형 등 확장 가능한 구조
컴포넌트 아키텍처
- UI 컴포넌트는 순수하게 UI만 담당
- 도메인 로직은 적절히 분리
- 일관된 컴포넌트 API 설계
기술 스택
- TailwindCSS 4.x: 디자인 토큰 기반 스타일링, 유틸리티 클래스
- shadcn/ui: Radix UI 기반 접근성 내장 컴포넌트
- CVA (Class Variance Authority): 선언적 variants 패턴
- React Hook Form + Zod: 선언적 폼 검증
개발 기간
약 2주 (2024.12)
Before 패키지에서 발견한 문제점
1. 지속 불가능한 하드코딩
레거시 코드는 #1976d2 같은 Hex 코드가 프로젝트 전체에 흩어져 있어서, 색 하나 바꾸는 게 거의 불가능했습니다. 값은 관리하지만 의미는 관리하지 못하는 구조였습니다.
// Before: 하드코딩된 색상 값
<div style={{ backgroundColor: '#1976d2' }}>
2. UI가 너무 똑똑함
테이블이 관리자 권한을 체크하고, 버튼이 게시글 상태를 판단하고, 화면에 보이는 컴포넌트들이 도메인 로직을 다 떠안고 있었습니다.
// Before: UI 컴포넌트에 비즈니스 로직이 섞임
<Button
style={{
background: role === 'admin' ? 'red' : 'blue'
}}
>
{status === 'active' ? '삭제' : '복구'}
</Button>
3. 명령형 코드
"어떻게" 구현할지부터 적기 시작하는 명령형 코드가 많아, 코드를 읽을 때마다 구현 세부사항을 해석해야 했습니다.
개편 과정에서 집중한 부분
1. 지속 가능한 아키텍처 구축
"값이 아니라 의미를 관리한다"는 개념을 구현했습니다. components.css를 단일 소스로 두고, 모든 컴포넌트가 --primary, --status-neutral 같은 토큰을 바라보도록 구조를 정리했습니다.
/* components.css - 디자인 토큰 */
:root {
--legacy-blue-main: #1775D2;
--radius: 0.25rem;
--primary: var(--legacy-blue-main);
}
// After: 의미 기반 토큰 사용
<Button variant="primary">삭제</Button>
2. 관심사의 분리
UI와 도메인을 명확히 분리했습니다.
- UI (
components/ui): 스타일만 신경 쓰는 멍청한 컴포넌트 - Domain (
components/domain): 비즈니스 로직을 책임지는 똑똑한 컴포넌트
// UI Component: 비즈니스 로직을 전혀 모름
const Button = ({ variant, children, ...props }) => {
return (
<button className={buttonVariants({ variant })} {...props}>
{children}
</button>
);
};
// Domain Component: 비즈니스 로직 관리
const ActionButton = ({ action }) => {
const handleClick = () => {
if (action === 'delete' && role !== 'admin') return;
// 비즈니스 로직...
};
return <Button variant="destructive" onClick={handleClick}>삭제</Button>;
};
3. 레거시 디자인 온전히 이식
새 기술로 바꾸되, UI는 최대한 레거시를 그대로 재현하는 데 집중했습니다. "디자인 시스템을 새로 만든다고 해서 디자인까지 바뀌면 안 된다"는 사실을 뼈저리게 느꼈습니다.
4. 선언적인 코드로 전환
"어떻게" 구현할지를 나열하는 방식에서 "무엇을 의도하는지"를 표현하는 방식으로 전환했습니다.
// Before: 명령형
style={{ background: role === 'admin' ? 'red' : 'blue' }}
// After: 선언형
<ActionButton action="delete" />
디자인 시스템 이해: 요리사와 재료 창고 비유
처음 Tailwind, 디자인 토큰, shadcn을 마주했을 때는 "그냥 스타일 도구들"이라고 생각했습니다. 하지만 이번 마이그레이션을 하면서 완전히 다른 관점을 얻었습니다.
Before: "테오 입맛"에만 맞춘 단일 요리
레거시 코드에서는 특정 상황에 맞춘 단일 용도의 CSS를 만들고, 색상이나 라운드 값을 그대로 적어 넣었습니다. 컴포넌트마다 스타일이 달라서 같은 버튼을 만들어도 맛이 매번 미묘하게 달랐습니다. 왜냐고? 레시피가 없으니까.
After: 재료를 먼저 정리하고, 주문이 들어오면 조리
디자인 시스템은 먼저 다양한 용도의 재료를 창고에 준비해두고, 주문이 들어오면 Variant와 CVA를 사용해 레시피대로 일관된 맛을 내는 방식입니다.
1. 재료 창고 — components.css (디자인 토큰)
재료 창고는 말 그대로 재료만 모아 놓는 곳입니다.
:root {
/* "나는 그냥 #1775D2라는 색깔이야. 쓰임새는 너희가 정해." */
--legacy-blue-main: #1775D2;
/* "나는 4px의 둥글기를 가진 재료야." */
--radius: 0.25rem;
}
- 재료는 무엇(What) 인지 알지만
- 어디에 어떻게(How) 쓰일지는 모른다
- 요리는 만들지 않지만, 요리에 반드시 필요한 기반 재료만 보관한다
2. 요리사/레시피 — Button.tsx (CVA)
요리사는 주문에 맞게 레시피대로 재료를 꺼냅니다.
const buttonVariants = cva(
// 기본 레시피: "4px 둥글기 재료를 써라"
"rounded-[var(--radius)] ...",
{
variants: {
variant: {
// "손님이 primary를 주문하면? 이 재료로 배경을 칠해라"
primary: "bg-[var(--legacy-blue-main)] ...",
},
},
}
);
이 구조의 장점:
- 재료 교체(Rebranding):
components.css에서 색 한 줄만 바꾸면 전체가 바뀜 - 레시피 변경(Refactoring):
Button.tsx의 CVA만 수정하면 끝
재료 창고와 레시피가 역할 분리되어 있어 수정 범위가 고립됩니다.
주요 트러블슈팅
다크 모드에서 색상 대비 붕괴
문제 상황
다크 모드를 처음 적용하면서 가장 크게 마주한 문제는 색상 대비 붕괴였습니다. 특히 통계 카드와 폼 입력창은 기존 라이트 모드의 파스텔톤 색상을 그대로 반전시키는 방식으로는 전혀 대응되지 않았습니다.
반전된 색은 텍스트가 묻히거나 지나치게 눈부셔져서, 실제 사용성 측면에서 문제가 오히려 더 커졌습니다.
원인 분석
처음에는 "다크 모드는 밝은 색 → 어두운 색, 어두운 색 → 밝은 색" 정도의 단순한 반전 규칙만 적용하면 된다고 생각했습니다. 하지만 이 방식이 접근성 기준(WCAG)의 대비 비율을 충족하지 못하고, 사용자 경험을 심각하게 해칠 수 있다는 것을 깨달았습니다.
해결 방법
단순 반전이 아니라 **대비 전략(Contrast Strategy)**을 세워 전반적인 색상 시스템을 재점검했습니다.
특히 Input, Button 같은 인터랙티브 요소는 다크 모드에서도 밝은 배경 + 어두운 텍스트 구조를 유지하도록 시멘틱 토큰을 재정의했습니다.
// Input.tsx - 다크 모드에서도 가독성 유지
<input
className={cn(
"bg-white text-gray-900",
"dark:bg-white dark:text-gray-900", // 명시적 오버라이딩
className
)}
{...props}
/>
배운 점
- 다크 모드는 단순한 스타일 변경이 아니라 접근성 문제의 연장선입니다
- 색상 토큰을 정의할 때 전역값만 손보는 것이 아니라, 컴포넌트가 실제로 어떻게 그 값을 사용하는지까지 고려해야 합니다
- 다크 모드를 단순한 "테마 기능"이 아니라, 진짜 사람이 사용하는 환경으로 바라보는 시각을 갖게 되었습니다
기술적 성장
1. 선언형 프로그래밍의 체화
"어떻게 구현할지"를 줄줄이 나열하는 방식에서 벗어나 "무엇을 의도하는지"를 코드로 표현하는 방식을 실제로 경험했습니다.
2. UI와 비즈니스 로직의 격리
- UI 컴포넌트는 도메인을 몰라야 재사용성이 높아진다
- Domain Component는 렌더링을 UI에 위임해야 로직에 집중할 수 있다
- 이 분리를 통해 결합도를 낮추고, 역할 중심의 응집도를 높일 수 있었습니다
유지보수도 단순해짐
- 디자인을 바꾸고 싶다? → UI Component 수정
- 권한/규칙을 바꾸고 싶다? → Domain Component 수정
3. 디자인 시스템의 본질
단순한 스타일 가이드가 아니라, 엔지니어링된 구조라는 걸 처음으로 이해했습니다. 토큰화는 "잘 정리된 재료 + 일관된 레시피"와 같습니다.
4. 추상화에 대한 사고 변화
"코드 짧으면 장땡 아니냐?"라고 생각했던 과거와 달리, 복잡한 조건문을 계속 해석해야 하는 코드는 읽기 어렵다는 것을 깨달았습니다.
의미 있는 컴포넌트로 추상화해두면 코드만 읽고도 비즈니스 규칙과 도메인이 자연스럽게 드러납니다. 이 경험이 추상화를 바라보는 관점을 완전히 바꿨습니다.
프로젝트를 통해 배운 것
토큰화의 진짜 의미
토큰화는 단순한 스타일 분리가 아니라 잘 정리된 재료를 체계적으로 준비해두는 일입니다.
- 좋은 재료를 잘 정리해두면
- 어떤 주문이 들어와도
- 항상 같은 맛, 같은 품질을 유지할 수 있습니다
토큰 + shadcn + Tailwind 조합은 자연스럽게 "어떻게 그릴지(How)" → "무엇을 의도하는지(What)" 중심의 코드 작성 방식으로 이끌었습니다. 넓게 보면 React의 선언형 프로그래밍과 매우 비슷한 철학을 공유하고 있었습니다.
레거시 마이그레이션의 핵심
이번 과제는 예쁘게 만들기가 아니라 "우리 모두의 회사에 존재할만한 레거시 코드를 마이그레이션 한다면 어떻게 할 것인가?"에 대한 과제였습니다.
- 기술은 바뀌었지만 UI는 이전과 비슷하게 유지
- 디자인 시스템을 새로 만든다고 해서 디자인까지 바뀌면 안 됨
- 실무에서의 pragmatic한 선택이 무엇인지 경험
멘토 피드백
"components.css를 재료 창고로, CVA를 레시피로 설명하면서 '재료는 무엇(What)인지 알지만 어디에 어떻게(How) 쓰일지는 모른다'는 표현이 정말 탁월했습니다. 이 비유는 다른 사람에게 토큰화를 설명할 때도 쓸 수 있을 정도로 명확해요."
"다크모드에서 단순 반전이 아니라 접근성을 고려한 대비 전략을 세우고, Input에 밝은 배경을 유지한 결정도 훌륭했습니다. '다크모드는 접근성 문제의 연장선'이라는 깨달음이 핵심이죠."
"실제로 작업하면서 '이 분기 처리를 어디에 둬야 하지?'하는 복잡함을 느꼈을 텐데, 다음 과제에서는 엔티티별로 컴포넌트를 완전히 분리하는 방식을 경험해보기를 바랍니다."
마치며
이번 프로젝트는 단순히 새 기술을 적용하는 것이 아니라, 레거시 코드를 어떻게 지속 가능하게 개선할 것인가에 대한 실무적 접근이었습니다.
- 디자인 시스템은 스타일 가이드가 아니라 엔지니어링 구조
- UI와 비즈니스 로직의 분리는 선택이 아니라 필수
- 다크 모드는 접근성 문제의 연장선
- 추상화는 코드를 짧게 만드는 게 아니라 의미를 명확하게 만드는 것
"어떻게"가 아닌 "무엇을"에 집중하는 선언형 사고방식을 체득한 소중한 경험이었습니다.