이미지 로딩 중...

Pinia 상태관리 완벽 가이드 - Store 모듈화와 실전 패턴 - 슬라이드 1/8
A

AI Generated

2025. 11. 8. · 9 Views

Pinia 상태관리 완벽 가이드 - Store 모듈화와 실전 패턴

Pinia를 활용한 효율적인 상태관리 방법을 배웁니다. Store 모듈화, 컴포지션 패턴, 플러그인 활용부터 실전 프로젝트 구조까지 실무에서 바로 적용할 수 있는 핵심 패턴들을 다룹니다.


목차

  1. Store 모듈화하기 - 대규모 앱의 상태 구조화
  2. Composition API 스타일로 Store 작성하기 - Setup Store 패턴
  3. Store Composable 패턴 - 재사용 가능한 상태 로직
  4. Pinia 플러그인 활용하기 - 전역 기능 확장
  5. Store 간 통신과 의존성 관리 - 느슨한 결합 유지하기
  6. Pinia State 리셋과 초기화 패턴 - 상태 관리 생명주기
  7. Pinia Getters 고급 패턴 - 효율적인 계산 속성

1. Store 모듈화하기 - 대규모 앱의 상태 구조화

시작하며

여러분이 Vue 프로젝트를 진행하다 보면 한 개의 거대한 Store 파일이 수백 줄로 늘어나는 상황을 겪어본 적 있나요? 사용자 정보, 장바구니, 알림, 설정 등 모든 상태를 하나의 파일에 몰아넣다 보면 코드를 찾기도 어렵고 유지보수가 악몽이 됩니다.

이런 문제는 팀 프로젝트에서 특히 심각합니다. 여러 개발자가 같은 Store 파일을 수정하려다 충돌이 발생하고, 어떤 상태가 어디서 사용되는지 추적하기도 힘들어집니다.

결국 버그 발견과 수정에 많은 시간을 소비하게 됩니다. 바로 이럴 때 필요한 것이 Store 모듈화입니다.

Pinia는 기본적으로 모듈화를 지원하여, 각 도메인별로 독립적인 Store를 만들고 필요할 때 조합해서 사용할 수 있습니다.

개요

간단히 말해서, Store 모듈화는 애플리케이션의 상태를 기능별, 도메인별로 분리하여 관리하는 것입니다. 각 Store는 자신의 책임 영역만 담당하며 독립적으로 동작합니다.

왜 이것이 필요한지 실무 관점에서 보면, 대규모 앱에서는 수십 개의 상태와 액션이 필요합니다. 예를 들어, 이커머스 앱에서 사용자 인증, 상품 목록, 장바구니, 주문 내역, 알림 등을 모두 한 곳에서 관리하면 코드가 복잡해지고 테스트도 어려워집니다.

기존 Vuex에서는 모듈을 만들려면 네임스페이스를 설정하고 복잡한 구조를 따라야 했습니다. 하지만 Pinia에서는 그냥 별도의 파일에 defineStore를 호출하면 끝입니다.

각 Store는 완전히 독립적이면서도 서로를 참조할 수 있습니다. Store 모듈화의 핵심 특징은 세 가지입니다: 첫째, 각 Store가 고유한 ID를 가지고 독립적으로 동작합니다.

둘째, Store 간에 의존성을 명확하게 표현할 수 있습니다. 셋째, 타입스크립트와 완벽하게 통합되어 자동완성과 타입 체크가 가능합니다.

이러한 특징들이 코드의 가독성과 유지보수성을 크게 향상시킵니다.

코드 예제

// stores/auth.js - 인증 관련 Store
import { defineStore } from 'pinia'

export const useAuthStore = defineStore('auth', {
  state: () => ({
    user: null,
    token: null,
    isAuthenticated: false
  }),

  actions: {
    async login(credentials) {
      // API 호출하여 로그인 처리
      const response = await fetch('/api/login', {
        method: 'POST',
        body: JSON.stringify(credentials)
      })
      const data = await response.json()

      this.user = data.user
      this.token = data.token
      this.isAuthenticated = true
    },

    logout() {
      this.user = null
      this.token = null
      this.isAuthenticated = false
    }
  }
})

// stores/cart.js - 장바구니 Store (auth Store 참조)
import { defineStore } from 'pinia'
import { useAuthStore } from './auth'

export const useCartStore = defineStore('cart', {
  state: () => ({
    items: [],
    total: 0
  }),

  actions: {
    async addItem(product) {
      const authStore = useAuthStore()

      // 로그인 여부 확인
      if (!authStore.isAuthenticated) {
        throw new Error('로그인이 필요합니다')
      }

      this.items.push(product)
      this.total += product.price
    }
  }
})

설명

이것이 하는 일: 위 코드는 인증(auth)과 장바구니(cart)라는 두 개의 독립적인 Store를 만들고, 장바구니 Store에서 인증 Store의 상태를 참조하여 사용자 로그인 여부를 확인합니다. 첫 번째로, useAuthStore는 사용자 인증과 관련된 모든 로직을 담당합니다.

state에는 사용자 정보, 토큰, 인증 상태를 저장하고, actions에는 로그인과 로그아웃 메서드를 정의합니다. 이렇게 인증 로직을 한 곳에 모아두면 보안 관련 코드를 관리하기가 훨씬 쉬워집니다.

두 번째로, useCartStore는 장바구니 기능만 담당하지만, 내부에서 useAuthStore()를 호출하여 인증 Store의 인스턴스를 가져옵니다. 이것이 Pinia의 강력한 점입니다.

Store 간 의존성을 명확하게 표현할 수 있으며, addItem 메서드에서 authStore.isAuthenticated를 체크하여 로그인하지 않은 사용자의 장바구니 추가를 방지합니다. 세 번째로, 각 Store는 고유한 ID('auth', 'cart')를 가집니다.

Pinia는 이 ID를 사용하여 Store 인스턴스를 싱글톤으로 관리하므로, 어디서든 useAuthStore()를 호출하면 항상 같은 인스턴스를 받게 됩니다. 이는 상태의 일관성을 보장합니다.

여러분이 이 코드를 사용하면 각 도메인 로직을 독립적으로 개발하고 테스트할 수 있습니다. 예를 들어, 인증 로직이 변경되어도 장바구니 Store의 코드를 수정할 필요가 없습니다.

또한 새로운 기능(예: 위시리스트)을 추가할 때도 새로운 Store 파일만 만들면 되므로 기존 코드에 영향을 주지 않습니다. 실무에서는 보통 stores/ 폴더 안에 auth.js, cart.js, products.js 등의 파일을 만들어 관리합니다.

각 파일은 100-200줄 정도로 유지되어 읽기 쉽고, Git 충돌도 최소화됩니다.

실전 팁

💡 Store ID는 앱 전체에서 고유해야 합니다. 네이밍 컨벤션을 정하세요. 예: 'auth', 'user-cart', 'admin-dashboard' 등 도메인을 명확히 표현하는 이름을 사용하면 나중에 디버깅할 때 훨씬 쉽습니다.

💡 Store 간 순환 참조를 조심하세요. A Store가 B Store를 참조하고, B가 다시 A를 참조하면 문제가 발생할 수 있습니다. 이럴 때는 공통 로직을 별도의 composable로 분리하거나, 의존성 방향을 재설계하는 것이 좋습니다.

💡 각 Store는 하나의 책임만 가져야 합니다(단일 책임 원칙). 만약 하나의 Store가 너무 커진다면 더 작은 단위로 쪼개세요. 예를 들어 'user' Store가 프로필, 설정, 알림을 모두 관리한다면 'userProfile', 'userSettings', 'userNotifications'로 분리하는 것을 고려하세요.

💡 Store를 import할 때는 setup() 함수나 라이프사이클 훅 내부에서 호출하세요. 컴포넌트 최상단에서 바로 호출하면 SSR 환경에서 문제가 발생할 수 있습니다. 올바른 방법: const authStore = useAuthStore() in setup().

💡 개발 도구를 활용하세요. Vue Devtools에서 Pinia 탭을 열면 모든 Store의 상태를 실시간으로 확인하고, 시간 여행 디버깅도 가능합니다. 각 Store가 분리되어 있으면 특정 Store의 상태 변화만 추적하기가 훨씬 쉽습니다.


2. Composition API 스타일로 Store 작성하기 - Setup Store 패턴

시작하며

여러분이 Vue 3의 Composition API에 익숙하다면, Options API 스타일의 Pinia Store가 조금 어색하게 느껴질 수 있습니다. ref, computed, watch 같은 익숙한 함수 대신 state, getters, actions 객체를 사용하는 것이 불편할 수 있죠.

