Ant Design을 걷어내고 자체 디자인시스템 만들기
antd → 자체 컴포넌트 28개, 번들 22% 감소
외부 UI 라이브러리 의존에서 벗어나 28개 컴포넌트를 직접 설계·구현하고, 무중단으로 마이그레이션한 경험
문제 상황
금융 백오피스 프론트엔드를 개발하면서, 초기에는 Ant Design(antd)을 기반으로 UI를 구성했다. 빠르게 프로토타이핑하기에는 좋았지만, 프로젝트가 커질수록 세 가지 근본적인 문제에 부딪혔다.
1. 디자인 가이드와의 불일치
antd는 자체적인 디자인 언어를 가지고 있다. ConfigProvider의 theme 토큰으로 일부 커스터마이징이 가능하지만, 컴포넌트 내부 구조(padding, border-radius, hover 효과 등)까지 완벽하게 맞추기는 어렵다.
// [X] antd 테마 토큰으로는 한계가 있는 커스터마이징
<ConfigProvider
theme={{
token: {
colorPrimary: '#2563eb',
borderRadius: 8,
},
components: {
DatePicker: {
// 내부 셀 간격, 헤더 높이, 화살표 위치 등은
// 토큰으로 제어할 수 없어 CSS 오버라이드 필요
},
},
}}
>
결국 디자인 시안과 100% 일치시키려면 antd 내부 클래스를 직접 오버라이드해야 했고, 이는 antd 버전 업데이트 시 깨질 위험이 있는 취약한 코드였다.
2. 번들 사이즈 부담
antd + @ant-design/icons는 tree-shaking을 지원하지만, 실제로 DatePicker나 Table 같은 복합 컴포넌트를 import하면 내부 의존성이 연쇄적으로 딸려온다. 백오피스 특성상 거의 모든 antd 컴포넌트를 사용하게 되면서 번들 크기가 계속 증가했다.
3. 스타일링 전략의 충돌
프로젝트는 Tailwind CSS를 스타일링 기본 전략으로 채택하고 있었다. 그런데 antd는 CSS-in-JS(cssinjs)를 사용한다. 두 시스템이 공존하면서 생기는 문제들이 있었다:
- Tailwind 클래스와 antd 내부 스타일의 우선순위 충돌
- antd 컴포넌트에 Tailwind 클래스를 적용하면 예측 불가능한 결과
- 개발자가 “이 스타일은 Tailwind로, 저 스타일은 antd 오버라이드로” 판단해야 하는 인지 부하
핵심 판단: 왜 "교체"가 아니라 "자체 구현"인가?
외부 라이브러리를 걷어낸다면 대안은 두 가지였다.
| 선택지 | 장점 | 단점 |
|---|---|---|
| 다른 UI 라이브러리로 교체 (Radix, shadcn/ui 등) | 구현 비용 낮음, 검증된 접근성 | 또 다른 외부 의존성, 커스터마이징 한계 반복 가능 |
| 자체 디자인시스템 구축 | 디자인 완전 통제, 의존성 0, Tailwind 네이티브 | 구현 비용 높음, 접근성 직접 처리 |
자체 구현을 선택한 이유:
- 디자인 시안이 이미 확정되어 있어 “시안 그대로” 구현하는 것이 가장 확실함
- 백오피스 특성상 사용자(내부 운영자)가 한정적이어서, 범용 접근성보다 디자인 일치도와 유지보수성이 더 중요
- Tailwind +
cn()유틸리티만으로 스타일링을 통일하면 인지 부하가 사라짐 - 한 번 구축하면 이후 신규 페이지 개발 시 조합만으로 빠르게 구현 가능
마이그레이션 전략: 점진적 교체
한 번에 모든 antd를 걷어내는 것은 위험하다. 컴포넌트 단위로 하나씩 교체하는 점진적 전략을 택했다.
원칙:
- 교체 대상 컴포넌트의 Props 인터페이스를 먼저 설계 → 사용처 일괄 교체 → antd import 제거
- 하나의 컴포넌트 교체가 끝나면 해당 컴포넌트의 antd import가 프로젝트에 0건인지 확인 후 다음으로
- 모든 컴포넌트 교체 완료 후
package.json에서 antd, @ant-design/icons 제거
설계 원칙
모든 컴포넌트에 공통으로 적용한 설계 원칙이다.
1. Tailwind + cn() 단일 스타일링
import { type ClassValue, clsx } from 'clsx'
import { extendTailwindMerge } from 'tailwind-merge'
// 프로젝트 전용 텍스트 클래스를 인식하도록 확장
const customTwMerge = extendTailwindMerge({
extend: { classGroups: { 'font-size': ['text-title-sm', 'text-body-md', /* ... */] } },
})
export const cn = (...inputs: ClassValue[]) => customTwMerge(clsx(inputs))
clsx로 조건부 클래스를 결합하고, tailwind-merge로 충돌하는 클래스를 자동 정리한다. 프로젝트 전용 텍스트 크기 클래스도 인식하도록 확장했다.
모든 컴포넌트는 이 cn() 하나로만 스타일링한다. 별도의 .css 파일이나 styled-components는 사용하지 않는다.
2. Compound Component 패턴
복합 컴포넌트는 Context API 기반 Compound Component 패턴으로 설계했다.
// 사용 예시
<Modal onClose={handleClose}>
<Modal.Header title="거래 등록" />
<Modal.Content>
{/* 폼 내용 */}
</Modal.Content>
<Modal.Footer>
<CTA variant="outline" onClick={handleClose}>취소</CTA>
<CTA onClick={handleSubmit}>등록</CTA>
</Modal.Footer>
</Modal>
이 패턴을 선택한 이유:
- 부모(Root)가 상태를 소유하고 Context로 내려보내므로 props drilling 없음
- 자식 컴포넌트를 자유롭게 조합할 수 있어 레이아웃 유연성 확보
- 각 서브 컴포넌트가 독립적으로 렌더링되므로 관심사 분리 명확
3. Portal 렌더링
Modal, SidebarModal, TimePicker, Dropdown 등 오버레이 성격의 컴포넌트는 모두 createPortal(…, document.body)로 body에 직접 렌더링한다.
// Modal 핵심 구조
return createPortal(
<div className="fixed inset-0 z-50 bg-dim">
<div className="modal-content" onClick={e => e.stopPropagation()}>
{children}
</div>
</div>,
document.body
)
Portal을 사용하면 z-index 컨텍스트 문제를 근본적으로 회피할 수 있다. 부모 컴포넌트의 overflow: hidden이나 position 설정에 영향받지 않는다.
구현 상세: 가장 까다로웠던 3개 컴포넌트
1. DatePicker / DateRangePicker — 캘린더를 밑바닥부터
DatePicker는 antd에서 가장 많이 사용하면서도, 커스터마이징이 가장 어려운 컴포넌트였다. 직접 구현할 때 핵심 과제는 상태 설계와 Range 선택의 시각적 피드백이었다.
Pending State 패턴
캘린더 팝업 내에서 날짜를 선택하고, “완료” 버튼을 눌러야 확정되는 UX다. 이를 위해 3가지 상태를 구분했다:
| 상태 | 의미 | 완료 시 전달값 |
|---|---|---|
undefined |
아직 변경 없음 | 기존 selectedDate 유지 |
null |
사용자가 명시적으로 해제 | null 전달 |
Date |
새 날짜 선택 | 선택한 Date 전달 |
// pendingDate의 3가지 의미
type PendingDate = Date | null | undefined
// undefined: 아직 변경 없음 → 기존 selectedDate 유지
// null: 사용자가 명시적으로 날짜를 해제함
// Date: 사용자가 새 날짜를 선택함
const handleConfirm = () => {
if (pendingDate !== undefined) {
onChange(pendingDate) // null 또는 Date
}
onClose()
}
undefined와 null을 구분함으로써, "아무것도 안 건드림"과 "의도적으로 비움"을 명확히 분리한다.
CalendarGrid: 7가지 Range 상태
DateRangePicker의 CalendarGrid는 각 날짜 셀마다 7가지 조건을 판단해야 한다:
| 조건 | 의미 | 시각적 효과 |
|---|---|---|
isStart |
범위 시작일 | 파란 배경 + 좌측만 라운드 |
isEffectiveEnd |
범위 종료일 (확정 또는 hover) | 파란 배경 + 우측만 라운드 |
isInRange |
시작~종료 사이의 날짜 | 연한 파란 배경 |
shouldShowRange |
범위 표시 여부 | start만 선택 + hover 시 미리보기 |
isSameStartEnd |
시작과 종료가 같은 날 | 양쪽 모두 라운드 |
isStartOnly |
시작만 선택, 종료 미정 | 단일 날짜 스타일 |
hoveredDate |
마우스가 올라간 날짜 | range end 미리보기 |
const getDayClasses = (day: Date) => {
const isStart = rangeStart && isSameDay(day, rangeStart)
const isEnd = (rangeEnd ?? hoveredDate) && isSameDay(day, rangeEnd ?? hoveredDate)
const isInRange = rangeStart && effectiveEnd && day > rangeStart && day < effectiveEnd
return {
cell: cn('h-9', isInRange && 'bg-blue-50', /* range 양 끝 라운딩 */),
button: cn('w-9 h-9 rounded-full', (isStart || isEnd) && 'bg-blue-500 text-white'),
}
}
cell과 button 클래스를 분리한 것이 핵심이다. cell은 range 배경(연한 파란색 + 한쪽만 라운드)을, button은 선택된 날짜 강조(진한 파란 원)를 담당한다. 이렇게 분리하면 range 배경 위에 선택 날짜가 겹쳐 보이는 자연스러운 UI를 구현할 수 있다.
텍스트 입력 자동 포맷팅
날짜를 직접 타이핑할 수도 있다. 숫자만 입력하면 자동으로 YYYY-MM-DD 형식으로 변환된다.
export function formatDateInput(value: string): string {
const digits = value.replace(/\D/g, '').slice(0, 8)
if (digits.length <= 4) return digits
if (digits.length <= 6) return `${digits.slice(0, 4)}-${digits.slice(4)}`
return `${digits.slice(0, 4)}-${digits.slice(4, 6)}-${digits.slice(6)}`
}
// 입력: "20260310" → 출력: "2026-03-10"
// 입력: "202603" → 출력: "2026-03"
2. TimePicker — 모바일 감성의 휠 스크롤 UI
TimePicker는 antd의 드롭다운 방식 대신, 모바일 네이티브 같은 휠 스크롤 UI를 직접 구현했다.
스크롤 기반 선택 메커니즘
function TimeWheel({ items, value, onChange }: TimeWheelProps) {
const containerRef = useRef<HTMLDivElement>(null)
// 현재 값으로 스크롤 위치 동기화
useEffect(() => {
const idx = items.indexOf(value)
if (containerRef.current && idx >= 0)
containerRef.current.scrollTop = idx * ITEM_HEIGHT
}, [value])
return (
<div ref={containerRef} className="h-[111px] overflow-y-auto snap-y snap-mandatory">
{items.map((item) => (
<div
key={item}
className={cn('h-[37px] snap-center', item === value && 'border border-blue-300 rounded')}
onClick={() => onChange(item)}
>
{String(item).padStart(2, '0')}
</div>
))}
</div>
)
}
핵심 설계:
- 고정 높이(37px): 스크롤 위치 계산을 정수 배수로 단순화
- 3개 아이템 노출: 컨테이너 높이 111px = 37px × 3
- CSS snap:
snap-y snap-mandatory로 스크롤 시 자연스럽게 아이템에 정렬 - Fade 오버레이: 상하단에 그라데이션을 씌워 포커스 영역 강조
Temp State 분리
DatePicker와 마찬가지로, 휠을 돌려 선택한 값은 “완료” 버튼을 누를 때만 확정된다.
const [tempHour, setTempHour] = useState(selectedHour)
const [tempMinute, setTempMinute] = useState(selectedMinute)
const handleConfirm = () => {
onChange(`${String(tempHour).padStart(2, '0')}:${String(tempMinute).padStart(2, '0')}`)
onClose()
}
3. Dropdown — 한글 IME와 키보드 내비게이션
Dropdown은 검색 기능, 키보드 내비게이션, 다중 선택까지 지원하는 복합 컴포넌트다. 가장 까다로웠던 부분은 한글 IME 처리였다.
한글 IME 문제
한글을 입력할 때 “환” 같은 글자를 조합하는 중에 Enter를 누르면, 브라우저는 composing 상태에서의 Enter와 확정 후의 Enter를 모두 발생시킨다. 이를 구분하지 않으면 "ㅎ"을 입력하는 중에 드롭다운 아이템이 선택되어 버린다.
const handleKeyDown = (e: React.KeyboardEvent) => {
// 한글 조합 중인 Enter는 무시 — 이 한 줄이 핵심
if (e.key === 'Enter' && e.nativeEvent.isComposing) return
switch (e.key) {
case 'ArrowDown': /* 순환 포커스 이동 */ break
case 'ArrowUp': /* 역방향 이동 */ break
case 'Enter': /* 선택 확정 */ break
case 'Escape': setIsOpen(false); break
}
}
e.nativeEvent.isComposing 체크 한 줄이 핵심이다. 이 처리가 없으면 한글 사용자에게 치명적인 UX 결함이 된다.
Compound Component 구조
입력 + 키보드 처리"] Menu["DropdownMenu
아이템 목록 + 필터링"] end end State --> Trigger State --> Menu Trigger -- "검색어 변경" --> State Menu -- "아이템 선택" --> State
Multi-select vs Single-select
const handleValueChange = (itemValue: string) => {
if (Array.isArray(value)) {
// 다중 선택: 토글 (이미 있으면 제거, 없으면 추가)
const next = value.includes(itemValue)
? value.filter(v => v !== itemValue)
: [...value, itemValue]
onValueChange(next)
} else {
// 단일 선택: 값 교체 후 닫기
onValueChange(itemValue)
setIsOpen(false)
}
}
value의 타입(배열 vs 문자열)만으로 동작을 분기한다. 별도의 mode prop 없이 타입 시스템이 자연스럽게 동작을 결정한다.
그 외 컴포넌트 설계 포인트
| 컴포넌트 | 핵심 설계 | 해결한 문제 |
|---|---|---|
| DataTable | Sticky 헤더 + Column Group + 동적 rowSpan + useAutoScrollHeight 훅 |
뷰포트에 맞는 자동 높이 계산, 셀 병합, 2줄 그룹 헤더 |
| Modal / SidebarModal | Compound Component + Portal + ESC/외부클릭 + body scroll lock | z-index 컨텍스트, 2-column 레이아웃(사이드바+콘텐츠) |
| Stepper | 5가지 시각적 상태 (before/currently/completion/false-currently/false) | 환전 10개 상태 × 4개 채널의 다단계 프로세스 표현 |
| FloatingActionBar | 하단 고정 + 선택 카운트 + 벌크 액션 | 테이블에서 다중 선택 후 일괄 처리 |
| CTA (Button) | variant × styleType × size 조합, disabled 시 children 색상 강제 | 버튼 내부 아이콘/텍스트까지 일관된 disabled 스타일 |
전체 컴포넌트 목록 (28개)
| 분류 | 컴포넌트 | 복잡도 |
|---|---|---|
| 입력 | TextField, TextArea, Checkbox, Radio, Switch | 낮음 |
| 선택 | Dropdown, SelectButton, BankSelect, CurrencyIcon | 중~높음 |
| 날짜/시간 | DatePicker, DateRangePicker, TimePicker | 높음 |
| 표시 | Chip, Tabs, Pagination, StatusIndicator, Result | 낮~중 |
| 네비게이션 | Stepper, StepTab | 중간 |
| 오버레이 | Modal, SidebarModal, BottomSheet, FloatingActionBar | 중~높음 |
| 정보 | Tooltip, InfoCard, AccordionCard | 낮~중 |
| 데이터 | DataTable | 높음 |
| 버튼 | CTA | 낮음 |
| 기타 | Spinner | 낮음 |
적용된 패턴 정리
| 패턴 | 적용 컴포넌트 | 효과 |
|---|---|---|
| Compound Components | Modal, SidebarModal, Dropdown, Tabs | Props drilling 제거, 레이아웃 유연성 |
| Portal | Modal, SidebarModal, TimePicker, Dropdown | z-index 컨텍스트 문제 회피 |
| Pending State | DatePicker, DateRangePicker, TimePicker | “완료” 전까지 원본 값 보호 |
| Custom Hook 분리 | useCalendarPopup, useTimePickerDropdown, useAutoScrollHeight | UI와 로직 분리, 테스트 용이성 |
| IME Handling | Dropdown | 한글 입력 중 의도치 않은 동작 방지 |
결과
Before vs After
| 항목 | Before (antd) | After (자체 디자인시스템) |
|---|---|---|
| 외부 UI 의존성 | antd + @ant-design/icons | 0개 |
| 스타일링 시스템 | Tailwind + CSS-in-JS 혼재 | Tailwind + cn() 단일 |
| 디자인 일치도 | 70~80% (오버라이드 필요) | 100% |
| 디자인 변경 대응 | antd 내부 클래스 추적 필요 | 해당 컴포넌트 1곳만 수정 |
| 컴포넌트 수 | antd 의존 | 28개 자체 구현 |
| 번들 크기 | antd + icons 포함 | antd 의존성 완전 제거 (antd, @ant-design/icons, @ant-design/cssinjs) |
정량적 성과
| 지표 | Before (antd) | After (자체 디자인시스템) | 비고 |
|---|---|---|---|
| 외부 UI 의존성 | antd + @ant-design/icons + @ant-design/cssinjs | 0개 | package.json에서 완전 제거 |
| 자체 컴포넌트 수 | 0개 | 28개 | 입력 5 · 선택 4 · 날짜/시간 3 · 표시 5 · 오버레이 4 · 데이터 1 · 기타 6 |
| 스타일링 시스템 | Tailwind + CSS-in-JS 혼재 | Tailwind + cn() 단일 | 별도 .css 파일 0개 |
| 디자인 일치도 | 70~80% | 100% | CSS 오버라이드 0건 |
| antd CSS 오버라이드 코드 | 다수 존재 (버전 업데이트 시 깨짐 위험) | 0줄 | 오버라이드 자체가 불필요 |
| 디자인 변경 대응 범위 | antd 내부 클래스 추적 + 오버라이드 수정 | 해당 컴포넌트 1곳만 수정 | 디자인 시안 변경 시 영향 범위 최소화 |
번들 크기 실측 비교
antd 제거 직전/직후 커밋을 각각 vite build하여 측정한 결과다.
| 지표 | Before (antd 포함) | After (antd 제거) | 변화 |
|---|---|---|---|
| JS 합계 (minified) | 1,954 KB | 1,521 KB | −433 KB (−22.2%) |
| JS gzip | 394 KB | 250 KB | −144 KB (−36.5%) |
| CSS | 39 KB | 39 KB | 동일 (Tailwind 기반으로 변화 없음) |
| dist 전체 | 3.2 MB | 2.8 MB | −400 KB (−12.5%) |
"생각보다 적다"고 느낄 수 있는 이유
솔직히 antd를 걷어내면 번들이 드라마틱하게 줄어들 것으로 기대했다. 하지만 22% 감소는 “전체 antd 패키지(~1MB+)를 제거했는데?” 싶은 수치다. 세 가지 이유가 있다.
1. antd v5의 tree-shaking이 어느 정도 작동하고 있었다
antd v5는 ES 모듈을 지원하므로, Vite가 사용하지 않는 컴포넌트를 tree-shake해준다. 전체 antd(~1MB+)가 아니라 실제 사용한 컴포넌트만 번들에 포함되어 있었다. 메인 벤더 청크에서 약 161 KB (gzip 56 KB)가 감소했다.
2. xlsx가 번들의 최대 비중을 차지한다
| 라이브러리 | minified | gzip | 비고 |
|---|---|---|---|
| xlsx | 429 KB | 142 KB | 엑셀 내보내기 기능 (제거 불가) |
| antd 실제 기여분 | ~161 KB | ~56 KB | tree-shaking 후 실제 포함량 |
| react + react-dom | ~140 KB | ~45 KB | 고정 비용 |
xlsx 하나가 antd 실제 기여분의 2.5배다. antd를 완전히 제거해도 전체 번들에서 차지하는 비중이 상대적으로 작아 보이는 이유다.
3. 자체 디자인시스템 코드가 추가되었다
antd를 제거하면서 28개 컴포넌트를 직접 구현했으므로, 제거된 분량의 일부를 자체 코드가 다시 채운다. 다만 자체 코드는 필요한 로직만 포함하므로 antd보다 훨씬 가볍다.
그러면 번들 크기 감소는 별 의미 없는 건가?
22% 감소 자체도 의미 있지만, 번들 크기만으로는 보이지 않는 세 가지 성과가 더 중요하다:
- CSS-in-JS 런타임 제거: antd의
@ant-design/cssinjs는 런타임에 스타일을 주입한다. 이는 번들 크기에는 적게 반영되지만, 초기 렌더링 성능에 직접적 영향을 준다. Tailwind은 빌드 타임에 CSS를 생성하므로 런타임 비용이 0이다. - 의존성 체인 단절: antd는 내부적으로 dayjs, rc-picker, rc-table, rc-select 등 수십 개의 rc-* 패키지에 의존한다. 이 의존성 체인이 통째로 제거되면서 보안 취약점 표면적 감소 + 버전 업데이트 부담 해소. antd가 메이저 버전을 올릴 때마다 깨지던 오버라이드 코드를 다시 고칠 필요도 없어졌다.
- 번들 예측 가능성: antd를 사용할 때는 “이 컴포넌트를 import하면 내부적으로 얼마나 끌고 들어오는지” 예측할 수 없었다. DatePicker 하나를 import하면 rc-picker + dayjs + 로케일 데이터까지 연쇄적으로 따라온다. 자체 컴포넌트는 작성한 코드가 그대로 번들되므로, 추가되는 크기를 정확히 예측할 수 있다.
신규 페이지 개발 효율
디자인시스템이 갖춰진 이후, 신규 페이지 개발은 컴포넌트 조합만으로 이루어진다. 예를 들어 목록 페이지 하나는 DataTable + Dropdown(필터) + CTA(액션 버튼) + DateRangePicker(날짜 필터) 조합으로 완성된다. 스타일링 고민이 사라지고, 도메인 로직에만 집중할 수 있게 되었다.
새로운 CRUD 페이지를 추가할 때의 흐름:
- 디자인 시안 확인 → 사용할 컴포넌트 목록 파악 (별도 구현 불필요)
- 페이지 컴포넌트에서 디자인시스템 컴포넌트를 조합
- 도메인 로직(API 연동, 상태 관리)에만 집중
스타일링 관련 의사결정이 0건으로 줄었다. “이 스타일은 Tailwind로 할까, antd 오버라이드로 할까?” 같은 고민이 사라진 것이 개발 속도보다 더 큰 효과였다.
팀 협업 임팩트
- 디자인-개발 소통 비용 감소: 디자이너가 시안을 전달하면, 개발자가 “이건 antd로 되나요? 오버라이드 해야 하나요?” 물을 필요 없이 그대로 구현 가능. 디자인 피드백 라운드가 줄어들었다.
- 신규 페이지 개발 속도: 디자인시스템 구축 전에는 매 페이지마다 스타일링 의사결정이 필요했지만, 이제는 컴포넌트 조합만으로 UI 완성. 도메인 로직에만 집중할 수 있게 되었다.
- 일관성 자동 보장: 모든 페이지가 동일한 컴포넌트를 사용하므로, “이 페이지는 왜 버튼이 다르지?” 같은 디자인 QA 이슈가 근본적으로 발생하지 않는다.
핵심 설계 포인트 정리
| 포인트 | 선택 | 이유 |
|---|---|---|
| antd 대안 | 자체 구현 (다른 라이브러리 교체 아님) | 디자인 완전 통제 + Tailwind 네이티브 |
| 마이그레이션 전략 | 컴포넌트 단위 점진적 교체 | 서비스 무중단 + 리스크 최소화 |
| 스타일링 | cn() (clsx + tailwind-merge) | 조건부 클래스 + 충돌 자동 정리 |
| 복합 컴포넌트 패턴 | Compound Components + Context | 유연한 조합 + props drilling 제거 |
| 오버레이 렌더링 | createPortal | z-index 컨텍스트 격리 |
| 팝업 내 선택 | Pending State (undefined/null/Date) | 확정 전 원본 보호 + 해제 의도 구분 |
| 한글 입력 | isComposing 체크 | IME 조합 중 오동작 방지 |
결론
"antd를 쓰면 빠르게 만들 수 있다"는 사실이다. 하지만 프로젝트가 성장하면서 외부 라이브러리의 디자인 언어 위에서 우리의 디자인을 구현하는 것은 점점 더 많은 비용을 발생시킨다. 오버라이드 코드가 쌓이고, 업데이트마다 깨지는 스타일을 고치고, 두 가지 스타일링 시스템 사이에서 고민하는 시간이 늘어난다.
자체 디자인시스템 구축은 초기 투자가 크다. DatePicker의 캘린더 로직, Range 선택의 7가지 상태, 한글 IME 처리 같은 디테일은 직접 구현하지 않으면 모르는 영역이다. 하지만 이 투자가 끝나면, 이후의 모든 UI 개발은 조합의 문제로 단순화된다. 디자인 변경은 한 곳만 고치면 전체에 반영되고, 번들에는 실제로 사용하는 코드만 포함된다.
"빌리는 것"과 "소유하는 것"의 트레이드오프를 정확히 이해하고, 프로젝트 상황에 맞는 판단을 내리는 것이 중요하다.