본문으로 건너뛰기
Advertisement

14.3 Composition API 핵심 — setup(), ref, reactive, computed, watch, watchEffect

Composition API의 핵심 빌딩 블록

Composition API는 여러 반응형 API라이프사이클 훅으로 구성됩니다. 각 API의 역할과 사용법을 정확히 이해하면 어떤 복잡한 컴포넌트도 명확하게 작성할 수 있습니다.

API역할
setup()Composition API의 진입점, 또는 <script setup>
ref()단일 값을 반응형으로 만들기
reactive()객체/배열을 반응형으로 만들기
computed()다른 반응형 값에서 파생된 값
watch()특정 반응형 값의 변화 감지
watchEffect()사용하는 반응형 값을 자동 추적하며 즉시 실행

setup() 함수

setup()은 컴포넌트가 생성될 때 가장 먼저 실행되는 함수로, Composition API의 시작점입니다. propscontext(emit, attrs, slots)를 인수로 받습니다.

<script>
import { ref } from 'vue'

export default {
// props 정의
props: {
initialCount: {
type: Number,
default: 0,
},
},

// setup은 props와 context를 인수로 받음
setup(props, context) {
const count = ref(props.initialCount)

const increment = () => { count.value++ }

// context.emit: 이벤트 발생
const done = () => {
context.emit('complete', count.value)
}

// context.attrs: 상속된 HTML 속성
console.log(context.attrs)

// context.slots: 슬롯 접근
console.log(context.slots.default?.())

// 템플릿에서 사용할 값 반환
return { count, increment, done }
},
}
</script>

<script setup> — 현대적 표준

<script setup>은 컴파일 타임 변환을 통해 setup() 함수를 자동으로 생성합니다. 더 적은 코드로 더 명확한 구조를 만들 수 있습니다.

<script setup>
import { ref } from 'vue'

// props 정의 (컴파일러 매크로 — import 없이 사용)
const props = defineProps({
initialCount: {
type: Number,
default: 0,
},
})

// emits 정의
const emit = defineEmits(['complete'])

const count = ref(props.initialCount)
const increment = () => { count.value++ }
const done = () => { emit('complete', count.value) }

// ← return 불필요: 모든 변수/함수가 자동으로 템플릿에 노출됨
</script>

ref()

ref()단일 반응형 값을 만드는 가장 기본적인 API입니다. 원시값(숫자, 문자열, 불리언)뿐 아니라 객체와 배열도 감쌀 수 있습니다.

ref의 .value 속성

<script setup>
import { ref } from 'vue'

// 숫자
const count = ref(0)
console.log(count.value) // 0
count.value++
console.log(count.value) // 1

// 문자열
const name = ref('Vue')
name.value = 'Vue 3' // 반응형 업데이트

// 불리언
const isVisible = ref(false)
isVisible.value = !isVisible.value

// 배열
const items = ref([1, 2, 3])
items.value.push(4) // 반응형 업데이트

// 객체 (ref로도 가능하나 reactive 권장)
const user = ref({ name: 'Alice', age: 30 })
user.value.name = 'Bob' // 반응형 업데이트
</script>

<template>
<!-- 템플릿에서는 .value 없이 사용 (자동 언래핑) -->
<p>{{ count }}</p>
<p>{{ name }}</p>
<p>{{ isVisible }}</p>
</template>

ref의 자동 언래핑(Auto-Unwrapping)

<script setup>
import { ref, reactive } from 'vue'

const count = ref(0)

// reactive 객체 내부에서는 .value 없이 접근 가능 (자동 언래핑)
const state = reactive({ count })
console.log(state.count) // 0 (.value 불필요)
state.count++
console.log(state.count) // 1
console.log(count.value) // 1 (동일한 참조)
</script>

<template>
<!-- 템플릿에서도 자동 언래핑 -->
<p>{{ count }}</p> <!-- 0, 1, 2, ... -->
</template>

useTemplateRef — DOM 요소 접근

<script setup>
import { useTemplateRef, onMounted } from 'vue'

// Vue 3.5+: useTemplateRef
const inputEl = useTemplateRef('searchInput')

onMounted(() => {
inputEl.value?.focus() // DOM 마운트 후 자동 포커스
})

// Vue 3.4 이전 방식
// const inputEl = ref(null)
</script>

<template>
<input ref="searchInput" type="text" placeholder="검색..." />
</template>

reactive()

reactive()객체나 배열을 깊은(deep) 반응형으로 만듭니다. ref와 달리 .value가 필요 없고, 중첩된 속성 변경도 감지합니다.

