14.7 Vue 3 실전 고수 팁
Vue 3를 실무 수준으로 사용하려면 단순한 문법 이상이 필요합니다. 이 장에서는 Composable 패턴, <script setup> 심화, TypeScript 연동, Vitest 테스팅이라는 네 가지 핵심 주제를 통해 프로덕션 품질의 Vue 앱을 만드는 방법을 다룹니다.
1. Composable 패턴
Composable은 Vue 3 Composition API를 기반으로 상태와 로직을 재사용 가능한 함수로 캡슐화하는 패턴입니다. React의 Custom Hook과 동일한 개념입니다.
기본 구조
// composables/useCounter.js
import { ref, computed } from 'vue'
export function useCounter(initialValue = 0) {
const count = ref(initialValue)
const doubled = computed(() => count.value * 2)
function increment() { count.value++ }
function decrement() { count.value-- }
function reset() { count.value = initialValue }
return { count, doubled, increment, decrement, reset }
}
<!-- 컴포넌트에서 사용 -->
<script setup>
import { useCounter } from '@/composables/useCounter'
const { count, doubled, increment, decrement, reset } = useCounter(10)
</script>
<template>
<div>
<p>{{ count }} (doubled: {{ doubled }})</p>
<button @click="increment">+</button>
<button @click="decrement">-</button>
<button @click="reset">초기화</button>
</div>
</template>
실전 예제: 비동기 데이터 fetching
// composables/useFetch.js
import { ref, watchEffect, toValue } from 'vue'
export function useFetch(url) {
const data = ref(null)
const error = ref(null)
const isLoading = ref(false)
async function fetchData() {
// toValue(): ref, getter, 일반 값 모두 처리 (Vue 3.3+)
const resolvedUrl = toValue(url)
if (!resolvedUrl) return
isLoading.value = true
error.value = null
try {
const response = await fetch(resolvedUrl)
if (!response.ok) throw new Error(`HTTP ${response.status}`)
data.value = await response.json()
} catch (err) {
error.value = err.message
} finally {
isLoading.value = false
}
}
// url이 reactive하면 변경 시 자동으로 다시 fetch
watchEffect(() => fetchData())
return { data, error, isLoading, refetch: fetchData }
}
<script setup>
import { ref } from 'vue'
import { useFetch } from '@/composables/useFetch'
const userId = ref(1)
// userId가 변경되면 자동으로 새 데이터를 가져옴
const { data: user, error, isLoading } = useFetch(
() => `https://jsonplaceholder.typicode.com/users/${userId.value}`
)
</script>
<template>
<div>
<button @click="userId++">다음 사용자</button>
<p v-if="isLoading">로딩 중...</p>
<p v-else-if="error">오류: {{ error }}</p>
<pre v-else>{{ user }}</pre>
</div>
</template>
실전 예제: 폼 유효성 검사 Composable
// composables/useForm.js
import { reactive, computed } from 'vue'
export function useForm(initialValues, validationRules) {
const values = reactive({ ...initialValues })
const touched = reactive({})
const errors = reactive({})
function validate(field) {
const rule = validationRules[field]
if (!rule) return true
const value = values[field]
const error = rule(value, values)
if (error) {
errors[field] = error
return false
} else {
delete errors[field]
return true
}
}
function handleBlur(field) {
touched[field] = true
validate(field)
}
function validateAll() {
let isValid = true
for (const field in validationRules) {
touched[field] = true
if (!validate(field)) isValid = false
}
return isValid
}
const isValid = computed(() => Object.keys(errors).length === 0)
const isDirty = computed(() =>
Object.keys(initialValues).some((key) => values[key] !== initialValues[key])
)
function reset() {
Object.assign(values, initialValues)
Object.keys(touched).forEach((k) => delete touched[k])
Object.keys(errors).forEach((k) => delete errors[k])
}
return { values, touched, errors, isValid, isDirty, handleBlur, validateAll, reset }
}
<!-- RegisterForm.vue -->
<script setup>
import { useForm } from '@/composables/useForm'
const { values, touched, errors, isValid, handleBlur, validateAll, reset } = useForm(
{ name: '', email: '', password: '' },
{
name: (v) => !v ? '이름을 입력하세요' : v.length < 2 ? '2자 이상 입력하세요' : null,
email: (v) => !v ? '이메일을 입력하세요' : !/\S+@\S+\.\S+/.test(v) ? '올바른 이메일 형식이 아닙니다' : null,
password: (v) => !v ? '비밀번호를 입력하세요' : v.length < 8 ? '8자 이상 입력하세요' : null,
}
)
function handleSubmit() {
if (validateAll()) {
console.log('제출:', values)
}
}
</script>
<template>
<form @submit.prevent="handleSubmit">
<div>
<input v-model="values.name" placeholder="이름" @blur="handleBlur('name')" />
<span v-if="touched.name && errors.name" class="error">{{ errors.name }}</span>
</div>
<div>
<input v-model="values.email" placeholder="이메일" @blur="handleBlur('email')" />
<span v-if="touched.email && errors.email" class="error">{{ errors.email }}</span>
</div>
<div>
<input v-model="values.password" type="password" placeholder="비밀번호" @blur="handleBlur('password')" />
<span v-if="touched.password && errors.password" class="error">{{ errors.password }}</span>
</div>
<button type="submit" :disabled="!isValid">가입하기</button>
<button type="button" @click="reset">초기화</button>
</form>
</template>
2. <script setup> 심화
<script setup>은 setup() 함수를 컴파일 타임 신택스 슈가로 변환해 더 간결한 코드를 작성할 수 있게 합니다.
defineProps와 defineEmits
<!-- ParentComponent.vue -->
<script setup>
import { ref } from 'vue'
import ChildComponent from './ChildComponent.vue'
const parentMessage = ref('부모 메시지')
const items = ref(['사과', '바나나', '체리'])
function handleUpdate(newMessage) {
parentMessage.value = newMessage
}
</script>
<template>
<ChildComponent
:message="parentMessage"
:items="items"
@update:message="handleUpdate"
@item-selected="console.log('선택:', $event)"
/>
</template>
<!-- ChildComponent.vue -->
<script setup>
// Props 정의 (타입 + 기본값 + 필수 여부)
const props = defineProps({
message: {
type: String,
required: true,
},
items: {
type: Array,
default: () => [],
},
count: {
type: Number,
default: 0,
validator: (v) => v >= 0,
},
})
// Emits 정의 (이벤트 이름 + 유효성 검사)
const emit = defineEmits({
'update:message': (value) => typeof value === 'string',
'item-selected': (item) => item !== undefined,
})
function updateMessage() {
emit('update:message', '새로운 메시지')
}
function selectItem(item) {
emit('item-selected', item)
}
</script>
<template>
<div>
<p>{{ message }}</p>
<ul>
<li
v-for="item in items"
:key="item"
@click="selectItem(item)"
style="cursor: pointer"
>
{{ item }}
</li>
</ul>
<button @click="updateMessage">메시지 업데이트</button>
</div>
</template>
defineExpose — 부모에서 자식 메서드 호출
<!-- InputWithValidation.vue -->
<script setup>
import { ref } from 'vue'
const inputRef = ref(null)
const value = ref('')
const isValid = ref(true)
function focus() {
inputRef.value?.focus()
}
function validate() {
isValid.value = value.value.length >= 3
return isValid.value
}
function clear() {
value.value = ''
isValid.value = true
}
// 부모 컴포넌트가 접근할 수 있도록 명시적으로 노출
defineExpose({ focus, validate, clear })
</script>
<template>
<input
ref="inputRef"
v-model="value"
:class="{ invalid: !isValid }"
placeholder="3자 이상 입력"
/>
</template>
<!-- ParentForm.vue -->
<script setup>
import { ref } from 'vue'
import InputWithValidation from './InputWithValidation.vue'
const inputRef = ref(null)
function submitForm() {
if (inputRef.value?.validate()) {
console.log('폼 제출')
} else {
inputRef.value?.focus()
}
}
</script>
<template>
<form @submit.prevent="submitForm">
<InputWithValidation ref="inputRef" />
<button type="submit">제출</button>
</form>
</template>
useTemplateRef — Vue 3.5 신기능
<script setup>
import { useTemplateRef, onMounted } from 'vue'
// Vue 3.5+: 타입 안전한 template ref
const inputEl = useTemplateRef('my-input')
onMounted(() => {
inputEl.value?.focus()
})
</script>
<template>
<!-- ref 이름이 useTemplateRef 인자와 일치해야 함 -->
<input ref="my-input" placeholder="자동 포커스" />
</template>
defineModel — v-model 간소화 (Vue 3.4+)
<!-- BeforeVue34.vue (구 방식) -->
<script setup>
const props = defineProps({ modelValue: String })
const emit = defineEmits(['update:modelValue'])
function onInput(e) {
emit('update:modelValue', e.target.value)
}
</script>
<!-- AfterVue34.vue (새 방식 — Vue 3.4+) -->
<script setup>
const model = defineModel() // 자동으로 props + emit 처리
</script>
<template>
<input :value="model" @input="model = $event.target.value" />
</template>
<!-- 여러 v-model 지원 -->
<script setup>
const firstName = defineModel('firstName')
const lastName = defineModel('lastName')
</script>
<template>
<input v-model="firstName" placeholder="이름" />
<input v-model="lastName" placeholder="성" />
</template>
3. TypeScript 연동
Vue 3는 TypeScript로 처음부터 작성되어 뛰어난 타입 지원을 제공합니다.
기본 설정
npm create vue@latest my-vue-ts-app
# TypeScript 지원? → Yes
// tsconfig.json (핵심 설정)
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"jsx": "preserve",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"paths": {
"@/*": ["./src/*"]
}
}
}
TypeScript로 컴포넌트 작성
<!-- UserProfile.vue -->
<script setup lang="ts">
import { ref, computed } from 'vue'
// 타입 정의
interface User {
id: number
name: string
email: string
avatar?: string
role: 'admin' | 'editor' | 'viewer'
}
interface Props {
user: User
showEmail?: boolean
}
// withDefaults: 기본값 설정
const props = withDefaults(defineProps<Props>(), {
showEmail: false,
})
const emit = defineEmits<{
'edit': [user: User]
'delete': [userId: number]
'role-change': [userId: number, role: User['role']]
}>()
const isExpanded = ref(false)
const displayName = computed(() =>
props.user.name.split(' ').map((n) => n[0].toUpperCase() + n.slice(1)).join(' ')
)
function handleEdit() {
emit('edit', props.user)
}
function handleRoleChange(newRole: User['role']) {
emit('role-change', props.user.id, newRole)
}
</script>
<template>
<div class="user-profile">
<img :src="user.avatar ?? '/default-avatar.png'" :alt="user.name" />
<h3>{{ displayName }}</h3>
<p v-if="showEmail">{{ user.email }}</p>
<span class="role-badge">{{ user.role }}</span>
<div v-if="isExpanded" class="actions">
<button @click="handleEdit">편집</button>
<select @change="handleRoleChange(($event.target as HTMLSelectElement).value as User['role'])">
<option value="admin">관리자</option>
<option value="editor">편집자</option>
<option value="viewer">뷰어</option>
</select>
</div>
<button @click="isExpanded = !isExpanded">
{{ isExpanded ? '접기' : '펼치기' }}
</button>
</div>
</template>
TypeScript Composable
// composables/useLocalStorage.ts
import { ref, watch } from 'vue'
export function useLocalStorage<T>(key: string, defaultValue: T) {
const storedValue = localStorage.getItem(key)
const initial: T = storedValue ? (JSON.parse(storedValue) as T) : defaultValue
const value = ref<T>(initial)
watch(
value,
(newValue) => {
localStorage.setItem(key, JSON.stringify(newValue))
},
{ deep: true }
)
function remove() {
localStorage.removeItem(key)
value.value = defaultValue
}
return { value, remove }
}
<script setup lang="ts">
import { useLocalStorage } from '@/composables/useLocalStorage'
interface Settings {
theme: 'light' | 'dark'
language: string
notifications: boolean
}
// 타입이 자동 추론됨
const { value: settings } = useLocalStorage<Settings>('app-settings', {
theme: 'light',
language: 'ko',
notifications: true,
})
</script>
<template>
<div>
<button @click="settings.theme = settings.theme === 'light' ? 'dark' : 'light'">
테마: {{ settings.theme }}
</button>
</div>
</template>
4. Vitest 테스팅
Vitest는 Vite 기반 Vue 프로젝트를 위한 빠르고 가벼운 테스트 프레임워크입니다. Jest와 호환되는 API를 제공합니다.
설치
npm install -D vitest @vue/test-utils jsdom @vitejs/plugin-vue
// vitest.config.ts
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
export default defineConfig({
plugins: [vue()],
test: {
environment: 'jsdom',
globals: true,
},
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
},
},
})
Composable 테스트
// composables/__tests__/useCounter.test.ts
import { describe, it, expect, beforeEach } from 'vitest'
import { useCounter } from '@/composables/useCounter'
describe('useCounter', () => {
it('초기값으로 시작한다', () => {
const { count } = useCounter(5)
expect(count.value).toBe(5)
})
it('increment가 count를 1 증가시킨다', () => {
const { count, increment } = useCounter(0)
increment()
expect(count.value).toBe(1)
})
it('decrement가 count를 1 감소시킨다', () => {
const { count, decrement } = useCounter(5)
decrement()
expect(count.value).toBe(4)
})
it('reset이 초기값으로 돌아간다', () => {
const { count, increment, reset } = useCounter(3)
increment()
increment()
expect(count.value).toBe(5)
reset()
expect(count.value).toBe(3)
})
it('doubled가 count의 두 배를 반환한다', () => {
const { count, doubled, increment } = useCounter(2)
expect(doubled.value).toBe(4)
increment()
expect(doubled.value).toBe(6)
})
})
컴포넌트 테스트
// components/__tests__/ShoppingCart.test.ts
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import ShoppingCart from '@/components/ShoppingCart.vue'
import { useCartStore } from '@/stores/cart'
describe('ShoppingCart', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('빈 장바구니 메시지를 렌더링한다', () => {
const wrapper = mount(ShoppingCart)
expect(wrapper.text()).toContain('장바구니가 비어있습니다')
})
it('상품이 추가되면 목록을 표시한다', async () => {
const cart = useCartStore()
cart.addItem({ id: 1, name: '사과', price: 1000, image: '/apple.jpg' })
const wrapper = mount(ShoppingCart)
expect(wrapper.text()).toContain('사과')
expect(wrapper.text()).toContain('1,000')
})
it('삭제 버튼 클릭 시 상품이 제거된다', async () => {
const cart = useCartStore()
cart.addItem({ id: 1, name: '사과', price: 1000, image: '/apple.jpg' })
const wrapper = mount(ShoppingCart)
await wrapper.find('.remove-btn').trigger('click')
expect(cart.items).toHaveLength(0)
})
it('totalItems가 올바르게 계산된다', () => {
const cart = useCartStore()
cart.addItem({ id: 1, name: '사과', price: 1000, image: '/apple.jpg' })
cart.addItem({ id: 1, name: '사과', price: 1000, image: '/apple.jpg' }) // 수량 증가
cart.addItem({ id: 2, name: '바나나', price: 500, image: '/banana.jpg' })
expect(cart.totalItems).toBe(3)
})
})
비동기 Composable 테스트
// composables/__tests__/useFetch.test.ts
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { nextTick } from 'vue'
import { useFetch } from '@/composables/useFetch'
describe('useFetch', () => {
beforeEach(() => {
// fetch를 mock으로 교체
global.fetch = vi.fn()
})
afterEach(() => {
vi.restoreAllMocks()
})
it('성공적으로 데이터를 가져온다', async () => {
const mockData = { id: 1, name: 'Test User' }
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => mockData,
} as Response)
const { data, isLoading, error } = useFetch('https://api.example.com/user/1')
// 초기 상태: 로딩 중
expect(isLoading.value).toBe(true)
await nextTick()
await nextTick()
expect(isLoading.value).toBe(false)
expect(data.value).toEqual(mockData)
expect(error.value).toBeNull()
})
it('에러 발생 시 error 상태를 설정한다', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: false,
status: 404,
} as Response)
const { data, error, isLoading } = useFetch('https://api.example.com/not-found')
await nextTick()
await nextTick()
expect(isLoading.value).toBe(false)
expect(error.value).toBe('HTTP 404')
expect(data.value).toBeNull()
})
})
Pinia Store 테스트
// stores/__tests__/cart.test.ts
import { describe, it, expect, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useCartStore } from '@/stores/cart'
describe('useCartStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('초기 상태가 비어있다', () => {
const cart = useCartStore()
expect(cart.items).toHaveLength(0)
expect(cart.isEmpty).toBe(true)
expect(cart.totalItems).toBe(0)
})
it('같은 상품 추가 시 수량이 증가한다', () => {
const cart = useCartStore()
const product = { id: 1, name: '사과', price: 1000, image: '/apple.jpg' }
cart.addItem(product)
cart.addItem(product)
expect(cart.items).toHaveLength(1)
expect(cart.items[0].quantity).toBe(2)
})
it('수량을 0으로 업데이트하면 상품이 제거된다', () => {
const cart = useCartStore()
cart.addItem({ id: 1, name: '사과', price: 1000, image: '/apple.jpg' })
cart.updateQuantity(1, 0)
expect(cart.items).toHaveLength(0)
})
it('totalPrice가 올바르게 계산된다', () => {
const cart = useCartStore()
cart.addItem({ id: 1, name: '사과', price: 1000, image: '/apple.jpg' })
cart.addItem({ id: 2, name: '바나나', price: 500, image: '/banana.jpg' })
cart.updateQuantity(1, 3) // 사과 3개: 3000원
expect(cart.totalPrice).toBe(3500) // 3000 + 500
})
})
통합 실전 예제: Todo 앱 전체 구성
Store
// stores/todo.ts
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
export interface Todo {
id: number
text: string
completed: boolean
createdAt: Date
}
export type Filter = 'all' | 'active' | 'completed'
export const useTodoStore = defineStore('todo', () => {
const todos = ref<Todo[]>([])
const filter = ref<Filter>('all')
const filteredTodos = computed(() => {
switch (filter.value) {
case 'active': return todos.value.filter((t) => !t.completed)
case 'completed': return todos.value.filter((t) => t.completed)
default: return todos.value
}
})
const stats = computed(() => ({
total: todos.value.length,
active: todos.value.filter((t) => !t.completed).length,
completed: todos.value.filter((t) => t.completed).length,
}))
function addTodo(text: string) {
if (!text.trim()) return
todos.value.push({
id: Date.now(),
text: text.trim(),
completed: false,
createdAt: new Date(),
})
}
function toggleTodo(id: number) {
const todo = todos.value.find((t) => t.id === id)
if (todo) todo.completed = !todo.completed
}
function removeTodo(id: number) {
todos.value = todos.value.filter((t) => t.id !== id)
}
function clearCompleted() {
todos.value = todos.value.filter((t) => !t.completed)
}
return { todos, filter, filteredTodos, stats, addTodo, toggleTodo, removeTodo, clearCompleted }
})
컴포넌트
<!-- TodoApp.vue -->
<script setup lang="ts">
import { ref } from 'vue'
import { storeToRefs } from 'pinia'
import { useTodoStore } from '@/stores/todo'
import type { Filter } from '@/stores/todo'
const store = useTodoStore()
const { filteredTodos, filter, stats } = storeToRefs(store)
const newTodoText = ref('')
function handleAdd() {
store.addTodo(newTodoText.value)
newTodoText.value = ''
}
</script>
<template>
<div class="todo-app">
<h1>할 일 목록</h1>
<form @submit.prevent="handleAdd" class="add-form">
<input
v-model="newTodoText"
placeholder="새 할 일 입력..."
class="todo-input"
/>
<button type="submit">추가</button>
</form>
<div class="filters">
<button
v-for="f in (['all', 'active', 'completed'] as Filter[])"
:key="f"
:class="{ active: filter === f }"
@click="store.filter = f"
>
{{ f === 'all' ? '전체' : f === 'active' ? '진행 중' : '완료' }}
</button>
</div>
<ul class="todo-list">
<li
v-for="todo in filteredTodos"
:key="todo.id"
:class="{ completed: todo.completed }"
class="todo-item"
>
<input
type="checkbox"
:checked="todo.completed"
@change="store.toggleTodo(todo.id)"
/>
<span>{{ todo.text }}</span>
<button @click="store.removeTodo(todo.id)">삭제</button>
</li>
</ul>
<div class="stats">
<span>전체: {{ stats.total }} / 진행 중: {{ stats.active }} / 완료: {{ stats.completed }}</span>
<button v-if="stats.completed > 0" @click="store.clearCompleted">
완료 항목 삭제
</button>
</div>
</div>
</template>
테스트
// stores/__tests__/todo.test.ts
import { describe, it, expect, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useTodoStore } from '@/stores/todo'
describe('useTodoStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('할 일을 추가할 수 있다', () => {
const store = useTodoStore()
store.addTodo('Vue 공부하기')
expect(store.todos).toHaveLength(1)
expect(store.todos[0].text).toBe('Vue 공부하기')
expect(store.todos[0].completed).toBe(false)
})
it('빈 텍스트는 추가되지 않는다', () => {
const store = useTodoStore()
store.addTodo(' ')
expect(store.todos).toHaveLength(0)
})
it('완료 토글이 동작한다', () => {
const store = useTodoStore()
store.addTodo('테스트')
const id = store.todos[0].id
store.toggleTodo(id)
expect(store.todos[0].completed).toBe(true)
store.toggleTodo(id)
expect(store.todos[0].completed).toBe(false)
})
it('필터가 올바르게 동작한다', () => {
const store = useTodoStore()
store.addTodo('할 일 1')
store.addTodo('할 일 2')
store.toggleTodo(store.todos[0].id)
store.filter = 'active'
expect(store.filteredTodos).toHaveLength(1)
store.filter = 'completed'
expect(store.filteredTodos).toHaveLength(1)
store.filter = 'all'
expect(store.filteredTodos).toHaveLength(2)
})
})
빠른 참조 치트시트
Composable 체크리스트
use로 시작하는 함수명 (useCounter,useFetch)- 내부에서 Vue reactivity API 사용 (
ref,computed,watch) onMounted등 라이프사이클 훅 사용 가능- 필요한 상태와 함수만 반환
- 컴포넌트 외부에서는
app.runWithContext()또는 Pinia로 대체
<script setup> 핵심 API
| API | 용도 |
|---|---|
defineProps | Props 선언 |
defineEmits | Emit 이벤트 선언 |
defineExpose | 부모에 노출할 멤버 |
defineModel | v-model 간소화 (3.4+) |
useTemplateRef | 타입 안전한 template ref (3.5+) |
withDefaults | Props 기본값 설정 (TS) |
Vitest 주요 함수
| 함수 | 용도 |
|---|---|
describe | 테스트 그룹 |
it / test | 개별 테스트 케이스 |
expect | 단언 |
beforeEach | 각 테스트 전 실행 |
vi.fn() | Mock 함수 생성 |
vi.mocked() | Mock 타입 캐스팅 |
vi.spyOn() | 기존 함수 스파이 |
nextTick() | DOM 업데이트 대기 |