특히 복잡한 로직을 작성할 때 문제가 됩니다. Composition API의 장점인 로직 재사용, 타입 추론, 유연한 구성을 Store에서도 활용하고 싶은데, Options API 스타일로는 제약이 많습니다.

바로 이럴 때 필요한 것이 Setup Store 패턴입니다. Pinia는 Composition API 스타일로 Store를 작성할 수 있는 방법을 제공하며, 컴포넌트의 setup() 함수와 똑같은 방식으로 Store를 정의할 수 있습니다.

개요

간단히 말해서, Setup Store는 컴포넌트의 setup 함수처럼 Store를 작성하는 방식입니다. ref는 state가 되고, computed는 getters가 되며, 일반 함수는 actions가 됩니다.

왜 이것이 필요한지 실무 관점에서 보면, Composition API의 모든 기능을 Store에서도 사용할 수 있기 때문입니다. 예를 들어, watchEffect로 상태 변화를 감지하거나, 커스텀 composable을 Store 내부에서 직접 사용할 수 있습니다.

복잡한 비즈니스 로직을 더 유연하게 구성할 수 있죠. 기존 Options API 스타일에서는 state, getters, actions라는 정해진 구조를 따라야 했습니다.

하지만 Setup Store에서는 자유롭게 변수와 함수를 선언하고, 어떤 것을 외부에 노출할지 직접 결정할 수 있습니다. Setup Store의 핵심 특징은: 첫째, Composition API의 모든 기능(ref, reactive, computed, watch 등)을 그대로 사용할 수 있습니다.

둘째, 타입스크립트의 타입 추론이 더 정확하게 작동합니다. 셋째, 로직을 더 세밀하게 구성할 수 있어 복잡한 상태 관리가 쉬워집니다.

이러한 특징들이 현대적인 Vue 개발 경험을 Store에서도 가능하게 합니다.

코드 예제

// stores/todos.js - Setup Store 스타일
import { defineStore } from 'pinia'
import { ref, computed, watch } from 'vue'

export const useTodosStore = defineStore('todos', () => {
  // State - ref로 선언
  const todos = ref([])
  const filter = ref('all') // 'all', 'active', 'completed'

  // Getters - computed로 선언
  const filteredTodos = computed(() => {
    if (filter.value === 'active') {
      return todos.value.filter(todo => !todo.completed)
    } else if (filter.value === 'completed') {
      return todos.value.filter(todo => todo.completed)
    }
    return todos.value
  })

  const todoCount = computed(() => todos.value.length)
  const completedCount = computed(() =>
    todos.value.filter(todo => todo.completed).length
  )

  // Actions - 일반 함수로 선언
  function addTodo(text) {
    todos.value.push({
      id: Date.now(),
      text,
      completed: false,
      createdAt: new Date()
    })
  }

  function toggleTodo(id) {
    const todo = todos.value.find(t => t.id === id)
    if (todo) {
      todo.completed = !todo.completed
    }
  }

  function removeTodo(id) {
    const index = todos.value.findIndex(t => t.id === id)
    if (index > -1) {
      todos.value.splice(index, 1)
    }
  }

  // Watch - 상태 변화 감지
  watch(todos, (newTodos) => {
    // localStorage에 자동 저장
    localStorage.setItem('todos', JSON.stringify(newTodos))
  }, { deep: true })

  // 반드시 return으로 노출할 것들을 명시
  return {
    todos,
    filter,
    filteredTodos,
    todoCount,
    completedCount,
    addTodo,
    toggleTodo,
    removeTodo
  }
})

설명

이것이 하는 일: 위 코드는 할 일(Todo) 관리 Store를 Setup Store 패턴으로 작성한 것입니다. ref로 반응형 상태를 만들고, computed로 파생 데이터를 계산하며, watch로 상태 변화를 감지하여 localStorage에 자동 저장합니다.

첫 번째로, todosfilterref로 선언합니다. 이들은 Store의 state가 됩니다.

Options API 스타일에서 state: () => ({ todos: [] })라고 썼던 것을 이제는 컴포넌트에서처럼 const todos = ref([])로 작성합니다. 훨씬 직관적이죠.

.value를 사용해서 값에 접근하는 것도 동일합니다. 두 번째로, computed로 getters를 정의합니다.

filteredTodos는 현재 필터 설정에 따라 할 일 목록을 필터링한 결과를 반환합니다. todoCountcompletedCount는 각각 전체 개수와 완료된 개수를 계산합니다.

이 computed 값들은 의존하는 상태가 변경될 때 자동으로 재계산되며, 캐싱도 됩니다. 세 번째로, 일반 함수로 actions를 정의합니다.

addTodo, toggleTodo, removeTodo 함수들이 할 일을 추가, 토글, 삭제하는 로직을 담당합니다. Options API 스타일에서는 this.todos로 접근했지만, 여기서는 todos.value로 접근합니다.

함수 내부에서 다른 함수를 호출할 때도 this 없이 바로 호출할 수 있어 더 간결합니다. 네 번째로, watch를 사용하여 todos가 변경될 때마다 localStorage에 자동 저장하는 사이드 이펙트를 추가합니다.

이것이 Setup Store의 강력한 점입니다. Options API 스타일에서는 이런 로직을 추가하기 까다로웠지만, Setup Store에서는 컴포넌트에서 하듯이 자연스럽게 watch를 사용할 수 있습니다.

마지막으로, return 문에서 외부에 노출할 속성과 메서드를 명시합니다. 이것이 Store의 공개 API가 됩니다.

내부에서만 사용하는 private 변수나 함수는 return하지 않으면 됩니다. 이는 캡슐화를 통해 Store의 인터페이스를 명확하게 정의할 수 있게 해줍니다.

여러분이 이 패턴을 사용하면 Composition API의 모든 장점을 Store에서도 누릴 수 있습니다. 커스텀 composable을 Store 내부에서 사용하거나, 복잡한 리액티브 로직을 자유롭게 구성할 수 있습니다.

타입스크립트 사용 시 타입 추론도 더 정확하게 작동합니다.

실전 팁

💡 Setup Store에서도 Options API 스타일의 기능들(plugins, $reset, $subscribe 등)을 모두 사용할 수 있습니다. 단, $reset을 사용하려면 초기 상태를 별도로 저장해두고 수동으로 리셋 함수를 만들어야 합니다.

💡 Private 메서드나 변수를 만들 때는 return에서 제외하세요. 예를 들어 내부에서만 사용하는 _validateTodo 같은 함수는 return하지 않으면 외부에서 접근할 수 없습니다. 이는 Store의 API를 깔끔하게 유지하는 데 도움이 됩니다.

💡 Setup Store는 타입스크립트와 완벽하게 호환됩니다. 별도의 타입 정의 없이도 IDE가 자동으로 타입을 추론하므로, Options API 스타일보다 개발 경험이 훨씬 좋습니다. 특히 제네릭을 사용한 복잡한 타입도 쉽게 다룰 수 있습니다.

💡 composable을 Store 내부에서 사용할 때는 주의하세요. useRouter, useRoute 같은 것들은 컴포넌트 컨텍스트에서만 작동합니다. Store는 컴포넌트 외부에서도 사용될 수 있으므로, 이런 composable은 action 함수의 매개변수로 받거나 다른 방법을 사용해야 합니다.

💡 watch를 Store에서 사용할 때는 메모리 누수를 조심하세요. Store는 싱글톤으로 앱이 살아있는 동안 계속 유지되므로, watch도 계속 활성 상태입니다. 필요하다면 watch의 stop 함수를 활용하여 정리하세요.


3. Store Composable 패턴 - 재사용 가능한 상태 로직

시작하며

여러분이 여러 Store에서 비슷한 패턴을 반복해서 작성하는 자신을 발견한 적 있나요? 예를 들어, API에서 데이터를 가져오는 로직, 로딩 상태 관리, 에러 처리 등은 대부분의 Store에서 필요한 공통 기능입니다.

이런 보일러플레이트 코드를 매번 복사-붙여넣기하다 보면 코드 중복이 심해지고, 나중에 로직을 수정할 때 모든 곳을 찾아다니며 고쳐야 하는 문제가 생깁니다. 예를 들어, 에러 처리 방식을 변경하려면 수십 개의 Store를 모두 수정해야 할 수도 있습니다.

바로 이럴 때 필요한 것이 Store Composable 패턴입니다. Vue 3의 composable 개념을 Store에 적용하여, 재사용 가능한 상태 로직을 별도의 함수로 분리하고 여러 Store에서 공유할 수 있습니다.

개요