<script setup>
import { reactive } from 'vue'

const state = reactive({
count: 0,
name: 'Alice',
address: {
city: 'Seoul',
district: 'Gangnam',
},
hobbies: ['coding', 'reading'],
})

// .value 없이 직접 접근
state.count++
state.name = 'Bob'
state.address.city = 'Busan' // 중첩 속성도 반응형
state.hobbies.push('gaming') // 배열 변경도 감지
</script>

ref vs reactive 선택 기준

<script setup>
import { ref, reactive } from 'vue'

// ✅ ref 사용: 단일 원시값, 또는 전체를 교체해야 할 때
const count = ref(0)
const data = ref(null) // API 응답을 담을 때
count.value = 100 // 전체 값 교체 가능

// ✅ reactive 사용: 구조가 있는 상태 객체, 부분 업데이트가 많을 때
const form = reactive({
username: '',
email: '',
password: '',
})
form.username = 'alice' // 자연스러운 객체 접근

// ❌ reactive의 함정: 전체 교체 시 반응성 손실
// form = { username: 'bob' } // 이렇게 하면 반응성이 깨짐!
// Object.assign(form, newData) // 올바른 전체 업데이트 방법
</script>

reactive의 주의사항 — 구조분해 시 반응성 손실

<script setup>
import { reactive, toRefs } from 'vue'

const state = reactive({ count: 0, name: 'Alice' })

// ❌ 잘못된 방법: 구조분해 시 반응성 손실
// const { count, name } = state
// count++ // 이 변경은 화면에 반영되지 않음

// ✅ 올바른 방법 1: toRefs로 ref로 변환
const { count, name } = toRefs(state)
count.value++ // 반응형 유지 (.value 필요)

// ✅ 올바른 방법 2: 그냥 state를 통해 접근
state.count++
</script>

computed()

computed()는 다른 반응형 값에서 파생된 값을 만듭니다. 의존하는 값이 변경될 때만 재계산되며, 결과를 캐싱합니다.

기본 사용

<script setup>
import { ref, computed } from 'vue'

const firstName = ref('길동')
const lastName = ref('홍')
const age = ref(25)

// 읽기 전용 computed
const fullName = computed(() => `${lastName.value} ${firstName.value}`)
const isAdult = computed(() => age.value >= 18)
const ageMessage = computed(() => `${age.value}세${isAdult.value ? ' (성인)' : ' (미성년)'}`)

console.log(fullName.value) // "홍 길동"
console.log(isAdult.value) // true
</script>

<template>
<p>이름: {{ fullName }}</p>
<p>{{ ageMessage }}</p>
</template>

쓰기 가능한 computed (get + set)

<script setup>
import { ref, computed } from 'vue'

const firstName = ref('길동')
const lastName = ref('홍')

// getter와 setter를 모두 가진 computed
const fullName = computed({
get() {
return `${lastName.value} ${firstName.value}`
},
set(newValue) {
// "홍 길동" → ["홍", "길동"]
const parts = newValue.split(' ')
lastName.value = parts[0]
firstName.value = parts[1] || ''
},
})

// getter: 현재 값 읽기
console.log(fullName.value) // "홍 길동"

// setter: 전체 이름으로 개별 값 업데이트
fullName.value = '김 철수'
console.log(firstName.value) // "철수"
console.log(lastName.value) // "김"
</script>

computed vs 메서드 — 캐싱의 중요성

<script setup>
import { ref, computed } from 'vue'

const count = ref(0)

// computed: 의존값이 바뀔 때만 재실행 (캐싱 O)
const expensiveComputed = computed(() => {
console.log('computed 재계산됨') // count가 바뀔 때만 출력
return count.value * 2
})

// 메서드: 호출될 때마다 실행 (캐싱 X)
const expensiveMethod = () => {
console.log('메서드 실행됨') // 렌더링마다 출력
return count.value * 2
}

// 성능 차이: 아래 두 접근이 각각 100번 템플릿에서 호출된다면
// computed: 1번 계산 후 캐시에서 100번 반환
// method: 100번 계산 실행
</script>

<template>
<!-- computed: 같은 값이라도 .value 접근 시 재계산 안 함 -->
<p>{{ expensiveComputed }}</p>
<p>{{ expensiveComputed }}</p> <!-- 캐시된 값 반환 -->

<!-- method: 호출마다 함수 실행 -->
<p>{{ expensiveMethod() }}</p>
<p>{{ expensiveMethod() }}</p> <!-- 두 번 실행됨 -->
</template>

watch()

watch()는 특정 반응형 값을 명시적으로 감시하고, 값이 변경될 때 콜백을 실행합니다.

