14.6 Pinia 상태 관리
Pinia는 Vue 3의 공식 상태 관리 라이브러리입니다. Vuex의 후속으로 설계된 Pinia는 Composition API를 완벽히 지원하고, TypeScript 친화적이며, 훨씬 간결한 API를 제공합니다. Vue 팀이 공식적으로 권장하는 상태 관리 솔루션입니다.
왜 Pinia인가?
상태 관리(State Management)란 여러 컴포넌트가 공유하는 데이터를 하나의 중앙 저장소에서 관리하는 패턴입니다. 작은 앱에서는 props와 emit으로 충분하지만, 컴포넌트가 깊어지거나 많아질수록 "props 드릴링" 문제가 발생합니다.
App
├── Header (사용자 이름 필요)
├── Sidebar (장바구니 개수 필요)
└── Main
└── ProductList
└── ProductCard (장바구니에 추가 필요)
이처럼 멀리 떨어진 컴포넌트들이 같은 데이터를 필요로 할 때 Pinia가 해결책입니다.
Pinia vs Vuex 비교
| 항목 | Vuex 4 | Pinia |
|---|---|---|
| API 스타일 | Options API 중심 | Composition API 완벽 지원 |
| 보일러플레이트 | 많음 (mutations 필수) | 적음 (mutations 없음) |
| TypeScript | 복잡한 타입 설정 | 자동 타입 추론 |
| DevTools | 지원 | 지원 (더 상세) |
| 모듈 시스템 | namespaced modules | 독립적인 store |
| 번들 크기 | ~10KB | ~1.5KB |
| Vue 공식 지원 | 레거시 | 현재 권장 |
설치 및 기본 설정
npm install pinia
// main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
const app = createApp(App)
const pinia = createPinia()
app.use(pinia)
app.mount('#app')
Store 정의 — 세 가지 방법
방법 1: Setup Store (권장)
Composition API setup() 함수와 동일한 방식으로 작성합니다. 가장 유연하고 TypeScript 친화적입니다.
// stores/counter.js
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', () => {
// state: ref()로 선언
const count = ref(0)
const name = ref('Counter')
// getters: computed()로 선언
const doubleCount = computed(() => count.value * 2)
const displayName = computed(() => `${name.value}: ${count.value}`)
// actions: 일반 함수로 선언
function increment() {
count.value++
}
function decrement() {
count.value--
}
async function fetchCount() {
const response = await fetch('/api/count')
const data = await response.json()
count.value = data.count
}
// 외부에 노출할 것들을 반환
return { count, name, doubleCount, displayName, increment, decrement, fetchCount }
})
방법 2: Options Store
Vuex에 익숙한 개발자에게 친숙한 방식입니다.
// stores/user.js
import { defineStore } from 'pinia'
export const useUserStore = defineStore('user', {
// state
state: () => ({
userId: null,
username: '',
email: '',
isLoggedIn: false,
}),
// getters
getters: {
fullProfile: (state) => `${state.username} <${state.email}>`,
isAdmin: (state) => state.userId === 1,
},
// actions
actions: {
login(userData) {
this.userId = userData.id
this.username = userData.name
this.email = userData.email
this.isLoggedIn = true
},
logout() {
this.$reset() // state를 초기값으로 리셋
},
async fetchUser(id) {
const res = await fetch(`/api/users/${id}`)
const user = await res.json()
this.login(user)
},
},
})
방법 3: 혼합 패턴
두 방식을 상황에 맞게 선택하세요. 새 프로젝트라면 Setup Store를 권장합니다.
컴포넌트에서 Store 사용
<!-- Counter.vue -->
<script setup>
import { useCounterStore } from '@/stores/counter'
import { storeToRefs } from 'pinia'
const counter = useCounterStore()
// 반응성을 유지하면서 구조분해: storeToRefs 사용
const { count, doubleCount, displayName } = storeToRefs(counter)
// actions는 직접 구조분해 가능 (함수는 반응성 불필요)
const { increment, decrement } = counter
</script>
<template>
<div class="counter">
<h2>{{ displayName }}</h2>
<p>현재 값: {{ count }}</p>
<p>두 배 값: {{ doubleCount }}</p>
<div class="buttons">
<button @click="decrement">-</button>
<button @click="increment">+</button>
</div>
</div>
</template>
주의: store를 구조분해할 때 반응성을 잃지 않으려면
storeToRefs()를 사용해야 합니다. actions(함수)는storeToRefs없이 바로 구조분해해도 됩니다.
실전 예제: 쇼핑몰 장바구니
Store 설계
// stores/cart.js
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
export const useCartStore = defineStore('cart', () => {
// State
const items = ref([])
const isLoading = ref(false)
// Getters
const totalItems = computed(() =>
items.value.reduce((sum, item) => sum + item.quantity, 0)
)
const totalPrice = computed(() =>
items.value.reduce((sum, item) => sum + item.price * item.quantity, 0)
)
const formattedTotal = computed(() =>
new Intl.NumberFormat('ko-KR', {
style: 'currency',
currency: 'KRW',
}).format(totalPrice.value)
)
const isEmpty = computed(() => items.value.length === 0)
// Actions
function addItem(product) {
const existing = items.value.find((item) => item.id === product.id)
if (existing) {
existing.quantity += 1
} else {
items.value.push({
id: product.id,
name: product.name,
price: product.price,
image: product.image,
quantity: 1,
})
}
}
function removeItem(productId) {
const index = items.value.findIndex((item) => item.id === productId)
if (index !== -1) {
items.value.splice(index, 1)
}
}
function updateQuantity(productId, quantity) {
const item = items.value.find((item) => item.id === productId)
if (item) {
if (quantity <= 0) {
removeItem(productId)
} else {
item.quantity = quantity
}
}
}
function clearCart() {
items.value = []
}
async function checkout() {
if (isEmpty.value) return
isLoading.value = true
try {
const response = await fetch('/api/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
items: items.value,
total: totalPrice.value,
}),
})
if (!response.ok) throw new Error('결제 실패')
const result = await response.json()
clearCart()
return result
} finally {
isLoading.value = false
}
}
return {
items,
isLoading,
totalItems,
totalPrice,
formattedTotal,
isEmpty,
addItem,
removeItem,
updateQuantity,
clearCart,
checkout,
}
})
장바구니 컴포넌트
<!-- components/ShoppingCart.vue -->
<script setup>
import { useCartStore } from '@/stores/cart'
import { storeToRefs } from 'pinia'
const cart = useCartStore()
const { items, isLoading, totalItems, formattedTotal, isEmpty } = storeToRefs(cart)
async function handleCheckout() {
try {
const result = await cart.checkout()
alert(`주문 완료! 주문번호: ${result.orderId}`)
} catch (error) {
alert('결제 중 오류가 발생했습니다.')
}
}
</script>
<template>
<div class="cart">
<h2>장바구니 ({{ totalItems }}개)</h2>
<div v-if="isEmpty" class="cart-empty">
<p>장바구니가 비어있습니다.</p>
</div>
<ul v-else class="cart-items">
<li v-for="item in items" :key="item.id" class="cart-item">
<img :src="item.image" :alt="item.name" width="60" />
<div class="item-info">
<span class="item-name">{{ item.name }}</span>
<span class="item-price">{{ item.price.toLocaleString('ko-KR') }}원</span>
</div>
<div class="quantity-control">
<button @click="cart.updateQuantity(item.id, item.quantity - 1)">-</button>
<span>{{ item.quantity }}</span>
<button @click="cart.updateQuantity(item.id, item.quantity + 1)">+</button>
</div>
<button class="remove-btn" @click="cart.removeItem(item.id)">삭제</button>
</li>
</ul>
<div class="cart-footer">
<div class="total">합계: {{ formattedTotal }}</div>
<button
class="checkout-btn"
:disabled="isEmpty || isLoading"
@click="handleCheckout"
>
{{ isLoading ? '처리 중...' : '결제하기' }}
</button>
</div>
</div>
</template>
상품 목록 컴포넌트 (다른 컴포넌트에서 같은 store 사용)
<!-- components/ProductCard.vue -->
<script setup>
import { useCartStore } from '@/stores/cart'
import { storeToRefs } from 'pinia'
defineProps({
product: {
type: Object,
required: true,
},
})
const cart = useCartStore()
const { totalItems } = storeToRefs(cart)
function addToCart(product) {
cart.addItem(product)
// 같은 store를 공유하므로 ShoppingCart 컴포넌트에도 즉시 반영
}
</script>
<template>
<div class="product-card">
<img :src="product.image" :alt="product.name" />
<h3>{{ product.name }}</h3>
<p>{{ product.price.toLocaleString('ko-KR') }}원</p>
<button @click="addToCart(product)">
장바구니 담기 (현재 {{ totalItems }}개)
</button>
</div>
</template>
Store 간 통신
Pinia에서는 store가 서로 독립적이지만, 필요하면 한 store 안에서 다른 store를 사용할 수 있습니다.
// stores/order.js
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
import { useCartStore } from './cart'
import { useUserStore } from './user'
export const useOrderStore = defineStore('order', () => {
const orders = ref([])
const cartStore = useCartStore() // 다른 store 사용
const userStore = useUserStore()
async function placeOrder() {
if (!userStore.isLoggedIn) {
throw new Error('로그인이 필요합니다')
}
const order = {
userId: userStore.userId,
items: [...cartStore.items],
total: cartStore.totalPrice,
createdAt: new Date().toISOString(),
}
const response = await fetch('/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(order),
})
const newOrder = await response.json()
orders.value.push(newOrder)
cartStore.clearCart() // 주문 후 장바구니 비우기
return newOrder
}
const orderHistory = computed(() =>
[...orders.value].sort(
(a, b) => new Date(b.createdAt) - new Date(a.createdAt)
)
)
return { orders, orderHistory, placeOrder }
})
플러그인: 상태 영속화 (Persist)
새로고침해도 상태가 유지되도록 localStorage에 저장합니다.
npm install pinia-plugin-persistedstate
// main.js
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
// stores/cart.js (persist 옵션 추가)
export const useCartStore = defineStore('cart', () => {
// ... store 코드
}, {
persist: {
key: 'shopping-cart', // localStorage 키
storage: localStorage, // sessionStorage도 가능
paths: ['items'], // 특정 state만 저장 (전체 저장은 생략)
// serializer: { ... } // 커스텀 직렬화
},
})
고수 팁
팁 1: $patch로 여러 state 한 번에 업데이트
// 개별 업데이트 (여러 번의 반응성 트리거)
store.name = '새 이름'
store.email = 'new@email.com'
store.isActive = true
// $patch 사용 (단 한 번의 반응성 트리거)
store.$patch({
name: '새 이름',
email: 'new@email.com',
isActive: true,
})
// $patch에 함수 전달 (복잡한 업데이트)
store.$patch((state) => {
state.items.push(newItem)
state.lastUpdated = new Date()
})
팁 2: $subscribe로 state 변경 감지
const cart = useCartStore()
// state 변화를 감지해 localStorage에 자동 저장
cart.$subscribe((mutation, state) => {
console.log('변경 타입:', mutation.type) // 'direct' | 'patch object' | 'patch function'
console.log('변경 내용:', mutation.payload)
localStorage.setItem('cart-backup', JSON.stringify(state.items))
})
팁 3: $onAction으로 action 후킹
const store = useCartStore()
store.$onAction(({ name, store, args, after, onError }) => {
console.log(`Action "${name}" 실행 시작`, args)
after((result) => {
console.log(`Action "${name}" 완료:`, result)
})
onError((error) => {
console.error(`Action "${name}" 실패:`, error)
})
})
팁 4: TypeScript와 함께 사용
// stores/auth.ts
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
interface User {
id: number
name: string
email: string
role: 'admin' | 'user'
}
export const useAuthStore = defineStore('auth', () => {
const user = ref<User | null>(null)
const token = ref<string | null>(null)
const isAuthenticated = computed(() => !!user.value && !!token.value)
const isAdmin = computed(() => user.value?.role === 'admin')
async function login(credentials: { email: string; password: string }): Promise<void> {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credentials),
})
if (!response.ok) throw new Error('로그인 실패')
const data: { user: User; token: string } = await response.json()
user.value = data.user
token.value = data.token
}
function logout(): void {
user.value = null
token.value = null
}
return { user, token, isAuthenticated, isAdmin, login, logout }
})
팁 5: Composable과 Store 조합
// composables/useAuth.js
// Store를 래핑한 Composable — 뷰 레이어에 편리한 인터페이스 제공
import { useAuthStore } from '@/stores/auth'
import { useRouter } from 'vue-router'
import { storeToRefs } from 'pinia'
export function useAuth() {
const authStore = useAuthStore()
const router = useRouter()
const { user, isAuthenticated, isAdmin } = storeToRefs(authStore)
async function loginAndRedirect(credentials) {
await authStore.login(credentials)
await router.push('/dashboard')
}
async function logoutAndRedirect() {
authStore.logout()
await router.push('/login')
}
return {
user,
isAuthenticated,
isAdmin,
loginAndRedirect,
logoutAndRedirect,
}
}
요약 및 마이그레이션 가이드
| 개념 | Vuex | Pinia |
|---|---|---|
| State 정의 | state: () => ({}) | ref() 또는 state: () => ({}) |
| Getter | getters: {} | computed() 또는 getters: {} |
| Action | actions: {} | 일반 함수 또는 actions: {} |
| Mutation | 필수 (동기 전용) | 없음 (action이 대체) |
| 모듈 | modules: {} | 별도 defineStore 파일 |
| 스토어 접근 | this.$store / useStore() | useXxxStore() |
| Namespacing | namespaced: true | store ID가 네임스페이스 역할 |
Vuex에서 Pinia로 마이그레이션할 때:
- mutations 제거 — actions로 통합
useStore()를useXxxStore()로 교체store.commit()제거 —store.action()직접 호출store.dispatch()를store.action()으로 교체