Pinia 심화
Pinia의 진가는 플러그인 시스템과 스토어 간 통신에서 나타납니다. 실전에서는 영속화, 디버깅, SSR 대응이 필수적입니다.
면접에서 "상태를 새로고침 후에도 유지하려면 어떻게 하나요?"라고 물으면, Pinia 플러그인과 localStorage의 조합을 설명할 수 있어야 합니다.
Pinia 플러그인 작성
// plugins/persistPlugin.ts
import type { PiniaPluginContext } from 'pinia'
// 영속화 플러그인 — 상태를 localStorage에 자동 저장/복원
export function persistPlugin({ store }: PiniaPluginContext) {
// 저장된 상태 복원
const savedState = localStorage.getItem(`pinia-${store.$id}`)
if (savedState) {
store.$patch(JSON.parse(savedState))
}
// 상태 변경 시 자동 저장
store.$subscribe((mutation, state) => {
localStorage.setItem(`pinia-${store.$id}`, JSON.stringify(state))
})
}
// 로깅 플러그인
export function loggerPlugin({ store }: PiniaPluginContext) {
store.$onAction(({ name, args, after, onError }) => {
const startTime = Date.now()
console.log(`[${store.$id}] ${name} 시작`, args)
after((result) => {
const duration = Date.now() - startTime
console.log(`[${store.$id}] ${name} 완료 (${duration}ms)`, result)
})
onError((error) => {
console.error(`[${store.$id}] ${name} 에러`, error)
})
})
}
// main.ts
import { createPinia } from 'pinia'
import { persistPlugin, loggerPlugin } from './plugins/persistPlugin'
const pinia = createPinia()
pinia.use(persistPlugin)
pinia.use(loggerPlugin)
스토어 간 통신
// stores/auth.ts
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
export const useAuthStore = defineStore('auth', () => {
const token = ref<string | null>(null)
const isAuthenticated = computed(() => !!token.value)
const login = async (credentials: { email: string; password: string }) => {
const res = await fetch('/api/login', {
method: 'POST',
body: JSON.stringify(credentials)
})
const data = await res.json()
token.value = data.token
}
const logout = () => {
token.value = null
}
return { token, isAuthenticated, login, logout }
})
// stores/cart.ts
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
import { useAuthStore } from './auth'
export const useCartStore = defineStore('cart', () => {
const items = ref<Array<{ id: number; name: string; price: number }>>([])
const total = computed(() =>
items.value.reduce((sum, item) => sum + item.price, 0)
)
// 다른 스토어 사용 — 액션 내부에서 호출
const checkout = async () => {
const authStore = useAuthStore()
if (!authStore.isAuthenticated) {
throw new Error('로그인이 필요합니다')
}
await fetch('/api/checkout', {
headers: { Authorization: `Bearer ${authStore.token}` },
method: 'POST',
body: JSON.stringify({ items: items.value })
})
items.value = []
}
const addItem = (item: { id: number; name: string; price: number }) => {
items.value.push(item)
}
return { items, total, checkout, addItem }
})
선택적 영속화 (pinia-plugin-persistedstate)
// stores/settings.ts
import { defineStore } from 'pinia'
export const useSettingsStore = defineStore('settings', {
state: () => ({
theme: 'light' as 'light' | 'dark',
language: 'ko',
notifications: true,
tempData: '' // 이 데이터는 영속화하지 않을 것
}),
actions: {
setTheme(theme: 'light' | 'dark') {
this.theme = theme
}
},
// pinia-plugin-persistedstate 사용 시
persist: {
key: 'app-settings',
storage: localStorage,
pick: ['theme', 'language', 'notifications'] // 특정 속성만 영속화
}
})
스토어 상태 초기화 패턴
// stores/form.ts
import { ref } from 'vue'
import { defineStore } from 'pinia'
export const useFormStore = defineStore('form', () => {
const initialState = {
name: '',
email: '',
message: ''
}
const formData = ref({ ...initialState })
const isDirty = ref(false)
const updateField = (field: keyof typeof initialState, value: string) => {
formData.value[field] = value
isDirty.value = true
}
// Setup Store에서는 $reset이 없으므로 직접 구현
const reset = () => {
formData.value = { ...initialState }
isDirty.value = false
}
return { formData, isDirty, updateField, reset }
})
Pinia와 Vue Router 통합
// router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const router = createRouter({
history: createWebHistory(),
routes: [/* ... */]
})
// Pinia가 초기화된 후에 스토어 사용
router.beforeEach((to) => {
// 가드 내부에서 스토어 접근
const authStore = useAuthStore()
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
return { name: 'login', query: { redirect: to.fullPath } }
}
})
테스트에서 Pinia 사용
// counter.test.ts
import { describe, it, expect, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useCounterStore } from '@/stores/counter'
describe('Counter Store', () => {
beforeEach(() => {
// 각 테스트마다 새로운 Pinia 인스턴스 생성
setActivePinia(createPinia())
})
it('초기 상태는 0이어야 한다', () => {
const store = useCounterStore()
expect(store.count).toBe(0)
})
it('increment 액션은 count를 1 증가시킨다', () => {
const store = useCounterStore()
store.increment()
expect(store.count).toBe(1)
})
it('doubleCount getter는 count의 2배를 반환한다', () => {
const store = useCounterStore()
store.count = 5
expect(store.doubleCount).toBe(10)
})
})
HMR (Hot Module Replacement) 지원
// stores/counter.ts
import { defineStore, acceptHMRUpdate } from 'pinia'
export const useCounterStore = defineStore('counter', () => {
// ... 스토어 로직
})
// HMR 지원 — 개발 중 상태를 유지하면서 코드 업데이트
if (import.meta.hot) {
import.meta.hot.accept(acceptHMRUpdate(useCounterStore, import.meta.hot))
}
스토어 설계 원칙
1. 단일 책임 — 하나의 스토어는 하나의 도메인을 담당
✓ useAuthStore, useCartStore, useSettingsStore
✗ useEverythingStore
2. 필요한 것만 스토어에 — 로컬 상태는 컴포넌트에
✓ 전역적으로 공유되는 사용자 정보
✗ 모달의 열림/닫힘 상태 (컴포넌트 로컬이면 충분)
3. 정규화 — 중첩 데이터보다 플랫한 구조
✓ { users: { 1: {...}, 2: {...} }, userIds: [1, 2] }
✗ { posts: [{ user: { comments: [...] } }] }
면접 팁
- Pinia 플러그인을 직접 만들어본 경험이 있다면, 미들웨어 패턴 이해도를 보여줄 수 있습니다
- 스토어 간 순환 의존성을 피하는 방법을 물어보면 "액션 내부에서 다른 스토어를 호출하되, 최상위 레벨에서는 import하지 않는다"고 답하세요
- "모든 상태를 스토어에 넣어야 하나요?"라는 질문에는 "전역적으로 공유되는 상태만" 이라고 명확하게 답하세요
요약
Pinia 플러그인으로 영속화, 로깅 등의 횡단 관심사를 처리하고, 스토어 간 통신은 액션 내부에서 다른 스토어를 호출하는 방식으로 구현합니다. 테스트에서는 매번 새로운 Pinia 인스턴스를 생성하여 격리하고, HMR 설정으로 개발 생산성을 높일 수 있습니다.
댓글 로딩 중...