간단히 말해서, Store Composable은 상태 관련 로직을 재사용 가능한 함수로 만드는 패턴입니다. 컴포넌트에서 useCounter, useFetch 같은 composable을 만들듯이, Store에서도 공통 로직을 composable로 추출할 수 있습니다.

왜 이것이 필요한지 실무 관점에서 보면, 대부분의 앱에서 데이터 fetching, 페이지네이션, 무한 스크롤, 검색 필터링 같은 패턴이 반복됩니다. 예를 들어, 사용자 목록, 상품 목록, 게시글 목록 Store에서 모두 비슷한 API 호출과 로딩 상태 관리가 필요합니다.

이를 composable로 만들면 한 번만 작성하고 여러 곳에서 재사용할 수 있습니다. 기존에는 각 Store마다 loading, error, data 같은 상태와 fetch, refresh 같은 액션을 중복으로 작성했습니다.

이제는 useAsyncState 같은 composable을 만들어서, 모든 Store에서 일관된 방식으로 비동기 상태를 관리할 수 있습니다. Store Composable의 핵심 특징은: 첫째, DRY(Don't Repeat Yourself) 원칙을 지켜 코드 중복을 제거합니다.

둘째, 테스트가 쉬워집니다. composable 자체를 독립적으로 테스트할 수 있죠.

셋째, 관심사의 분리가 명확해져서 코드 구조가 깨끗해집니다. 이러한 특징들이 대규모 앱에서 상태 관리 코드의 품질을 크게 향상시킵니다.

코드 예제

// composables/useAsyncState.js - 재사용 가능한 비동기 상태 관리
import { ref } from 'vue'

export function useAsyncState() {
  const data = ref(null)
  const loading = ref(false)
  const error = ref(null)

  async function execute(asyncFunction) {
    loading.value = true
    error.value = null

    try {
      const result = await asyncFunction()
      data.value = result
      return result
    } catch (e) {
      error.value = e.message
      throw e
    } finally {
      loading.value = false
    }
  }

  function reset() {
    data.value = null
    loading.value = false
    error.value = null
  }

  return {
    data,
    loading,
    error,
    execute,
    reset
  }
}

// stores/users.js - Composable을 활용한 Store
import { defineStore } from 'pinia'
import { useAsyncState } from '@/composables/useAsyncState'
import { computed } from 'vue'

export const useUsersStore = defineStore('users', () => {
  // Composable 사용
  const { data: users, loading, error, execute, reset } = useAsyncState()

  // 추가적인 computed 속성
  const userCount = computed(() => users.value?.length || 0)

  // API 호출 함수들
  async function fetchUsers() {
    return execute(async () => {
      const response = await fetch('/api/users')
      if (!response.ok) throw new Error('사용자 목록을 불러올 수 없습니다')
      return response.json()
    })
  }

  async function searchUsers(query) {
    return execute(async () => {
      const response = await fetch(`/api/users/search?q=${query}`)
      if (!response.ok) throw new Error('검색에 실패했습니다')
      return response.json()
    })
  }

  return {
    users,
    loading,
    error,
    userCount,
    fetchUsers,
    searchUsers,
    reset
  }
})

설명

이것이 하는 일: 위 코드는 비동기 작업의 상태(data, loading, error)를 관리하는 useAsyncState composable을 만들고, 이를 사용자 목록을 관리하는 Store에서 재사용합니다. 첫 번째로, useAsyncState composable은 모든 비동기 작업에 필요한 핵심 기능을 제공합니다.

data, loading, error 세 가지 반응형 상태를 관리하며, execute 함수는 비동기 함수를 받아서 실행하면서 자동으로 로딩 상태를 관리하고 에러를 처리합니다. 이 패턴은 API 호출, 파일 업로드, 무거운 계산 등 모든 비동기 작업에 적용할 수 있습니다.

두 번째로, execute 함수의 동작을 자세히 보면, 먼저 loading을 true로 설정하고 이전 에러를 초기화합니다. 그 다음 전달받은 비동기 함수를 실행하고 결과를 data에 저장합니다.

만약 에러가 발생하면 error에 에러 메시지를 저장하고, 마지막으로 finally 블록에서 항상 loading을 false로 되돌립니다. 이 로직을 한 번만 작성하면 됩니다.

세 번째로, useUsersStore에서 이 composable을 사용하는 방법을 봅시다. const { data: users, loading, error, execute, reset } = useAsyncState()처럼 destructuring으로 필요한 것들을 가져옵니다.

datausers로 이름을 바꿔서 더 명확하게 만들었습니다. 이제 이 Store는 비동기 상태 관리 로직을 직접 작성할 필요가 없습니다.

네 번째로, fetchUserssearchUsers 함수는 각각의 API 호출 로직만 담당합니다. execute를 호출하면서 비동기 함수를 전달하면, 나머지 상태 관리는 composable이 알아서 처리합니다.

이렇게 하면 각 함수는 자신의 비즈니스 로직(어떤 API를 호출할지, 파라미터는 무엇인지)에만 집중할 수 있습니다. 여러분이 이 패턴을 사용하면 엄청난 이점을 얻을 수 있습니다.

첫째, 새로운 Store를 만들 때 boilerplate 코드를 거의 작성하지 않아도 됩니다. useAsyncState만 가져다 쓰면 끝이죠.

둘째, 에러 처리나 로딩 상태 표시 방식을 변경하고 싶을 때 composable 한 곳만 수정하면 모든 Store에 적용됩니다. 셋째, 테스트 작성이 훨씬 쉬워집니다.

composable 자체를 유닛 테스트로 검증하고, Store에서는 비즈니스 로직만 테스트하면 됩니다. 실무에서는 useAsyncState 외에도 usePagination, useInfiniteScroll, useDebounceSearch, useLocalStorage 같은 다양한 composable을 만들어 사용합니다.

이들을 조합하면 복잡한 Store도 레고 블록처럼 간단하게 구성할 수 있습니다.

실전 팁

💡 Composable의 이름은 항상 use로 시작하세요. 이는 Vue 커뮤니티의 네이밍 컨벤션이며, 코드를 읽는 사람이 "이것은 재사용 가능한 로직이구나"라고 즉시 알 수 있게 해줍니다.

💡 Composable은 순수 함수로 만들어서 사이드 이펙트를 최소화하세요. 외부 상태에 의존하거나 변경하지 말고, 매개변수를 받아서 새로운 상태와 함수를 반환하는 형태가 이상적입니다. 이렇게 하면 테스트와 재사용이 쉬워집니다.

💡 여러 Store에서 같은 로직을 3번 이상 반복한다면 composable로 추출하는 것을 고려하세요. 하지만 너무 성급하게 추상화하지 마세요. 패턴이 명확해질 때까지 기다렸다가 추출하는 것이 좋습니다. 잘못된 추상화는 중복 코드보다 더 나쁠 수 있습니다.

💡 Composable을 조합할 때는 각각이 하나의 책임만 가지도록 하세요. 예를 들어 useAsyncStateusePagination을 별도로 만들고, 필요한 Store에서 둘 다 사용하는 것이 하나의 거대한 useAsyncPaginatedState를 만드는 것보다 유연합니다.

💡 타입스크립트를 사용한다면 제네릭을 활용하여 composable을 타입 안전하게 만드세요. 예: function useAsyncState<T>() 이렇게 하면 data의 타입이 자동으로 추론되어 IDE의 자동완성이 완벽하게 작동합니다.


4. Pinia 플러그인 활용하기 - 전역 기능 확장

시작하며

여러분이 모든 Store에 동일한 기능을 추가하고 싶었던 적이 있나요? 예를 들어, 모든 상태 변경을 로그로 남기거나, 자동으로 localStorage에 저장하거나, 개발 환경에서만 특정 디버깅 기능을 활성화하고 싶을 때가 있습니다.

이런 기능을 각 Store마다 일일이 추가하는 것은 비효율적입니다. 코드 중복도 문제지만, 나중에 기능을 수정하거나 제거할 때도 모든 Store를 찾아다녀야 합니다.

특히 팀 프로젝트에서는 다른 개발자가 만든 Store에 이런 기능이 누락될 수도 있습니다. 바로 이럴 때 필요한 것이 Pinia 플러그인입니다.

플러그인을 사용하면 모든 Store에 자동으로 기능을 추가할 수 있으며, Store 생성 시점에 커스텀 로직을 실행할 수 있습니다.

개요

간단히 말해서, Pinia 플러그인은 모든 Store에 전역적으로 기능을 추가하는 메커니즘입니다. Store가 생성될 때 자동으로 호출되는 함수를 등록하여, 상태 추가, 메서드 확장, 이벤트 리스닝 등을 할 수 있습니다.

왜 이것이 필요한지 실무 관점에서 보면, 로깅, 분석, 퍼시스턴스(persistence), 디버깅 같은 횡단 관심사(cross-cutting concerns)를 중앙에서 관리할 수 있기 때문입니다. 예를 들어, 사용자 행동 분석을 위해 모든 액션 호출을 추적하고 싶다면, 각 Store의 모든 액션에 코드를 추가하는 대신 플러그인 하나로 해결할 수 있습니다.

기존에는 Vuex의 플러그인 시스템이 있었지만 사용하기 복잡했습니다. Pinia의 플러그인은 훨씬 간단하면서도 강력합니다.

함수 하나만 작성하면 되고, 타입스크립트 지원도 훌륭합니다. Pinia 플러그인의 핵심 특징은: 첫째, 모든 Store에 일괄적으로 기능을 추가할 수 있습니다.

둘째, Store의 생명주기에 접근하여 초기화, 구독, 리셋 등의 시점에 커스텀 로직을 실행할 수 있습니다. 셋째, 플러그인 간의 조합이 자유롭습니다.

여러 개의 플러그인을 등록하면 순서대로 실행됩니다. 이러한 특징들이 앱 전체의 상태 관리를 일관되고 효율적으로 만들어줍니다.

코드 예제

// plugins/piniaLogger.js - 상태 변경 로깅 플러그인
export function piniaLogger({ store, options }) {
  // 개발 환경에서만 동작
  if (import.meta.env.PROD) return

  console.log(`🔵 Store "${store.$id}" 생성됨`)

  // 모든 액션 호출을 감지
  store.$onAction(({ name, args, after, onError }) => {
    const startTime = Date.now()
    console.log(`⚡ Action "${name}" 호출됨`, args)

    after((result) => {
      const duration = Date.now() - startTime
      console.log(`✅ Action "${name}" 완료 (${duration}ms)`, result)
    })

    onError((error) => {
      console.error(`❌ Action "${name}" 에러:`, error)
    })
  })

  // 상태 변경 감지
  store.$subscribe((mutation, state) => {
    console.log(`🔄 State 변경:`, {
      storeId: mutation.storeId,
      type: mutation.type,
      payload: mutation.payload
    })
  })
}

// plugins/piniaPersistence.js - localStorage 자동 저장 플러그인
export function piniaPersistence({ store, options }) {
  // Store 옵션에서 persist 설정 확인
  if (!options.persist) return

  const storageKey = `pinia-${store.$id}`

  // localStorage에서 이전 상태 복원
  const savedState = localStorage.getItem(storageKey)
  if (savedState) {
    try {
      store.$patch(JSON.parse(savedState))
      console.log(`💾 Store "${store.$id}" 상태 복원됨`)
    } catch (e) {
      console.error(`복원 실패:`, e)
    }
  }

  // 상태 변경 시 자동 저장 (디바운싱 적용)
  let saveTimer = null
  store.$subscribe((mutation, state) => {
    if (saveTimer) clearTimeout(saveTimer)

    saveTimer = setTimeout(() => {
      localStorage.setItem(storageKey, JSON.stringify(state))
    }, 500) // 500ms 디바운스
  })
}

// main.js - 플러그인 등록
import { createPinia } from 'pinia'
import { piniaLogger } from './plugins/piniaLogger'
import { piniaPersistence } from './plugins/piniaPersistence'

const pinia = createPinia()
pinia.use(piniaLogger)
pinia.use(piniaPersistence)

app.use(pinia)

// stores/auth.js - 플러그인 활용
export const useAuthStore = defineStore('auth', {
  state: () => ({
    user: null,
    token: null
  }),
  actions: {
    login(credentials) {
      // ... 로그인 로직
    }
  }
}, {
  persist: true // persistence 플러그인 활성화
})

설명

이것이 하는 일: 위 코드는 두 개의 플러그인을 만듭니다. piniaLogger는 개발 환경에서 모든 액션 호출과 상태 변경을 콘솔에 로그로 출력하고, piniaPersistence는 Store의 상태를 localStorage에 자동으로 저장하고 복원합니다.

첫 번째로, 플러그인 함수의 구조를 이해해야 합니다. 모든 플러그인은 { store, options }를 매개변수로 받는 함수입니다.

store는 생성된 Store 인스턴스이며, optionsdefineStore의 세 번째 인자로 전달된 옵션입니다. 플러그인은 Store가 생성될 때마다 호출되므로, 여기서 Store에 기능을 추가하거나 이벤트를 구독할 수 있습니다.

두 번째로, piniaLogger의 동작을 보면, store.$onAction()을 사용하여 모든 액션 호출을 감지합니다. name은 호출된 액션의 이름, args는 전달된 인자입니다.

after 콜백은 액션이 성공적으로 완료되면 호출되고, onError는 에러 발생 시 호출됩니다. 이를 활용하면 각 액션의 실행 시간을 측정하고, 에러를 중앙에서 처리할 수 있습니다.

store.$subscribe()는 상태가 변경될 때마다 호출되어 어떤 값이 어떻게 바뀌었는지 추적할 수 있습니다. 세 번째로, piniaPersistence는 더 실용적인 기능을 제공합니다.

Store 정의 시 { persist: true } 옵션이 있는 경우에만 동작하도록 했습니다. Store가 생성되면 localStorage에서 pinia-${store.$id} 키로 저장된 상태를 불러와서 store.$patch()로 복원합니다.

그리고 $subscribe()로 상태 변경을 감지하여 자동으로 localStorage에 저장합니다. 여기서 디바운싱을 적용한 것이 중요한데, 상태가 연속으로 변경될 때 매번 저장하지 않고 500ms 후에 한 번만 저장하여 성능을 최적화합니다.

네 번째로, 플러그인 등록은 main.js에서 pinia.use()를 호출하면 됩니다. 여러 플러그인을 등록하면 등록 순서대로 실행됩니다.

각 Store가 생성될 때 등록된 모든 플러그인이 차례로 호출되어 기능을 추가합니다. 여러분이 이 플러그인들을 사용하면 개발 경험이 크게 향상됩니다.

로거 플러그인으로 디버깅이 쉬워지고, 어떤 액션이 언제 호출되는지 한눈에 볼 수 있습니다. 퍼시스턴스 플러그인은 사용자가 페이지를 새로고침해도 로그인 상태나 장바구니 내용이 유지되도록 해줍니다.

가장 좋은 점은 이 모든 기능을 각 Store에서 신경 쓸 필요 없이 플러그인이 자동으로 처리한다는 것입니다. 실무에서는 이 외에도 에러 추적(Sentry), 사용자 분석(Google Analytics), API 요청 캐싱, 언두/리두 기능 등을 플러그인으로 구현할 수 있습니다.

실전 팁

💡 플러그인에서 새로운 속성을 추가할 때는 store.$state에 직접 추가하지 말고 store.$patch() 또는 반응형 속성으로 추가하세요. 예: store.customProperty = ref('value'). 이렇게 해야 반응성이 제대로 작동합니다.

💡 퍼시스턴스 플러그인을 만들 때 민감한 정보(비밀번호, 토큰 등)를 저장하지 않도록 주의하세요. optionspersistExclude 같은 필드를 추가하여 특정 속성을 제외할 수 있게 만드는 것이 좋습니다.

💡 프로덕션 환경에서는 로깅 플러그인을 비활성화하세요. 위 예제처럼 import.meta.env.PROD를 체크하거나, 환경 변수로 제어하는 것이 좋습니다. 불필요한 로그는 성능에 영향을 줄 수 있습니다.

💡 플러그인에서 비동기 작업을 할 때는 주의하세요. 플러그인 함수 자체는 동기적으로 실행되어야 하지만, 내부에서 $subscribe$onAction 콜백에서 비동기 작업을 할 수는 있습니다.

💡 타입스크립트를 사용한다면 플러그인이 추가하는 속성에 대한 타입 선언을 해주세요. declare module 'pinia' { ... }를 사용하여 Store 인터페이스를 확장하면 IDE 자동완성이 완벽하게 작동합니다.


5. Store 간 통신과 의존성 관리 - 느슨한 결합 유지하기

시작하며

여러분이 여러 개의 Store를 사용하다 보면 Store 간에 데이터를 공유하거나 서로의 액션을 호출해야 할 때가 있습니다. 예를 들어, 사용자가 로그아웃하면 장바구니, 알림, 설정 등 모든 Store를 초기화해야 할 수 있습니다.

이때 잘못된 방법으로 Store를 연결하면 강한 결합(tight coupling)이 발생합니다. A Store가 B를 참조하고, B가 C를 참조하고, C가 다시 A를 참조하는 순환 참조가 생기거나, 한 Store를 수정했을 때 연쇄적으로 다른 Store들이 영향을 받는 문제가 생깁니다.

바로 이럴 때 필요한 것이 올바른 Store 간 통신 패턴입니다. 의존성 방향을 명확히 하고, 이벤트 기반 통신을 활용하며, 필요시 중재자 패턴을 사용하여 Store 간의 결합도를 낮추는 방법을 배워야 합니다.

개요

간단히 말해서, Store 간 통신 패턴은 여러 Store가 협력하면서도 독립성을 유지하는 방법입니다. 직접 참조, 이벤트 버스, 중재자 패턴 등 상황에 맞는 방법을 선택할 수 있습니다.

왜 이것이 필요한지 실무 관점에서 보면, 앱이 커질수록 Store 간의 관계가 복잡해집니다. 예를 들어, 주문 완료 시 장바구니를 비우고, 포인트를 차감하고, 알림을 보내고, 분석 데이터를 전송해야 한다면, 이 모든 Store를 어떻게 조율할까요?

잘못 설계하면 스파게티 코드가 됩니다. 기존 접근 방식에서는 Store가 다른 Store를 직접 import하여 사용했습니다.

이것도 나쁘지 않지만, 의존성이 복잡해지면 문제가 됩니다. 더 나은 방법은 의존성의 방향을 한 방향으로 유지하거나, 이벤트를 통해 느슨하게 연결하는 것입니다.

Store 간 통신의 핵심 패턴은: 첫째, 직접 참조 - 간단한 경우 한 Store가 다른 Store를 import하여 사용합니다. 둘째, 이벤트 버스 - Store가 이벤트를 발행하고 다른 Store가 구독하는 pub-sub 패턴입니다.

셋째, 중재자 Store - 여러 Store를 조율하는 별도의 조정자 역할 Store를 만듭니다. 이러한 패턴들을 상황에 맞게 조합하면 복잡한 앱도 깔끔하게 관리할 수 있습니다.

코드 예제

// utils/eventBus.js - 간단한 이벤트 버스
import mitt from 'mitt'
export const eventBus = mitt()

// stores/auth.js - 이벤트 발행
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { eventBus } from '@/utils/eventBus'

export const useAuthStore = defineStore('auth', () => {
  const user = ref(null)
  const isAuthenticated = ref(false)

  function login(userData) {
    user.value = userData
    isAuthenticated.value = true

    // 로그인 성공 이벤트 발행
    eventBus.emit('auth:login', { user: userData })
  }

  function logout() {
    user.value = null
    isAuthenticated.value = false

    // 로그아웃 이벤트 발행
    eventBus.emit('auth:logout')
  }

  return { user, isAuthenticated, login, logout }
})

// stores/cart.js - 이벤트 구독
import { defineStore } from 'pinia'
import { ref, onMounted } from 'vue'
import { eventBus } from '@/utils/eventBus'

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

  function addItem(product) {
    items.value.push(product)
  }

  function clearCart() {
    items.value = []
  }

  // 로그아웃 시 자동으로 장바구니 비우기
  eventBus.on('auth:logout', () => {
    clearCart()
    console.log('장바구니가 초기화되었습니다')
  })

  return { items, addItem, clearCart }
})

