본문 바로가기
tech epilogue

번들 크기 100KB를 찾아서(2)

by rami_ 2026. 5. 7.

기능(Logic)은 웹 접근성이 검증된 Radix Vue(Headless UI)에 맡기고, 스타일(Style)은 Tailwind CSS v4로 100% 제어함으로써 프로젝트만의 독자적인 UI 시스템을 구축하고자 했습니다.
 

작업 전 데이터 진단

항목크기비고
전체 dist/9.8 MB최적화가 시급한 상태
JS (전체)4.2 MBVuetify 및 관련 라이브러리 비중 높음
CSS (전체)2.1 MB사용되지 않는 스타일 누적
이미지 / SVG2.4 MB 
폰트11 MBdist 외 포함

 
가장 큰 목표는 UI 프레임워크가 강제하는 스타일과 컴포넌트의 무게를 덜어내어 JS와 CSS의 절대적인 크기를 줄이는 것이었습니다.
 

전략: Spec Kit 워크플로우로 실현한 '초단기 자동화'

대규모 마이그레이션에서 AI가 길을 잃지 않게 하려면 정교한 설계가 필요합니다. Spec Kit(Spec → Plan → Tasks) 워크플로우를 도입하여 AI 에이전트가 자율적으로 작업을 완수할 수 있는 환경을 만들었습니다.
 

예측된 작업 기간 (Spec Kit 유무 비교)

구분일반적인 방식Spec Kit 
설계 및 태스크 분해3~5일0.5일
단순 교체 작업7~10일1~2일 (자동화)
복잡한 컴포넌트5~7일3~4일
QA 및 디버깅3~5일2~3일
합계3~4주8~13일

단순 반복적인 코드 치환은 AI에게 맡기고, 사람이 반드시 확인해야 하는 UI 요소(elevation, ripple 등)에만 검토 리소스를 집중하는 전략을 세웠습니다.
 

실전: 5개 Phase와 82개의 체크리스트

AI 에이전트는 제가 설계한 specs/ 하위의 문서들을 나침반 삼아 작업을 수행했습니다. 특히 tasks.md에 정의된 T001부터 T082까지의 82개 태스크는 세션이 끊겨도 AI가 다음 할 일을 즉시 파악하게 하는 결정적인 역할을 했습니다.
 
[마이그레이션 단계별 로드맵]

  • Phase 0-1: Tailwind v4 및 Radix Vue 기반 인프라 설정 및 8개의 Base UI 컴포넌트 생성.
  • Phase 2-3: 12개 공통 컴포넌트 내부 교체 및 레이아웃 시스템(NavigationBar 등) 전환.
  • Phase 4-5: 28개 페이지를 단순도 순으로 교체하고, 최종적으로 Vuetify를 uninstall.

 
실제 사용한 plan.md 

더보기
더보기

 

# Implementation Plan: Vuetify → Radix Vue + TailwindCSS v4 마이그레이션

**Branch**: `002-vuetify-to-radix-tailwind` | **Date**: 2026-05-06 | **Spec**: [spec.md](./spec.md)  
**Input**: Feature specification from `/specs/002-vuetify-to-radix-tailwind/spec.md`

## Summary

Vuetify 3를 Radix Vue + TailwindCSS v4 조합으로 완전 교체하는 마이그레이션. Phase 0(기반 설정)과 Phase 1~4(Base 컴포넌트 8개 생성, 공통 컴포넌트 12개 교체, 레이아웃 교체, Vuetify 패키지 제거)는 **이미 완료**. 현재는 Phase 5~7(28개 페이지 파일 및 도메인 컴포넌트 교체)이 남은 상태.

## Technical Context

