Vue.js 를 배워보자/1. Vue.js 소개

Vue.js와 Vuetify로 구현하는 복잡한 반응형 데이터 처리

_Blue_Sky_ 2025. 6. 8. 20:30
728x90
 
안녕하세요, 프론트엔드 개발자 여러분! 오늘은 Vue 3의 <script setup> 문법과 TypeScript, 그리고 Vuetify를 활용해 복잡한 데이터 감시 로직을 구현하는 방법을 소개합니다. 실무에서 자주 마주치는 다중 속성 감시, 비동기 API 호출, 동적 UI 제어, 그리고 Vuetify 컴포넌트를 활용한 예제를 통해 실전 감각을 익혀보겠습니다. 이 글은 주문 관리 시스템을 예로 들어, watch를 사용해 다양한 상태를 관리하고 Vuetify로 UI를 동적으로 렌더링하는 방법을 다룹니다.
목표
  • Vue 3의 <script setup>: 간결하고 직관적인 컴포넌트 작성.
  • TypeScript: 타입 안정성을 보장하여 유지보수성을 높임.
  • Vuetify: 반응형 UI 컴포넌트를 활용해 동적 UI 구현.
  • 복잡한 watch 로직: 다중 속성 감시, 비동기 처리, 조건부 UI 제어.
예제 시나리오
전자상거래 플랫폼에서 주문 상세 페이지(OrderDetail)를 구현한다고 가정해봅시다. 주문 유형, 고객 정보, 배송 방식, 상품 목록, 결제 상태에 따라 UI를 동적으로 렌더링하고, 비동기 API 호출로 데이터를 검증하며, Vuetify 컴포넌트를 사용해 직관적인 UI를 제공합니다.
구현 코드
1. 기본 설정
먼저, Vuetify와 TypeScript를 사용하는 Vue 3 프로젝트를 설정합니다. Vuetify는 npm install vuetify @mdi/font로 설치하고, main.ts에서 플러그인을 등록합니다.
 
 
728x90
 
// main.ts
import { createApp } from 'vue';
import App from './App.vue';
import { createVuetify } from 'vuetify';
import 'vuetify/styles';
import '@mdi/font/css/materialdesignicons.css';

const vuetify = createVuetify();
const app = createApp(App);
app.use(vuetify).mount('#app');
 
 
2. 컴포넌트 코드
아래는 <script setup>을 사용한 OrderDetail.vue 컴포넌트입니다. watch로 다중 속성을 감시하고, Vuetify 컴포넌트를 활용해 UI를 렌더링합니다.
<template>
  <v-container fluid>
    <!-- 로딩 스피너 -->
    <v-progress-circular
      v-if="compoEvent.isLoading"
      indeterminate
      color="primary"
      class="ma-4"
    ></v-progress-circular>

    <!-- 고객 정보 카드 -->
    <v-card v-if="compoEvent.showCustomerInfo" class="mb-4">
      <v-card-title>고객 정보</v-card-title>
      <v-card-text>
        <v-text-field
          v-model="customerData.name"
          label="고객명"
          readonly
        ></v-text-field>
        <v-text-field
          v-model.number="customerData.creditLimit"
          label="신용 한도"
          readonly
        ></v-text-field>
      </v-card-text>
    </v-card>

    <!-- 긴급 배송 알림 -->
    <v-alert
      v-if="compoEvent.showExpressDelivery"
      type="info"
      title="긴급 배송 활성화"
      text="주문이 긴급 배송으로 설정되었습니다."
      class="mb-4"
    ></v-alert>

    <!-- 결제 경고 -->
    <v-alert
      v-if="compoEvent.showPaymentWarning"
      type="warning"
      title="결제 미완료"
      text="결제가 완료되지 않았습니다. 상품을 확인해주세요."
      class="mb-4"
    ></v-alert>

    <!-- 대량 할인 배너 -->
    <v-banner
      v-if="compoEvent.enableBulkDiscount"
      color="success"
      icon="mdi-cart"
      class="mb-4"
    >
      대량 할인 적용: {{ discountRate }}% 할인!
    </v-banner>

    <!-- 상품 목록 테이블 -->
    <v-data-table
      :items="orderDetail.productList"
      :headers="[
        { title: '상품명', key: 'name' },
        { title: '수량', key: 'quantity' },
        { title: '가격', key: 'price' },
      ]"
      class="elevation-1"
    ></v-data-table>

    <!-- 구독 경고 다이얼로그 -->
    <v-dialog v-model="compoEvent.showSubscriptionWarning" max-width="500">
      <v-card>
        <v-card-title>구독 자격 없음</v-card-title>
        <v-card-text>
          현재 고객은 구독 주문 자격이 없습니다. 관리자에게 문의하세요.
        </v-card-text>
        <v-card-actions>
          <v-btn color="primary" @click="compoEvent.showSubscriptionWarning = false">
            확인
          </v-btn>
        </v-card-actions>
      </v-card>
    </v-dialog>
  </v-container>