기본 사용법

<script setup>
import { ref, reactive, watch } from 'vue'

const count = ref(0)
const user = reactive({ name: 'Alice', age: 30 })

// 1. ref 감시
watch(count, (newVal, oldVal) => {
console.log(`count: ${oldVal} → ${newVal}`)
})

// 2. getter 함수로 감시 (reactive의 특정 속성)
watch(
() => user.name,
(newName, oldName) => {
console.log(`name: ${oldName} → ${newName}`)
}
)

// 3. 여러 값 동시 감시
watch(
[count, () => user.name],
([newCount, newName], [oldCount, oldName]) => {
console.log('count 또는 name 변경됨')
}
)
</script>

watch 옵션

<script setup>
import { ref, watch } from 'vue'

const searchQuery = ref('')
const settings = ref({
theme: 'light',
language: 'ko',
notifications: {
email: true,
sms: false,
},
})

// immediate: 컴포넌트 마운트 시 즉시 실행
watch(
searchQuery,
(newQuery) => {
console.log('검색어:', newQuery)
// API 호출 등
},
{ immediate: true } // 초기값으로 즉시 한 번 실행
)

// deep: 중첩된 객체 변경도 감지
watch(
settings,
(newSettings) => {
// settings.value.notifications.email이 변경되어도 감지
console.log('설정 변경됨:', newSettings)
localStorage.setItem('settings', JSON.stringify(newSettings))
},
{ deep: true }
)

// once: 최초 1회만 실행 (Vue 3.4+)
watch(
searchQuery,
(newQuery) => {
console.log('최초 검색어 입력:', newQuery)
},
{ once: true }
)
</script>

watch — 비동기 처리와 정리(cleanup)

<script setup>
import { ref, watch } from 'vue'

const userId = ref(1)
const userData = ref(null)
const loading = ref(false)

watch(userId, async (newId, oldId, onCleanup) => {
loading.value = true
userData.value = null

// AbortController로 이전 요청 취소
const controller = new AbortController()

// onCleanup: 다음 watch 실행 전 또는 컴포넌트 언마운트 시 호출
onCleanup(() => {
controller.abort()
console.log('이전 요청 취소됨')
})

try {
const res = await fetch(`/api/users/${newId}`, {
signal: controller.signal,
})
userData.value = await res.json()
} catch (err) {
if (err.name !== 'AbortError') {
console.error('요청 실패:', err)
}
} finally {
loading.value = false
}
})
</script>

watch 중지

<script setup>
import { ref, watch } from 'vue'

const count = ref(0)

// watch는 중지 함수를 반환
const stopWatch = watch(count, (newVal) => {
console.log('count:', newVal)
if (newVal >= 10) {
stopWatch() // 10에 도달하면 감시 중지
console.log('감시 중지됨')
}
})
</script>

watchEffect()

watchEffect()는 콜백 내부에서 사용하는 반응형 값을 자동으로 추적하고, 그 값이 변경될 때마다 콜백을 재실행합니다. watch와 달리 감시 대상을 명시하지 않아도 됩니다.

기본 사용

<script setup>
import { ref, watchEffect } from 'vue'

const count = ref(0)
const name = ref('Alice')

// 의존성 자동 추적 — count와 name 모두 감시
watchEffect(() => {
// 콜백 내부에서 사용하는 반응형 값이 자동으로 의존성이 됨
console.log(`현재 상태: count=${count.value}, name=${name.value}`)
})

// 컴포넌트 마운트 시 즉시 1회 실행 (watch의 immediate: true와 동일)
// count나 name이 변경될 때마다 재실행
</script>

watchEffect — 정리 함수

<script setup>
import { ref, watchEffect } from 'vue'

const query = ref('')

watchEffect((onCleanup) => {
const timer = setTimeout(() => {
if (query.value) {
console.log('API 호출:', query.value)
// fetch('/api/search?q=' + query.value)
}
}, 300) // 디바운스 300ms

// 다음 실행 전에 이전 타이머 취소
onCleanup(() => {
clearTimeout(timer)
})
})
</script>

watch vs watchEffect 비교

<script setup>
import { ref, watch, watchEffect } from 'vue'

const userId = ref(1)
const theme = ref('light')

// watch: 명시적 감시, oldValue 접근 가능, 지연 실행(기본)
watch(userId, (newId, oldId) => {
console.log(`userId: ${oldId} → ${newId}`)
// newId와 oldId 모두 접근 가능
})

