— Vue 3 + TanStack Query + MVVM + 함수형 리팩토링 스토리
“1,000라인이 넘는 통계 화면, 고쳐 쓸 것인가 갈아엎을 것인가?”
결론: **쪼개라. 역할로 쪼개고, 흐름은 ViewModel로 조율하라.**💪
왜 리팩토링이 필요했나
- 😵 비대해진 컴포넌트: 데이터 페칭, 가공, 차트, 모달, 상태가 한 파일에 혼재
- 🧪 테스트 불가: 사이드 이펙트 투성이, 시간 의존 로직이 난무
- 🧷 결합도↑: UI 변경이 서버 페칭/가공 로직까지 연쇄 파손
- 🐢 속도 저하: 비싼 연산이 렌더 사이클과 뒤엉켜 재계산 남발
목표
- ⚙️ MVVM으로 관심사 분리 (View ↔ ViewModel ↔ Model)
- 🧮 순수 함수 기반의 데이터 가공(예측 가능·테스트 가능)
- 🔁 TanStack Query로 페칭/캐싱/재시도 일원화
- 🧪 유닛 테스트 1순위: Model/Utils의 100% 커버리지 지향
아키텍처 한 장 요약
V[View (OrdersStatistics.vue)] --> VM[useOrdersViewModel]
VM --> F[useOrdersFilters]
VM --> Q[useOrdersQueries]
VM --> C[useOrdersCharts]
VM --> M[OrdersModel (pure)]
Q --> R[Repository ($api.*)]
R --> S[(REST API)]
- View: 렌더링/이벤트만
- ViewModel: “오케스트라의 지휘자” — 필터↔페칭↔가공↔차트 전체 흐름 통제
- Model(순수): 집계/변환/정렬 등 비즈니스 로직의 핵심
- Queries/Repository: 서버 데이터 페칭/캐싱/동기화 책임 집중
- Charts/Modals: UI 구성요소 제어를 별도 Composable로 격리
디렉토리 구조
src/
├─ views/analytics/OrdersStatistics.vue # UI만
├─ composables/orders/
│ ├─ useOrdersViewModel.ts # 조율 허브
│ ├─ useOrdersQueries.ts # 페칭/캐싱
│ ├─ useOrdersFilters.ts # 필터 상태
│ ├─ useOrdersCharts.ts # 차트 제어
│ └─ useOrdersModals.ts # 모달 제어
├─ models/orders/OrdersModel.ts # 순수 비즈니스 로직
├─ utils/orders/
│ ├─ dateUtils.ts
│ ├─ ordersCalculator.ts
│ ├─ chartDataTransformer.ts
│ └─ tests/… (vitest)
└─ types/
├─ orders.ts
└─ common.ts
Queries — TanStack Query로 “데이터 흐름”을 단순화
// src/composables/orders/useOrdersQueries.ts
import { useQuery } from '@tanstack/vue-query'
import { computed, Ref } from 'vue'
import type { OrdersFilter, Order } from '@/types/orders'
import { api } from '@/services/api' // axios 래퍼 가정
export const useOrdersQueries = (filters: Ref<OrdersFilter>) => {
const queryKey = computed(() => ['orders:list', filters.value])
const fetcher = async (): Promise<Order[]> => {
const { startDate, endDate, channel } = filters.value
const { data } = await api.get('/orders', { params: { startDate, endDate, channel }})
return data
}
const ordersQuery = useQuery({
queryKey,
queryFn: fetcher,
// ✅ “자동 의존” 전략 권장: 필터가 바뀌면 알아서 최신화
staleTime: 60_000,
gcTime: 5 * 60_000,
retry: 2,
})
return {
orders: ordersQuery.data, // Ref<Order[] | undefined>
isLoading: ordersQuery.isLoading,
refetch: ordersQuery.refetch,
}
}
의견(강함): 수동 트리거가 필요한 아주 드문 케이스가 아니라면,
enabled: true + queryKey에 필터 포함으로 “자연스러운 자동 업데이트”를 가져가라. ⚡
Model — 순수 함수로 예측 가능한 로직
// src/utils/orders/ordersCalculator.ts
import type { Order, AggregatedPoint, TimeUnit } from '@/types/orders'
import { eachBucketInclusive } from './dateUtils' // [bucketKey[]] 생성
export function aggregateOrders(
orders: Order[],
startDate: string,
endDate: string,
unit: TimeUnit
): AggregatedPoint[] {
// 1) 버킷 목록 구성
const buckets = eachBucketInclusive(startDate, endDate, unit) // ['2024-01-01', ...]
const map = new Map<string, { totalAmount: number; orderCount: number }>()
for (const b of buckets) map.set(b, { totalAmount: 0, orderCount: 0 })
// 2) 주문을 해당 버킷에 누적
for (const o of orders) {
const b = bucketOf(o.orderDate, unit) // '2024-01-01' / '2024-W02' / '2024-01'
const acc = map.get(b)
if (acc) {
acc.totalAmount += o.amount
acc.orderCount += 1
}
}
// 3) 정렬된 결과 반환
return buckets.map(b => ({
bucket: b,
totalAmount: map.get(b)!.totalAmount,
orderCount: map.get(b)!.orderCount,
}))
}
// 구현 예시(간략)
function bucketOf(iso: string, unit: TimeUnit): string {
// 날짜 문자열을 unit 단위 버킷 키로 변환하는 순수 함수
// (실 구현은 dayjs 등으로 대체 가능)
return unit === 'day' ? iso.slice(0,10)
: unit === 'week' ? iso.slice(0,4) + '-W' + weekNumber(iso)
: iso.slice(0,7)
}
function weekNumber(iso: string): string {
// 간단한 weekNo 계산(블로그 예시 수준으로 단순화)
return String(Math.ceil(parseInt(iso.slice(8,10)) / 7)).padStart(2, '0')
}
원칙: 입력 동일 ⇒ 출력 동일, 원본 불변성 유지, 사이드 이펙트 0
이렇게 해야 테스트가 ‘말도 안 되게’ 쉬워진다. ✅
Chart 변환 — 시각화와 계산을 분리하라
// src/utils/orders/chartDataTransformer.ts
import type { AggregatedPoint } from '@/types/orders'
export function toLineChartSeries(data: AggregatedPoint[]) {
return {
labels: data.map(d => d.bucket),
datasets: [
{ label: 'Revenue', data: data.map(d => d.totalAmount) },
{ label: 'Orders', data: data.map(d => d.orderCount) },
]
}
}
ViewModel — “지휘자”의 오케스트레이션
// src/composables/orders/useOrdersViewModel.ts
import { computed, ref } from 'vue'
import { useOrdersFilters } from './useOrdersFilters'
import { useOrdersQueries } from './useOrdersQueries'
import { aggregateOrders } from '@/utils/orders/ordersCalculator'
import { toLineChartSeries } from '@/utils/orders/chartDataTransformer'
export const useOrdersViewModel = () => {
const filters = useOrdersFilters()
const { orders, isLoading, refetch } = useOrdersQueries(filters.current)
const isUpdating = ref(false)
const chartState = ref<{ labels: string[]; datasets: any[] }>({ labels: [], datasets: [] })
const canApply = computed(() => !!filters.current.value.startDate && !!filters.current.value.endDate)
const handleApplyFilters = async () => {
if (!canApply.value || isUpdating.value) return
isUpdating.value = true
try {
await refetch() // 1) 최신 데이터 보장
const raw = orders.value ?? [] // 2) 원본
const f = filters.current.value // 3) 현재 필터
const agg = aggregateOrders(raw, f.startDate, f.endDate, f.unit) // 4) 가공(순수)
chartState.value = toLineChartSeries(agg) // 5) 차트 갱신
} finally {
isUpdating.value = false
}
}
return {
filters,
isLoading,
chartState,
handleApplyFilters,
}
}
핵심: ViewModel = 단일 진실 공급자(Single Orchestrator)
페칭/가공/표시의 타이밍을 한곳에서 통제한다.
View — 얇고 가벼운 UI
<!-- src/views/analytics/OrdersStatistics.vue -->
<script setup lang="ts">
import { useOrdersViewModel } from '@/composables/orders/useOrdersViewModel'
const vm = useOrdersViewModel()
</script>
<template>
<section>
<!-- 필터 -->
<div class="filters">
<input type="date" v-model="vm.filters.current.startDate" />
<input type="date" v-model="vm.filters.current.endDate" />
<select v-model="vm.filters.current.unit">
<option value="day">일</option>
<option value="week">주</option>
<option value="month">월</option>
</select>
<button :disabled="vm.isLoading" @click="vm.handleApplyFilters">
적용
</button>
</div>
<!-- 차트 (예: Chart.js/ECharts wrapper) -->
<OrdersLineChart :labels="vm.chartState.labels" :datasets="vm.chartState.datasets" />
<!-- 로딩/에러 토스트는 글로벌 컴포넌트에서 통일 -->
</section>
</template>
<style scoped>
.filters { display: flex; gap: 8px; }
</style>
테스트 — Model/Utils는 100%에 가까울수록 좋다
// src/utils/orders/tests/ordersCalculator.test.ts
import { describe, it, expect } from 'vitest'
import { aggregateOrders } from '../ordersCalculator'
describe('aggregateOrders', () => {
it('기간/단위에 따라 금액/건수를 집계한다', () => {
const mock = [
{ id: '1', orderDate: '2024-01-01T10:00:00Z', amount: 100, channel: 'web' },
{ id: '2', orderDate: '2024-01-01T12:00:00Z', amount: 50, channel: 'app' },
{ id: '3', orderDate: '2024-01-02T09:00:00Z', amount: 30, channel: 'store' },
]
const out = aggregateOrders(mock as any, '2024-01-01', '2024-01-02', 'day')
expect(out).toHaveLength(2)
expect(out[0]).toMatchObject({ bucket: '2024-01-01', totalAmount: 150, orderCount: 2 })
expect(out[1]).toMatchObject({ bucket: '2024-01-02', totalAmount: 30, orderCount: 1 })
})
})
UI를 건드리지 않고 핵심 로직만 번개처럼 테스트할 수 있다. ⚡
운영 팁 & 가드레일
- 🚦 경쟁 상태 방지: ViewModel에 isUpdating 가드, 필요 시 queryClient.cancelQueries
- 📦 캐시 전략: queryKey에 필터 포함 + staleTime/gcTime으로 네트워크 최소화
- 🧯 에러 UX 표준화: 글로벌 토스트 + “다시 시도” 버튼 일관 제공
- 🧩 교차 의존 금지: Composable 간 직접 호출 대신 항상 ViewModel 경유
안티패턴(피하자) ❌
- View에서 직접 API 호출/집계/차트 변환을 다 한다
- 서로 다른 Composable이 서로를 import해 그래프를 꼬이게 만든다
- any 남발, 암묵적 타입, 50줄이 넘는 함수
- 페칭 타이밍이 여러 곳에 흩어져 “언제 무엇이 갱신되는지” 아무도 모름
결과(실전 체감치)
- 🔻 라인 수 대폭 절감: 거대 파일 → 단일 책임 파일들로 분해
- ⚡ 반응성 노이즈 감소: 비싼 연산을 Model로 분리, 렌더 사이클 가벼워짐
- 🧪 테스트 가능한 구조: 순수 함수 덕분에 유닛 테스트가 빠르고 명확
- 🔧 유지보수성↑: UI/데이터/가공 어느 한 축만 바뀌어도 다른 축 영향 최소화
한 번의 ‘대청소’로 끝나지 않는다.
주 단위로 “순수 함수화 진척도”를 점검하고, 새 화면부터 MVVM 패턴을 기본값으로 삼아라. 팀 속도와 품질이 동시에 오른다. 🚀
체크리스트 ✅
- View는 UI/이벤트만 — 로직 금지
- ViewModel에서만 페칭/가공 타이밍을 통제
- Model은 순수 함수만 — 외부 상태 참조 금지
- Query queryKey에 필터 포함, staleTime/gcTime 설정
- 테스트는 Model/Utils 최우선, 통합/E2E는 핵심 시나리오만
마무리
MVVM과 함수형 분해는 “겉멋”이 아니다.
예측 가능성, 테스트 용이성, 팀 생산성을 동시에 올려주는 실전 생존 스킬이다.
다음 대시보드에 이 패턴을 바로 복제해보자. 복잡도는 내려가고, 속도와 자신감은 올라간다. 💪
'개발' 카테고리의 다른 글
| JVM 콜드 스타트와 웜업 전략 (0) | 2025.10.27 |
|---|---|
| null 초기화 전략 (0) | 2025.10.19 |
| 동시성 제어 실험기: Optimistic vs Pessimistic vs Redis Lock (0) | 2025.09.21 |
| 동시성 처리를 위한 락 종류 (0) | 2025.09.20 |
| 비동기 PDF 합성에서 트랜잭션 전파 실패 보완하기 (2) | 2025.08.05 |