</template>

<script setup lang="ts">
import { ref, reactive, watch } from 'vue';
import { apiService } from '@/services/api'; // 가정된 API 서비스
import { VContainer, VCard, VCardTitle, VCardText, VTextField, VAlert, VBanner, VDataTable, VDialog, VCardActions, VBtn, VProgressCircular } from 'vuetify/components';

// 타입 정의
interface OrderDetail {
  orderType: 'NORMAL' | 'URGENT' | 'SUBSCRIPTION';
  customerId: string | null;
  deliveryMethod: 'STANDARD' | 'EXPRESS';
  productList: { name: string; quantity: number; price: number }[];
  paymentStatus: 'PENDING' | 'PAID' | 'FAILED';
}

interface CustomerData {
  name: string;
  isActive: boolean;
  creditLimit: number;
}

interface CompoEvent {
  isLoading: boolean;
  showCustomerInfo: boolean;
  showExpressDelivery: boolean;
  showPaymentWarning: boolean;
  enableBulkDiscount: boolean;
  showSubscriptionWarning: boolean;
}

// 상태 정의
const orderDetail = ref<OrderDetail>({
  orderType: 'NORMAL',
  customerId: null,
  deliveryMethod: 'STANDARD',
  productList: [],
  paymentStatus: 'PENDING',
});

const customerData = ref<CustomerData>({ name: '', isActive: false, creditLimit: 0 });
const discountRate = ref<number>(0);

const compoEvent = reactive<CompoEvent>({
  isLoading: false,
  showCustomerInfo: false,
  showExpressDelivery: false,
  showPaymentWarning: false,
  enableBulkDiscount: false,
  showSubscriptionWarning: false,
});

// watch 로직
watch(
  () => ({
    orderType: orderDetail.value.orderType,
    customerId: orderDetail.value.customerId,
    deliveryMethod: orderDetail.value.deliveryMethod,
    productList: orderDetail.value.productList,
    paymentStatus: orderDetail.value.paymentStatus,
  }),
  async (
    { orderType, customerId, deliveryMethod, productList, paymentStatus },
    { orderType: oldOrderType, customerId: oldCustomerId, deliveryMethod: oldDeliveryMethod, productList: oldProductList, paymentStatus: oldPaymentStatus }
  ) => {
    try {
      // 변경사항 로깅
      console.log(`주문 유형 변경: [${oldOrderType}] -> [${orderType}]`);
      console.log(`고객 ID 변경: [${oldCustomerId}] -> [${customerId}]`);
      console.log(`배송 방식 변경: [${oldDeliveryMethod}] -> [${deliveryMethod}]`);
      console.log(`결제 상태 변경: [${oldPaymentStatus}] -> [${paymentStatus}]`);
      console.log(`상품 목록 변경: ${JSON.stringify(oldProductList)} -> ${JSON.stringify(productList)}`);

      // 1. 고객 정보 조회
      if (customerId && customerId !== oldCustomerId) {
        compoEvent.isLoading = true;
        try {
          const data = await apiService.fetchCustomerDetails(customerId);
          customerData.value = data;
          compoEvent.showCustomerInfo = data.isActive && data.creditLimit > 0;
        } catch (error) {
          console.error(`고객 정보 조회 실패: ${(error as Error).message}`);
          compoEvent.showCustomerInfo = false;
          alert('고객 정보를 불러오지 못했습니다.');
        } finally {
          compoEvent.isLoading = false;
        }
      }

      // 2. 긴급 배송 조건
      compoEvent.showExpressDelivery =
        deliveryMethod === 'EXPRESS' &&
        orderType === 'URGENT' &&
        productList.length > 0;

      // 3. 결제 상태 및 대량 할인
      if (paymentStatus === 'PAID' && productList.length >= 5) {
        compoEvent.enableBulkDiscount = true;
        const discount = await apiService.calculateBulkDiscount(productList);
        discountRate.value = discount.discountRate;
      } else {
        compoEvent.enableBulkDiscount = false;
        discountRate.value = 0;
      }

      // 4. 결제 미완료 경고
      compoEvent.showPaymentWarning =
        paymentStatus === 'PENDING' && productList.length > 0;

      // 5. 구독 주문 검증
      if (orderType !== oldOrderType && orderType === 'SUBSCRIPTION') {
        const isEligible = await apiService.checkSubscriptionEligibility(customerId);
        compoEvent.showSubscriptionWarning = !isEligible;
      } else {
        compoEvent.showSubscriptionWarning = false;
      }
    } catch (error) {
      console.error(`watch 처리 중 오류: ${(error as Error).message}`);
      alert('주문 처리 중 오류가 발생했습니다.');
    }
  },
  { deep: true, immediate: true }
);
</script>
코드 설명
1. <script setup>과 TypeScript
  • 간결함: <script setup>defineProps, defineEmits 등을 생략하고 바로 반응형 상태를 정의할 수 있어 코드가 간결합니다.
  • 타입 안정성: TypeScript의 인터페이스(OrderDetail, CustomerData, CompoEvent)를 정의해 데이터 구조를 명확히 하고, 런타임 에러를 줄입니다.
  • Vuetify 컴포넌트: VContainer, VCard, VAlert, VDataTable, VDialog 등을 사용해 반응형 UI를 구현.
