Vue 3 Composition API 완벽 가이드: 리액티비티 마스터하기

Vue 3 Composition API 실전 가이드입니다. ref, reactive, computed, watch, 컴포저블을 배워 고성능 Vue 애플리케이션을 구축하십시오.

리액티브 코드 블록이 상호 연결된 Vue 3 Composition API 일러스트레이션

Vue 3의 Composition API는 Vue 컴포넌트 설계 방식에 근본적인 변화를 가져왔습니다. 옵션 단위가 아닌 기능 단위로 코드를 구성할 수 있어 로직 재사용과 복잡한 애플리케이션의 유지보수가 훨씬 수월해집니다.

사전 지식

이 가이드는 Vue.js의 기본 지식을 전제로 합니다. 각 예제에서는 Vue 3.2에서 도입된 <script setup> 구문을 사용합니다. 현재 이 방식이 권장됩니다.

ref와 reactive로 리액티비티 이해하기

리액티비티는 Vue 3의 핵심 메커니즘입니다. 리액티브 데이터를 생성하는 주요 프리미티브는 두 가지입니다. 원시 값에는 ref를, 복잡한 객체에는 reactive를 사용합니다.

ref 함수는 값을 감싸는 리액티브 참조를 생성합니다. 스크립트 내에서 값에 접근하거나 수정하려면 .value 프로퍼티를 사용해야 합니다. 템플릿에서는 Vue가 자동으로 언래핑하므로 .value가 필요하지 않습니다.

Counter.vuejavascript
<script setup>
import { ref } from 'vue'

// Create a ref with initial value 0
const count = ref(0)

// Function that increments the counter
// Note: .value is required in the script
const increment = () => {
  count.value++
}

// Reset function
const reset = () => {
  count.value = 0
}
</script>

<template>
  <!-- In the template, no .value needed -->
  <div class="counter">
    <p>Counter: {{ count }}</p>
    <button @click="increment">+1</button>
    <button @click="reset">Reset</button>
  </div>
</template>

관련 프로퍼티가 여러 개인 객체에는 reactive가 더 자연스러운 구문을 제공합니다. .value가 필요하지 않습니다.

UserProfile.vuejavascript
<script setup>
import { reactive } from 'vue'

// reactive for complex objects
// All properties are automatically reactive
const user = reactive({
  name: 'Jane Smith',
  email: 'jane@example.com',
  preferences: {
    theme: 'dark',
    notifications: true
  }
})

// Direct property modification (no .value)
const updateTheme = (newTheme) => {
  user.preferences.theme = newTheme
}

// Warning: do not reassign the entire object
// user = { ... } would break reactivity
</script>

<template>
  <div>
    <h2>{{ user.name }}</h2>
    <p>{{ user.email }}</p>
    <p>Theme: {{ user.preferences.theme }}</p>
  </div>
</template>

기본 원칙은 다음과 같습니다. 원시 값(문자열, 숫자, 불리언)에는 ref를, 구조화된 객체에는 reactive를 사용합니다.

computed를 활용한 계산된 속성

계산된 속성은 리액티브 상태로부터 값을 파생합니다. 캐시가 적용되므로 의존하는 데이터가 변경될 때만 재계산되어 높은 성능을 보장합니다.

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

// Product list
const products = ref([
  { id: 1, name: 'Laptop', price: 999, inStock: true },
  { id: 2, name: 'Mouse', price: 29, inStock: true },
  { id: 3, name: 'Keyboard', price: 79, inStock: false },
  { id: 4, name: 'Monitor', price: 299, inStock: true }
])

// Active filter
const showOnlyInStock = ref(false)

// computed: automatic filtering based on toggle
// Only recalculates if products or showOnlyInStock changes
const filteredProducts = computed(() => {
  if (showOnlyInStock.value) {
    return products.value.filter(p => p.inStock)
  }
  return products.value
})

// computed: total calculation with caching
const totalValue = computed(() => {
  return filteredProducts.value.reduce((sum, p) => sum + p.price, 0)
})

// computed: number of displayed items
const productCount = computed(() => filteredProducts.value.length)
</script>

<template>
  <div>
    <label>
      <input type="checkbox" v-model="showOnlyInStock" />
      Show only in-stock products
    </label>

    <p>{{ productCount }} products - Total: ${{ totalValue }}</p>

    <ul>
      <li v-for="product in filteredProducts" :key="product.id">
        {{ product.name }} - ${{ product.price }}
      </li>
    </ul>
  </div>
</template>

