266 lines
5.9 KiB
Markdown
266 lines
5.9 KiB
Markdown
# 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
|
|
<script setup>
|
|
// 자동 임포트 — import 문 불필요
|
|
const { count, increment, reset } = useCounter(10)
|
|
</script>
|
|
```
|
|
|
|
---
|
|
|
|
## 핵심 규칙
|
|
|
|
### 1. setup() 최상위에서만 호출
|
|
|
|
```vue
|
|
<script setup>
|
|
// ✅ 올바른 위치
|
|
const { data } = usePurchases()
|
|
|
|
function handleClick() {
|
|
// ❌ 함수 내부에서 호출 불가 (onMounted, watch 내부도 마찬가지)
|
|
// const { data } = usePurchases()
|
|
}
|
|
</script>
|
|
```
|
|
|
|
### 2. 서버/클라이언트 환경 분기 안전하게 처리
|
|
|
|
```ts
|
|
// 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 안전 전역 상태
|
|
|
|
```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<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 (이 프로젝트 패턴)
|
|
|
|
```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
|
|
<!-- pages/purchases/index.vue -->
|
|
<script setup>
|
|
const { purchases, pending, create } = usePurchases()
|
|
</script>
|
|
```
|
|
|
|
---
|
|
|
|
## 서버/클라이언트 환경 분기
|
|
|
|
```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 고려
|
|
```
|