**Language/Version**: TypeScript 5.2.2 + Vue 3 (Composition API, `<script setup>`)  
**Primary Dependencies**:
- `radix-vue` ^1.9.17 — 헤드리스 UI 컴포넌트 (Dialog, Select, Tabs 등)
- `@tailwindcss/vite` ^4.2.4 — TailwindCSS v4 Vite 플러그인
- `lucide-vue-next` ^1.0.0 — MDI 아이콘 대체
- `tailwind-merge` ^3.5.0 — 동적 클래스 병합 유틸리티
- `prettier-plugin-tailwindcss` ^0.8.0 — 클래스 정렬  
**Storage**: N/A (프론트엔드 전용)  
**Testing**: 브라우저 수동 검증 (각 Phase 완료 후 `npm run build` 성공 확인)  
**Target Platform**: 웹 브라우저 (Chrome 기준, PWA 지원)  
**Project Type**: SPA (Single Page Application) — Vue 3 + Vite 5  
**Performance Goals**: 번들 크기 9.8 MB → 5 MB 이하 (Vuetify 완전 제거 시)  
**Constraints**: 외부 props/emits 인터페이스 변경 불가, 호출부 수정 최소화  
**Scale/Scope**: 28개 페이지 파일 + 16개 도메인 컴포넌트 = 총 44개 파일 교체

## Constitution Check

*GATE: 헌법 위반 여부 검토*

### I. Composition API & Component Architecture ✅
- 모든 Base 컴포넌트(`src/components/ui/`) — `<script setup>` 사용 확인
- 페이지 컴포넌트 — `<script setup>` 유지 필요

### II. Vuetify-First UI ⚠️ EXCEPTION JUSTIFIED
- 이 feature는 Vuetify 제거가 목적 → Vuetify-First 원칙의 예외 사례
- Constitution 제거 완료 후 새 Constitution에서 Tailwind-First로 갱신 필요
- **Complexity Tracking에 명시**

### III. i18n Mandatory ✅
- 교체 작업 중 `$t()` 사용 유지
- `npm run check-empty-keys` 통과 필수

### IV. Type Safety ✅
- Base 컴포넌트 props/emits 타입 명시 완료
- 페이지 교체 시 기존 타입 유지

### V. State Management Discipline ✅
- Pinia 스토어(nav.ts, user.ts) 변경 없음
- 로컬 UI 상태는 `ref`로 유지

**Gate Result**: PASS (Constitution II 위반은 이 feature의 목적 자체이므로 정당화됨)

## Project Structure

### Documentation (this feature)

```text
specs/002-vuetify-to-radix-tailwind/
├── plan.md              # This file
├── research.md          # Phase 0 output (see below)
├── data-model.md        # Phase 1 output (see below)
├── quickstart.md        # Phase 1 output (see below)
├── contracts/           # N/A (internal SPA, no public API)
└── tasks.md             # Task list (already generated)
```

### Source Code (current state)

```text
src/
├── index.css               # ✅ TailwindCSS v4 @theme 디자인 토큰 정의
├── style.css               # ✅ 폰트, dp__theme_light 유지 (Vuetify 스타일 제거됨)
├── main.ts                 # ✅ Vuetify 제거 완료
├── components/
│   ├── ui/                 # ✅ 8개 Base 컴포넌트 생성 완료
│   │   ├── BaseDialog.vue
│   │   ├── BaseSelect.vue
│   │   ├── BaseTabs.vue
│   │   ├── BaseButton.vue
│   │   ├── BaseInput.vue
│   │   ├── BaseCard.vue
│   │   ├── BaseTable.vue
│   │   └── BasePagination.vue
│   ├── common/             # ✅ 12개 교체 완료
│   ├── MainLayout.vue      # ✅ Vuetify 레이아웃 제거 완료
│   ├── NavigationBar.vue   # ✅ native nav + Tailwind 완료
│   └── [domain]/           # ⏳ 16개 도메인 컴포넌트 교체 대기
├── pages/                  # ⏳ 28개 페이지 파일 교체 대기
│   ├── login/
│   ├── dashboard/
│   ├── station/
│   ├── charging-price/
│   ├── charging-history/
│   ├── charging-statistic/
│   ├── station-board/
│   ├── inquiry/
│   ├── account/
│   └── operator-manage/
└── styles/
    ├── settings.scss       # ⏳ Vuetify 변수 파일 — 삭제 대기
    └── utils.scss          # 반응형 mixins (Vuetify 미사용, 유지)
```

**Structure Decision**: Single Vue 3 SPA. 교체 단위는 파일 단위이며, 레이어 순서(Base → Common → Layout → Pages)를 준수.

## Complexity Tracking

| Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|-------------------------------------|
| Constitution II (Vuetify-First) 위반 | 이 feature 자체가 Vuetify → TailwindCSS 교체가 목적 | Vuetify를 유지하면 번들 크기 절감 불가(~4MB), 마이그레이션 목표 달성 불가 |
| 병행 로드 기간 (Phase 5~7) | 교체 미완료 파일 존재로 Vuetify 완전 제거 불가 | 파일 단위 순차 교체로 안전하게 진행 — 전체 일괄 교체 시 검증 불가 |

---

## Phase 0: Research

> 완료 상태. 아래는 연구 결과 요약.

### 결정 사항

**Decision**: Radix Vue + TailwindCSS v4 조합 선택  
**Rationale**: Radix Vue는 포커스 트랩, Esc 닫기, ARIA 접근성을 내장 제공. TailwindCSS v4는 `@theme` 블록으로 디자인 토큰을 CSS 변수로 관리, Vite 플러그인으로 빌드 통합.  
**Alternatives considered**:
- Headless UI (Vue 버전 미성숙, 커뮤니티 작음)
- shadcn-vue (Radix Vue 기반이지만 코드 복사 방식 — 커스터마이징 가능하나 유지보수 부담)

**Decision**: MDI 아이콘 → Lucide Vue Next  
**Rationale**: Tree-shakable, TypeScript 지원 우수, MDI와 시각적 유사성 높음.  
**Alternatives considered**: Heroicons (Vue 지원 약함), Phosphor (번들 크기 큼)

**Decision**: 단계별 점진적 교체 (Base → Common → Layout → Pages)  
**Rationale**: 각 단계에서 브라우저 검증 가능, 롤백 용이.

---

## Phase 1: Design & Contracts

### Data Model

> `data-model.md` 참조 (아래에 인라인으로 포함)

#### Base 컴포넌트 인터페이스 (✅ 생성 완료)

```typescript
// BaseDialog
interface BaseDialogProps {
  open: boolean
  maxWidth?: string  // default: '500px'
}
interface BaseDialogEmits {
  'update:open': [value: boolean]
}

// BaseSelect
interface BaseSelectProps {
  modelValue: any
  items: Array<{ title: string; value: any }>
  placeholder?: string
  density?: 'compact' | 'default'  // default: 'compact'
}
interface BaseSelectEmits {
  'update:modelValue': [value: any]
}

// BaseTabs
interface BaseTabsProps {
  modelValue: string | number
  tabs: Array<{ name: string; value: string }>
  minWidth?: number  // default: 44
}
interface BaseTabsEmits {
  'update:modelValue': [value: string | number]
}

// BaseButton
interface BaseButtonProps {
  variant?: 'default' | 'ghost' | 'outline' | 'flat'  // default: 'default'
  size?: 'sm' | 'md' | 'lg' | 'xl'  // default: 'md'
  disabled?: boolean
  type?: 'button' | 'submit' | 'reset'  // default: 'button'
}

// BaseInput
interface BaseInputProps {
  modelValue: string
  type?: string  // default: 'text'
  placeholder?: string
  disabled?: boolean
}
interface BaseInputEmits {
  'update:modelValue': [value: string]
}

// BaseCard
interface BaseCardProps {
  elevation?: number  // default: 0
}

// BaseTable
interface BaseTableHeader {
  key: string
  title: string
  sortable?: boolean
}
interface BaseTableProps {
  headers: BaseTableHeader[]
  items: Array<Record<string, any>>
  density?: 'compact' | 'default'  // default: 'compact'
}

// BasePagination
interface BasePaginationProps {
  page: number
  totalPages: number
  visible?: number  // default: 5
}
interface BasePaginationEmits {
  'update:page': [value: number]
}
```

#### 디자인 토큰 (src/index.css @theme) — ✅ 정의 완료

| 토큰 | 값 | 용도 |
|------|-----|------|
| `--color-primary` | `#2f3235` | 주요 텍스트, 버튼 배경 |
| `--color-bg-main` | `#f5f6f7` | 배경, 호버 상태 |
| `--color-border-1` | `#e1e1e3` | 기본 테두리 |
| `--color-error` | `#f63e3e` | 오류, 필수 입력 표시 |
| `--color-success` | `#18cc8b` | 성공 상태 |
| `--radius-dialog` | `16px` | 다이얼로그 border-radius |
| `--shadow-nav` | `0px 0px 2px rgba(0,0,0,0.15)` | 네비게이션 그림자 |