// watchEffect: 자동 추적, oldValue 없음, 즉시 실행
watchEffect(() => {
// userId와 theme 모두 자동으로 추적됨
document.title = `User ${userId.value} | ${theme.value}`
// oldValue 접근 불가
})
</script>
특성watchwatchEffect
감시 대상명시적 지정자동 추적
초기 실행기본 없음 (immediate 옵션 필요)즉시 실행
oldValue 접근가능불가능
비동기 처리용이용이
사용 시점특정 값 변화에 반응부수 효과 동기화

실전 예제 — 검색 컴포넌트 (모든 API 통합)

<!-- SearchComponent.vue -->
<script setup>
import {
ref,
reactive,
computed,
watch,
watchEffect,
onMounted,
onUnmounted,
} from 'vue'

// ---- 상태 ----
const query = ref('')
const results = ref([])
const loading = ref(false)
const error = ref(null)
const filter = reactive({
category: 'all',
sortBy: 'relevance',
minPrice: 0,
maxPrice: 100000,
})

// ---- Computed ----
const filteredResults = computed(() => {
let list = results.value

if (filter.category !== 'all') {
list = list.filter(item => item.category === filter.category)
}

if (filter.sortBy === 'price-asc') {
list = [...list].sort((a, b) => a.price - b.price)
} else if (filter.sortBy === 'price-desc') {
list = [...list].sort((a, b) => b.price - a.price)
}

return list.filter(
item => item.price >= filter.minPrice && item.price <= filter.maxPrice
)
})

const hasResults = computed(() => filteredResults.value.length > 0)
const resultCount = computed(() => `${filteredResults.value.length}개 결과`)

// ---- Watch: 검색어 변경 시 API 호출 (디바운스) ----
let searchTimer = null
watch(query, (newQuery) => {
clearTimeout(searchTimer)
if (!newQuery.trim()) {
results.value = []
return
}
loading.value = true
searchTimer = setTimeout(async () => {
try {
// const res = await fetch(`/api/search?q=${encodeURIComponent(newQuery)}`)
// results.value = await res.json()

// 시뮬레이션
await new Promise(r => setTimeout(r, 400))
results.value = [
{ id: 1, title: `${newQuery} 관련 상품 1`, category: 'electronics', price: 29000 },
{ id: 2, title: `${newQuery} 관련 상품 2`, category: 'books', price: 15000 },
{ id: 3, title: `${newQuery} 관련 상품 3`, category: 'electronics', price: 75000 },
]
error.value = null
} catch (err) {
error.value = '검색 중 오류가 발생했습니다.'
} finally {
loading.value = false
}
}, 300)
})

// ---- watchEffect: URL 쿼리 파라미터 동기화 ----
watchEffect(() => {
const params = new URLSearchParams()
if (query.value) params.set('q', query.value)
if (filter.category !== 'all') params.set('cat', filter.category)
// URL을 히스토리에 기록 없이 업데이트
const newUrl = `${window.location.pathname}?${params.toString()}`
window.history.replaceState({}, '', newUrl)
})

// ---- 라이프사이클 ----
onMounted(() => {
// URL에서 초기 검색어 복원
const params = new URLSearchParams(window.location.search)
const q = params.get('q')
if (q) query.value = q
})

onUnmounted(() => {
clearTimeout(searchTimer)
})

// ---- 메서드 ----
const clearSearch = () => {
query.value = ''
results.value = []
}

const resetFilters = () => {
filter.category = 'all'
filter.sortBy = 'relevance'
filter.minPrice = 0
filter.maxPrice = 100000
}
</script>

<template>
<div class="search-container">
<!-- 검색 입력 -->
<div class="search-bar">
<input
v-model="query"
type="text"
placeholder="검색어를 입력하세요..."
class="search-input"
/>
<button v-if="query" @click="clearSearch" class="clear-btn">✕</button>
</div>

<!-- 필터 -->
<div class="filters">
<select v-model="filter.category">
<option value="all">전체</option>
<option value="electronics">전자기기</option>
<option value="books">도서</option>
</select>
<select v-model="filter.sortBy">
<option value="relevance">관련도순</option>
<option value="price-asc">낮은 가격순</option>
<option value="price-desc">높은 가격순</option>
</select>
<button @click="resetFilters">필터 초기화</button>
</div>

<!-- 결과 -->
<div v-if="loading" class="loading">검색 중...</div>
<div v-else-if="error" class="error">{{ error }}</div>
<div v-else-if="hasResults">
<p class="count">{{ resultCount }}</p>
<ul class="result-list">
<li v-for="item in filteredResults" :key="item.id" class="result-item">
<h3>{{ item.title }}</h3>
<span class="category">{{ item.category }}</span>
<span class="price">{{ item.price.toLocaleString() }}원</span>
</li>
</ul>
</div>
<div v-else-if="query" class="no-results">검색 결과가 없습니다.</div>
</div>
</template>

