5.9 KiB
5.9 KiB
6. 커스텀 Composables & useXxx 패턴
Composable이란?
반복되는 상태·로직을 useXxx 함수로 추상화하는 Vue/Nuxt의 패턴이다.
composables/ 디렉토리에 파일을 만들면 자동으로 전역 import된다.
composables/
├── useAuth.ts → useAuth() 자동 임포트
├── useApi.ts → useApi() 자동 임포트
└── usePurchases.ts → usePurchases() 자동 임포트
기본 구조
// composables/useCounter.ts
export function useCounter(initialValue = 0) {
const count = ref(initialValue)
function increment() { count.value++ }
function decrement() { count.value-- }
function reset() { count.value = initialValue }
return { count, increment, decrement, reset }
}
<script setup>
// 자동 임포트 — import 문 불필요
const { count, increment, reset } = useCounter(10)
</script>
핵심 규칙
1. setup() 최상위에서만 호출
<script setup>
// ✅ 올바른 위치
const { data } = usePurchases()
function handleClick() {
// ❌ 함수 내부에서 호출 불가 (onMounted, watch 내부도 마찬가지)
// const { data } = usePurchases()
}
</script>
2. 서버/클라이언트 환경 분기 안전하게 처리
// composables/useLocalStorage.ts
export function useLocalStorage<T>(key: string, defaultValue: T) {
const value = useState<T>(key, () => defaultValue)
// localStorage는 브라우저에만 존재
if (import.meta.client) {
const stored = localStorage.getItem(key)
if (stored) {
value.value = JSON.parse(stored)
}
}
watch(value, (newValue) => {
if (import.meta.client) {
localStorage.setItem(key, JSON.stringify(newValue))
}
})
return value
}
3. useState로 SSR 안전 전역 상태
// ❌ ref는 컴포넌트 인스턴스마다 별개
export function useBad() {
const count = ref(0) // 각 컴포넌트가 자신만의 count를 가짐
return { count }
}
// ✅ useState는 앱 전체에서 하나의 상태 공유
export function useGood() {
const count = useState('shared-count', () => 0)
return { count }
}
실전 예시: useApi
// composables/useApi.ts
export function useApi() {
const config = useRuntimeConfig()
const { user } = useAuth()
async function get<T>(path: string, options?: object): Promise<T> {
return $fetch<T>(path, {
baseURL: config.public.apiBase,
headers: {
Authorization: user.value ? `Bearer ${user.value.token}` : undefined,
},
...options
})
}
async function post<T>(path: string, body: object): Promise<T> {
return $fetch<T>(path, {
method: 'POST',
body,
baseURL: config.public.apiBase,
})
}
return { get, post }
}
실전 예시: usePurchases (이 프로젝트 패턴)
// composables/usePurchases.ts
export function usePurchases() {
const client = useSupabaseClient()
const toast = useToast()
const { data: purchases, pending, refresh } = useAsyncData(
'purchases',
() => client
.from('purchases')
.select('*')
.order('purchased_at', { ascending: false })
.then(({ data, error }) => {
if (error) throw error
return data
})
)
async function create(purchase: PurchaseInsert) {
const { error } = await client.from('purchases').insert(purchase)
if (error) {
toast.add({ title: '등록 실패', color: 'red', description: error.message })
return false
}
toast.add({ title: '등록 완료', color: 'green' })
await refresh()
return true
}
async function remove(id: string) {
const { error } = await client.from('purchases').delete().eq('id', id)
if (error) {
toast.add({ title: '삭제 실패', color: 'red' })
return false
}
await refresh()
return true
}
return { purchases, pending, create, remove, refresh }
}
<!-- pages/purchases/index.vue -->
<script setup>
const { purchases, pending, create } = usePurchases()
</script>
서버/클라이언트 환경 분기
// composables/usePlatform.ts
export function usePlatform() {
const isServer = import.meta.server
const isClient = import.meta.client
// 서버에서만 사용 가능한 기능
function getServerData() {
if (!isServer) return null
return process.env.SECRET_DATA
}
// 클라이언트에서만 사용 가능한 기능
function getBrowserData() {
if (!isClient) return null
return {
userAgent: navigator.userAgent,
language: navigator.language,
}
}
return { isServer, isClient, getServerData, getBrowserData }
}
Hydration 안전 패턴
서버에서 생성된 값과 클라이언트에서 재생성된 값이 달라 생기는 불일치를 방지한다.
// ❌ 위험: Math.random()은 서버와 클라이언트에서 다른 값을 생성
export function useUnsafeId() {
const id = ref(Math.random()) // Hydration mismatch!
return { id }
}
// ✅ 안전: useState로 서버에서 생성한 값을 클라이언트에 전달
export function useSafeId() {
const id = useState('unique-id', () => Math.random())
return { id }
}
// ✅ 안전: 브라우저 전용 값은 onMounted에서 설정
export function useSafeWindowSize() {
const width = ref(0) // 서버에서는 0
onMounted(() => {
width.value = window.innerWidth // 클라이언트에서 업데이트
})
return { width }
}
정리: 언제 무엇을 쓸까
반복되는 로직인가?
└── 예 → Composable로 추출
상태를 컴포넌트 간 공유하나?
├── 아니오 → 컴포넌트 내 ref/reactive
└── 예 →
SSR이 필요하거나 Hydration 안전이 필요한가?
├── 예 → useState
└── 아니오 → ref (composable 내부)
복잡한 비즈니스 로직이 많은가?
└── 예 → Pinia Store 고려