패턴을 알았으니 실전입니다. 프로덕션에서 자주 쓰이는 composable을 직접 만들어보면서 설계 감각을 키워봅시다.

Composables 패턴 편에서 기초를 다뤘다면, 이번에는 실전에서 바로 활용할 수 있는 구체적인 예제들을 모았습니다.


useForm — 폼 검증

TYPESCRIPT
// 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 : '유효한 이메일을 입력하세요'
VUE
<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 — 무한 스크롤

TYPESCRIPT
// 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 — 비동기 상태 관리

TYPESCRIPT
// 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 }
}
VUE
<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 — 이벤트 리스너 관리

TYPESCRIPT
// 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 — 클립보드 복사

TYPESCRIPT
// 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 조합 패턴

TYPESCRIPT
// 여러 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 같은 검증된 라이브러리도 적극 활용하세요.

댓글 로딩 중...