Composables 패턴
Composable은 Composition API를 활용하여 상태를 포함한 로직을 재사용 가능한 함수로 추출하는 패턴입니다. React의 Custom Hooks와 같은 역할을 합니다.
면접에서 "로직 재사용은 어떻게 하나요?"라고 물으면, Mixins의 문제점을 먼저 설명하고 Composables가 어떻게 해결하는지 대비해서 답하면 좋습니다.
첫 번째 Composable — useMouse
// composables/useMouse.ts
import { ref, onMounted, onUnmounted } from 'vue'
export function useMouse() {
const x = ref(0)
const y = ref(0)
const update = (event: MouseEvent) => {
x.value = event.pageX
y.value = event.pageY
}
// 생명주기 훅도 composable 안에서 사용 가능
onMounted(() => window.addEventListener('mousemove', update))
onUnmounted(() => window.removeEventListener('mousemove', update))
return { x, y }
}
<script setup lang="ts">
import { useMouse } from '@/composables/useMouse'
// 출처가 명확하고, 이름 충돌 없음
const { x, y } = useMouse()
</script>
<template>
<p>마우스 위치: {{ x }}, {{ y }}</p>
</template>
네이밍 컨벤션
use접두사 —useFetch,useCounter,useAuth- 파일명도 동일 —
useFetch.ts,useCounter.ts - 반환값은 ref 객체 — 구조 분해 시 반응형 유지
입력 인자 — ref도 받을 수 있게
// composables/useFetch.ts
import { ref, watchEffect, toValue, type Ref, type MaybeRefOrGetter } from 'vue'
interface UseFetchReturn<T> {
data: Ref<T | null>
error: Ref<Error | null>
isLoading: Ref<boolean>
}
export function useFetch<T>(url: MaybeRefOrGetter<string>): UseFetchReturn<T> {
const data = ref<T | null>(null) as Ref<T | null>
const error = ref<Error | null>(null)
const isLoading = ref(false)
watchEffect(async () => {
// toValue() — ref, getter, 일반값 모두 처리
const urlValue = toValue(url)
data.value = null
error.value = null
isLoading.value = true
try {
const response = await fetch(urlValue)
if (!response.ok) throw new Error(`HTTP ${response.status}`)
data.value = await response.json()
} catch (e) {
error.value = e as Error
} finally {
isLoading.value = false
}
})
return { data, error, isLoading }
}
<script setup lang="ts">
import { ref } from 'vue'
import { useFetch } from '@/composables/useFetch'
const userId = ref(1)
// url이 ref이므로 userId가 변경되면 자동으로 재요청
const { data: user, error, isLoading } = useFetch<{ name: string }>(
() => `/api/users/${userId.value}`
)
</script>
<template>
<div v-if="isLoading">로딩 중...</div>
<div v-else-if="error">에러: {{ error.message }}</div>
<div v-else>{{ user?.name }}</div>
<button @click="userId++">다음 사용자</button>
</template>
실전 Composable 모음
useLocalStorage
// composables/useLocalStorage.ts
import { ref, watch, type Ref } from 'vue'
export function useLocalStorage<T>(key: string, defaultValue: T): Ref<T> {
const stored = localStorage.getItem(key)
const data = ref<T>(stored ? JSON.parse(stored) : defaultValue) as Ref<T>
watch(data, (newValue) => {
localStorage.setItem(key, JSON.stringify(newValue))
}, { deep: true })
return data
}
useDebounce
// composables/useDebounce.ts
import { ref, watch, type Ref, type MaybeRefOrGetter, toValue } from 'vue'
export function useDebounce<T>(source: MaybeRefOrGetter<T>, delay = 300): Ref<T> {
const debounced = ref(toValue(source)) as Ref<T>
let timer: ReturnType<typeof setTimeout>
watch(
() => toValue(source),
(newValue) => {
clearTimeout(timer)
timer = setTimeout(() => {
debounced.value = newValue
}, delay)
}
)
return debounced
}
useMediaQuery
// composables/useMediaQuery.ts
import { ref, onMounted, onUnmounted } from 'vue'
export function useMediaQuery(query: string) {
const matches = ref(false)
let mediaQuery: MediaQueryList
const handler = (e: MediaQueryListEvent) => {
matches.value = e.matches
}
onMounted(() => {
mediaQuery = window.matchMedia(query)
matches.value = mediaQuery.matches
mediaQuery.addEventListener('change', handler)
})
onUnmounted(() => {
mediaQuery?.removeEventListener('change', handler)
})
return matches
}
<script setup lang="ts">
import { useMediaQuery } from '@/composables/useMediaQuery'
const isMobile = useMediaQuery('(max-width: 768px)')
const prefersDark = useMediaQuery('(prefers-color-scheme: dark)')
</script>
<template>
<div :class="isMobile ? 'mobile-layout' : 'desktop-layout'">
<p>모바일: {{ isMobile }}</p>
<p>다크 모드 선호: {{ prefersDark }}</p>
</div>
</template>
Composable 설계 원칙
1. 하나의 관심사 — 하나의 composable은 하나의 기능만
✓ useFetch, useAuth, useForm
✗ useFetchAndAuthAndForm
2. 반환값은 ref — 구조 분해 시 반응형 유지
✓ return { count, increment }
✗ return reactive({ count, increment })
3. 부수효과 정리 — onUnmounted에서 클린업
✓ 이벤트 리스너, 타이머, WebSocket 연결 해제
4. 입력은 유연하게 — MaybeRefOrGetter 타입 사용
✓ useFetch(url) — url이 string, ref, getter 모두 가능
Composable vs Utility 함수
| 항목 | Composable | Utility |
|---|---|---|
| 반응형 상태 | 포함 | 없음 |
| 생명주기 훅 | 사용 가능 | 사용 불가 |
| 호출 위치 | setup 내부 | 어디서든 |
| 네이밍 | use 접두사 | 일반 함수명 |
| 예시 | useMouse() | formatDate() |
면접 팁
- Composable은 ** 관심사의 분리(SoC)**를 함수 단위로 구현하는 패턴입니다
- React Custom Hooks와의 공통점(로직 재사용, use 접두사)과 차이점(Vue는 setup에서 한 번만 호출, React는 매 렌더마다 호출)을 비교할 수 있으면 좋습니다
toValue와MaybeRefOrGetter를 사용하면 composable의 유연성이 크게 높아집니다
요약
Composables는 Composition API를 활용한 로직 재사용 패턴으로, Mixins의 문제(출처 불명확, 이름 충돌, 암묵적 의존성)를 해결합니다. use 접두사 컨벤션을 따르고, 반환값은 ref로, 입력은 MaybeRefOrGetter로 유연하게, 부수효과는 반드시 정리하는 것이 설계 원칙입니다.
댓글 로딩 중...