반응형 상태(ref, reactive)를 선언했다면, 그 값이 변할 때 연쇄적으로 무언가를 처리해야 할 때가 있습니다.
이때 두 가지 핵심 도구를 사용합니다. 기존 데이터를 입맛에 맞게 가공하여 파생된 데이터를 리턴(계산)하는 computed,
그리고 데이터가 변하는 순간을 감지하여 API 호출이나 알림 등 외부 작업(사이드 이펙트)을 실행하는 watch입니다.
비교 항목
computed (계산된 속성)
watch (감시자)
주 목적
원본 데이터를 가공하여 새로운 값을 반환 (UI 렌더링용)
값이 변경될 때 발생하는 외부 작업 처리 (API 요청, 타이머 등)
반환값 (return)
필수적 (반드시 값을 return 해야 함)
불필요 (실행 후 결과를 반환하지 않음)
캐싱 (Caching)
지원함 (원본 데이터가 안 바뀌면 캐싱된 결과 재사용)
지원 안 함 (변할 때마다 무조건 실행)
어떤 변수의 값이 변할 때, 그에 따라 자동으로 변하는 또 다른 파생된 값을 만들거나, 값이 변할 때마다 특정 동작(API 호출 등)을 실행하게 만들고 싶을 때가 있습니다. 이때 사용하는 핵심 도구가 바로 computed와 watch입니다.
SearchFilter.vue
<!-- ========================================== -->
<!-- 📂 SearchFilter.vue (computed와 watch 실전 예제) -->
<!-- ========================================== -->
<script setup>
import { ref, computed, watch } from 'vue';
// [1. 기본 상태 선언]
const searchQuery = ref('');
const items = ref(['🍎 사과', '🍌 바나나', '🍇 포도', '🍓 딸기', '🍉 수박']);
const apiLogs = ref([]);
// ------------------------------------------------
// [💡 computed 사용법] - 원본 데이터를 가공해서 보여줄 때 (캐싱됨)
// ------------------------------------------------
const filteredItems = computed(() => {
// ⚠️ 반드시 무언가를 return 해야 합니다!
// searchQuery 나 items 의 값이 바뀔 때만 이 함수가 다시 실행됩니다.
// 원본 items 배열은 그대로 둔 채 필터링된 "새로운 배열"을 리턴합니다.
if (!searchQuery.value) return items.value;
return items.value.filter(item => item.includes(searchQuery.value));
});
// ------------------------------------------------
// [💡 watch 사용법] - 데이터가 변할 때 특정 행동(Side-effect)을 해야 할 때
// ------------------------------------------------
watch(searchQuery, (newValue, oldValue) => {
// searchQuery 값이 변할 때마다 이 함수가 무조건 실행됩니다.
// 이곳에서 백엔드 API를 호출하거나 무거운 작업을 수행하기 좋습니다.
const log = `'${oldValue}' -> '${newValue}' 감지! (API 호출 시뮬레이션)`;
apiLogs.value.unshift(log);
console.log(log);
// 로그가 너무 길어지지 않게 5개만 유지
if (apiLogs.value.length > 5) {
apiLogs.value.pop();
}
});
</script>
<template>
<div class="app-container">
<!-- 왼쪽: computed 영역 -->
<div class="panel computed-panel">
<h2>⚙️ computed (필터링)</h2>
<input v-model="searchQuery" placeholder="과일 검색..." class="search-input" />
<ul class="item-list">
<!-- 원본 items가 아닌, computed된 filteredItems를 순회합니다! -->
<li v-for="item in filteredItems" :key="item">
{{ item }}
</li>
<li v-if="filteredItems.length === 0" class="empty">
결과가 없습니다.
</li>
</ul>
</div>
<!-- 오른쪽: watch 영역 -->
<div class="panel watch-panel">
<h2>👁️ watch (사이드 이펙트)</h2>
<div class="terminal">
<div class="terminal-header">Terminal Watch Logs...</div>
<!-- watch에서 쌓아둔 로그를 출력합니다. -->
<div v-for="(log, index) in apiLogs" :key="index" class="log-line">
{{ log }}
</div>
</div>
</div>
</div>
</template>
<style scoped>
.app-container { display: flex; gap: 2rem; max-width: 800px; margin: 2rem auto; font-family: system-ui; }
.panel { flex: 1; padding: 2rem; border-radius: 12px; }
.computed-panel { border: 2px solid #10b981; background: #ecfdf5; }
.computed-panel h2 { color: #047857; margin-bottom: 1.5rem; }
.search-input { width: 100%; box-sizing: border-box; padding: 0.75rem; margin-bottom: 1rem; border: 2px solid #34d399; border-radius: 8px; font-size: 1rem; }
.item-list { list-style: none; padding: 0; display: grid; gap: 0.5rem; }
.item-list li { padding: 0.75rem; background: white; border: 1px solid #a7f3d0; border-radius: 6px; font-weight: bold; color: #064e3b; }
.item-list .empty { color: #059669; font-style: italic; text-align: center; border: none; background: transparent; }
.watch-panel { border: 2px solid #f59e0b; background: #fffbeb; }
.watch-panel h2 { color: #b45309; margin-bottom: 1.5rem; }
.terminal { background: #1e293b; color: #fcd34d; border-radius: 8px; padding: 1rem; font-family: monospace; font-size: 0.85rem; height: 200px; overflow-y: auto; }
.terminal-header { color: #94a3b8; margin-bottom: 0.5rem; border-bottom: 1px solid #334155; padding-bottom: 0.5rem; }
.log-line { margin-bottom: 0.25rem; }
</style>