# 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 고려
```