Vue 3 Pinia vs Vuex 완벽 비교: 2026년 상태 관리 전략과 면접 핵심 질문

Vue 3 생태계에서 Pinia와 Vuex를 비교 분석합니다. Options Store와 Setup Store 패턴, TypeScript 통합, 크로스 스토어 구성, SSR 지원, Vuex에서 Pinia로의 마이그레이션 전략, 그리고 2026년 면접에서 자주 출제되는 상태 관리 질문을 코드 예제와 함께 정리합니다.

Vue 3 Pinia vs Vuex state management comparison

Vue 3의 공식 상태 관리 라이브러리인 Pinia는 Vuex 4를 대체하는 차세대 솔루션으로 자리 잡았습니다. Vue 코어 팀이 공식적으로 권장하는 Pinia는 Composition API와의 자연스러운 통합, 완전한 TypeScript 추론, 그리고 뮤테이션 없는 직관적 API를 제공합니다. 이 글에서는 Pinia와 Vuex의 아키텍처적 차이를 실제 코드로 비교하고, 프로덕션 환경에서의 마이그레이션 전략과 2026년 프런트엔드 면접에서 빈출되는 상태 관리 질문을 체계적으로 정리합니다.

핵심 요약

Pinia 3는 Vue 3의 공식 상태 관리 솔루션입니다. 뮤테이션이 제거되어 직접 상태 변경이 가능하며, TypeScript 타입 추론이 자동으로 동작합니다. 번들 크기는 gzip 기준 약 1KB로 Vuex 4(약 6KB)의 6분의 1 수준입니다. 신규 프로젝트에서는 Pinia를 선택하고, 기존 Vuex 프로젝트는 점진적 마이그레이션을 권장합니다.

Pinia의 두 가지 스토어 패턴: Options Store vs Setup Store

Pinia는 스토어를 정의하는 두 가지 방식을 제공합니다. Options Store는 Vuex에 익숙한 개발자가 빠르게 적응할 수 있는 구조이며, Setup Store는 Composition API 패턴을 그대로 활용합니다.

Options Store

Options Store는 state, getters, actions 객체를 사용하여 스토어를 정의합니다. Vuex의 구조와 유사하지만 뮤테이션이 제거되어, 액션 내에서 this를 통해 상태를 직접 변경할 수 있습니다.

stores/counter-options.tstypescript
import { defineStore } from 'pinia'

// Options Store syntax — familiar to Vuex developers
export const useCounterStore = defineStore('counter', {
  state: () => ({
    count: 0,
    lastUpdated: null as Date | null,
  }),
  getters: {
    // Getters receive state as first argument with full type inference
    doubleCount: (state) => state.count * 2,
    isPositive(): boolean {
      // Access other getters via `this`
      return this.count > 0
    },
  },
  actions: {
    increment() {
      // Direct state mutation — no commit() needed
      this.count++
      this.lastUpdated = new Date()
    },
    async fetchCount(id: string) {
      // Async actions work without extra configuration
      const response = await fetch(`/api/counters/${id}`)
      const data = await response.json()
      this.count = data.count
    },
  },
})

Options Store에서 getter는 첫 번째 인자로 state를 받으며 완전한 타입 추론이 적용됩니다. 다른 getter에 접근할 때는 this를 사용합니다. 액션에서는 commit()이나 dispatch() 없이 this를 통해 상태를 직접 변경합니다. 비동기 액션도 추가 설정 없이 async/await 패턴을 그대로 사용할 수 있습니다.

Setup Store

Setup Store는 Composition API의 ref, computed, 일반 함수를 사용하여 스토어를 정의합니다. 컴포넌트의 <script setup>과 동일한 패턴이므로, Composition API에 익숙한 팀에서는 학습 비용이 거의 없습니다.

stores/counter-setup.tstypescript
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

