# 6. 커스텀 Composables & useXxx 패턴 ## Composable이란? 반복되는 상태·로직을 `useXxx` 함수로 추상화하는 Vue/Nuxt의 패턴이다. `composables/` 디렉토리에 파일을 만들면 **자동으로 전역 import**된다. ``` composables/ ├── useAuth.ts → useAuth() 자동 임포트 ├── useApi.ts → useApi() 자동 임포트 └── usePurchases.ts → usePurchases() 자동 임포트 ``` --- ## 기본 구조 ```ts // 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 } } ``` ```vue ``` --- ## 핵심 규칙 ### 1. setup() 최상위에서만 호출 ```vue ``` ### 2. 서버/클라이언트 환경 분기 안전하게 처리 ```ts // composables/useLocalStorage.ts export function useLocalStorage(key: string, defaultValue: T) { const value = useState(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 안전 전역 상태 ```ts // ❌ ref는 컴포넌트 인스턴스마다 별개 export function useBad() { const count = ref(0) // 각 컴포넌트가 자신만의 count를 가짐 return { count } } // ✅ useState는 앱 전체에서 하나의 상태 공유 export function useGood() { const count = useState('shared-count', () => 0) return { count } } ``` --- ## 실전 예시: useApi ```ts // composables/useApi.ts export function useApi() { const config = useRuntimeConfig() const { user } = useAuth() async function get(path: string, options?: object): Promise { return $fetch(path, { baseURL: config.public.apiBase, headers: { Authorization: user.value ? `Bearer ${user.value.token}` : undefined, }, ...options }) } async function post(path: string, body: object): Promise { return $fetch(path, { method: 'POST', body, baseURL: config.public.apiBase, }) } return { get, post } } ``` --- ## 실전 예시: usePurchases (이 프로젝트 패턴) ```ts // 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 } } ``` ```vue ``` --- ## 서버/클라이언트 환경 분기 ```ts // 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 안전 패턴 서버에서 생성된 값과 클라이언트에서 재생성된 값이 달라 생기는 불일치를 방지한다. ```ts // ❌ 위험: 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 고려 ```