계산된 속성은 getter와 setter를 결합하여 쓰기 가능하게 만들 수도 있습니다. 양방향 변환이 필요한 경우에 유용합니다.

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

const firstName = ref('John')
const lastName = ref('Doe')

// computed with getter AND setter
const fullName = computed({
  // Read: combine first and last name
  get() {
    return `${firstName.value} ${lastName.value}`
  },
  // Write: split the string into first/last name
  set(newValue) {
    const parts = newValue.split(' ')
    firstName.value = parts[0] || ''
    lastName.value = parts.slice(1).join(' ') || ''
  }
})
</script>

<template>
  <!-- Modifying fullName updates firstName and lastName -->
  <input v-model="fullName" placeholder="Full name" />
  <p>First name: {{ firstName }}</p>
  <p>Last name: {{ lastName }}</p>
</template>

watch와 watchEffect를 활용한 감시자

감시자는 데이터 변경에 따라 부수 효과를 실행합니다. Vue 3에는 두 가지 방식이 있습니다. 정밀하게 제어할 수 있는 watch와 의존성을 자동으로 추적하는 watchEffect입니다.

watch와 watchEffect 중 어떤 것을 사용해야 합니까?

watch는 의존성을 명시적으로 지정할 수 있으며 변경 전후의 값을 모두 제공합니다. watchEffect는 사용하는 모든 리액티브 의존성에 대해 자동으로 실행되므로 더 간결하게 작성할 수 있습니다.

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

const searchQuery = ref('')
const searchResults = ref([])
const isLoading = ref(false)

// watch: observes a specific source
// Provides oldValue and newValue, allows debouncing
watch(searchQuery, async (newQuery, oldQuery) => {
  console.log(`Search changed from "${oldQuery}" to "${newQuery}"`)

  // Don't search if less than 3 characters
  if (newQuery.length < 3) {
    searchResults.value = []
    return
  }

  isLoading.value = true

  try {
    // Simulated API call
    const response = await fetch(`/api/search?q=${newQuery}`)
    searchResults.value = await response.json()
  } finally {
    isLoading.value = false
  }
}, {
  // Watcher options
  debounce: 300, // Wait 300ms after last keystroke
  immediate: false // Don't execute immediately
})

// watchEffect: automatic dependency tracking
// Runs immediately and on every change
watchEffect(() => {
  // All refs used here are automatically tracked
  console.log(`Current state: ${searchResults.value.length} results`)
  console.log(`Loading: ${isLoading.value}`)
})
</script>

<template>
  <div>
    <input v-model="searchQuery" placeholder="Search..." />
    <p v-if="isLoading">Loading...</p>
    <ul v-else>
      <li v-for="result in searchResults" :key="result.id">
        {{ result.title }}
      </li>
    </ul>
  </div>
</template>

중첩된 객체나 배열을 감시하려면 watchdeep 옵션을 지정해야 합니다.

DeepWatch.vuejavascript
<script setup>
import { reactive, watch } from 'vue'

const settings = reactive({
  display: {
    theme: 'light',
    fontSize: 14
  },
  notifications: {
    email: true,
    push: false
  }
})

// deep: true to observe nested changes
watch(
  () => settings.display,
  (newDisplay) => {
    console.log('Display settings changed:', newDisplay)
    // Save to localStorage for example
    localStorage.setItem('display', JSON.stringify(newDisplay))
  },
  { deep: true }
)
</script>

Vue.js / Nuxt.js 면접 준비가 되셨나요?

인터랙티브 시뮬레이터, flashcards, 기술 테스트로 연습하세요.

재사용 가능한 컴포저블 만들기

컴포저블은 재사용 가능한 리액티브 로직을 캡슐화하는 함수입니다. 컴포넌트 간 코드를 공유하는 데 있어 Composition API의 가장 큰 장점 중 하나입니다.

composables/useFetch.jsjavascript
import { ref, watchEffect, toValue } from 'vue'

// Composable for HTTP requests
// Automatically handles loading, errors, and refetching
export function useFetch(url) {
  const data = ref(null)
  const error = ref(null)
  const isLoading = ref(false)

  // Reusable fetch function
  async function fetchData() {
    isLoading.value = true
    error.value = null

    try {
      // toValue() allows accepting a ref or a value
      const response = await fetch(toValue(url))

      if (!response.ok) {
        throw new Error(`HTTP ${response.status}`)
      }

      data.value = await response.json()
    } catch (e) {
      error.value = e.message
    } finally {
      isLoading.value = false
    }
  }

  // watchEffect for automatic refetch if url is a ref
  watchEffect(() => {
    fetchData()
  })

  // Expose state and methods
  return {
    data,
    error,
    isLoading,
    refetch: fetchData
  }
}