// Setup Store syntax — identical patterns to Composition API
export const useCounterStore = defineStore('counter', () => {
  // ref() becomes state
  const count = ref(0)
  const lastUpdated = ref<Date | null>(null)

  // computed() becomes getters
  const doubleCount = computed(() => count.value * 2)
  const isPositive = computed(() => count.value > 0)

  // Functions become actions
  function increment() {
    count.value++
    lastUpdated.value = new Date()
  }

  async function fetchCount(id: string) {
    const response = await fetch(`/api/counters/${id}`)
    const data = await response.json()
    count.value = data.count
  }

  // Must return all state, getters, and actions
  return { count, lastUpdated, doubleCount, isPositive, increment, fetchCount }
})

Setup Store의 핵심 이점은 유연성입니다. ref()가 상태, computed()가 getter, 일반 함수가 액션으로 매핑되므로, 외부 컴포저블을 스토어 내부에서 직접 사용할 수 있습니다. Options Store에서는 불가능한 watch, watchEffect, VueUse 컴포저블 등의 활용이 가능합니다.

TypeScript 통합: Pinia vs Vuex

TypeScript 지원은 Pinia와 Vuex 사이의 가장 두드러진 차이 중 하나입니다. Vuex 4에서는 스토어의 타입을 수동으로 선언해야 하며, 모듈 증강(module augmentation)이 필수적입니다.

typescript
// Vuex 4 — manual type augmentation required
import { Store } from 'vuex'

declare module 'vuex' {
  interface State {
    counter: {
      count: number
      lastUpdated: Date | null
    }
  }
}

// Accessing state requires type assertions or custom helpers
const count = (store.state as { counter: { count: number } }).counter.count

Vuex에서는 commit('SET_COUNT', value) 호출 시 뮤테이션 이름이 문자열이므로, 오타가 있어도 컴파일 타임에 잡히지 않습니다. 타입 안전성을 확보하려면 별도의 헬퍼 함수나 타입 가드를 작성해야 합니다.

Pinia에서는 이러한 보일러플레이트가 완전히 제거됩니다.

typescript
// Pinia — full type inference, zero configuration
const counter = useCounterStore()

// TypeScript knows counter.count is number
// TypeScript knows counter.doubleCount is number
// TypeScript knows counter.increment() returns void
// TypeScript knows counter.fetchCount() returns Promise<void>
counter.increment()
console.log(counter.doubleCount)

defineStore에서 반환된 컴포저블은 상태, getter, 액션의 타입을 자동으로 추론합니다. 모듈 증강이 불필요하며, IDE의 자동 완성과 리팩토링 도구가 완벽하게 동작합니다. 대규모 코드베이스에서 이 차이는 개발 생산성에 상당한 영향을 미칩니다.

컴포저블과 결합한 고급 스토어 패턴

Setup Store의 가장 강력한 기능은 Vue 컴포저블과 외부 라이브러리를 스토어 내부에서 직접 사용할 수 있다는 점입니다. 아래 예제는 VueUse의 디바운스 함수와 Vue Router를 결합한 검색 스토어입니다.

stores/search.tstypescript
import { defineStore } from 'pinia'
import { ref, watch } from 'vue'
import { useDebounceFn } from '@vueuse/core'
import { useRoute } from 'vue-router'

export const useSearchStore = defineStore('search', () => {
  const route = useRoute()
  const query = ref('')
  const results = ref<SearchResult[]>([])
  const isLoading = ref(false)

  // VueUse composable used directly inside the store
  const debouncedSearch = useDebounceFn(async (term: string) => {
    if (!term.trim()) {
      results.value = []
      return
    }
    isLoading.value = true
    try {
      const res = await fetch(`/api/search?q=${encodeURIComponent(term)}`)
      results.value = await res.json()
    } finally {
      isLoading.value = false
    }
  }, 300)

  // Watcher syncs URL query param to store state
  watch(() => route.query.q, (q) => {
    if (typeof q === 'string') {
      query.value = q
      debouncedSearch(q)
    }
  })

  function setQuery(term: string) {
    query.value = term
    debouncedSearch(term)
  }

  return { query, results, isLoading, setQuery }
})

이 패턴에서 useDebounceFn은 VueUse에서 제공하는 디바운스 컴포저블로, 검색 API 호출을 300ms 간격으로 제한합니다. watch는 URL의 쿼리 파라미터 변경을 감지하여 스토어 상태와 동기화합니다. Options Store에서는 이러한 조합이 불가능하므로, 복잡한 비즈니스 로직이 필요한 경우 Setup Store가 적합합니다.

