Pinia의 진가는 플러그인 시스템과 스토어 간 통신에서 나타납니다. 실전에서는 영속화, 디버깅, SSR 대응이 필수적입니다.

면접에서 "상태를 새로고침 후에도 유지하려면 어떻게 하나요?"라고 물으면, Pinia 플러그인과 localStorage의 조합을 설명할 수 있어야 합니다.


Pinia 플러그인 작성

TYPESCRIPT
// 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)
    })
  })
}
TYPESCRIPT
// main.ts
import { createPinia } from 'pinia'
import { persistPlugin, loggerPlugin } from './plugins/persistPlugin'

const pinia = createPinia()
pinia.use(persistPlugin)
pinia.use(loggerPlugin)

스토어 간 통신

TYPESCRIPT
// 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 }
})
TYPESCRIPT
// 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)

TYPESCRIPT
// 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']  // 특정 속성만 영속화
  }
})

스토어 상태 초기화 패턴

TYPESCRIPT
// 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 통합

TYPESCRIPT
// 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 사용

TYPESCRIPT
// 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) 지원

TYPESCRIPT
// 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))
}

스토어 설계 원칙

PLAINTEXT
1. 단일 책임 — 하나의 스토어는 하나의 도메인을 담당
   ✓ useAuthStore, useCartStore, useSettingsStore
   ✗ useEverythingStore

2. 필요한 것만 스토어에 — 로컬 상태는 컴포넌트에
   ✓ 전역적으로 공유되는 사용자 정보
   ✗ 모달의 열림/닫힘 상태 (컴포넌트 로컬이면 충분)

3. 정규화 — 중첩 데이터보다 플랫한 구조
   ✓ { users: { 1: {...}, 2: {...} }, userIds: [1, 2] }
   ✗ { posts: [{ user: { comments: [...] } }] }

면접 팁

  • Pinia 플러그인을 직접 만들어본 경험이 있다면, 미들웨어 패턴 이해도를 보여줄 수 있습니다
  • 스토어 간 순환 의존성을 피하는 방법을 물어보면 "액션 내부에서 다른 스토어를 호출하되, 최상위 레벨에서는 import하지 않는다"고 답하세요
  • "모든 상태를 스토어에 넣어야 하나요?"라는 질문에는 "전역적으로 공유되는 상태만" 이라고 명확하게 답하세요

요약

Pinia 플러그인으로 영속화, 로깅 등의 횡단 관심사를 처리하고, 스토어 간 통신은 액션 내부에서 다른 스토어를 호출하는 방식으로 구현합니다. 테스트에서는 매번 새로운 Pinia 인스턴스를 생성하여 격리하고, HMR 설정으로 개발 생산성을 높일 수 있습니다.

댓글 로딩 중...