이 컴포저블을 사용하면 어떤 컴포넌트에서든 선언적으로 데이터를 가져올 수 있습니다.

UserList.vuejavascript
<script setup>
import { useFetch } from '@/composables/useFetch'

// Simple composable usage
const { data: users, isLoading, error, refetch } = useFetch('/api/users')
</script>

<template>
  <div>
    <button @click="refetch" :disabled="isLoading">
      Refresh
    </button>

    <p v-if="isLoading">Loading users...</p>
    <p v-else-if="error" class="error">Error: {{ error }}</p>

    <ul v-else-if="users">
      <li v-for="user in users" :key="user.id">
        {{ user.name }}
      </li>
    </ul>
  </div>
</template>

다음은 유효성 검사를 포함한 폼 상태 관리 컴포저블의 예제입니다.

composables/useForm.jsjavascript
import { reactive, computed } from 'vue'

// Form management composable
export function useForm(initialValues, validationRules) {
  // Form state
  const form = reactive({
    values: { ...initialValues },
    errors: {},
    touched: {}
  })

  // Validate a specific field
  const validateField = (field) => {
    const rules = validationRules[field]
    if (!rules) return true

    for (const rule of rules) {
      const result = rule(form.values[field])
      if (result !== true) {
        form.errors[field] = result
        return false
      }
    }

    form.errors[field] = null
    return true
  }

  // Validate entire form
  const validate = () => {
    let isValid = true
    for (const field in validationRules) {
      if (!validateField(field)) {
        isValid = false
      }
    }
    return isValid
  }

  // computed: form is valid if no errors
  const isValid = computed(() => {
    return Object.values(form.errors).every(e => !e)
  })

  // Mark a field as touched (to display errors)
  const touch = (field) => {
    form.touched[field] = true
    validateField(field)
  }

  // Reset the form
  const reset = () => {
    form.values = { ...initialValues }
    form.errors = {}
    form.touched = {}
  }

  return {
    form,
    isValid,
    validate,
    validateField,
    touch,
    reset
  }
}

라이프사이클 훅 관리

Composition API에서는 라이프사이클 훅을 setup 내에서 호출하는 함수로 제공합니다. 컴포넌트 라이프사이클의 특정 시점에 코드를 실행할 수 있습니다.

클린업의 중요성

메모리 누수를 방지하기 위해 onUnmounted에서 타이머, 이벤트 리스너, 구독 등의 부수 효과를 반드시 정리하십시오.

LifecycleDemo.vuejavascript
<script setup>
import {
  ref,
  onMounted,
  onUnmounted,
  onBeforeUpdate,
  onUpdated
} from 'vue'

const windowWidth = ref(window.innerWidth)
const updateCount = ref(0)

// Handler for resize
const handleResize = () => {
  windowWidth.value = window.innerWidth
}

// onMounted: component inserted into DOM
// Ideal for initial API calls and event listeners
onMounted(() => {
  console.log('Component mounted')
  window.addEventListener('resize', handleResize)

  // Example: initialize a third-party library
  // chart = new Chart(chartRef.value, config)
})

// onUnmounted: component removed from DOM
// CRITICAL: clean up all side effects
onUnmounted(() => {
  console.log('Component unmounted')
  window.removeEventListener('resize', handleResize)

  // Clean up subscriptions, timers, etc.
  // chart?.destroy()
})

// onBeforeUpdate: before DOM update
onBeforeUpdate(() => {
  console.log('Before update')
})

// onUpdated: after DOM update
onUpdated(() => {
  updateCount.value++
  console.log(`DOM updated (${updateCount.value} times)`)
})
</script>

<template>
  <div>
    <p>Window width: {{ windowWidth }}px</p>
    <p>DOM updates: {{ updateCount }}</p>
  </div>
</template>

템플릿 참조와 DOM 접근

템플릿 참조를 사용하면 DOM 요소나 자식 컴포넌트 인스턴스에 직접 접근할 수 있습니다. 직접 조작이 필요한 경우에 여전히 유용합니다.

InputFocus.vuejavascript
<script setup>
import { ref, onMounted } from 'vue'

// ref for the DOM element
// Same name as the ref attribute in the template
const inputRef = ref(null)

// Function to focus the input
const focusInput = () => {
  // Access to the native DOM element
  inputRef.value?.focus()
}

// Auto-focus on mount
onMounted(() => {
  focusInput()
})

// Expose the method to parent if needed
defineExpose({
  focus: focusInput
})
</script>