크로스 스토어 통신

Pinia에서 스토어 간 통신은 컴포저블 호출로 이루어집니다. Vuex의 네임스페이스 모듈 간 dispatch('auth/logout', null, { root: true }) 같은 복잡한 패턴 대신, 다른 스토어의 컴포저블을 직접 호출합니다.

stores/cart.tstypescript
import { defineStore } from 'pinia'
import { useProductStore } from './products'
import { useAuthStore } from './auth'
import { ref, computed } from 'vue'

export const useCartStore = defineStore('cart', () => {
  const items = ref<CartItem[]>([])

  const total = computed(() => {
    // Access another store by calling its composable
    const productStore = useProductStore()
    return items.value.reduce((sum, item) => {
      const product = productStore.getById(item.productId)
      return sum + (product?.price ?? 0) * item.quantity
    }, 0)
  })

  async function checkout() {
    const auth = useAuthStore()
    if (!auth.isAuthenticated) {
      throw new Error('Authentication required')
    }
    // Checkout logic
  }

  return { items, total, checkout }
})

computed 내부에서 useProductStore()를 호출하면, 상품 스토어의 상태가 변경될 때 total이 자동으로 재계산됩니다. Vuex에서 rootGetters를 통해 다른 모듈에 접근하던 방식에 비해 코드의 명시성과 타입 안전성이 크게 향상됩니다.

Vue.js / Nuxt.js 면접 준비가 되셨나요?

인터랙티브 시뮬레이터, flashcards, 기술 테스트로 연습하세요.

Pinia vs Vuex 기능 비교표

| 기능 | Pinia 3 | Vuex 4 | |------|---------|--------| | 뮤테이션 | 없음 (직접 상태 변경) | 상태 변경 시 필수 | | TypeScript | 완전한 타입 추론, 증강 불필요 | 수동 타입 증강 필요 | | 스토어 아키텍처 | 다수의 플랫 스토어 | 중첩 모듈을 가진 단일 스토어 | | Composition API | 네이티브 지원 | Options API 기반 | | 번들 크기 | gzip 약 1KB | gzip 약 6KB | | Vue Devtools | 완전 지원 (v7) | 완전 지원 | | SSR | 내장 지원 | 별도 설정 필요 | | Hot Module Replacement | 내장 지원 | 수동 설정 필요 |

Pinia는 아키텍처적으로 플랫 스토어(flat store) 구조를 채택합니다. Vuex의 네임스페이스 모듈은 깊은 중첩 구조를 형성하며, store.state.cart.items처럼 접근 경로가 길어지고 타입 추론이 복잡해집니다. Pinia에서는 각 스토어가 독립적인 컴포저블이므로, 필요한 스토어만 임포트하여 사용합니다.

Vuex에서 Pinia로의 마이그레이션 전략

기존 Vuex 프로젝트를 Pinia로 마이그레이션할 때는 모듈 단위로 점진적으로 전환하는 것이 권장됩니다. Vuex와 Pinia는 동일한 애플리케이션에서 공존할 수 있으므로, 리스크 없이 하나의 모듈씩 변환할 수 있습니다.

다음은 Vuex 모듈의 전형적인 구조입니다.

typescript
// Before: Vuex module
// store/modules/user.ts
const userModule = {
  namespaced: true,
  state: () => ({
    profile: null as UserProfile | null,
    preferences: {} as UserPreferences,
  }),
  mutations: {
    SET_PROFILE(state, profile: UserProfile) {
      state.profile = profile
    },
    UPDATE_PREFERENCES(state, prefs: Partial<UserPreferences>) {
      state.preferences = { ...state.preferences, ...prefs }
    },
  },
  actions: {
    async fetchProfile({ commit }) {
      const profile = await api.getProfile()
      commit('SET_PROFILE', profile)
    },
    async updatePreferences({ commit }, prefs: Partial<UserPreferences>) {
      await api.updatePreferences(prefs)
      commit('UPDATE_PREFERENCES', prefs)
    },
  },
  getters: {
    isLoggedIn: (state) => state.profile !== null,
    displayName: (state) => state.profile?.name ?? 'Guest',
  },
}