// stores/analytics.js - 여러 이벤트 구독
import { defineStore } from 'pinia'
import { eventBus } from '@/utils/eventBus'

export const useAnalyticsStore = defineStore('analytics', () => {
  function trackEvent(eventName, data) {
    // 분석 서비스로 전송 (예: Google Analytics)
    console.log('📊 Analytics:', eventName, data)
  }

  // 여러 이벤트 구독
  eventBus.on('auth:login', (data) => {
    trackEvent('user_login', { userId: data.user.id })
  })

  eventBus.on('auth:logout', () => {
    trackEvent('user_logout', {})
  })

  eventBus.on('cart:checkout', (data) => {
    trackEvent('purchase', { total: data.total, items: data.items.length })
  })

  return { trackEvent }
})

// stores/coordinator.js - 중재자 패턴 예시
import { defineStore } from 'pinia'
import { useAuthStore } from './auth'
import { useCartStore } from './cart'
import { useNotificationStore } from './notification'

export const useCheckoutCoordinator = defineStore('checkoutCoordinator', () => {
  const authStore = useAuthStore()
  const cartStore = useCartStore()
  const notificationStore = useNotificationStore()

  async function processCheckout() {
    // 로그인 확인
    if (!authStore.isAuthenticated) {
      throw new Error('로그인이 필요합니다')
    }

    // 장바구니 확인
    if (cartStore.items.length === 0) {
      throw new Error('장바구니가 비어있습니다')
    }

    try {
      // 결제 처리 (API 호출)
      const response = await fetch('/api/checkout', {
        method: 'POST',
        body: JSON.stringify({
          userId: authStore.user.id,
          items: cartStore.items
        })
      })

      if (!response.ok) throw new Error('결제 실패')

      // 성공 시 여러 Store 업데이트
      const result = await response.json()
      cartStore.clearCart()
      notificationStore.showSuccess('주문이 완료되었습니다!')

      // 이벤트 발행
      eventBus.emit('cart:checkout', {
        total: result.total,
        items: result.items
      })

      return result
    } catch (error) {
      notificationStore.showError('결제 처리 중 오류가 발생했습니다')
      throw error
    }
  }

  return { processCheckout }
})