#### Vuetify Utility Class 매핑 규칙

| Vuetify | TailwindCSS |
|---------|-------------|
| `d-flex` | `flex` |
| `flex-column` | `flex-col` |
| `ga-{n}` | `gap-{n}` |
| `pa-{n}` | `p-{n}` |
| `ma-{n}` | `m-{n}` |
| `justify-space-between` | `justify-between` |
| `align-center` | `items-center` |
| `font-weight-bold` | `font-bold` |
| `text-h6` | `text-xl` |
| `fill-height` | `h-full` |
| `w-75` | `w-3/4` |
| `position-relative` | `relative` |
| `position-absolute` | `absolute` |
| `rounded-circle` | `rounded-full` |
| `elevation-0` | `shadow-none` |

#### MDI 아이콘 매핑

| MDI | Lucide Vue Next |
|-----|----------------|
| `mdi-eye` | `Eye` |
| `mdi-eye-off` | `EyeOff` |
| `mdi-check` | `Check` |
| `mdi-heart-outline` | `Heart` |
| `mdi-close` | `X` |
| `mdi-comment-processing-outline` | `MessageCircle` |

### Contracts

해당 없음 — 순수 프론트엔드 SPA. 외부 API 변경 없음.

### Quickstart (교체 작업 빠른 참조)

#### 페이지 교체 패턴

```vue
<!-- Before (Vuetify) -->
<v-btn color="#2f3235" @click="handler">저장</v-btn>

<!-- After (BaseButton) -->
<BaseButton variant="default" @click="handler">{{ t('COMMON.BUTTONS.SAVE') }}</BaseButton>
```

```vue
<!-- Before (Vuetify) -->
<v-select v-model="perPage" :items="PER_PAGE" density="compact" />

<!-- After (native select) -->
<select v-model="perPage" class="border border-border-1 rounded-lg px-3 py-1 text-sm bg-white">
  <option v-for="item in PER_PAGE" :key="item" :value="item">{{ item }}</option>
</select>
```

```vue
<!-- Before (Vuetify) -->
<v-pagination v-model="page" :length="totalPage" total-visible="5" />

<!-- After (BasePagination) -->
<BasePagination v-model:page="page" :total-pages="totalPage" :visible="5" />
```

```vue
<!-- Before (Vuetify) -->
<v-dialog v-model="isOpen" max-width="500">
  <v-card>...</v-card>
</v-dialog>

<!-- After (BaseDialog) -->
<BaseDialog :open="isOpen" max-width="500px" @update:open="isOpen = $event">
  ...
</BaseDialog>
```

#### 교체 후 검증 체크리스트

```bash
# 1. v- 태그 잔여 확인
grep -r "<v-" src --include="*.vue" | grep -v "<!--"

# 2. Vuetify utility class 잔여 확인
grep -r "d-flex\|ga-[0-9]\|text-h[0-9]\|font-weight-bold\|justify-space-between" src --include="*.vue"

# 3. i18n 빈 키 확인
npm run check-empty-keys

# 4. 빌드 성공 확인
npm run build
```

---

## 현재 진행 상태 (2026-05-06 기준)

### ✅ 완료

| Phase | 내용 | 증거 |
|-------|------|------|
| Phase 0 (US1) | 기반 설정 — TailwindCSS v4, Radix Vue, Lucide 설치 | `package.json`, `vite.config.ts`, `src/index.css` |
| Phase 2 (US2) | Base 컴포넌트 8개 생성 | `src/components/ui/` 8파일 존재 |
| Phase 3 (US3) | 공통 컴포넌트 12개 교체 | `src/components/common/` — `<v-*>` 태그 없음 |
| Phase 4 (US4) | 레이아웃 교체 | `main.ts` Vuetify 없음, `vite.config.ts` Vuetify 플러그인 없음 |
| Vuetify 제거 | `main.ts`, `vite.config.ts`, `package.json` | Vuetify 관련 import 없음 |

### ⏳ 남은 작업

