API 통신이 왜 중요한가?
현대 웹 애플리케이션은 프론트엔드와 백엔드가 분리되어 있습니다. Vue.js는 사용자 인터페이스를 담당하고, 실제 데이터는 서버의 API를 통해 가져옵니다.
🌐 사용자 브라우저 (Vue.js)
↕️ HTTP 통신
🖥️ 웹 서버 (REST API)
↕️
🗄️ 데이터베이스
일반적인 API 통신 패턴
// 1. 데이터 조회 (GET)
const products = await axios.get('/api/products');
// 2. 데이터 생성 (POST)
const newProduct = await axios.post('/api/products', productData);
// 3. 데이터 수정 (PUT/PATCH)
const updated = await axios.put(`/api/products/${id}`, updatedData);
// 4. 데이터 삭제 (DELETE)
await axios.delete(`/api/products/${id}`);
Axios 기본 설정과 사용법
1. Axios 설치 및 기본 설정
npm install axios
// api/index.js - Axios 인스턴스 생성
import axios from 'axios';
const apiClient = axios.create({
baseURL: 'https://api.example.com', // API 서버 주소
timeout: 10000, // 10초 타임아웃
headers: {
'Content-Type': 'application/json'
}
});
// 요청 인터셉터 (요청 전 처리)
apiClient.interceptors.request.use(
(config) => {
// 토큰이 있으면 헤더에 추가
const token = localStorage.getItem('accessToken');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
console.log('📤 API 요청:', config.method?.toUpperCase(), config.url);
return config;
},
(error) => {
console.error('❌ 요청 오류:', error);
return Promise.reject(error);
}
);
// 응답 인터셉터 (응답 후 처리)
apiClient.interceptors.response.use(
(response) => {
console.log('📥 API 응답:', response.status, response.config.url);
return response;
},
(error) => {
console.error('❌ 응답 오류:', error.response?.status, error.message);
// 401 에러 시 로그인 페이지로 리다이렉트
if (error.response?.status === 401) {
localStorage.removeItem('accessToken');
window.location.href = '/login';
}
return Promise.reject(error);
}
);
export default apiClient;
2. API 서비스 모듈화
// api/productService.js - 상품 관련 API 모음
import apiClient from './index.js';
export const productService = {
// 전체 상품 조회
async getProducts(params = {}) {
const response = await apiClient.get('/products', { params });
return response.data;
},
// 특정 상품 조회
async getProduct(id) {
const response = await apiClient.get(`/products/${id}`);
return response.data;
},
// 상품 생성
async createProduct(productData) {
const response = await apiClient.post('/products', productData);
return response.data;
},
// 상품 수정
async updateProduct(id, productData) {
const response = await apiClient.put(`/products/${id}`, productData);
return response.data;
},
// 상품 삭제
async deleteProduct(id) {
await apiClient.delete(`/products/${id}`);
return true;
},
// 상품 검색
async searchProducts(keyword, filters = {}) {
const params = {
search: keyword,
...filters
};
const response = await apiClient.get('/products/search', { params });
return response.data;
}
};
Vue 3 Composition API와 Axios 활용
1. 기본적인 데이터 조회
<template>
<div class="product-list">
<!-- 로딩 상태 -->
<div v-if="isLoading" class="loading">
상품 목록을 불러오는 중...
</div>
<!-- 에러 상태 -->
<div v-else-if="error" class="error">
❌ {{ error }}
<button @click="loadProducts">다시 시도</button>
</div>
<!-- 상품 목록 -->
<div v-else class="products">
<div
v-for="product in products"
:key="product.id"
class="product-card"
>
<h3>{{ product.name }}</h3>
<p>{{ formatPrice(product.price) }}</p>
<button @click="addToCart(product)">장바구니 담기</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { productService } from '@/api/productService';
interface Product {
id: number;
name: string;
price: number;
category: string;
}
// 반응형 상태
const products = ref<Product[]>([]);
const isLoading = ref<boolean>(false);
const error = ref<string>("");
// 상품 목록 조회
const loadProducts = async () => {
isLoading.value = true;
error.value = "";
try {
const data = await productService.getProducts();
products.value = data.products || [];
} catch (err: any) {
error.value = err.message || "상품을 불러오는데 실패했습니다.";
} finally {
isLoading.value = false;
}
};
// 장바구니 추가
const addToCart = async (product: Product) => {
try {
await cartService.addItem(product.id, 1);
alert(`${product.name}이 장바구니에 추가되었습니다.`);
} catch (err: any) {
alert("장바구니 추가에 실패했습니다.");
}
};
// 가격 포맷팅
const formatPrice = (price: number): string => {
return `₩${price.toLocaleString()}`;
};
// 컴포넌트 마운트 시 데이터 로드
onMounted(() => {
loadProducts();
});
</script>
2. 검색 기능 구현
<template>
<div class="product-search">
<!-- 검색 입력 -->
<div class="search-bar">
<input
v-model="searchKeyword"
@input="handleSearch"
placeholder="상품명을 입력하세요..."
class="search-input"
/>
<select v-model="selectedCategory" @change="handleSearch" class="category-filter">
<option value="">전체 카테고리</option>
<option v-for="category in categories" :key="category" :value="category">
{{ category }}
</option>
</select>
</div>
<!-- 검색 결과 -->
<div class="search-results">
<p v-if="isSearching">검색 중...</p>
<p v-else-if="searchResults.length === 0 && searchKeyword">
"{{ searchKeyword }}"에 대한 검색 결과가 없습니다.
</p>
<div v-else class="results-grid">
<ProductCard
v-for="product in searchResults"
:key="product.id"
:product="product"
@add-to-cart="handleAddToCart"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue';
import { debounce } from 'lodash';
import { productService } from '@/api/productService';
// 검색 상태
const searchKeyword = ref<string>("");
const selectedCategory = ref<string>("");
const searchResults = ref<Product[]>([]);
const isSearching = ref<boolean>(false);
const categories = ref<string[]>(['전자제품', '의류', '도서', '스포츠']);
// 디바운스된 검색 함수 (300ms 딜레이)
const debouncedSearch = debounce(async () => {
if (!searchKeyword.value.trim()) {
searchResults.value = [];
return;
}
isSearching.value = true;
try {
const filters = selectedCategory.value ?
{ category: selectedCategory.value } : {};
const data = await productService.searchProducts(
searchKeyword.value,
filters
);
searchResults.value = data.products || [];
} catch (error) {
console.error('검색 실패:', error);
searchResults.value = [];
} finally {
isSearching.value = false;
}
}, 300);
// 검색 핸들러
const handleSearch = () => {
debouncedSearch();
};
// 검색어 변경 감지
watch(searchKeyword, (newKeyword) => {
if (newKeyword.length >= 2) {
handleSearch();
} else {
searchResults.value = [];
}
});
// 장바구니 추가 핸들러
const handleAddToCart = async (product: Product) => {
try {
await productService.addToCart(product.id);
// 성공 알림 처리
} catch (error) {
// 에러 처리
}
};
</script>
3. CRUD 작업 구현
<template>
<div class="product-management">
<!-- 상품 추가 폼 -->
<form @submit.prevent="handleSubmit" class="product-form">
<h2>{{ isEditing ? '상품 수정' : '상품 추가' }}</h2>
<div class="form-group">
<label>상품명</label>
<input
v-model="formData.name"
required
placeholder="상품명을 입력하세요"
/>
</div>
<div class="form-group">
<label>가격</label>
<input
v-model.number="formData.price"
type="number"
required
min="0"
placeholder="가격을 입력하세요"
/>
</div>
<div class="form-group">
<label>카테고리</label>
<select v-model="formData.category" required>
<option value="">카테고리 선택</option>
<option value="electronics">전자제품</option>
<option value="clothing">의류</option>
<option value="books">도서</option>
</select>
</div>
<div class="form-group">
<label>설명</label>
<textarea
v-model="formData.description"
rows="4"
placeholder="상품 설명을 입력하세요"
></textarea>
</div>
<div class="form-actions">
<button type="submit" :disabled="isSubmitting">
{{ isSubmitting ? '처리중...' : (isEditing ? '수정' : '추가') }}
</button>
<button type="button" @click="resetForm">취소</button>
</div>
</form>
<!-- 상품 목록 -->
<div class="product-table">
<h2>상품 목록</h2>
<table>
<thead>
<tr>
<th>ID</th>
<th>상품명</th>
<th>가격</th>
<th>카테고리</th>
<th>액션</th>
</tr>
</thead>
<tbody>
<tr v-for="product in products" :key="product.id">
<td>{{ product.id }}</td>
<td>{{ product.name }}</td>
<td>{{ formatPrice(product.price) }}</td>
<td>{{ product.category }}</td>
<td>
<button @click="editProduct(product)" class="edit-btn">수정</button>
<button @click="deleteProduct(product.id)" class="delete-btn">삭제</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue';
import { productService } from '@/api/productService';
interface ProductForm {
name: string;
price: number;
category: string;
description: string;
}
// 상태 관리
const products = ref<Product[]>([]);
const isEditing = ref<boolean>(false);
const editingId = ref<number | null>(null);
const isSubmitting = ref<boolean>(false);
// 폼 데이터
const formData = reactive<ProductForm>({
name: '',
price: 0,
category: '',
description: ''
});
// 상품 목록 로드
const loadProducts = async () => {
try {
const data = await productService.getProducts();
products.value = data.products || [];
} catch (error) {
console.error('상품 목록 로드 실패:', error);
}
};
// 폼 제출 처리
const handleSubmit = async () => {
isSubmitting.value = true;
try {
if (isEditing.value && editingId.value) {
// 수정
await productService.updateProduct(editingId.value, formData);
alert('상품이 수정되었습니다.');
} else {
// 생성
await productService.createProduct(formData);
alert('상품이 추가되었습니다.');
}
resetForm();
await loadProducts(); // 목록 새로고침
} catch (error: any) {
alert(`실패: ${error.message}`);
} finally {
isSubmitting.value = false;
}
};
// 상품 수정
const editProduct = (product: Product) => {
isEditing.value = true;
editingId.value = product.id;
// 폼에 기존 데이터 설정
Object.assign(formData, {
name: product.name,
price: product.price,
category: product.category,
description: product.description || ''
});
};
// 상품 삭제
const deleteProduct = async (id: number) => {
if (!confirm('정말 삭제하시겠습니까?')) return;
try {
await productService.deleteProduct(id);
alert('상품이 삭제되었습니다.');
await loadProducts(); // 목록 새로고침
} catch (error: any) {
alert(`삭제 실패: ${error.message}`);
}
};
// 폼 초기화
const resetForm = () => {
isEditing.value = false;
editingId.value = null;
Object.assign(formData, {
name: '',
price: 0,
category: '',
description: ''
});
};
// 가격 포맷팅
const formatPrice = (price: number): string => {
return `₩${price.toLocaleString()}`;
};
// 컴포넌트 마운트 시 데이터 로드
onMounted(() => {
loadProducts();
});
</script>
고급 패턴과 최적화
1. 커스텀 훅으로 API 로직 재사용
// composables/useApi.ts
import { ref } from 'vue';
interface UseApiOptions {
immediate?: boolean;
onSuccess?: (data: any) => void;
onError?: (error: any) => void;
}
export function useApi<T>(
apiFunction: (...args: any[]) => Promise<T>,
options: UseApiOptions = {}
) {
const data = ref<T | null>(null);
const isLoading = ref<boolean>(false);
const error = ref<string>("");
const execute = async (...args: any[]) => {
isLoading.value = true;
error.value = "";
try {
const result = await apiFunction(...args);
data.value = result;
if (options.onSuccess) {
options.onSuccess(result);
}
return result;
} catch (err: any) {
const errorMessage = err.response?.data?.message || err.message || '요청에 실패했습니다.';
error.value = errorMessage;
if (options.onError) {
options.onError(err);
}
throw err;
} finally {
isLoading.value = false;
}
};
// immediate 옵션이 true면 즉시 실행
if (options.immediate) {
execute();
}
return {
data,
isLoading,
error,
execute
};
}
사용 예시:
<script setup lang="ts">
import { useApi } from '@/composables/useApi';
import { productService } from '@/api/productService';
// 상품 목록 조회
const {
data: products,
isLoading: isLoadingProducts,
error: productsError,
execute: loadProducts
} = useApi(productService.getProducts, {
immediate: true,
onSuccess: (data) => {
console.log(`${data.products.length}개 상품 로드됨`);
}
});
// 상품 검색
const {
data: searchResults,
isLoading: isSearching,
execute: searchProducts
} = useApi(productService.searchProducts);
// 검색 실행
const handleSearch = (keyword: string) => {
searchProducts(keyword, { category: 'electronics' });
};
</script>
2. 에러 처리 및 재시도 로직
// composables/useApiWithRetry.ts
import { ref } from 'vue';
interface RetryOptions {
maxRetries?: number;
retryDelay?: number;
retryCondition?: (error: any) => boolean;
}
export function useApiWithRetry<T>(
apiFunction: (...args: any[]) => Promise<T>,
retryOptions: RetryOptions = {}
) {
const {
maxRetries = 3,
retryDelay = 1000,
retryCondition = (error) => error.response?.status >= 500
} = retryOptions;
const data = ref<T | null>(null);
const isLoading = ref<boolean>(false);
const error = ref<string>("");
const retryCount = ref<number>(0);
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
const execute = async (...args: any[]): Promise<T> => {
isLoading.value = true;
error.value = "";
retryCount.value = 0;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
const result = await apiFunction(...args);
data.value = result;
return result;
} catch (err: any) {
retryCount.value = attempt;
// 재시도 조건 확인
if (attempt < maxRetries && retryCondition(err)) {
console.log(`API 호출 실패, ${retryDelay}ms 후 재시도... (${attempt + 1}/${maxRetries})`);
await sleep(retryDelay * (attempt + 1)); // 지수 백오프
continue;
}
// 최종 실패
error.value = err.response?.data?.message || err.message || '요청에 실패했습니다.';
throw err;
} finally {
if (attempt === maxRetries) {
isLoading.value = false;
}
}
}
throw new Error('예상치 못한 오류가 발생했습니다.');
};
return {
data,
isLoading,
error,
retryCount,
execute
};
}
3. 캐싱 기능 구현
// utils/apiCache.ts
class ApiCache {
private cache = new Map<string, { data: any; timestamp: number; ttl: number }>();
set(key: string, data: any, ttl: number = 5 * 60 * 1000) { // 기본 5분 TTL
this.cache.set(key, {
data,
timestamp: Date.now(),
ttl
});
}
get(key: string): any | null {
const item = this.cache.get(key);
if (!item) return null;
// TTL 체크
if (Date.now() - item.timestamp > item.ttl) {
this.cache.delete(key);
return null;
}
return item.data;
}
clear() {
this.cache.clear();
}
delete(key: string) {
this.cache.delete(key);
}
}
export const apiCache = new ApiCache();
// composables/useCachedApi.ts
import { apiCache } from '@/utils/apiCache';
export function useCachedApi<T>(
apiFunction: (...args: any[]) => Promise<T>,
cacheKey: string,
ttl: number = 5 * 60 * 1000 // 5분
) {
const data = ref<T | null>(null);
const isLoading = ref<boolean>(false);
const error = ref<string>("");
const fromCache = ref<boolean>(false);
const execute = async (...args: any[]): Promise<T> => {
const fullCacheKey = `${cacheKey}_${JSON.stringify(args)}`;
// 캐시에서 확인
const cachedData = apiCache.get(fullCacheKey);
if (cachedData) {
data.value = cachedData;
fromCache.value = true;
return cachedData;
}
// 캐시에 없으면 API 호출
isLoading.value = true;
error.value = "";
fromCache.value = false;
try {
const result = await apiFunction(...args);
data.value = result;
// 캐시에 저장
apiCache.set(fullCacheKey, result, ttl);
return result;
} catch (err: any) {
error.value = err.message;
throw err;
} finally {
isLoading.value = false;
}
};
return {
data,
isLoading,
error,
fromCache,
execute
};
}
실전 활용 예제 - 쇼핑몰 상품 관리
<template>
<div class="product-store">
<!-- 헤더 -->
<header class="store-header">
<h1>상품 관리 시스템</h1>
<div class="header-actions">
<button @click="showAddForm = true" class="add-btn">상품 추가</button>
<button @click="refreshProducts" class="refresh-btn">새로고침</button>
</div>
</header>
<!-- 검색 바 -->
<div class="search-section">
<input
v-model="searchKeyword"
placeholder="상품명 또는 설명 검색..."
class="search-input"
/>
<select v-model="filterCategory" class="category-filter">
<option value="">전체 카테고리</option>
<option value="electronics">전자제품</option>
<option value="clothing">의류</option>
<option value="books">도서</option>
</select>
<div class="search-stats">
{{ filteredProducts.length }}개 상품 ({{ fromCache ? '캐시됨' : '실시간' }})
</div>
</div>
<!-- 로딩 상태 -->
<div v-if="isLoading" class="loading-state">
<div class="spinner"></div>
<p>상품 목록을 불러오는 중...</p>
<p v-if="retryCount > 0">재시도 중... ({{ retryCount }}/3)</p>
</div>
<!-- 에러 상태 -->
<div v-else-if="error" class="error-state">
<h3>❌ 오류가 발생했습니다</h3>
<p>{{ error }}</p>
<button @click="loadProducts">다시 시도</button>
</div>
<!-- 상품 목록 -->
<div v-else class="products-grid">
<div
v-for="product in filteredProducts"
:key="product.id"
class="product-card"
>
<div class="card-header">
<h3>{{ product.name }}</h3>
<span class="category-tag">{{ getCategoryName(product.category) }}</span>
</div>
<div class="card-content">
<p class="price">{{ formatPrice(product.price) }}</p>
<p class="description">{{ product.description }}</p>
</div>
<div class="card-actions">
<button @click="editProduct(product)" class="edit-btn">수정</button>
<button @click="deleteProduct(product)" class="delete-btn">삭제</button>
<button @click="duplicateProduct(product)" class="duplicate-btn">복제</button>
</div>
</div>
</div>
<!-- 상품 추가/수정 모달 -->
<ProductFormModal
v-if="showAddForm || editingProduct"
:product="editingProduct"
:is-loading="isSubmitting"
@submit="handleProductSubmit"
@cancel="closeForm"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue';
import { useApiWithRetry } from '@/composables/useApiWithRetry';
import { useCachedApi } from '@/composables/useCachedApi';
import { productService } from '@/api/productService';
interface Product {
id: number;
name: string;
price: number;
category: string;
description: string;
createdAt: string;
}
// UI 상태
const showAddForm = ref<boolean>(false);
const editingProduct = ref<Product | null>(null);
const searchKeyword = ref<string>("");
const filterCategory = ref<string>("");
// API 상태 (캐싱과 재시도 기능 포함)
const {
data: products,
isLoading,
error,
retryCount,
execute: loadProducts
} = useApiWithRetry(productService.getProducts, {
maxRetries: 3,
retryDelay: 1000,
retryCondition: (error) => error.response?.status >= 500
});
// 캐시된 검색 API
const {
data: searchResults,
isLoading: isSearching,
fromCache,
execute: searchProducts
} = useCachedApi(productService.searchProducts, 'product_search', 2 * 60 * 1000); // 2분 캐시
// 상품 제출 상태
const isSubmitting = ref<boolean>(false);
// 필터링된 상품 목록
const filteredProducts = computed(() => {
if (!products.value) return [];
let result = products.value.products || [];
// 카테고리 필터
if (filterCategory.value) {
result = result.filter(p => p.category === filterCategory.value);
}
// 키워드 검색
if (searchKeyword.value) {
const keyword = searchKeyword.value.toLowerCase();
result = result.filter(p =>
p.name.toLowerCase().includes(keyword) ||
p.description.toLowerCase().includes(keyword)
);
}
return result;
});
// 검색 기능 (디바운스)
let searchTimeout: NodeJS.Timeout;
watch(searchKeyword, (newKeyword) => {
clearTimeout(searchTimeout);
if (newKeyword.trim()) {
searchTimeout = setTimeout(() => {
searchProducts(newKeyword, { category: filterCategory.value });
}, 300);
}
});
// 상품 목록 새로고침
const refreshProducts = async () => {
try {
await loadProducts();
showSuccessMessage('상품 목록이 새로고침되었습니다.');
} catch (error) {
// 에러는 useApiWithRetry에서 처리됨
}
};
// 상품 추가/수정 제출
const handleProductSubmit = async (productData: Omit<Product, 'id' | 'createdAt'>) => {
isSubmitting.value = true;
try {
if (editingProduct.value) {
// 수정
await productService.updateProduct(editingProduct.value.id, productData);
showSuccessMessage('상품이 수정되었습니다.');
} else {
// 추가
await productService.createProduct(productData);
showSuccessMessage('상품이 추가되었습니다.');
}
closeForm();
await refreshProducts();
} catch (error: any) {
showErrorMessage(error.response?.data?.message || '상품 저장에 실패했습니다.');
} finally {
isSubmitting.value = false;
}
};
// 상품 수정
const editProduct = (product: Product) => {
editingProduct.value = { ...product };
showAddForm.value = false;
};
// 상품 삭제
const deleteProduct = async (product: Product) => {
if (!confirm(`"${product.name}"을 정말 삭제하시겠습니까?`)) return;
try {
await productService.deleteProduct(product.id);
showSuccessMessage('상품이 삭제되었습니다.');
await refreshProducts();
} catch (error: any) {
showErrorMessage(error.response?.data?.message || '상품 삭제에 실패했습니다.');
}
};
// 상품 복제
const duplicateProduct = (product: Product) => {
editingProduct.value = {
...product,
id: 0, // 새 상품으로 처리
name: `${product.name} (복사본)`
};
};
// 폼 닫기
const closeForm = () => {
showAddForm.value = false;
editingProduct.value = null;
};
// 카테고리 이름 변환
const getCategoryName = (category: string): string => {
const categoryMap: Record<string, string> = {
electronics: '전자제품',
clothing: '의류',
books: '도서'
};
return categoryMap[category] || category;
};
// 가격 포맷팅
const formatPrice = (price: number): string => {
return `₩${price.toLocaleString()}`;
};
// 알림 메시지 (실제로는 toast 라이브러리 사용 권장)
const showSuccessMessage = (message: string) => {
alert(`✅ ${message}`);
};
const showErrorMessage = (message: string) => {
alert(`❌ ${message}`);
};
// 초기 데이터 로드
loadProducts();
</script>
<style scoped>
.product-store {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.store-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30px;
padding-bottom: 20px;
border-bottom: 2px solid #e0e0e0;
}
.header-actions {
display: flex;
gap: 10px;
}
.add-btn, .refresh-btn {
padding: 10px 20px;
border: none;
border-radius: 6px;
cursor: pointer;
font-weight: bold;
}
.add-btn {
background: #007bff;
color: white;
}
.refresh-btn {
background: #28a745;
color: white;
}
.search-section {
display: flex;
gap: 15px;
align-items: center;
margin-bottom: 30px;
padding: 20px;
background: #f8f9fa;
border-radius: 8px;
}
.search-input {
flex: 1;
padding: 10px 15px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 16px;
}
.category-filter {
padding: 10px 15px;
border: 1px solid #ddd;
border-radius: 6px;
min-width: 150px;
}
.search-stats {
font-size: 14px;
color: #666;
white-space: nowrap;
}
.loading-state, .error-state {
text-align: center;
padding: 60px 20px;
}
.spinner {
width: 40px;
height: 40px;
border: 4px solid #f3f3f3;
border-top: 4px solid #007bff;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 20px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.error-state button {
margin-top: 20px;
padding: 10px 25px;
background: #dc3545;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
}
.products-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 20px;
}
.product-card {
background: white;
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
transition: transform 0.2s, box-shadow 0.2s;
}
.product-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 15px;
}
.card-header h3 {
margin: 0;
font-size: 18px;
color: #333;
}
.category-tag {
background: #e9ecef;
color: #495057;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: bold;
}
.card-content .price {
font-size: 20px;
font-weight: bold;
color: #007bff;
margin: 10px 0;
}
.card-content .description {
color: #666;
font-size: 14px;
line-height: 1.4;
margin-bottom: 15px;
}
.card-actions {
display: flex;
gap: 8px;
}
.card-actions button {
flex: 1;
padding: 8px 12px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
font-weight: bold;
}
.edit-btn {
background: #ffc107;
color: #212529;
}
.delete-btn {
background: #dc3545;
color: white;
}
.duplicate-btn {
background: #6c757d;
color: white;
}
</style>
핵심
API 통신의 핵심 패턴
| 패턴 | 목적 | 구현 방법 |
| 기본 CRUD | 데이터 생성/조회/수정/삭제 | axios + async/await |
| 에러 처리 | 안정적인 사용자 경험 | try/catch + 사용자 친화적 메시지 |
| 로딩 상태 | 사용자에게 진행 상황 표시 | ref<boolean> + UI 피드백 |
| 재시도 로직 | 일시적 오류 복구 | 지수 백오프 + 조건부 재시도 |
| 캐싱 | 성능 최적화 | 메모리 캐시 + TTL |
| 디바운싱 | 과도한 API 호출 방지 | lodash.debounce |
실무에서 꼭 필요한 기능들
- 인터셉터 설정: 토큰 자동 추가, 공통 에러 처리
- 재사용 가능한 훅: useApi, useApiWithRetry 등
- 타입스크립트: API 응답 타입 정의로 안전성 확보
- 에러 바운더리: 예상치 못한 오류 처리
- 로딩/에러 상태 관리: 사용자 경험 향상