본문 바로가기
개발

MVVM 구조로 리팩토링

by sudong 2025. 10. 10.

— 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과 함수형 분해는 “겉멋”이 아니다.
예측 가능성, 테스트 용이성, 팀 생산성을 동시에 올려주는 실전 생존 스킬이다.
다음 대시보드에 이 패턴을 바로 복제해보자. 복잡도는 내려가고, 속도와 자신감은 올라간다. 💪