| Phase | 내용 | 파일 수 |
|-------|------|---------|
| Phase 5 (US5-A) | 단순/표준 페이지 교체 | T026~T048 (23개 태스크) |
| Phase 6 (US5-B) | 복잡한 도메인 컴포넌트 교체 | T049~T066 (18개 태스크) |
| Phase 7 (US5-C) | 1,000줄 이상 복잡한 페이지 | T067~T072 (6개 태스크) |
| Phase 8 | 마무리 검증 및 잔여 파일 정리 | T073~T082 (10개 태스크) |

> **주의**: `<v-*>` 태그와 Vuetify utility class 잔여가 현재 0건으로 확인됨. 실제로 tasks.md의 T026~T082가 이미 처리된 상태일 수 있음. `/speckit-implement` 실행 전 각 파일 수동 확인 권장.

---

## 실행 전략

### 단기 (즉시)
1. 각 페이지 파일 실제 상태 확인 — `grep "<v-" src/pages/**/*.vue`
2. `npm run build` 실행하여 현재 빌드 상태 확인
3. `du -sh dist/` 번들 크기 측정

### 중기 (페이지 교체)
- tasks.md T026~T048: 단순 페이지 병렬 처리 가능
- tasks.md T049~T066: 도메인 컴포넌트 — 각 완료 후 해당 페이지 브라우저 검증
- tasks.md T067~T072: 1,000줄 이상 파일 — 섹션별 분할 처리

### 완료 기준
- `grep -r "<v-" src --include="*.vue"` → 0건
- `npm run build` → 에러 없음
- `du -sh dist/` → 5 MB 이하

 

기술적 허들: 사람이 개입해야 했던 순간들

마이그레이션 과정에서 AI의 효율을 극대화하기 위해 직접 해결하거나 가이드를 준 부분들입니다.

  • 컴포넌트 인터페이스 유지: v-data-table의 복잡한 슬롯(#item.{key})을 Radix Vue 환경에서 동일하게 동작하도록 동적 슬롯 구조로 재설계했습니다.
  • 거대 파일 처리: 약 2,000줄에 달하는 ChargingPriceReflectRegisterPage 같은 거대 파일은 컨텍스트 한계를 고려해 섹션별로 분할 처리하도록 유도했습니다.
  • 의존성 충돌: @intlify 플러그인 등과의 peer dependency 충돌 이슈를 --legacy-peer-deps 옵션으로 해결했습니다.

 

결과: 드라마틱한 번들 사이즈 감소와 성능 향상

15만 개의 토큰을 사용하며 1.5일 동안 집중한 결과, 프로젝트는 완전히 새로운 모습으로 탈바꿈했습니다.

항목크기비고
dist/ 전체5.3 MB기존 9.8 MB 대비 약 46% 감소
JS 합계1,632 KBgzip 기준 주요 청크 약 340 KB
CSS 합계193 KB기존 2.1 MB 대비 압도적 감소
빌드 시간4.66s빠른 생산성 확보

 
가장 큰 청크였던 index(vendor)가 241KB(gzip 82KB) 수준으로 줄어들었고, Tailwind v4가 적용된 auto 청크 역시 204KB로 최적화되었습니다.
 

마무리하며

이번 마이그레이션은 초기에 계획했던 일정보다 훨씬 빠르게 진행되었다. 실제 코드 치환 작업에 소요된 시간은 약 1.5일이었으며, 사용된 토큰은 15만 개 수준이었다. 사람이 직접 했다면 한 달은 소요되었을 분량이지만, 명확한 '플랜'과 '태스크'를 기반으로 에이전트를 활용함으로써 압도적인 속도로 마칠 수 있었다.
 
작업 완료 후 진행된 실제 QA 기간은 약 2일 정도가 소요되었다. AI가 생성한 코드의 일관성이 높았던 덕분에 디버깅 시간이 대폭 단축된 결과였다.
 
리팩토링을 통해 다시금 확인한 점은, AI와의 협업에서 개발자의 역할은 '코드 한 줄을 직접 고치는 것'보다 'AI가 오류 없이 실행할 수 있는 논리적 구조(Spec)를 설계하는 것'에 있다는 사실이다. 82개의 태스크가 하나씩 완료되는 과정은 단순히 도구를 쓰는 것을 넘어, 기술 부채를 효율적으로 관리하는 새로운 표준을 경험하는 과정이었다.