<style scoped>
.search-container { max-width: 600px; margin: 0 auto; padding: 1rem; }
.search-bar { display: flex; gap: 0.5rem; margin-bottom: 1rem; }
.search-input { flex: 1; padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px; font-size: 1rem; }
.filters { display: flex; gap: 0.5rem; margin-bottom: 1rem; flex-wrap: wrap; }
.result-list { list-style: none; padding: 0; }
.result-item { border: 1px solid #eee; border-radius: 8px; padding: 1rem; margin-bottom: 0.5rem; display: flex; justify-content: space-between; align-items: center; }
.category { background: #e8f4f8; padding: 0.2rem 0.5rem; border-radius: 4px; font-size: 0.8rem; }
.price { font-weight: bold; color: #e44d26; }
</style>

고수 팁

shallowRef와 shallowReactive — 성능 최적화

<script setup>
import { shallowRef, shallowReactive } from 'vue'

// shallowRef: 최상위 .value만 반응형 (내부 객체는 비반응형)
// 대용량 데이터나 외부 라이브러리 객체를 다룰 때 유용
const bigData = shallowRef({ items: new Array(10000).fill(0) })

// 전체 교체 → 반응형 업데이트 발생
bigData.value = { items: new Array(10000).fill(1) }

// 내부 수정 → 반응형 업데이트 없음 (성능 이점)
bigData.value.items[0] = 99 // 화면 업데이트 안 됨

// shallowReactive: 최상위 속성만 반응형
const state = shallowReactive({ count: 0, nested: { value: 0 } })
state.count++ // 반응형 ✓
state.nested.value++ // 반응형 ✗
</script>

computed와 watch의 실행 시점 제어

<script setup>
import { ref, computed, watch, nextTick } from 'vue'

const count = ref(0)

// watch의 flush 옵션
watch(count, (newVal) => {
// 'pre' (기본): DOM 업데이트 전에 실행
// 'post': DOM 업데이트 후에 실행 (최신 DOM에 접근 가능)
// 'sync': 동기적으로 즉시 실행 (일반적으로 비권장)
}, { flush: 'post' })

// nextTick: DOM이 업데이트된 후 코드 실행
const updateAndRead = async () => {
count.value++
await nextTick()
// 이제 DOM이 업데이트된 상태
console.log('DOM 업데이트 완료')
}
</script>

커스텀 컴포저블 패턴

// composables/useFetch.js — 재사용 가능한 데이터 페칭 로직
import { ref, watchEffect, toValue } from 'vue'

export function useFetch(urlOrRef) {
const data = ref(null)
const error = ref(null)
const loading = ref(false)

watchEffect(async (onCleanup) => {
// toValue: ref, reactive, 또는 일반 값을 모두 처리
const url = toValue(urlOrRef)
if (!url) return

data.value = null
error.value = null
loading.value = true

const controller = new AbortController()
onCleanup(() => controller.abort())

try {
const res = await fetch(url, { signal: controller.signal })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
data.value = await res.json()
} catch (err) {
if (err.name !== 'AbortError') {
error.value = err.message
}
} finally {
loading.value = false
}
})

return { data, error, loading }
}
<!-- 사용 예 -->
<script setup>
import { ref } from 'vue'
import { useFetch } from '@/composables/useFetch'

const userId = ref(1)

// userId가 변경되면 자동으로 새 URL로 재요청
const { data: user, loading, error } = useFetch(
() => `/api/users/${userId.value}`
)
</script>

<template>
<div v-if="loading">불러오는 중...</div>
<div v-else-if="error">에러: {{ error }}</div>
<div v-else-if="user">{{ user.name }}</div>
<button @click="userId++">다음 사용자</button>
</template>

effectScope — 관련 효과를 한 번에 정리

import { effectScope, ref, watch, watchEffect } from 'vue'

// 여러 watch/watchEffect를 하나의 scope로 묶어 일괄 정리
const scope = effectScope()

scope.run(() => {
const count = ref(0)

watchEffect(() => console.log('count:', count.value))
watch(count, (n) => console.log('changed:', n))
})

// 한 번의 호출로 scope 내 모든 효과 정리
scope.stop()

이 패턴은 전역 상태(Pinia 스토어 등)에서 라이프사이클과 무관하게 반응형 효과를 관리할 때 특히 유용합니다.

Advertisement