computed()
computed가 무엇인가요?
computed는 다른 반응형 데이터에 의존하여 자동으로 계산되는 값입니다. 마치 Excel의 수식 셀처럼, 참조하는 셀의 값이 바뀌면 자동으로 다시 계산됩니다.
// 기본 데이터
const firstName = ref("김");
const lastName = ref("철수");
// 자동으로 계산되는 값
const fullName = computed(() => {
return `${firstName.value} ${lastName.value}`;
});
// firstName이나 lastName이 바뀌면 fullName도 자동으로 업데이트!
computed의 핵심 특징
핵심: computed는 의존하는 데이터가 변할 때만 다시 계산됩니다!
const expensiveComputed = computed(() => {
console.log("계산 실행됨!"); // 의존 데이터가 변할 때만 출력
return someRef.value * 1000;
});
쇼핑몰 시스템 실무 예제로 배우는 computed
1. 상품 타입별 할인율 표시
// 선택된 상품 정보
const selectedProduct = ref(null);
// 상품 타입에 따라 할인율을 동적으로 계산
const discountRate = computed(() => {
if (!selectedProduct.value?.category) return "할인없음";
switch (selectedProduct.value.category) {
case 'electronics':
return "10% 할인";
case 'clothing':
return "20% 할인";
case 'books':
return "15% 할인";
default:
return "할인없음";
}
});
템플릿에서 사용:
<template>
<div class="product-info">
<span class="discount-label">{{ discountRate }}</span>
<span class="product-category">{{ selectedProduct?.category }}</span>
</div>
</template>
2. 장바구니 총액 자동 계산
// 장바구니 아이템들
const cartItems = ref([]);
// 총 금액 자동 계산
const totalPrice = computed(() => {
return cartItems.value.reduce((sum, item) => {
return sum + (item.price * item.quantity);
}, 0);
});
// 배송비 자동 계산 (50,000원 이상 무료배송)
const shippingFee = computed(() => {
return totalPrice.value >= 50000 ? 0 : 3000;
});
// 최종 결제 금액
const finalAmount = computed(() => {
return totalPrice.value + shippingFee.value;
});
3. 재고 상태 표시
// 상품 재고와 주문 수량
const stockQuantity = ref(10);
const orderQuantity = ref(1);
// 재고 상태 텍스트 자동 계산
const stockStatus = computed(() => {
const remaining = stockQuantity.value - orderQuantity.value;
if (remaining < 0) return "재고 부족";
if (remaining === 0) return "마지막 재고";
if (remaining <= 3) return `${remaining}개 남음 (품절임박)`;
return "재고 충분";
});
// 주문 가능 여부
const canOrder = computed(() => {
return orderQuantity.value <= stockQuantity.value && orderQuantity.value > 0;
});
실무에서 자주 사용하는 computed 패턴
1. 검색 및 필터링
const searchKeyword = ref("");
const selectedCategory = ref("");
const allProducts = ref([]);
// 검색 결과 자동 필터링
const filteredProducts = computed(() => {
let results = allProducts.value;
// 카테고리 필터
if (selectedCategory.value) {
results = results.filter(product =>
product.category === selectedCategory.value
);
}
// 키워드 검색
if (searchKeyword.value) {
results = results.filter(product =>
product.name.toLowerCase().includes(searchKeyword.value.toLowerCase())
);
}
return results;
});
// 검색 결과 개수
const resultCount = computed(() => filteredProducts.value.length);
2. 폼 유효성 검사
const registerForm = ref({
username: "",
email: "",
password: "",
confirmPassword: ""
});
// 각 필드별 유효성 검사
const isUsernameValid = computed(() => {
return registerForm.value.username.length >= 3;
});
const isEmailValid = computed(() => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(registerForm.value.email);
});
const isPasswordValid = computed(() => {
return registerForm.value.password.length >= 8;
});
const isPasswordMatch = computed(() => {
return registerForm.value.password === registerForm.value.confirmPassword;
});
// 전체 폼 유효성
const isFormValid = computed(() => {
return isUsernameValid.value &&
isEmailValid.value &&
isPasswordValid.value &&
isPasswordMatch.value;
});
// 에러 메시지 배열
const validationErrors = computed(() => {
const errors = [];
if (!isUsernameValid.value) errors.push("사용자명은 3자 이상이어야 합니다");
if (!isEmailValid.value) errors.push("올바른 이메일 형식이 아닙니다");
if (!isPasswordValid.value) errors.push("비밀번호는 8자 이상이어야 합니다");
if (!isPasswordMatch.value) errors.push("비밀번호가 일치하지 않습니다");
return errors;
});
3. 데이터 포맷팅
const orderDate = ref(new Date());
const price = ref(25000);
// 날짜 포맷팅
const formattedDate = computed(() => {
return orderDate.value.toLocaleDateString('ko-KR', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
});
// 가격 포맷팅
const formattedPrice = computed(() => {
return `₩${price.value.toLocaleString()}`;
});
// 배송 예정일 (주문일 + 3일)
const estimatedDelivery = computed(() => {
const deliveryDate = new Date(orderDate.value);
deliveryDate.setDate(deliveryDate.getDate() + 3);
return deliveryDate.toLocaleDateString('ko-KR');
});
watch() - 변화의 파수꾼
watch가 필요한 상황
computed는 값을 계산하지만, watch는 값이 변할 때 특별한 작업을 수행합니다.
// 검색어가 변할 때마다 API 호출
const searchKeyword = ref("");
watch(searchKeyword, async (newKeyword, oldKeyword) => {
if (newKeyword.length > 2) {
console.log(`"${oldKeyword}"에서 "${newKeyword}"로 변경됨`);
await searchProducts(newKeyword);
}
});
쇼핑몰 시스템에서의 watch 활용
1. 자동 검색 기능
import { debounce } from 'lodash';
const searchQuery = ref("");
const searchResults = ref([]);
const isSearching = ref(false);
// 검색어 변경 시 자동 검색 (300ms 딜레이)
watch(searchQuery,
debounce(async (newQuery) => {
if (newQuery.length < 2) {
searchResults.value = [];
return;
}
isSearching.value = true;
try {
const response = await fetch(`/api/search?q=${newQuery}`);
searchResults.value = await response.json();
} catch (error) {
console.error("검색 오류:", error);
} finally {
isSearching.value = false;
}
}, 300)
);
2. 장바구니 자동 저장
const cartItems = ref([]);
// 장바구니 변경 시 로컬스토리지에 자동 저장
watch(cartItems, (newItems) => {
localStorage.setItem('cartItems', JSON.stringify(newItems));
// 구글 애널리틱스 이벤트 전송
gtag('event', 'cart_update', {
item_count: newItems.length,
total_value: newItems.reduce((sum, item) => sum + item.price, 0)
});
}, { deep: true }); // 배열 내부 변화도 감지
3. 사용자 행동 추적
const currentPage = ref("home");
const viewStartTime = ref(Date.now());
// 페이지 변경 시 체류 시간 기록
watch(currentPage, (newPage, oldPage) => {
const viewDuration = Date.now() - viewStartTime.value;
// 이전 페이지 체류 시간 기록
analytics.track('page_view_duration', {
page: oldPage,
duration: viewDuration
});
// 새 페이지 시작 시간 기록
viewStartTime.value = Date.now();
console.log(`${oldPage}에서 ${newPage}로 이동 (체류시간: ${viewDuration}ms)`);
});
4. 폼 자동 저장
const draftForm = ref({
title: "",
content: "",
category: ""
});
// 폼 데이터 변경 시 자동 임시저장 (2초 후)
watch(draftForm,
debounce(async (newForm) => {
try {
await fetch('/api/drafts/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newForm)
});
console.log("임시저장 완료");
} catch (error) {
console.error("임시저장 실패:", error);
}
}, 2000),
{ deep: true }
);
computed vs watch 언제 무엇을 사용할까?
computed 사용 상황
상황 예시
| 값 계산 | 총합, 평균, 할인가격 |
| 데이터 변환 | 날짜 포맷팅, 통화 표시 |
| 필터링 | 검색 결과, 카테고리별 상품 |
| 상태 판단 | 유효성 검사, 재고 상태 |
| UI 표시 | CSS 클래스, 버튼 텍스트 |
// ✅ computed 적합한 예시들
const discountedPrice = computed(() => price.value * 0.9);
const isFormValid = computed(() => email.value.includes('@'));
const stockStatus = computed(() => quantity.value > 0 ? "재고있음" : "품절");
watch 사용 상황
상황 예시
| API 호출 | 검색, 데이터 저장 |
| 로컬 저장 | 장바구니, 설정 저장 |
| 외부 라이브러리 | 차트 업데이트, 지도 표시 |
| 분석/추적 | 사용자 행동 로깅 |
| 알림 | 실시간 메시지 표시 |
// ✅ watch 적합한 예시들
watch(searchKeyword, async (keyword) => {
const results = await searchAPI(keyword);
});
watch(cartItems, (items) => {
localStorage.setItem('cart', JSON.stringify(items));
});
watch(errorMessage, (message) => {
if (message) {
showToast(message);
}
});
고급 활용 패턴
1. watchEffect - 의존성 자동 추적
import { watchEffect } from 'vue';
// 사용하는 반응형 데이터를 자동으로 추적
watchEffect(() => {
// orderQuantity나 stockQuantity가 변하면 자동 실행
if (orderQuantity.value > stockQuantity.value) {
showAlert("주문 수량이 재고를 초과했습니다!");
}
});
2. watch 옵션 활용
// 즉시 실행 - 컴포넌트 마운트 시에도 실행
watch(selectedCategory, (category) => {
loadProductsByCategory(category);
}, { immediate: true });
// 깊은 감시 - 객체 내부 변화도 감지
watch(userProfile, (newProfile) => {
saveUserProfile(newProfile);
}, { deep: true });
// 이전 값과 비교
watch(currentPage, (newPage, oldPage) => {
console.log(`${oldPage} → ${newPage}로 페이지 이동`);
});
3. 조건부 watch 제어
const shouldTrackUser = ref(true);
const stopWatcher = ref(null);
// 추적 시작
const startTracking = () => {
stopWatcher.value = watch(userActivity, (activity) => {
if (shouldTrackUser.value) {
sendAnalytics(activity);
}
});
};
// 추적 중지
const stopTracking = () => {
if (stopWatcher.value) {
stopWatcher.value(); // watch 중지
stopWatcher.value = null;
}
};
성능 최적화 팁
1. computed 캐싱 활용
// ❌ 함수는 매번 실행됨 (비효율적)
const getExpensiveValue = () => {
console.log("무거운 계산 실행!");
return heavyCalculation(productList.value);
};
// ✅ computed는 의존 데이터가 변할 때만 실행 (효율적)
const expensiveValue = computed(() => {
console.log("무거운 계산 실행!");
return heavyCalculation(productList.value);
});
2. watch debounce로 API 호출 최적화
import { debounce } from 'lodash';
// 사용자가 타이핑할 때마다 API 호출하지 않고 300ms 후에 호출
watch(searchInput,
debounce(async (searchTerm) => {
if (searchTerm.length > 2) {
await searchProducts(searchTerm);
}
}, 300)
);
3. 조건부 계산으로 불필요한 연산 방지
// 검색어가 있을 때만 필터링 수행
const filteredProducts = computed(() => {
if (!searchKeyword.value) {
return allProducts.value; // 조기 반환으로 불필요한 필터링 방지
}
return allProducts.value.filter(product =>
product.name.includes(searchKeyword.value)
);
});
실무 중복 제거 패턴
헬퍼 함수로 중복 로직 제거
// 🔄 공통 유효성 검사 함수
const createValidator = (fieldName, validationRules) => {
return computed(() => {
const errors = [];
const value = formData.value[fieldName];
for (const rule of validationRules) {
if (!rule.test(value)) {
errors.push(rule.message);
}
}
return {
isValid: errors.length === 0,
errors
};
});
};
// 각 필드별 유효성 검사 computed 생성
const emailValidator = createValidator('email', [
{ test: (v) => v.length > 0, message: '이메일을 입력해주세요' },
{ test: (v) => v.includes('@'), message: '올바른 이메일 형식이 아닙니다' }
]);
const passwordValidator = createValidator('password', [
{ test: (v) => v.length >= 8, message: '비밀번호는 8자 이상이어야 합니다' },
{ test: (v) => /[A-Z]/.test(v), message: '대문자를 포함해야 합니다' }
]);
공통 상태 관리 패턴
// API 호출 상태를 관리하는 composable
const useAsyncData = (apiFunction) => {
const data = ref(null);
const isLoading = ref(false);
const error = ref(null);
const execute = async (...args) => {
isLoading.value = true;
error.value = null;
try {
data.value = await apiFunction(...args);
} catch (err) {
error.value = err.message;
} finally {
isLoading.value = false;
}
};
return { data, isLoading, error, execute };
};
// 사용 예시
const { data: products, isLoading: isLoadingProducts, execute: loadProducts } = useAsyncData(fetchProducts);
const { data: categories, isLoading: isLoadingCategories, execute: loadCategories } = useAsyncData(fetchCategories);
자주 하는 실수와 해결법
1. computed에서 부작용 수행
// ❌ computed에서 API 호출 (잘못된 사용)
const userData = computed(async () => {
const response = await fetch('/api/user'); // computed는 동기적이어야 함!
return response.json();
});
// ✅ watch에서 API 호출 (올바른 사용)
const userData = ref(null);
watch(userId, async (id) => {
if (id) {
const response = await fetch(`/api/user/${id}`);
userData.value = await response.json();
}
});
2. watch에서 단순 값 계산
// ❌ watch에서 값 계산 (비효율적)
const totalPrice = ref(0);
watch(cartItems, (items) => {
totalPrice.value = items.reduce((sum, item) => sum + item.price, 0);
});
// ✅ computed로 값 계산 (효율적)
const totalPrice = computed(() =>
cartItems.value.reduce((sum, item) => sum + item.price, 0)
);
3. 무한 루프 주의
// ❌ 무한 루프 위험
watch(count, (newValue) => {
count.value = newValue + 1; // 자기 자신을 변경하면 무한 루프!
});
// ✅ 조건부 변경
watch(count, (newValue) => {
if (newValue >= 100) {
reachedMaximum.value = true; // 다른 변수 변경
}
});
실전 디버깅 팁
1. computed 실행 추적
const expensiveComputed = computed(() => {
console.log("🔄 computed 실행:", {
searchKeyword: searchKeyword.value,
selectedCategory: selectedCategory.value,
timestamp: new Date().toISOString()
});
return filterProducts(allProducts.value, searchKeyword.value, selectedCategory.value);
});
2. watch 변화 로깅
watch(
[searchKeyword, selectedCategory],
([newKeyword, newCategory], [oldKeyword, oldCategory]) => {
console.log("👀 watch 실행:", {
keyword: { old: oldKeyword, new: newKeyword },
category: { old: oldCategory, new: newCategory }
});
updateProductList(newKeyword, newCategory);
}
);
3. 성능 모니터링
const performanceWatch = (name, watchFunction) => {
return (...args) => {
const start = performance.now();
const result = watchFunction(...args);
const end = performance.now();
console.log(`⏱️ ${name} 실행 시간: ${end - start}ms`);
return result;
};
};
// 사용 예시
watch(productList, performanceWatch('상품목록 필터링', (products) => {
return products.filter(p => p.inStock);
}));
실전 활용 예제 - 쇼핑카트 구현
<template>
<div class="shopping-cart">
<!-- 상품 목록 -->
<div class="product-list">
<div v-for="product in availableProducts" :key="product.id" class="product-item">
<h3>{{ product.name }}</h3>
<p>{{ formatPrice(product.price) }}</p>
<button @click="addToCart(product)" :disabled="!product.inStock">
{{ product.inStock ? '장바구니 담기' : '품절' }}
</button>
</div>
</div>
<!-- 장바구니 -->
<div class="cart">
<h2>장바구니 ({{ cartItemCount }}개)</h2>
<div v-for="item in cartItems" :key="item.id" class="cart-item">
<span>{{ item.name }}</span>
<input
type="number"
v-model.number="item.quantity"
min="1"
@change="updateCartItem(item)"
/>
<span>{{ formatPrice(item.price * item.quantity) }}</span>
<button @click="removeFromCart(item.id)">삭제</button>
</div>
<!-- 주문 요약 -->
<div class="order-summary">
<p>상품 금액: {{ formatPrice(subtotal) }}</p>
<p>배송비: {{ formatPrice(shippingFee) }}</p>
<p class="total">총 결제금액: {{ formatPrice(totalAmount) }}</p>
<button
@click="checkout"
:disabled="!canCheckout"
class="checkout-btn"
>
{{ checkoutButtonText }}
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue';
interface Product {
id: number;
name: string;
price: number;
inStock: boolean;
}
interface CartItem extends Product {
quantity: number;
}
// 반응형 데이터
const allProducts = ref<Product[]>([
{ id: 1, name: "노트북", price: 1200000, inStock: true },
{ id: 2, name: "마우스", price: 50000, inStock: true },
{ id: 3, name: "키보드", price: 80000, inStock: false }
]);
const cartItems = ref<CartItem[]>([]);
// computed - 자동 계산되는 값들
const availableProducts = computed(() =>
allProducts.value.filter(product => product.inStock)
);
const cartItemCount = computed(() =>
cartItems.value.reduce((count, item) => count + item.quantity, 0)
);
const subtotal = computed(() =>
cartItems.value.reduce((sum, item) => sum + (item.price * item.quantity), 0)
);
const shippingFee = computed(() =>
subtotal.value >= 100000 ? 0 : 3000
);
const totalAmount = computed(() =>
subtotal.value + shippingFee.value
);
const canCheckout = computed(() =>
cartItems.value.length > 0 && totalAmount.value > 0
);
const checkoutButtonText = computed(() => {
if (cartItems.value.length === 0) return "장바구니가 비어있습니다";
if (!canCheckout.value) return "주문할 수 없습니다";
return `${formatPrice(totalAmount.value)} 결제하기`;
});
// watch - 부작용 처리
watch(cartItems, (newItems) => {
// 장바구니 변경 시 로컬스토리지에 저장
localStorage.setItem('cartItems', JSON.stringify(newItems));
// 분석 이벤트 전송
console.log(`장바구니 업데이트: ${newItems.length}개 상품, 총 ${totalAmount.value}원`);
}, { deep: true });
// 메서드들
const formatPrice = (price: number): string => {
return `₩${price.toLocaleString()}`;
};
const addToCart = (product: Product) => {
const existingItem = cartItems.value.find(item => item.id === product.id);
if (existingItem) {
existingItem.quantity += 1;
} else {
cartItems.value.push({ ...product, quantity: 1 });
}
};
const updateCartItem = (item: CartItem) => {
if (item.quantity <= 0) {
removeFromCart(item.id);
}
};
const removeFromCart = (productId: number) => {
cartItems.value = cartItems.value.filter(item => item.id !== productId);
};
const checkout = () => {
if (canCheckout.value) {
alert(`${formatPrice(totalAmount.value)} 결제를 진행합니다.`);
cartItems.value = [];
}
};
</script>
비교
| 구분 | computed | watch |
| 목적 | 값 계산 | 부작용 처리 |
| 반환값 | 계산된 값 | 없음 |
| 캐싱 | ✅ 있음 | ❌ 없음 |
| 동기/비동기 | 동기만 | 둘 다 가능 |
| 사용 예 | 총합, 필터링, 포맷팅 | API 호출, 저장, 알림 |
| 성능 | 높음 (캐싱) | 낮음 (매번 실행) |
마무리
computed와 watch는 Vue.js 반응형 시스템의 핵심 도구입니다:
- computed: "이 값들이 바뀌면 저 값도 자동으로 계산해줘"
- watch: "이 값이 바뀌면 특별한 일을 해줘"
올바른 사용 원칙:
- 값 계산이 목적이면 computed 우선 고려
- 부작용(API 호출, 저장 등)이 필요하면 watch 사용
- 성능을 위해 computed의 캐싱 기능 적극 활용
- watch에는 debounce 적용으로 과도한 실행 방지