2. watch 로직
  • 다중 속성 감시: orderDetail 객체의 여러 속성(orderType, customerId, deliveryMethod, productList, paymentStatus)을 한 번에 감시.
  • 비동기 처리: 고객 정보 조회, 대량 할인 계산, 구독 자격 검증을 비동기 API 호출로 처리.
  • 상태 관리: compoEventreactive로 정의해 UI 상태를 동적으로 제어.
  • 옵션:
    • deep: true: 배열/객체의 깊은 변경 감지.
    • immediate: true: 컴포넌트 마운트 시 즉시 실행.
3. Vuetify UI
  • 로딩 스피너 (VProgressCircular): API 호출 중 사용자에게 로딩 상태 표시.
  • 고객 정보 카드 (VCard): 고객 정보 표시, v-if로 조건부 렌더링.
  • 알림 및 경고 (VAlert, VBanner): 긴급 배송, 결제 미완료, 대량 할인 상태를 직관적으로 표시.
  • 상품 목록 테이블 (VDataTable): 상품 목록을 테이블 형태로 렌더링.
  • 다이얼로그 (VDialog): 구독 자격 오류 시 사용자에게 알림.
4. 비즈니스 로직
  • 고객 정보 조회: 고객 ID 변경 시 API 호출로 최신 데이터 가져옴.
  • 긴급 배송: 주문 유형이 URGENT이고 배송 방식이 EXPRESS일 때 활성화.
  • 대량 할인: 상품 5개 이상, 결제 완료 시 할인 적용.
  • 결제 경고: 결제 미완료 시 경고 표시.
  • 구독 검증: 구독 주문 시 고객 자격 확인.
실무 팁
  1. 성능 최적화:
    • deep: true는 성능에 영향을 줄 수 있으므로, 꼭 필요한 속성만 감시하도록 설계.
    • API 호출이 빈번할 경우 debounce를 적용해 요청을 제한.
  2. 에러 핸들링:
    • try-catch로 API 호출 에러를 처리해 사용자 경험을 개선.
    • 사용자에게 적절한 피드백(알림, 다이얼로그) 제공.
  3. Vuetify 활용:
    • Vuetify의 테마와 스타일을 커스터마이징해 브랜드에 맞는 UI 제공.
    • 반응형 그리드(VContainer, VRow, VCol)로 다양한 화면 크기에 대응.
  4. TypeScript:
    • 인터페이스를 명확히 정의해 코드 가독성과 유지보수성을 높임.
    • null 체크와 타입 가드를 활용해 안전한 코드 작성.
결론
이번 예제는 Vue 3의 <script setup>, TypeScript, Vuetify를 활용해 복잡한 watch 로직과 동적 UI를 구현하는 방법을 다뤘습니다. 주문 관리 시스템처럼 다중 속성 감시와 비동기 처리가 필요한 실무 환경에서 이 패턴은 큰 도움이 됩니다. Vuetify의 풍부한 컴포넌트를 활용하면 사용자 친화적인 UI를 빠르게 구축할 수 있죠. 
728x90