이 Vuex 모듈을 Pinia의 Setup Store로 변환하면 다음과 같습니다.

typescript
// After: Pinia store
// stores/user.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { api } from '@/lib/api'

export const useUserStore = defineStore('user', () => {
  const profile = ref<UserProfile | null>(null)
  const preferences = ref<UserPreferences>({})

  const isLoggedIn = computed(() => profile.value !== null)
  const displayName = computed(() => profile.value?.name ?? 'Guest')

  async function fetchProfile() {
    profile.value = await api.getProfile()
  }

  async function updatePreferences(prefs: Partial<UserPreferences>) {
    await api.updatePreferences(prefs)
    Object.assign(preferences.value, prefs)
  }

  return { profile, preferences, isLoggedIn, displayName, fetchProfile, updatePreferences }
})

마이그레이션에서 핵심적인 변경 사항은 세 가지입니다. 첫째, 뮤테이션이 완전히 제거됩니다. commit('SET_PROFILE', profile) 대신 profile.value = await api.getProfile()으로 직접 할당합니다. 둘째, state() 함수가 ref()로, getterscomputed()로, actions가 일반 함수로 대응됩니다. 셋째, 네임스페이스 문자열('user/')이 제거되고, 스토어 식별은 defineStore의 첫 번째 인자로 처리됩니다.

컴포넌트 측 코드도 간결해집니다. mapState, mapActions, mapGetters 헬퍼가 불필요해지며, useUserStore()를 호출하여 구조 분해 없이 직접 사용합니다.

SSR과 Nuxt 4에서의 Pinia 활용

Pinia는 SSR(서버 사이드 렌더링) 환경에서 상태 하이드레이션을 자동으로 처리합니다. Nuxt 4에서는 @pinia/nuxt 모듈을 통해 서버에서 생성된 스토어 상태가 클라이언트로 전달됩니다.

stores/products.ts — SSR-safe Pinia store for Nuxt 4typescript
import { defineStore } from 'pinia'

export const useProductStore = defineStore('products', {
  state: () => ({
    items: [] as Product[],
    selectedCategory: 'all',
  }),
  actions: {
    async fetchProducts() {
      // useFetch is Nuxt-specific — use $fetch for store actions
      this.items = await $fetch<Product[]>('/api/products', {
        query: { category: this.selectedCategory },
      })
    },
  },
  getters: {
    getById: (state) => (id: string) =>
      state.items.find((item) => item.id === id),
    filteredCount: (state) =>
      state.items.filter((p) => p.inStock).length,
  },
})

Nuxt 환경에서 주의할 점은 스토어 액션 내에서 useFetch 대신 $fetch를 사용해야 한다는 것입니다. useFetch는 컴포저블로서 <script setup> 내부에서만 호출해야 하며, 스토어 액션에서 사용하면 컨텍스트 오류가 발생합니다. $fetch는 범용 HTTP 클라이언트로 스토어 내부에서 안전하게 사용할 수 있습니다.

SSR에서 Pinia는 요청별로 독립된 스토어 인스턴스를 생성하여 크로스 리퀘스트 상태 오염을 방지합니다. Vuex에서는 이를 위해 스토어 팩토리 패턴을 수동으로 구현해야 했으나, Pinia에서는 내장 메커니즘으로 처리됩니다.

2026년 면접에서 자주 출제되는 상태 관리 질문

Vue 생태계의 상태 관리는 프런트엔드 면접에서 핵심 주제입니다. 아래는 2026년 기준으로 빈출되는 질문과 답변 방향을 정리한 것입니다.

Q1: Pinia에서 뮤테이션이 제거된 이유는 무엇입니까?

Vuex의 뮤테이션은 DevTools 추적과 상태 변경의 명시성을 위해 도입되었습니다. 그러나 실무에서는 대부분의 뮤테이션이 단순한 setter에 불과했고, 뮤테이션-액션 간의 불필요한 중복이 발생했습니다. Pinia는 Vue 3의 Proxy 기반 반응성 시스템을 활용하여 직접 상태 변경을 DevTools에서 추적할 수 있으므로, 뮤테이션 레이어가 불필요해졌습니다.

