235 lines
5.1 KiB
Markdown
235 lines
5.1 KiB
Markdown
# 5. 상태관리 전략
|
|
|
|
## 세 가지 선택지
|
|
|
|
| 방법 | 범위 | 언제 사용 |
|
|
|------|------|----------|
|
|
| **Local State** (`ref`, `reactive`) | 컴포넌트 내부 | 해당 컴포넌트만 사용하는 UI 상태 |
|
|
| **Composable** (`useState`) | 여러 컴포넌트 공유 | 경량 전역 상태, 인증 정보 등 |
|
|
| **Pinia** | 앱 전체 | 복잡한 비즈니스 로직, 대규모 앱 |
|
|
|
|
---
|
|
|
|
## Local State — 컴포넌트 내부 상태
|
|
|
|
```vue
|
|
<script setup>
|
|
// 컴포넌트에서만 쓰는 상태
|
|
const isOpen = ref(false)
|
|
const formData = reactive({
|
|
name: '',
|
|
price: 0
|
|
})
|
|
|
|
function toggle() {
|
|
isOpen.value = !isOpen.value
|
|
}
|
|
</script>
|
|
```
|
|
|
|
**적합한 경우:**
|
|
- 모달 열림/닫힘
|
|
- 폼 입력값
|
|
- 로컬 필터/정렬 상태
|
|
|
|
---
|
|
|
|
## Composable (useState) — 경량 전역 상태
|
|
|
|
`useState`는 Nuxt 전용으로, SSR에서 서버/클라이언트 간 상태를 안전하게 공유한다.
|
|
`ref`와 달리 SSR payload에 포함되어 Hydration 시 상태가 유지된다.
|
|
|
|
```ts
|
|
// composables/useAuth.ts
|
|
export function useAuth() {
|
|
// 첫 번째 인자는 고유 키 (SSR payload 직렬화에 사용됨)
|
|
const user = useState('auth-user', () => null)
|
|
|
|
async function login(email: string, password: string) {
|
|
const data = await $fetch('/api/auth/login', {
|
|
method: 'POST',
|
|
body: { email, password }
|
|
})
|
|
user.value = data.user
|
|
}
|
|
|
|
function logout() {
|
|
user.value = null
|
|
}
|
|
|
|
const isLoggedIn = computed(() => !!user.value)
|
|
|
|
return { user, isLoggedIn, login, logout }
|
|
}
|
|
```
|
|
|
|
```vue
|
|
<!-- 어떤 컴포넌트에서든 같은 상태를 공유 -->
|
|
<script setup>
|
|
const { user, isLoggedIn, logout } = useAuth()
|
|
</script>
|
|
```
|
|
|
|
---
|
|
|
|
## Pinia — 대규모 앱의 전역 상태
|
|
|
|
Nuxt에서 Pinia는 `@pinia/nuxt` 모듈로 자동 연동된다.
|
|
|
|
### Store 정의
|
|
|
|
```ts
|
|
// stores/cart.ts
|
|
export const useCartStore = defineStore('cart', () => {
|
|
// State
|
|
const items = ref<CartItem[]>([])
|
|
|
|
// Getters (computed)
|
|
const totalCount = computed(() =>
|
|
items.value.reduce((sum, item) => sum + item.quantity, 0)
|
|
)
|
|
|
|
const totalPrice = computed(() =>
|
|
items.value.reduce((sum, item) => sum + item.price * item.quantity, 0)
|
|
)
|
|
|
|
// Actions
|
|
function addItem(product: Product) {
|
|
const existing = items.value.find(i => i.id === product.id)
|
|
if (existing) {
|
|
existing.quantity++
|
|
} else {
|
|
items.value.push({ ...product, quantity: 1 })
|
|
}
|
|
}
|
|
|
|
function removeItem(productId: string) {
|
|
items.value = items.value.filter(i => i.id !== productId)
|
|
}
|
|
|
|
async function checkout() {
|
|
await $fetch('/api/orders', {
|
|
method: 'POST',
|
|
body: { items: items.value }
|
|
})
|
|
items.value = []
|
|
}
|
|
|
|
return { items, totalCount, totalPrice, addItem, removeItem, checkout }
|
|
})
|
|
```
|
|
|
|
### Store 사용
|
|
|
|
```vue
|
|
<script setup>
|
|
const cart = useCartStore()
|
|
|
|
// storeToRefs로 반응성을 유지하며 구조분해
|
|
const { items, totalCount, totalPrice } = storeToRefs(cart)
|
|
// 액션은 그냥 구조분해 가능
|
|
const { addItem, removeItem } = cart
|
|
</script>
|
|
|
|
<template>
|
|
<button @click="addItem(product)">
|
|
장바구니 담기 ({{ totalCount }})
|
|
</button>
|
|
</template>
|
|
```
|
|
|
|
---
|
|
|
|
## 전역 UI 상태 — 모달/토스트 패턴
|
|
|
|
```ts
|
|
// composables/useModal.ts
|
|
export function useModal() {
|
|
const isOpen = useState('modal-open', () => false)
|
|
const modalContent = useState<string | null>('modal-content', () => null)
|
|
|
|
function open(content: string) {
|
|
modalContent.value = content
|
|
isOpen.value = true
|
|
}
|
|
|
|
function close() {
|
|
isOpen.value = false
|
|
modalContent.value = null
|
|
}
|
|
|
|
return { isOpen, modalContent, open, close }
|
|
}
|
|
```
|
|
|
|
```ts
|
|
// composables/useToast.ts — Nuxt UI의 useToast 활용
|
|
export function useAppToast() {
|
|
const toast = useToast()
|
|
|
|
function success(message: string) {
|
|
toast.add({
|
|
title: '성공',
|
|
description: message,
|
|
color: 'green'
|
|
})
|
|
}
|
|
|
|
function error(message: string) {
|
|
toast.add({
|
|
title: '오류',
|
|
description: message,
|
|
color: 'red'
|
|
})
|
|
}
|
|
|
|
return { success, error }
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 인증 상태 관리
|
|
|
|
프로젝트에서 Supabase를 사용할 때의 패턴:
|
|
|
|
```ts
|
|
// composables/useAuth.ts
|
|
export function useAuth() {
|
|
const client = useSupabaseClient()
|
|
const user = useSupabaseUser() // @nuxtjs/supabase 제공
|
|
|
|
const isLoggedIn = computed(() => !!user.value)
|
|
|
|
async function signInWithEmail(email: string) {
|
|
await client.auth.signInWithOtp({ email })
|
|
}
|
|
|
|
async function signOut() {
|
|
await client.auth.signOut()
|
|
navigateTo('/login')
|
|
}
|
|
|
|
return { user, isLoggedIn, signInWithEmail, signOut }
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 전략 선택 가이드
|
|
|
|
```
|
|
이 상태를 다른 컴포넌트에서도 쓰나?
|
|
├── 아니오 → Local State (ref, reactive)
|
|
└── 예 →
|
|
복잡한 로직이나 비동기 작업이 있나?
|
|
├── 예 → Pinia Store
|
|
└── 아니오 → Composable (useState)
|
|
```
|
|
|
|
**흔한 패턴:**
|
|
- 모달 열림/닫힘 → `Local State`
|
|
- 사용자 정보, 인증 → `Composable` (useAuth)
|
|
- 장바구니, 알림 목록 → `Pinia`
|
|
- 테마, 언어 설정 → `Composable` (useState + localStorage)
|