<template>
  <div>
    <!-- The ref attribute binds the element to inputRef -->
    <input ref="inputRef" type="text" placeholder="Auto-focused" />
    <button @click="focusInput">Refocus</button>
  </div>
</template>

Props와 Emits를 활용한 부모-자식 통신

Composition API에서는 definePropsdefineEmits를 사용하여 props와 이벤트 선언 방식을 현대화했습니다. TypeScript와의 통합도 우수합니다.

ChildComponent.vuejavascript
<script setup>
// defineProps: declares expected props
// With default values via withDefaults
const props = withDefaults(defineProps<{
  title: string
  count?: number
  items?: string[]
}>(), {
  count: 0,
  items: () => []
})

// defineEmits: declares emitted events
// Precise payload typing
const emit = defineEmits<{
  (e: 'update', value: number): void
  (e: 'submit', data: { title: string; items: string[] }): void
}>()

// Function that emits an event
const handleSubmit = () => {
  emit('submit', {
    title: props.title,
    items: props.items
  })
}

const increment = () => {
  emit('update', props.count + 1)
}
</script>

<template>
  <div class="card">
    <h3>{{ title }}</h3>
    <p>Count: {{ count }}</p>
    <button @click="increment">Increment</button>
    <button @click="handleSubmit">Submit</button>
  </div>
</template>
ParentComponent.vuejavascript
<script setup>
import { ref } from 'vue'
import ChildComponent from './ChildComponent.vue'

const currentCount = ref(0)
const items = ref(['Item 1', 'Item 2'])

// Handler for update event
const onUpdate = (newValue) => {
  currentCount.value = newValue
}

// Handler for submit event
const onSubmit = (data) => {
  console.log('Data submitted:', data)
}
</script>

<template>
  <ChildComponent
    title="My Component"
    :count="currentCount"
    :items="items"
    @update="onUpdate"
    @submit="onSubmit"
  />
</template>

Vue.js / Nuxt.js 면접 준비가 되셨나요?

인터랙티브 시뮬레이터, flashcards, 기술 테스트로 연습하세요.

provide와 inject를 활용한 의존성 주입

props 전달 체인(prop drilling) 없이 멀리 떨어진 컴포넌트 간에 데이터를 공유하기 위해 Vue 3는 provideinject를 제공합니다.

App.vue (or an ancestor component)javascript
<script setup>
import { provide, ref, readonly } from 'vue'

// Global theme state
const theme = ref('light')

// Function to toggle theme
const toggleTheme = () => {
  theme.value = theme.value === 'light' ? 'dark' : 'light'
}

// provide: makes available to descendants
// readonly prevents direct modification by children
provide('theme', readonly(theme))
provide('toggleTheme', toggleTheme)
</script>

<template>
  <div :class="theme">
    <slot />
  </div>
</template>
DeepNestedComponent.vue (anywhere in the tree)javascript
<script setup>
import { inject } from 'vue'

// inject: retrieves value provided by an ancestor
// Default value if not found
const theme = inject('theme', 'light')
const toggleTheme = inject('toggleTheme', () => {})
</script>

<template>
  <div>
    <p>Current theme: {{ theme }}</p>
    <button @click="toggleTheme">
      Switch to {{ theme === 'light' ? 'dark' : 'light' }}
    </button>
  </div>
</template>

결론

Vue 3의 Composition API는 Vue 애플리케이션의 코드를 구성하는 강력하고 유연한 방법을 제공합니다. 핵심 요점은 다음과 같습니다.

  • 원시 값에는 ref, 복잡한 객체에는 reactive를 사용합니다
  • computed로 자동 캐싱이 적용되는 파생 값을 정의합니다
  • watchwatchEffect로 리액티브 부수 효과를 관리합니다
  • 컴포저블로 컴포넌트 간 로직을 추출하고 재사용합니다
  • 함수형 라이프사이클 훅(onMounted, onUnmounted 등)을 활용합니다
  • provide/inject로 props 전달 체인 없이 의존성 주입을 수행합니다

이 접근 방식을 통해 유지보수성과 테스트 용이성이 뛰어난 애플리케이션을 구축하면서 TypeScript와의 우수한 통합도 실현할 수 있습니다. 다음 단계로 비동기 컴포저블이나 Pinia를 활용한 전역 상태 관리와 같은 고급 패턴을 탐구해 보십시오.

연습을 시작하세요!

면접 시뮬레이터와 기술 테스트로 지식을 테스트하세요.

태그

#vue 3
#composition api
#javascript
#frontend
#reactivity

공유

관련 기사