설명

이것이 하는 일: 위 코드는 세 가지 Store 간 통신 패턴을 보여줍니다. 인증 Store는 로그인/로그아웃 이벤트를 발행하고, 장바구니와 분석 Store는 이 이벤트를 구독하여 반응합니다.

체크아웃 조정자 Store는 여러 Store를 조율하여 복잡한 비즈니스 로직을 처리합니다. 첫 번째로, 이벤트 버스 패턴을 이해해야 합니다.

mitt 라이브러리(또는 Vue의 내장 이벤트 시스템)를 사용하여 간단한 pub-sub 시스템을 만듭니다. eventBus.emit()으로 이벤트를 발행하면, eventBus.on()으로 구독한 모든 리스너가 호출됩니다.

이 방식의 장점은 발행자가 구독자를 전혀 알 필요가 없다는 것입니다. 인증 Store는 "로그아웃 했어요"라고 알리기만 하면, 누가 이 정보를 듣고 있는지 신경 쓰지 않습니다.

두 번째로, useAuthStore에서 loginlogout 액션을 보면, 자신의 상태를 변경한 후 적절한 이벤트를 발행합니다. auth:login, auth:logout 같은 이벤트 이름은 네임스페이스를 사용하여 충돌을 방지합니다.

이벤트와 함께 필요한 데이터(예: 사용자 정보)를 전달할 수도 있습니다. 세 번째로, useCartStoreuseAnalyticsStore는 이벤트 구독자입니다.

eventBus.on()을 사용하여 특정 이벤트를 구독하고, 이벤트가 발생하면 적절한 액션을 수행합니다. 장바구니는 로그아웃 시 자동으로 비워지고, 분석 Store는 모든 중요한 사용자 행동을 추적합니다.

이들은 인증 Store를 import하지 않아도 되므로 완전히 독립적입니다. 네 번째로, useCheckoutCoordinator는 중재자 패턴의 예시입니다.

체크아웃 같은 복잡한 프로세스는 여러 Store를 조율해야 합니다. 이때 각 Store에 로직을 분산시키는 대신, 별도의 조정자 Store를 만들어서 전체 플로우를 관리합니다.

이 Store는 인증, 장바구니, 알림 Store를 직접 참조하지만, 이들 사이의 복잡한 상호작용을 한 곳에 모아서 관리하므로 코드가 명확해집니다. 여러분이 이 패턴들을 사용하면 앱의 복잡도가 증가해도 관리 가능한 상태를 유지할 수 있습니다.

이벤트 버스는 Store 간의 결합을 최소화하고, 새로운 기능(예: 이메일 알림 Store)을 추가할 때 기존 코드를 수정하지 않아도 됩니다. 중재자 패턴은 복잡한 비즈니스 로직을 한 곳에 모아서 테스트와 유지보수를 쉽게 만듭니다.

실무 팁: 의존성 그래프를 그려보세요. A → B → C 같은 단방향 흐름은 괜찮지만, 순환 참조가 생기면 문제입니다.

이벤트 버스는 이런 순환을 끊는 데 유용합니다.

실전 팁

