Composables 실전
패턴을 알았으니 실전입니다. 프로덕션에서 자주 쓰이는 composable을 직접 만들어보면서 설계 감각을 키워봅시다.
Composables 패턴 편에서 기초를 다뤘다면, 이번에는 실전에서 바로 활용할 수 있는 구체적인 예제들을 모았습니다.
useForm — 폼 검증
// composables/useForm.ts
import { ref, computed, reactive } from 'vue'
type ValidationRule = (value: any) => string | true
type FieldRules = Record<string, ValidationRule[]>
export function useForm<T extends Record<string, any>>(
initialValues: T,
rules: Partial<Record<keyof T, ValidationRule[]>> = {}
) {
const values = reactive({ ...initialValues }) as T
const errors = reactive<Record<string, string>>({})
const touched = reactive<Record<string, boolean>>({})
const isValid = computed(() =>
Object.keys(errors).every(key => !errors[key])
)
const isDirty = computed(() =>
Object.keys(values).some(
key => values[key] !== initialValues[key]
)
)
const validateField = (field: keyof T) => {
const fieldRules = rules[field] || []
for (const rule of fieldRules) {
const result = rule(values[field])
if (result !== true) {
errors[field as string] = result
return false
}
}
errors[field as string] = ''
return true
}
const validateAll = () => {
let valid = true
for (const field of Object.keys(rules)) {
if (!validateField(field as keyof T)) {
valid = false
}
}
return valid
}
const handleBlur = (field: keyof T) => {
touched[field as string] = true
validateField(field)
}
const reset = () => {
Object.assign(values, initialValues)
Object.keys(errors).forEach(key => { errors[key] = '' })
Object.keys(touched).forEach(key => { touched[key] = false })
}
return { values, errors, touched, isValid, isDirty, validateField, validateAll, handleBlur, reset }
}
// 공통 유효성 검사 규칙
export const required = (msg = '필수 입력 항목입니다'): ValidationRule =>
(value) => (value ? true : msg)
export const minLength = (min: number): ValidationRule =>
(value) => (value.length >= min ? true : `최소 ${min}자 이상 입력하세요`)
export const email: ValidationRule = (value) =>
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) ? true : '유효한 이메일을 입력하세요'
<script setup lang="ts">
import { useForm, required, minLength, email } from '@/composables/useForm'
const { values, errors, touched, isValid, handleBlur, validateAll, reset } = useForm(
{ name: '', email: '', password: '' },
{
name: [required(), minLength(2)],
email: [required(), email],
password: [required(), minLength(8)]
}
)
const handleSubmit = () => {
if (validateAll()) {
console.log('제출:', values)
}
}
</script>
<template>
<form @submit.prevent="handleSubmit">
<div>
<input v-model="values.name" @blur="handleBlur('name')" placeholder="이름" />
<span v-if="touched.name && errors.name">{{ errors.name }}</span>
</div>
<div>
<input v-model="values.email" @blur="handleBlur('email')" placeholder="이메일" />
<span v-if="touched.email && errors.email">{{ errors.email }}</span>
</div>
<div>
<input v-model="values.password" @blur="handleBlur('password')" type="password" placeholder="비밀번호" />
<span v-if="touched.password && errors.password">{{ errors.password }}</span>
</div>
<button type="submit" :disabled="!isValid">제출</button>
<button type="button" @click="reset">초기화</button>
</form>
</template>
useInfiniteScroll — 무한 스크롤
// composables/useInfiniteScroll.ts
import { ref, onMounted, onUnmounted } from 'vue'
interface UseInfiniteScrollOptions {
threshold?: number // 하단에서 몇 px 전에 로드할지
container?: HTMLElement | null
}
export function useInfiniteScroll(
loadMore: () => Promise<void>,
options: UseInfiniteScrollOptions = {}
) {
const { threshold = 200 } = options
const isLoading = ref(false)
const isFinished = ref(false)
const checkScroll = async () => {
if (isLoading.value || isFinished.value) return
const scrollHeight = document.documentElement.scrollHeight
const scrollTop = window.scrollY
const clientHeight = window.innerHeight
if (scrollHeight - scrollTop - clientHeight < threshold) {
isLoading.value = true
try {
await loadMore()
} finally {
isLoading.value = false
}
}
}
onMounted(() => {
window.addEventListener('scroll', checkScroll)
})
onUnmounted(() => {
window.removeEventListener('scroll', checkScroll)
})
const finish = () => { isFinished.value = true }
return { isLoading, isFinished, finish }
}
useAsync — 비동기 상태 관리
// composables/useAsync.ts
import { ref, type Ref } from 'vue'
interface UseAsyncReturn<T> {
data: Ref<T | null>
error: Ref<Error | null>
isLoading: Ref<boolean>
execute: (...args: any[]) => Promise<T | null>
}
export function useAsync<T>(
asyncFn: (...args: any[]) => Promise<T>
): UseAsyncReturn<T> {
const data = ref<T | null>(null) as Ref<T | null>
const error = ref<Error | null>(null)
const isLoading = ref(false)
const execute = async (...args: any[]): Promise<T | null> => {
isLoading.value = true
error.value = null
try {
const result = await asyncFn(...args)
data.value = result
return result
} catch (e) {
error.value = e as Error
return null
} finally {
isLoading.value = false
}
}
return { data, error, isLoading, execute }
}
<script setup lang="ts">
import { useAsync } from '@/composables/useAsync'
interface User {
id: number
name: string
}
const fetchUser = async (id: number): Promise<User> => {
const res = await fetch(`/api/users/${id}`)
return res.json()
}
const { data: user, error, isLoading, execute } = useAsync(fetchUser)
// 수동으로 실행
execute(1)
</script>
<template>
<div v-if="isLoading">로딩 중...</div>
<div v-else-if="error">{{ error.message }}</div>
<div v-else-if="user">{{ user.name }}</div>
<button @click="execute(2)">다른 사용자</button>
</template>
useEventListener — 이벤트 리스너 관리
// composables/useEventListener.ts
import { onMounted, onUnmounted, type MaybeRefOrGetter, toValue, watch } from 'vue'
export function useEventListener(
target: MaybeRefOrGetter<EventTarget | null | undefined>,
event: string,
handler: EventListener,
options?: AddEventListenerOptions
) {
let cleanup: (() => void) | undefined
const attach = () => {
cleanup?.()
const el = toValue(target)
if (!el) return
el.addEventListener(event, handler, options)
cleanup = () => el.removeEventListener(event, handler, options)
}
onMounted(attach)
onUnmounted(() => cleanup?.())
// target이 반응형이면 변경 시 재연결
watch(() => toValue(target), attach)
}
useClipboard — 클립보드 복사
// composables/useClipboard.ts
import { ref } from 'vue'
export function useClipboard() {
const copied = ref(false)
const text = ref('')
const copy = async (value: string) => {
try {
await navigator.clipboard.writeText(value)
text.value = value
copied.value = true
setTimeout(() => {
copied.value = false
}, 2000)
} catch (e) {
console.error('클립보드 복사 실패:', e)
}
}
return { copied, text, copy }
}
Composable 조합 패턴
// 여러 composable을 조합하여 더 복잡한 기능 구현
export function useSearchWithDebounce(apiUrl: string) {
const query = ref('')
const debouncedQuery = useDebounce(query, 300)
const { data, error, isLoading } = useFetch<any[]>(
() => `${apiUrl}?q=${debouncedQuery.value}`
)
return { query, results: data, error, isLoading }
}
면접 팁
- composable을 "직접 만들어본 경험"이 있으면 구체적인 예시를 들 수 있어 좋습니다
- 조합 패턴 — 작은 composable을 조합하여 큰 기능을 만드는 것이 핵심 설계 원칙
- VueUse 라이브러리를 알고 있다면, "바퀴를 재발명하지 않는다"는 실용적 관점도 보여줄 수 있습니다
요약
실전 composable은 폼 검증(useForm), 비동기 상태(useAsync), 무한 스크롤(useInfiniteScroll) 등 반복되는 로직을 캡슐화합니다. 작은 composable을 조합하여 복잡한 기능을 구현하는 것이 핵심이며, VueUse 같은 검증된 라이브러리도 적극 활용하세요.
댓글 로딩 중...