Q2: Options Store와 Setup Store 중 어느 것을 선택해야 합니까?

Options Store는 명확한 구조 분리(state, getters, actions)가 필요한 경우에 적합합니다. Setup Store는 컴포저블 통합, watch/watchEffect 사용, 복잡한 비즈니스 로직 구현에 적합합니다. 동일한 프로젝트에서 두 가지 방식을 혼용할 수 있으며, 스토어의 복잡도에 따라 선택합니다.

Q3: Pinia에서 스토어 간 순환 의존성은 어떻게 처리합니까?

스토어 A가 스토어 B를, 스토어 B가 스토어 A를 참조하는 경우, 컴포저블 호출을 함수 내부로 이동시킵니다. 최상위에서 useStoreA()를 호출하는 대신, 필요한 시점에 함수 내부에서 호출하면 순환 참조가 해결됩니다.

Q4: storeToRefs는 언제 사용합니까?

스토어에서 상태와 getter를 구조 분해할 때 반응성을 유지하려면 storeToRefs가 필요합니다. const { count } = useCounterStore()로 구조 분해하면 반응성이 끊어집니다. const { count } = storeToRefs(useCounterStore())를 사용해야 합니다. 단, 액션은 storeToRefs에 포함되지 않으므로 별도로 구조 분해합니다.

Q5: Pinia의 플러그인 시스템은 어떻게 동작합니까?

Pinia 플러그인은 모든 스토어에 공통 기능을 추가할 수 있습니다. pinia.use()로 등록하며, 각 스토어가 생성될 때 플러그인 함수가 실행됩니다. 로깅, 영속성(persistence), 에러 처리 등의 크로스커팅 관심사를 구현하는 데 활용됩니다.

Q6: SSR 환경에서 Pinia가 Vuex보다 유리한 점은 무엇입니까?

Pinia는 요청별 스토어 인스턴스 격리, 자동 상태 하이드레이션, 직렬화 안전한 상태 관리를 내장으로 제공합니다. Vuex에서는 createStore 팩토리 패턴, 상태 직렬화/역직렬화 로직, 크로스 리퀘스트 오염 방지를 위한 수동 설정이 필요했습니다.

면접 준비 전략

면접에서는 단순한 API 사용법보다 설계 결정의 이유를 묻는 질문이 증가하고 있습니다. "Pinia에서 뮤테이션이 왜 제거되었는가"처럼 아키텍처적 배경을 설명할 수 있어야 합니다. Vue 상태 관리 면접 준비에서 더 많은 실전 질문을 확인할 수 있습니다.

결론

  • Pinia는 Vue 3의 공식 상태 관리 라이브러리로, Vuex 4를 완전히 대체하며 뮤테이션 없는 직관적 API를 제공
  • Options Store는 구조적 명확성, Setup Store는 Composition API와의 완전한 통합 및 컴포저블 활용에 각각 적합
  • TypeScript 지원에서 Pinia는 수동 타입 증강 없이 완전한 타입 추론을 제공하여, 대규모 코드베이스에서의 개발 생산성을 크게 향상
  • 크로스 스토어 통신은 컴포저블 호출로 처리되며, Vuex의 네임스페이스 기반 방식 대비 명시적이고 타입 안전함
  • Vuex에서 Pinia로의 마이그레이션은 모듈 단위로 점진적으로 수행 가능하며, 두 라이브러리는 동일 앱에서 공존 가능
  • SSR 환경에서 Pinia는 요청별 인스턴스 격리와 자동 하이드레이션을 내장 지원
  • 번들 크기(gzip 약 1KB), DevTools 통합, HMR 내장 등 실용적 측면에서도 Pinia가 우위

Vue.js / Nuxt.js 면접 준비가 되셨나요?

인터랙티브 시뮬레이터, flashcards, 기술 테스트로 연습하세요.

태그

#vue
#pinia
#vuex
#state-management
#interview

공유

관련 기사