💡 이벤트 이름에 네임스페이스를 사용하세요. auth:login, cart:checkout 처럼 도메인을 명시하면 이벤트 충돌을 방지하고 코드 검색도 쉬워집니다. 나중에 어떤 이벤트가 있는지 찾을 때 eventBus.emit('auth:로 검색하면 인증 관련 이벤트를 모두 찾을 수 있습니다.

💡 이벤트 구독은 메모리 누수의 원인이 될 수 있습니다. Store는 싱글톤이므로 보통 문제없지만, 동적으로 생성되는 Store가 있다면 eventBus.off()로 구독을 해제해야 합니다. mitt 라이브러리의 경우 리턴값을 저장했다가 나중에 호출하면 됩니다.

💡 Store 간 직접 참조는 나쁜 것이 아닙니다. 명확한 의존성 관계가 있고 순환 참조가 없다면 직접 import하는 것이 오히려 간단합니다. 이벤트 버스는 결합도를 낮추고 싶거나, 여러 구독자가 필요할 때 사용하세요. 모든 것을 이벤트로 만들면 코드 추적이 어려워질 수 있습니다.

💡 중재자 Store는 "use case" 또는 "service" 레이어라고 생각하세요. 비즈니스 로직의 오케스트레이션을 담당하고, 각 도메인 Store는 자신의 데이터만 관리합니다. 예: useLoginUseCase, useCheckoutService 같은 네이밍을 사용할 수 있습니다.

💡 타입스크립트를 사용한다면 이벤트 타입을 정의하세요. eventBus.emit('auth:login', data) 할 때 data의 타입이 체크되도록 mitt의 제네릭을 활용하거나, 커스텀 타입 안전 이벤트 버스를 만들 수 있습니다. 이렇게 하면 이벤트 페이로드의 오타나 타입 불일치를 컴파일 타임에 잡을 수 있습니다.


6. Pinia State 리셋과 초기화 패턴 - 상태 관리 생명주기

시작하며

여러분이 사용자가 로그아웃하거나 앱의 특정 섹션을 떠날 때, Store의 상태를 초기값으로 되돌려야 할 때가 있습니다. 예를 들어, 사용자가 로그아웃했는데 이전 사용자의 데이터가 메모리에 남아있으면 보안 문제가 될 수 있습니다.

단순히 각 상태 변수를 하나씩 초기값으로 설정하는 것은 번거롭고 실수하기 쉽습니다. 새로운 상태를 추가했을 때 리셋 로직에 포함시키는 것을 깜빡할 수도 있고, 중첩된 객체의 경우 깊은 복사를 제대로 하지 않으면 원치 않는 참조가 남을 수 있습니다.

바로 이럴 때 필요한 것이 체계적인 상태 리셋 패턴입니다. Pinia는 Options API 스타일에서는 $reset() 메서드를 제공하지만, Setup Store에서는 직접 구현해야 합니다.

상황에 맞는 리셋 전략을 배워야 합니다.

개요

간단히 말해서, 상태 리셋 패턴은 Store의 상태를 초기값으로 안전하게 되돌리는 방법입니다. 전체 리셋, 부분 리셋, 조건부 리셋 등 다양한 전략이 있습니다.

왜 이것이 필요한지 실무 관점에서 보면, 앱의 생명주기 동안 여러 번 상태를 초기화해야 하는 순간이 있습니다. 예를 들어, 멀티 테넌트 앱에서 다른 계정으로 전환할 때, SPA에서 라우트 변경 시 이전 페이지의 데이터를 정리할 때, 테스트 환경에서 각 테스트 전후로 상태를 깨끗하게 만들 때 등입니다.

기존 방법에서는 각 Store에 reset() 같은 메서드를 만들어서 수동으로 모든 상태를 초기값으로 설정했습니다. 하지만 이는 오류가 발생하기 쉽고, 새로운 상태를 추가할 때마다 업데이트해야 합니다.

더 나은 방법은 초기 상태를 저장해두고, 필요할 때 이를 복원하는 것입니다. 상태 리셋의 핵심 패턴은: 첫째, Options API 스타일에서는 내장된 $reset() 사용.

둘째, Setup Store에서는 초기 상태를 factory 함수로 만들어 재사용. 셋째, 부분 리셋을 위해 $patch()와 함께 사용.

넷째, 플러그인으로 모든 Store에 리셋 기능 자동 추가. 이러한 패턴들이 상태 관리의 안정성과 예측 가능성을 높여줍니다.

코드 예제

// stores/userSettings.js - Options API 스타일 (내장 $reset)
import { defineStore } from 'pinia'

export const useUserSettingsStore = defineStore('userSettings', {
  state: () => ({
    theme: 'light',
    language: 'ko',
    notifications: true,
    fontSize: 14
  }),

  actions: {
    updateTheme(theme) {
      this.theme = theme
    },

    // $reset()이 자동으로 제공됨
    // this.$reset() 호출하면 state가 초기값으로 돌아감
  }
})

// stores/form.js - Setup Store 스타일 (수동 리셋)
import { defineStore } from 'pinia'
import { ref, reactive } from 'vue'

export const useFormStore = defineStore('form', () => {
  // 초기 상태를 factory 함수로 정의
  function getInitialState() {
    return {
      name: '',
      email: '',
      age: 0,
      preferences: {
        newsletter: false,
        updates: false
      }
    }
  }

  // 초기 상태로 반응형 객체 생성
  const formData = reactive(getInitialState())
  const submitCount = ref(0)

  function updateField(field, value) {
    formData[field] = value
  }

  async function submitForm() {
    // 폼 제출 로직
    submitCount.value++
    console.log('Form submitted:', formData)
  }

  // 수동 리셋 함수
  function $reset() {
    // 방법 1: Object.assign으로 덮어쓰기
    Object.assign(formData, getInitialState())
    submitCount.value = 0
  }

  // 부분 리셋 - 특정 필드만 초기화
  function resetFields(...fields) {
    const initial = getInitialState()
    fields.forEach(field => {
      if (field in formData) {
        formData[field] = initial[field]
      }
    })
  }

  return {
    formData,
    submitCount,
    updateField,
    submitForm,
    $reset,
    resetFields
  }
})

// plugins/piniaReset.js - 모든 Setup Store에 $reset 추가
export function piniaResetPlugin({ store, options }) {
  // Options API 스타일은 이미 $reset이 있음
  if (store.$reset) return

  // Setup Store용 $reset 추가
  const initialState = JSON.parse(JSON.stringify(store.$state))

  store.$reset = function() {
    // 깊은 복사된 초기 상태로 복원
    this.$patch(JSON.parse(JSON.stringify(initialState)))
  }
}

// composables/useStoreReset.js - 여러 Store 한 번에 리셋
import { useAuthStore } from '@/stores/auth'
import { useCartStore } from '@/stores/cart'
import { useUserSettingsStore } from '@/stores/userSettings'

export function useStoreReset() {
  function resetAllStores() {
    const authStore = useAuthStore()
    const cartStore = useCartStore()
    const settingsStore = useUserSettingsStore()

    // 모든 Store 리셋
    authStore.$reset()
    cartStore.$reset()
    settingsStore.$reset()

    console.log('모든 Store가 초기화되었습니다')
  }

  function resetUserData() {
    // 사용자 관련 Store만 선택적으로 리셋
    const authStore = useAuthStore()
    const cartStore = useCartStore()

    authStore.$reset()
    cartStore.$reset()

    // 설정은 유지 (사용자 경험 개선)
  }

  return {
    resetAllStores,
    resetUserData
  }
}

// 사용 예시 - 로그아웃 시
// components/LogoutButton.vue
import { useStoreReset } from '@/composables/useStoreReset'
import { useRouter } from 'vue-router'

export default {
  setup() {
    const { resetUserData } = useStoreReset()
    const router = useRouter()

    async function handleLogout() {
      try {
        // API 호출로 서버 세션 종료
        await fetch('/api/logout', { method: 'POST' })

        // 모든 사용자 데이터 초기화
        resetUserData()

        // 로그인 페이지로 이동
        router.push('/login')
      } catch (error) {
        console.error('로그아웃 실패:', error)
      }
    }

    return { handleLogout }
  }
}

설명

이것이 하는 일: 위 코드는 세 가지 상태 리셋 전략을 보여줍니다. Options API 스타일 Store는 자동으로 $reset()을 제공하고, Setup Store는 초기 상태를 factory 함수로 만들어 수동으로 리셋 메서드를 구현하며, 플러그인으로 모든 Store에 일관된 리셋 기능을 추가할 수 있습니다.

첫 번째로, useUserSettingsStore는 Options API 스타일로 작성되어 있습니다. 이 경우 Pinia가 자동으로 $reset() 메서드를 제공합니다.

컴포넌트에서 settingsStore.$reset()을 호출하기만 하면 state() 함수가 반환한 초기값으로 모든 상태가 되돌아갑니다. 이것이 가장 간단한 방법이지만 Setup Store에서는 작동하지 않습니다.

두 번째로, useFormStore는 Setup Store 스타일이므로 리셋 로직을 직접 구현해야 합니다. 핵심은 getInitialState() factory 함수입니다.

이 함수는 초기 상태 객체를 생성하는 순수 함수로, 호출할 때마다 새로운 객체를 반환합니다. 이렇게 하면 $reset() 함수에서 Object.assign(formData, getInitialState())를 호출하여 현재 상태를 초기값으로 덮어쓸 수 있습니다.

submitCount 같은 ref도 수동으로 0으로 설정해줍니다. 세 번째로, resetFields() 메서드는 부분 리셋의 예시입니다.

전체 폼을 초기화하는 대신 특정 필드만 선택적으로 리셋할 수 있습니다. 예를 들어 resetFields('name', 'email')을 호출하면 이 두 필드만 초기값으로 돌아가고 나머지는 유지됩니다.

이는 다단계 폼이나 위저드 UI에서 유용합니다. 네 번째로, piniaResetPlugin은 모든 Setup Store에 자동으로 $reset() 메서드를 추가하는 플러그인입니다.

Store가 생성될 때 현재 상태를 JSON.stringify로 깊은 복사하여 저장해둡니다. 그리고 store.$reset을 정의하여, 호출 시 저장된 초기 상태를 $patch()로 복원합니다.

이 방법의 장점은 모든 Store에서 일관된 방식으로 리셋을 사용할 수 있다는 것입니다. 단점은 JSON 직렬화가 불가능한 값(함수, Symbol 등)은 처리할 수 없다는 것입니다.

다섯 번째로, useStoreReset composable은 여러 Store를 한 번에 관리하는 패턴입니다. 로그아웃처럼 앱 전체의 상태를 초기화해야 할 때, 각 Store를 찾아다니며 리셋하는 대신 이 composable 하나를 호출하면 됩니다.

resetAllStores()는 모든 Store를 리셋하고, resetUserData()는 사용자 관련 Store만 선택적으로 리셋합니다. 이는 사용자 경험을 고려한 것으로, 테마나 언어 설정은 로그아웃 후에도 유지하는 것이 좋습니다.

여러분이 이 패턴들을 사용하면 상태 관리의 생명주기를 완벽하게 제어할 수 있습니다. 보안 문제를 방지하고, 메모리 누수를 막으며, 사용자에게 일관된 경험을 제공할 수 있습니다.

특히 테스트 환경에서는 각 테스트 전후로 Store를 리셋하여 테스트 간 간섭을 방지할 수 있습니다.

실전 팁

💡 Object.assign을 사용한 리셋은 얕은 복사(shallow copy)입니다. 중첩된 객체가 있다면 JSON.parse(JSON.stringify())를 사용하여 깊은 복사를 하거나, lodash의 cloneDeep을 사용하세요. 그렇지 않으면 중첩된 객체의 참조가 남아서 완전히 초기화되지 않을 수 있습니다.

💡 Setup Store에서는 리셋 로직을 잊어버리기 쉽습니다. factory 함수 패턴을 팀 컨벤션으로 정하고, 모든 Setup Store에서 일관되게 사용하세요. 코드 리뷰 시 체크리스트에 포함시키는 것도 좋습니다.

💡 민감한 데이터(비밀번호, 토큰 등)는 리셋만으로는 부족할 수 있습니다. 메모리에서 완전히 제거하려면 값을 null로 설정하고, 가능하다면 가비지 컬렉션이 즉시 실행되도록 유도하세요. 또한 devtools에서도 보이지 않도록 주의해야 합니다.

💡 리셋 후 사이드 이펙트를 처리해야 할 수도 있습니다. 예를 들어, WebSocket 연결을 끊거나, 타이머를 정리하거나, 캐시를 비워야 할 수 있습니다. $reset() 메서드를 확장하여 이런 정리 작업도 포함시키세요.

💡 SPA에서 라우트 변경 시 자동으로 특정 Store를 리셋하고 싶다면, 네비게이션 가드에서 처리할 수 있습니다. 예: router.afterEach(() => { pageStore.$reset() }). 하지만 성능에 주의하세요. 모든 라우트 변경마다 리셋하면 불필요한 재렌더링이 발생할 수 있습니다.


7. Pinia Getters 고급 패턴 - 효율적인 계산 속성

시작하며

여러분이 Store에서 복잡한 데이터 가공이나 필터링을 해본 적이 있나요? 예를 들어, 전체 상품 목록에서 할인 중인 상품만 찾거나, 사용자의 권한에 따라 보여줄 메뉴를 계산하거나, 여러 상태를 조합하여 UI에 표시할 데이터를 만드는 경우가 있습니다.

이런 로직을 컴포넌트에서 직접 작성하면 코드 중복이 발생하고, 같은 계산을 여러 번 하게 되어 성능이 낮아집니다. 또한 비즈니스 로직이 UI 컴포넌트에 섞이면서 유지보수가 어려워집니다.

바로 이럴 때 필요한 것이 Pinia Getters입니다. Getters는 Vue의 computed 속성처럼 동작하면서 자동으로 캐싱되고, 의존하는 상태가 변경될 때만 재계산됩니다.

고급 패턴을 배우면 더 효율적이고 강력한 getters를 작성할 수 있습니다.

개요

간단히 말해서, Getters는 Store의 상태로부터 파생된 데이터를 계산하는 computed 속성입니다. 다른 getters를 참조하거나, 매개변수를 받거나, 다른 Store의 getters를 사용할 수도 있습니다.

왜 이것이 필요한지 실무 관점에서 보면, 대부분의 앱에서 원본 데이터를 그대로 표시하는 경우는 드뭅니다. 예를 들어, 이커머스 앱에서 상품의 최종 가격은 정가, 할인율, 회원 등급, 쿠폰, 프로모션 등 여러 요소를 고려하여 계산됩니다.

이런 로직을 getters로 만들면 한 곳에서 관리하고, 자동 캐싱으로 성능도 최적화됩니다. 기존 방법에서는 단순한 getters만 사용했습니다.

하지만 고급 패턴을 사용하면 더 복잡한 시나리오를 우아하게 처리할 수 있습니다. 매개변수를 받는 getters, 다른 Store를 참조하는 getters, 비동기 데이터를 다루는 getters 등이 있습니다.

Getters 고급 패턴의 핵심은: 첫째, 매개변수를 받는 getters는 함수를 반환하여 동적인 계산을 가능하게 합니다. 둘째, getters 체이닝으로 복잡한 계산을 작은 단위로 나눕니다.

셋째, 다른 Store의 getters를 사용하여 데이터를 조합합니다. 넷째, 타입스크립트와 함께 사용하면 완벽한 타입 안전성을 제공합니다.

이러한 패턴들이 Store를 단순한 저장소가 아닌 강력한 데이터 처리 레이어로 만들어줍니다.

코드 예제

// stores/products.js - 고급 Getters 패턴
import { defineStore } from 'pinia'
import { useAuthStore } from './auth'
import { computed, ref } from 'vue'

export const useProductsStore = defineStore('products', () => {
  // State
  const products = ref([
    { id: 1, name: '노트북', price: 1000000, category: 'electronics', stock: 5, discount: 10 },
    { id: 2, name: '마우스', price: 30000, category: 'electronics', stock: 20, discount: 0 },
    { id: 3, name: '키보드', price: 80000, category: 'electronics', stock: 0, discount: 15 },
    { id: 4, name: '책상', price: 200000, category: 'furniture', stock: 3, discount: 0 }
  ])

  const filters = ref({
    category: null,
    minPrice: 0,
    maxPrice: Infinity,
    inStockOnly: false
  })

  // 기본 Getters - 단순 계산
  const totalProducts = computed(() => products.value.length)

  const inStockProducts = computed(() =>
    products.value.filter(p => p.stock > 0)
  )

  const averagePrice = computed(() => {
    if (products.value.length === 0) return 0
    const sum = products.value.reduce((acc, p) => acc + p.price, 0)
    return Math.round(sum / products.value.length)
  })

  // Getters 체이닝 - 다른 getters 사용
  const discountedProducts = computed(() =>
    inStockProducts.value.filter(p => p.discount > 0)
  )

  // 매개변수를 받는 Getters - 함수 반환
  const getProductById = computed(() => {
    return (productId) => {
      return products.value.find(p => p.id === productId)
    }
  })

  const getProductsByCategory = computed(() => {
    return (category) => {
      return products.value.filter(p => p.category === category)
    }
  })

  // 복잡한 계산 - 최종 가격 계산 (할인 + 회원 등급)
  const getProductPrice = computed(() => {
    return (productId) => {
      const authStore = useAuthStore()
      const product = products.value.find(p => p.id === productId)
      if (!product) return 0

      // 기본 할인 적용
      let finalPrice = product.price * (1 - product.discount / 100)

      // 회원 등급별 추가 할인
      if (authStore.user) {
        const memberDiscount = {
          'bronze': 0,
          'silver': 5,
          'gold': 10,
          'platinum': 15
        }
        const additionalDiscount = memberDiscount[authStore.user.tier] || 0
        finalPrice = finalPrice * (1 - additionalDiscount / 100)
      }

      return Math.round(finalPrice)
    }
  })

  // 필터링된 목록 - 여러 조건 조합
  const filteredProducts = computed(() => {
    let result = products.value

    // 카테고리 필터
    if (filters.value.category) {
      result = result.filter(p => p.category === filters.value.category)
    }

    // 가격 범위 필터
    result = result.filter(p =>
      p.price >= filters.value.minPrice &&
      p.price <= filters.value.maxPrice
    )

    // 재고 필터
    if (filters.value.inStockOnly) {
      result = result.filter(p => p.stock > 0)
    }

    return result
  })

  // 통계 정보 - 복잡한 집계
  const statistics = computed(() => {
    const filtered = filteredProducts.value

    return {
      count: filtered.length,
      totalValue: filtered.reduce((sum, p) => sum + (p.price * p.stock), 0),
      averagePrice: filtered.length > 0
        ? filtered.reduce((sum, p) => sum + p.price, 0) / filtered.length
        : 0,
      outOfStockCount: filtered.filter(p => p.stock === 0).length,
      categoryBreakdown: filtered.reduce((acc, p) => {
        acc[p.category] = (acc[p.category] || 0) + 1
        return acc
      }, {})
    }
  })

  // Actions
  function setFilter(key, value) {
    filters.value[key] = value
  }

  function clearFilters() {
    filters.value = {
      category: null,
      minPrice: 0,
      maxPrice: Infinity,
      inStockOnly: false
    }
  }

  return {
    products,
    filters,
    totalProducts,
    inStockProducts,
    averagePrice,
    discountedProducts,
    getProductById,
    getProductsByCategory,
    getProductPrice,
    filteredProducts,
    statistics,
    setFilter,
    clearFilters
  }
})

// 컴포넌트에서 사용
// ProductList.vue
import { useProductsStore } from '@/stores/products'

const productsStore = useProductsStore()

// 단순 getters 사용
console.log(productsStore.totalProducts) // 4
console.log(productsStore.inStockProducts) // 재고 있는 상품

// 매개변수를 받는 getters 사용
const product = productsStore.getProductById(1)
const electronics = productsStore.getProductsByCategory('electronics')
const finalPrice = productsStore.getProductPrice(1) // 회원 등급 할인 적용된 가격

// 필터 적용
productsStore.setFilter('category', 'electronics')
productsStore.setFilter('inStockOnly', true)
console.log(productsStore.filteredProducts) // 재고 있는 전자제품만

// 통계 정보
console.log(productsStore.statistics)
// {
//   count: 2,
//   totalValue: 1150000,
//   averagePrice: 515000,
//   outOfStockCount: 0,
//   categoryBreakdown: { electronics: 2 }
// }

설명

이것이 하는 일: 위 코드는 상품 목록을 관리하는 Store에서 다양한 고급 getters 패턴을 보여줍니다. 단순 계산부터 매개변수를 받는 동적 getters, 다른 Store를 참조하는 getters, 복잡한 필터링과 통계 계산까지 실무에서 자주 사용하는 패턴들을 다룹니다.

첫 번째로, 기본 getters인 totalProducts, inStockProducts, averagePrice는 단순한 계산과 필터링을 수행합니다. 이들은 computed로 선언되어 의존하는 products 상태가 변경될 때만 재계산되고, 그 외에는 캐시된 값을 반환하여 성능을 최적화합니다.

예를 들어 averagePrice는 상품 목록이 바뀌지 않는 한 매번 계산하지 않습니다. 두 번째로, getters 체이닝 패턴인 discountedProducts는 다른 getter인 inStockProducts를 재사용합니다.

이렇게 하면 "재고 있는 상품 중 할인 상품"이라는 복잡한 조건을 명확하게 표현할 수 있습니다. 각 getter가 하나의 책임만 가지므로 코드가 깔끔하고 테스트하기 쉽습니다.

또한 inStockProducts가 캐시되므로 여러 getter에서 사용해도 중복 계산이 없습니다. 세 번째로, 매개변수를 받는 getters인 getProductByIdgetProductsByCategory는 함수를 반환하는 패턴을 사용합니다.

computed(() => (param) => { ... }) 형태로 작성하면, 컴포넌트에서 getProductById(1) 처럼 인자를 전달하여 동적인 조회가 가능합니다.

주의할 점은 이렇게 함수를 반환하는 getters는 캐싱되지 않는다는 것입니다. 매번 호출 시마다 계산이 실행되므로, 성능이 중요하다면 별도의 캐싱 로직을 추가해야 할 수 있습니다.

네 번째로, getProductPrice는 다른 Store인 useAuthStore를 참조하는 복잡한 getter입니다. 상품의 기본 할인뿐만 아니라 사용자의 회원 등급에 따른 추가 할인을 계산합니다.

이렇게 여러 Store의 데이터를 조합하면 비즈니스 로직을 중앙에서 관리할 수 있습니다. 컴포넌트는 그냥 getProductPrice(1)을 호출하기만 하면 되고, 가격 계산 로직이 변경되어도 컴포넌트 코드는 수정할 필요가 없습니다.

다섯 번째로, filteredProducts는 여러 필터 조건을 조합하는 실용적인 예시입니다. 카테고리, 가격 범위, 재고 여부 등 다양한 필터를 적용하여 최종 상품 목록을 만듭니다.

UI에서 필터를 변경하면 이 getter가 자동으로 재계산되어 화면이 업데이트됩니다. 이는 Vue의 반응성 시스템 덕분입니다.

여섯 번째로, statistics getter는 필터링된 결과를 기반으로 복잡한 통계 정보를 계산합니다. 개수, 총 가치, 평균 가격, 품절 개수, 카테고리별 분포 등을 한 번에 제공합니다.

이런 집계 데이터를 getter로 만들면 대시보드나 분석 화면을 쉽게 구현할 수 있고, 데이터가 변경될 때마다 자동으로 업데이트됩니다. 여러분이 이런 패턴들을 사용하면 비즈니스 로직을 Store에 집중시키고 컴포넌트는 UI 렌더링에만 집중할 수 있습니다.

또한 자동 캐싱으로 성능을 최적화하고, 코드 재사용성을 높일 수 있습니다. 테스트할 때도 Store만 테스트하면 되므로 훨씬 간단합니다.

실전 팁

💡 매개변수를 받는 getters는 캐싱되지 않습니다. 자주 호출되는 경우 성능 문제가 발생할 수 있으므로, 직접 메모이제이션을 구현하거나 라이브러리(예: lodash memoize)를 사용하세요. 예: const memoizedGetter = computed(() => memoize((id) => products.value.find(p => p.id === id))).

💡 Getters가 너무 복잡해지면 별도의 유틸리티 함수로 분리하세요. Getter는 상태 조회와 간단한 계산만 담당하고, 복잡한 비즈니스 로직은 별도 함수나 서비스 레이어로 추출하는 것이 좋습니다. 이렇게 하면 테스트와 재사용이 쉬워집니다.

💡 Getters에서 배열 메서드(filter, map, reduce 등)를 사용할 때 주의하세요. 매번 새로운 배열을 생성하므로, 불필요하게 여러 번 체이닝하면 성능이 낮아집니다. 가능하면 하나의 루프에서 여러 작업을 처리하거나, 중간 결과를 별도 getter로 만들어 캐싱하세요.

💡 다른 Store의 getters를 참조할 때는 순환 참조를 조심하세요. A Store의 getter가 B Store를 참조하고, B의 getter가 다시 A를 참조하면 무한 루프가 발생할 수 있습니다. 의존성 그래프를 그려보고, 필요시 중재자 패턴을 사용하세요.

💡 타입스크립트를 사용한다면 getters의 반환 타입을 명시하세요. computed<Product[]>(() => ...) 처럼 제네릭을 사용하면 IDE의 자동완성이 완벽하게 작동하고, 타입 안전성이 보장됩니다. 특히 매개변수를 받는 getters는 함수 시그니처를 명확히 정의하는 것이 중요합니다.


#Vue#Pinia#StateManagement#Composables#StorePattern#중급

댓글 (0)

댓글을 작성하려면 로그인이 필요합니다.