Files
nuxt-deep/docs/curriculum/06-composables.md

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