이미지 로딩 중...

Pinia 상태관리 완벽 가이드 - 슬라이드 1/11
A

AI Generated

2025. 11. 8. · 5 Views

Pinia 상태관리 완벽 가이드

Vue 3의 공식 상태관리 라이브러리인 Pinia를 활용하여 애플리케이션의 상태를 효율적으로 관리하는 방법을 배웁니다. Store 생성부터 Actions, Getters, 그리고 Composition API와의 통합까지 실무에서 바로 활용할 수 있는 핵심 개념들을 다룹니다.


목차

  1. Pinia Store 생성하기 - 상태관리의 시작점
  2. Actions로 상태 변경하기 - 비즈니스 로직의 중심
  3. Getters로 계산된 값 만들기 - 효율적인 데이터 가공
  4. 컴포넌트에서 Store 사용하기 - 실전 활용법
  5. Store 간 통신하기 - 모듈화된 상태 관리
  6. $subscribe로 상태 변화 감지하기 - 실시간 반응형 로직
  7. Composition API 스타일로 Store 정의하기 - 현대적인 접근
  8. $patch로 효율적인 상태 업데이트 - 성능 최적화
  9. $reset으로 초기 상태 복원하기 - 상태 초기화 패턴
  10. Pinia Plugins로 기능 확장하기 - 재사용 가능한 기능 추가

1. Pinia Store 생성하기 - 상태관리의 시작점

시작하며

여러분이 Vue 애플리케이션을 개발할 때 컴포넌트 간에 데이터를 공유하려고 props를 5단계 이상 전달해본 적 있나요? 부모 컴포넌트에서 손자의 손자 컴포넌트까지 데이터를 전달하다 보면 코드가 복잡해지고 유지보수가 어려워집니다.

이런 문제를 "Props Drilling"이라고 하는데, 컴포넌트 깊이가 깊어질수록 데이터 흐름을 추적하기 어려워지고 불필요한 props 전달로 인해 성능 저하까지 발생할 수 있습니다. 바로 이럴 때 필요한 것이 Pinia Store입니다.

전역 상태를 중앙에서 관리하여 어떤 컴포넌트에서든 쉽게 접근할 수 있게 해줍니다.

개요

간단히 말해서, Pinia Store는 애플리케이션의 전역 상태를 저장하고 관리하는 중앙 저장소입니다. 여러분이 사용자 정보, 장바구니 데이터, 테마 설정 등 여러 컴포넌트에서 공유해야 하는 데이터가 있다면 Pinia Store가 완벽한 솔루션입니다.

예를 들어, 로그인한 사용자 정보를 헤더, 사이드바, 프로필 페이지에서 동시에 사용해야 하는 경우 매우 유용합니다. 기존 Vuex에서는 mutations, actions, getters를 모두 분리해서 작성해야 했다면, Pinia에서는 더 간단하고 직관적인 API로 상태를 관리할 수 있습니다.

Pinia Store의 핵심 특징은 TypeScript 완벽 지원, 모듈화된 구조, 그리고 devtools 통합입니다. 이러한 특징들이 개발 생산성과 디버깅 효율성을 크게 향상시켜줍니다.

코드 예제

// stores/user.js
import { defineStore } from 'pinia'

export const useUserStore = defineStore('user', {
  // 상태 정의 - 초기값 설정
  state: () => ({
    username: '',
    email: '',
    isLoggedIn: false,
    preferences: {
      theme: 'light',
      language: 'ko'
    }
  }),

  // 계산된 속성 정의
  getters: {
    fullProfile: (state) => `${state.username} (${state.email})`
  }
})

설명

이것이 하는 일: Pinia Store는 애플리케이션 전체에서 공유할 수 있는 상태 저장소를 생성합니다. defineStore 함수를 사용하여 고유한 ID와 설정을 가진 스토어를 만들어냅니다.

첫 번째로, defineStore의 첫 번째 인자인 'user'는 스토어의 고유 ID입니다. 이 ID는 devtools에서 스토어를 식별하고 여러 스토어를 구분하는 데 사용됩니다.

고유한 이름을 사용해야 다른 스토어와 충돌하지 않습니다. 그 다음으로, state 함수가 실행되면서 초기 상태 객체를 반환합니다.

함수 형태로 작성하는 이유는 서버 사이드 렌더링(SSR)에서 각 요청마다 새로운 상태 인스턴스를 생성하기 위함입니다. username, email 같은 기본 타입부터 preferences 같은 중첩 객체까지 자유롭게 정의할 수 있습니다.

마지막으로, getters에서 fullProfile 같은 계산된 값을 정의합니다. 이는 Vue의 computed와 동일하게 작동하며, state가 변경될 때만 재계산되어 성능을 최적화합니다.

여러분이 이 스토어를 사용하면 어느 컴포넌트에서든 useUserStore()를 호출해서 동일한 사용자 데이터에 접근할 수 있습니다. props를 여러 단계 전달할 필요 없이 직접 스토어에서 데이터를 가져오므로 코드가 깔끔해지고 유지보수가 쉬워집니다.

실전 팁

💡 스토어 ID는 애플리케이션 전체에서 고유해야 합니다. 파일명과 동일하게 유지하면 관리하기 쉽습니다.

💡 state는 반드시 함수 형태로 작성하세요. 객체를 직접 반환하면 SSR에서 모든 사용자가 동일한 상태를 공유하는 버그가 발생할 수 있습니다.

💡 중첩된 객체를 사용할 때는 초기값을 명확히 정의하세요. undefined 에러를 방지할 수 있습니다.

💡 관련된 상태끼리 그룹화하여 스토어를 분리하세요. 예를 들어 user, cart, settings 등으로 나누면 코드 관리가 훨씬 수월합니다.

💡 TypeScript를 사용한다면 state의 타입이 자동으로 추론되지만, 명시적으로 인터페이스를 정의하면 더 안전한 코드를 작성할 수 있습니다.


2. Actions로 상태 변경하기 - 비즈니스 로직의 중심

시작하며

여러분이 로그인 기능을 구현할 때 API 호출, 로딩 상태 관리, 에러 처리, 성공 시 사용자 정보 저장까지 모든 로직을 컴포넌트에 작성해본 적 있나요? 이렇게 하면 컴포넌트가 비대해지고 같은 로직을 여러 곳에서 중복 작성하게 됩니다.

이런 문제는 코드 재사용성을 떨어뜨리고 버그가 발생했을 때 수정해야 할 곳이 여러 군데가 되어 유지보수가 어려워집니다. 또한 비즈니스 로직과 UI 로직이 섞여 테스트하기도 복잡해집니다.

바로 이럴 때 필요한 것이 Pinia Actions입니다. 상태를 변경하는 모든 로직을 스토어의 actions에 모아서 관리하면 재사용성과 유지보수성이 크게 향상됩니다.

개요

간단히 말해서, Actions는 스토어의 상태를 변경하는 메서드들을 정의하는 곳입니다. 여러분이 사용자 로그인, 데이터 가져오기, 장바구니 아이템 추가 등 상태를 변경하는 모든 작업을 actions에서 처리해야 합니다.

예를 들어, API에서 사용자 목록을 가져와서 스토어에 저장하고 로딩 상태도 관리하는 복잡한 로직을 하나의 action으로 캡슐화할 수 있습니다. 기존 Vuex에서는 동기 작업은 mutations, 비동기 작업은 actions로 분리해야 했다면, Pinia에서는 모든 상태 변경 로직을 actions에서 처리할 수 있습니다.

Actions의 핵심 특징은 async/await 완벽 지원, this를 통한 state 직접 접근, 그리고 다른 actions 호출 가능입니다. 이러한 특징들이 복잡한 비즈니스 로직을 깔끔하게 구현할 수 있게 해줍니다.

코드 예제

// stores/user.js
export const useUserStore = defineStore('user', {
  state: () => ({
    users: [],
    currentUser: null,
    loading: false,
    error: null
  }),

  actions: {
    // 비동기 로그인 처리
    async login(email, password) {
      this.loading = true
      this.error = null
      try {
        const response = await fetch('/api/login', {
          method: 'POST',
          body: JSON.stringify({ email, password })
        })
        const data = await response.json()
        // this로 state 직접 변경
        this.currentUser = data.user
        return true
      } catch (err) {
        this.error = err.message
        return false
      } finally {
        this.loading = false
      }
    }
  }
})

설명

이것이 하는 일: Actions는 상태 변경을 위한 비즈니스 로직을 캡슐화합니다. API 호출, 데이터 가공, 에러 처리 등 복잡한 로직을 하나의 메서드로 관리할 수 있게 해줍니다.

첫 번째로, login action은 async 함수로 정의되어 비동기 작업을 깔끔하게 처리합니다. this.loading = true로 로딩 상태를 먼저 설정하여 UI에서 로딩 인디케이터를 표시할 수 있게 합니다.

이렇게 하면 사용자에게 작업이 진행 중임을 알려줄 수 있습니다. 그 다음으로, try-catch 블록 안에서 API 호출이 실행됩니다.

fetch를 사용해 서버에 인증 요청을 보내고, 응답이 오면 this.currentUser에 사용자 데이터를 저장합니다. this 키워드를 통해 state에 직접 접근할 수 있어서 코드가 간결하고 직관적입니다.

마지막으로, finally 블록에서 성공 여부와 관계없이 this.loading = false로 로딩 상태를 해제합니다. 이렇게 하면 네트워크 에러가 발생하더라도 로딩 인디케이터가 계속 표시되는 버그를 방지할 수 있습니다.

여러분이 이 action을 사용하면 컴포넌트에서는 단순히 userStore.login(email, password)만 호출하면 됩니다. 로딩 상태, 에러 처리, 사용자 정보 저장까지 모든 로직이 스토어에서 관리되므로 컴포넌트는 UI 렌더링에만 집중할 수 있습니다.

또한 여러 컴포넌트에서 동일한 로그인 로직을 재사용할 수 있어 코드 중복이 사라집니다.

실전 팁

💡 Actions 안에서는 this를 사용해서 다른 actions도 호출할 수 있습니다. 예: this.logout()를 login 실패 시 호출할 수 있습니다.

💡 에러 처리는 반드시 try-catch로 감싸세요. 처리되지 않은 에러는 애플리케이션 전체를 중단시킬 수 있습니다.

💡 로딩 상태를 관리할 때는 finally 블록을 활용하세요. 에러가 발생해도 로딩이 끝났음을 명확히 표시할 수 있습니다.

💡 API 응답 데이터를 그대로 저장하지 말고 필요한 부분만 추출하여 저장하면 메모리를 절약할 수 있습니다.

💡 Actions는 값을 반환할 수 있습니다. 성공/실패 여부를 boolean으로 반환하면 컴포넌트에서 후속 처리를 쉽게 할 수 있습니다.


3. Getters로 계산된 값 만들기 - 효율적인 데이터 가공

시작하며

여러분이 사용자 목록을 필터링하거나 총 가격을 계산할 때 매번 컴포넌트에서 filter()나 reduce()를 사용해본 적 있나요? 같은 계산을 여러 컴포넌트에서 반복하면 코드 중복이 발생하고 성능도 저하됩니다.

이런 문제는 특히 리스트가 길거나 계산이 복잡할 때 더 심각해집니다. 데이터가 변경되지 않았는데도 컴포넌트가 리렌더링될 때마다 불필요한 계산이 반복되어 앱이 느려질 수 있습니다.

바로 이럴 때 필요한 것이 Pinia Getters입니다. 계산된 값을 캐싱하여 동일한 계산을 여러 번 수행하지 않고 효율적으로 데이터를 가공할 수 있습니다.

개요

간단히 말해서, Getters는 state를 기반으로 계산된 값을 반환하는 computed 속성입니다. 여러분이 활성 사용자만 필터링하거나, 장바구니의 총 금액을 계산하거나, 정렬된 리스트를 제공하고 싶을 때 getters를 사용합니다.

예를 들어, 전체 상품 목록에서 할인 중인 상품만 추출하고 가격순으로 정렬하는 복잡한 로직도 getter 하나로 해결할 수 있습니다. 기존에 컴포넌트마다 computed를 정의해서 같은 계산을 반복했다면, 이제는 getter 하나만 정의하면 모든 컴포넌트에서 재사용할 수 있습니다.

Getters의 핵심 특징은 자동 캐싱, 다른 getters 참조 가능, 그리고 매개변수를 받을 수 있다는 점입니다. 이러한 특징들이 복잡한 데이터 변환을 효율적으로 처리할 수 있게 해줍니다.

코드 예제

// stores/product.js
export const useProductStore = defineStore('product', {
  state: () => ({
    products: [
      { id: 1, name: '노트북', price: 1000000, stock: 5, category: 'electronics' },
      { id: 2, name: '마우스', price: 30000, stock: 0, category: 'electronics' },
      { id: 3, name: '책상', price: 200000, stock: 3, category: 'furniture' }
    ]
  }),

  getters: {
    // 재고가 있는 상품만 필터링
    availableProducts: (state) => {
      return state.products.filter(p => p.stock > 0)
    },

    // 다른 getter를 참조하여 카테고리별 필터링
    availableElectronics() {
      return this.availableProducts.filter(p => p.category === 'electronics')
    },

    // 매개변수를 받는 getter (함수 반환)
    productsByCategory: (state) => {
      return (category) => state.products.filter(p => p.category === category)
    }
  }
})

설명

이것이 하는 일: Getters는 원본 state를 변경하지 않고 가공된 데이터를 제공합니다. Vue의 computed와 동일하게 의존하는 state가 변경될 때만 재계산되어 성능을 최적화합니다.

첫 번째로, availableProducts getter는 화살표 함수로 정의되어 state 매개변수를 받습니다. state.products 배열을 필터링하여 재고(stock)가 0보다 큰 상품만 반환합니다.

이 getter는 products가 변경될 때만 재계산되고, 그렇지 않으면 캐싱된 결과를 재사용합니다. 그 다음으로, availableElectronics getter는 일반 함수 형태로 정의되어 this 키워드를 사용합니다.

this.availableProducts를 참조하여 이미 필터링된 상품 중에서 electronics 카테고리만 추가로 필터링합니다. 이렇게 getter끼리 조합하면 복잡한 데이터 변환 로직을 단계적으로 구성할 수 있습니다.

마지막으로, productsByCategory는 함수를 반환하는 특별한 형태의 getter입니다. 이렇게 하면 컴포넌트에서 productsByCategory('electronics')처럼 인자를 전달하여 동적으로 필터링할 수 있습니다.

다만 이 경우에는 캐싱이 되지 않으므로 성능에 유의해야 합니다. 여러분이 이 getters를 사용하면 컴포넌트에서는 복잡한 필터링 로직을 작성할 필요가 없습니다.

단순히 store.availableProducts를 참조하기만 하면 항상 최신의 재고 있는 상품 목록을 받을 수 있습니다. 또한 products 배열이 변경되지 않는 한 캐싱된 결과를 사용하므로 불필요한 계산을 피할 수 있어 성능이 향상됩니다.

실전 팁

💡 매개변수가 필요 없는 getter는 화살표 함수보다 일반 함수로 작성하세요. this로 다른 getters를 참조할 수 있어 더 유연합니다.

💡 매개변수를 받는 getter는 캐싱되지 않습니다. 자주 호출되는 경우 computed ref를 사용하거나 결과를 메모이제이션하는 것을 고려하세요.

💡 Getters에서 비동기 작업을 하지 마세요. 데이터 fetching은 actions에서 처리하고 getter는 동기적인 계산만 수행해야 합니다.

💡 복잡한 계산은 여러 getters로 나누어 작성하면 코드 가독성이 높아지고 재사용성도 좋아집니다.

💡 Getters는 state를 변경해서는 안 됩니다. 읽기 전용으로 사용하여 예측 가능한 상태 관리를 유지하세요.


4. 컴포넌트에서 Store 사용하기 - 실전 활용법

시작하며

여러분이 만든 완벽한 Pinia Store가 있지만 컴포넌트에서 어떻게 사용해야 할지 막막하셨나요? Store의 state, actions, getters를 효율적으로 활용하려면 올바른 패턴을 따라야 합니다.

이런 부분을 잘못 이해하면 반응성이 깨지거나 예상치 못한 버그가 발생할 수 있습니다. 특히 destructuring을 잘못 사용하면 state 변경을 감지하지 못하는 치명적인 문제가 생깁니다.

바로 이럴 때 필요한 것이 올바른 Store 사용 패턴입니다. Composition API와 Options API 각각에서 어떻게 Store를 활용하는지 정확히 이해하면 안정적인 애플리케이션을 만들 수 있습니다.

개요

간단히 말해서, 컴포넌트에서 Store를 사용할 때는 반응성을 유지하는 것이 가장 중요합니다. 여러분이 Composition API(setup)를 사용한다면 useStore 함수를 호출하고, storeToRefs를 사용하여 state와 getters의 반응성을 유지해야 합니다.

예를 들어, 사용자 정보를 표시하고 로그인 버튼을 만들 때 store의 값이 변경되면 자동으로 UI가 업데이트되어야 합니다. 기존에 직접 destructuring을 했다면 반응성이 깨졌을 것입니다.

이제는 storeToRefs를 사용하면 반응성을 유지하면서도 깔끔하게 코드를 작성할 수 있습니다. Store 사용의 핵심 특징은 storeToRefs로 state 반응성 유지, actions는 직접 destructuring 가능, 그리고 $patch로 여러 state 한 번에 변경입니다.

이러한 특징들이 효율적이고 안전한 상태 관리를 가능하게 합니다.

코드 예제

// UserProfile.vue
<script setup>
import { storeToRefs } from 'pinia'
import { useUserStore } from '@/stores/user'

const userStore = useUserStore()

// storeToRefs로 반응성 유지하며 destructuring
const { currentUser, loading, error } = storeToRefs(userStore)

// actions는 직접 destructuring 가능 (반응성 필요 없음)
const { login, logout } = userStore

// 로그인 핸들러
async function handleLogin() {
  const success = await login('user@example.com', 'password123')
  if (success) {
    console.log('로그인 성공:', currentUser.value)
  }
}

// $patch로 여러 필드 한 번에 업데이트
function updateProfile() {
  userStore.$patch({
    currentUser: { ...currentUser.value, name: '홍길동' },
    loading: false
  })
}
</script>

<template>
  <div v-if="loading">로딩 중...</div>
  <div v-else-if="currentUser">
    환영합니다, {{ currentUser.name }}님!
    <button @click="logout">로그아웃</button>
  </div>
  <button v-else @click="handleLogin">로그인</button>
</template>

설명

이것이 하는 일: 컴포넌트에서 Store를 올바르게 사용하여 상태 변경이 자동으로 UI에 반영되도록 합니다. storeToRefs가 핵심 역할을 담당합니다.

첫 번째로, useUserStore()를 호출하여 스토어 인스턴스를 가져옵니다. 이 인스턴스는 reactive 객체로, 직접 destructuring하면 반응성이 사라집니다.

따라서 반드시 storeToRefs 헬퍼 함수를 사용해야 합니다. 그 다음으로, storeToRefs(userStore)를 통해 currentUser, loading, error를 destructuring합니다.

storeToRefs는 내부적으로 toRefs를 사용하여 각 속성을 ref로 변환해주므로 반응성이 유지됩니다. 이렇게 하면 currentUser.value로 접근할 수 있고, 값이 변경되면 템플릿이 자동으로 업데이트됩니다.

세 번째로, actions는 메서드이므로 반응성이 필요 없습니다. const { login, logout } = userStore처럼 직접 destructuring해도 정상 작동합니다.

handleLogin 함수에서 await login()을 호출하면 스토어의 action이 실행되고 성공 여부를 받을 수 있습니다. 마지막으로, $patch 메서드를 사용하면 여러 state 필드를 한 번에 업데이트할 수 있습니다.

이는 성능 최적화에 유용한데, 각 필드를 개별적으로 변경하면 여러 번 리렌더링이 발생하지만 $patch는 한 번만 트리거합니다. 여러분이 이 패턴을 따르면 state가 변경될 때 템플릿이 자동으로 업데이트되는 반응형 UI를 만들 수 있습니다.

loading이 true일 때 로딩 인디케이터가 표시되고, currentUser가 설정되면 사용자 정보가 나타나며, 로그아웃하면 다시 로그인 버튼이 보이는 등 모든 것이 자동으로 동작합니다.

실전 팁

💡 storeToRefs는 state와 getters에만 사용하세요. actions에 사용하면 에러가 발생하지는 않지만 불필요한 ref 래핑이 생깁니다.

💡 computed에서 store 값을 사용할 때는 .value를 붙여야 합니다. 예: computed(() => currentUser.value?.name)

💡 여러 state를 동시에 변경할 때는 $patch를 사용하면 성능이 향상됩니다. 단일 변경은 직접 할당이 더 간단합니다.

💡 store 인스턴스 자체를 props로 전달하지 마세요. 하위 컴포넌트에서 직접 useStore()를 호출하는 것이 더 명확합니다.

💡 Options API에서는 mapState, mapActions 헬퍼를 사용할 수 있지만, Composition API가 더 타입 안전하고 권장됩니다.


5. Store 간 통신하기 - 모듈화된 상태 관리

시작하며

여러분이 사용자 정보와 주문 정보를 분리된 스토어로 관리하다가 주문할 때 현재 로그인한 사용자의 정보가 필요한 상황을 겪어본 적 있나요? 하나의 거대한 스토어에 모든 것을 담기에는 코드가 복잡해지고, 완전히 분리하기에는 서로 참조가 필요합니다.

이런 문제는 애플리케이션이 커질수록 더 자주 발생합니다. 인증 스토어, 장바구니 스토어, 알림 스토어 등이 서로 데이터를 공유해야 할 때 어떻게 설계해야 할지 고민하게 됩니다.

바로 이럴 때 필요한 것이 Pinia의 Store 간 통신 패턴입니다. 각 스토어를 독립적으로 관리하면서도 필요할 때 다른 스토어의 데이터나 메서드에 접근할 수 있습니다.

개요

간단히 말해서, Pinia에서는 한 스토어 안에서 다른 스토어를 import하여 사용할 수 있습니다. 여러분이 주문 처리 로직을 작성할 때 사용자 인증 상태를 확인하거나, 알림을 보낼 때 현재 사용자의 설정을 참조해야 하는 경우가 많습니다.

예를 들어, 장바구니에서 결제 버튼을 누르면 userStore에서 로그인 여부를 확인하고, paymentStore에서 결제를 처리하고, notificationStore로 알림을 보내는 복합적인 플로우를 구현할 수 있습니다. 기존에는 이런 경우 전역 이벤트 버스나 복잡한 props 전달을 사용했다면, Pinia에서는 단순히 다른 스토어의 useStore 함수를 호출하면 됩니다.

Store 간 통신의 핵심 특징은 순환 참조 방지, 명확한 의존성 관리, 그리고 타입 안전성입니다. 이러한 특징들이 대규모 애플리케이션에서도 유지보수 가능한 상태 관리 구조를 만들어줍니다.

코드 예제

// stores/cart.js
import { defineStore } from 'pinia'
import { useUserStore } from './user'
import { useNotificationStore } from './notification'

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

  actions: {
    async checkout() {
      // 다른 스토어 사용
      const userStore = useUserStore()
      const notificationStore = useNotificationStore()

      // 로그인 확인
      if (!userStore.isLoggedIn) {
        notificationStore.showError('로그인이 필요합니다')
        return false
      }

      try {
        // 결제 처리
        const order = {
          userId: userStore.currentUser.id,
          items: this.items,
          total: this.total
        }

        await fetch('/api/orders', {
          method: 'POST',
          body: JSON.stringify(order)
        })

        // 성공 알림
        notificationStore.showSuccess('주문이 완료되었습니다')
        this.items = []
        this.total = 0
        return true
      } catch (error) {
        notificationStore.showError('주문 처리 중 오류가 발생했습니다')
        return false
      }
    }
  }
})

설명

이것이 하는 일: 여러 스토어를 조합하여 복잡한 비즈니스 로직을 구현합니다. 각 스토어는 단일 책임 원칙을 유지하면서도 필요할 때 다른 스토어의 기능을 활용할 수 있습니다.

첫 번째로, 파일 상단에서 필요한 다른 스토어들을 import합니다. useUserStore와 useNotificationStore를 가져와서 장바구니 스토어에서 사용할 준비를 합니다.

이렇게 명시적으로 import하면 의존성이 명확해져 코드를 이해하기 쉬워집니다. 그 다음으로, checkout action 내부에서 useUserStore()와 useNotificationStore()를 호출합니다.

중요한 점은 state나 getters가 아닌 actions 내부에서 호출해야 한다는 것입니다. state 레벨에서 다른 스토어를 참조하면 초기화 순서 문제가 발생할 수 있습니다.

세 번째로, userStore.isLoggedIn으로 로그인 상태를 확인하고, currentUser.id로 사용자 정보에 접근합니다. 이렇게 다른 스토어의 state와 getters를 자유롭게 사용할 수 있어 코드 재사용성이 높아집니다.

로그인하지 않은 경우 notificationStore.showError()를 호출하여 에러 메시지를 표시합니다. 마지막으로, API 호출 성공 시 notificationStore.showSuccess()로 성공 알림을 보내고, 실패 시에는 showError()를 호출합니다.

이렇게 알림 로직을 notification 스토어에 캡슐화하면 전체 애플리케이션에서 일관된 알림 UI를 제공할 수 있습니다. 여러분이 이 패턴을 사용하면 각 스토어를 독립적으로 테스트하고 유지보수할 수 있으면서도, 실제 비즈니스 로직에서는 여러 스토어의 기능을 조합하여 복잡한 플로우를 구현할 수 있습니다.

장바구니는 상품 관리에만 집중하고, 인증은 user 스토어가, 알림은 notification 스토어가 담당하는 명확한 책임 분리가 가능합니다.

실전 팁

💡 순환 참조를 피하세요. A 스토어가 B를 참조하고 B가 A를 참조하면 초기화 에러가 발생할 수 있습니다. 공통 로직은 별도 유틸 함수로 분리하세요.

💡 다른 스토어는 actions 내부에서만 사용하세요. state나 getters에서 사용하면 초기화 타이밍 이슈가 생길 수 있습니다.

💡 너무 많은 스토어를 한 곳에서 참조하면 결합도가 높아집니다. 2-3개 이상 참조한다면 설계를 다시 검토하세요.

💡 공통으로 사용되는 스토어(auth, notification 등)를 먼저 만들고, 도메인 스토어(cart, product 등)에서 참조하는 구조가 좋습니다.

💡 TypeScript를 사용한다면 스토어 타입을 명시적으로 정의하여 자동완성과 타입 체크의 이점을 최대한 활용하세요.


6. $subscribe로 상태 변화 감지하기 - 실시간 반응형 로직

시작하며

여러분이 사용자의 장바구니가 변경될 때마다 자동으로 localStorage에 저장하거나, 상태 변화를 로깅해야 하는 상황을 겪어본 적 있나요? watch를 사용할 수도 있지만 모든 컴포넌트에서 개별적으로 설정하기는 번거롭습니다.

이런 문제는 상태 동기화, 디버깅, 분석 등 다양한 상황에서 발생합니다. 특히 상태가 변경되는 모든 경로를 추적하고 싶을 때 각 action마다 로직을 추가하는 것은 비효율적입니다.

바로 이럴 때 필요한 것이 Pinia의 $subscribe 메서드입니다. 스토어의 모든 상태 변화를 한 곳에서 감지하고 반응할 수 있어 부가 기능을 깔끔하게 구현할 수 있습니다.

개요

간단히 말해서, $subscribe는 스토어의 state가 변경될 때마다 자동으로 실행되는 콜백 함수를 등록하는 메서드입니다. 여러분이 상태 변화를 localStorage에 자동 저장하거나, 변경 사항을 서버에 동기화하거나, 분석 이벤트를 전송하고 싶을 때 $subscribe를 사용합니다.

예를 들어, 장바구니에 상품을 추가하거나 제거할 때마다 자동으로 브라우저 스토리지에 저장하여 새로고침 후에도 데이터를 유지할 수 있습니다. 기존에 각 action 끝에 저장 로직을 추가했다면 코드 중복이 발생하고 빠뜨리기 쉬웠습니다.

이제는 $subscribe 한 곳에서 모든 변경 사항을 감지하여 처리할 수 있습니다. $subscribe의 핵심 특징은 모든 상태 변화 감지, mutation 정보 제공, 그리고 컴포넌트 언마운트 시 자동 구독 해제입니다.

이러한 특징들이 부수 효과(side effects)를 안전하고 효율적으로 관리할 수 있게 해줍니다.

코드 예제

// App.vue 또는 plugin
<script setup>
import { useCartStore } from '@/stores/cart'

const cartStore = useCartStore()

// 상태 변화 구독
cartStore.$subscribe((mutation, state) => {
  // mutation.type: 'direct' | 'patch object' | 'patch function'
  console.log('스토어 변경 감지:', mutation.type)
  console.log('변경된 스토어 ID:', mutation.storeId)
  console.log('현재 상태:', state)

  // localStorage에 자동 저장
  localStorage.setItem('cart', JSON.stringify({
    items: state.items,
    total: state.total
  }))
}, { detached: true }) // detached: true면 컴포넌트 언마운트 후에도 유지

// 초기화 시 localStorage에서 복원
const savedCart = localStorage.getItem('cart')
if (savedCart) {
  const data = JSON.parse(savedCart)
  cartStore.$patch(data)
}
</script>

// 또는 Pinia plugin으로 전역 설정
// main.js
import { createPinia } from 'pinia'

const pinia = createPinia()

// 모든 스토어에 자동 저장 기능 추가
pinia.use(({ store }) => {
  const savedState = localStorage.getItem(store.$id)
  if (savedState) {
    store.$patch(JSON.parse(savedState))
  }

  store.$subscribe((mutation, state) => {
    localStorage.setItem(store.$id, JSON.stringify(state))
  })
})

설명

이것이 하는 일: 스토어의 state가 어떤 방식으로든 변경될 때마다 등록된 콜백 함수를 실행합니다. 직접 할당, $patch, actions를 통한 변경 모두를 감지할 수 있습니다.

첫 번째로, $subscribe 메서드에 콜백 함수를 전달합니다. 이 콜백은 두 개의 인자를 받는데, mutation 객체는 변경에 대한 메타 정보를 담고 있고 state는 변경 후의 전체 상태입니다.

mutation.type을 확인하면 'direct'(직접 할당), 'patch object'($patch 사용), 'patch function'($patch에 함수 전달) 중 어떤 방식으로 변경되었는지 알 수 있습니다. 그 다음으로, 콜백 내부에서 state를 JSON.stringify로 직렬화하여 localStorage에 저장합니다.

이렇게 하면 사용자가 페이지를 새로고침하거나 브라우저를 닫았다가 다시 열어도 장바구니 데이터가 유지됩니다. state에는 items와 total이 포함되어 있어 전체 장바구니 상태를 저장할 수 있습니다.

세 번째로, detached 옵션을 true로 설정하면 구독이 컴포넌트의 생명주기와 분리됩니다. 기본값은 false인데, 이 경우 컴포넌트가 언마운트되면 구독도 자동으로 해제됩니다.

detached: true를 사용하면 App.vue가 언마운트되어도 구독이 유지되므로 전역적인 기능에 유용합니다. 마지막으로, Pinia plugin을 사용하면 모든 스토어에 자동으로 이 기능을 적용할 수 있습니다.

pinia.use()에 함수를 전달하면 각 스토어가 생성될 때마다 실행되어, 모든 스토어의 상태를 자동으로 localStorage에 저장하고 복원하는 범용 기능을 만들 수 있습니다. 여러분이 이 기능을 사용하면 상태 지속성(persistence)을 쉽게 구현할 수 있습니다.

사용자가 장바구니에 상품을 추가하고 브라우저를 닫았다가 다시 열어도 데이터가 그대로 남아있어 더 나은 사용자 경험을 제공할 수 있습니다. 또한 디버깅 시 모든 상태 변화를 로깅하여 문제를 빠르게 추적할 수도 있습니다.

실전 팁

💡 $subscribe는 컴포넌트 setup() 내부에서 호출하면 컴포넌트가 언마운트될 때 자동으로 구독이 해제됩니다. 메모리 누수를 방지할 수 있습니다.

💡 detached: true 옵션을 사용할 때는 수동으로 unsubscribe를 호출해야 메모리 누수를 방지할 수 있습니다. const unsubscribe = store.$subscribe(...)로 받아서 나중에 unsubscribe() 호출하세요.

💡 localStorage에 저장할 때는 민감한 정보(비밀번호, 토큰 등)를 제외하세요. 보안 위험이 있습니다.

💡 큰 객체를 저장하면 성능에 영향을 줄 수 있습니다. 디바운싱을 적용하거나 필요한 부분만 저장하는 것을 고려하세요.

💡 $onAction 메서드를 사용하면 actions 호출을 감지할 수 있습니다. action의 인자, 반환값, 에러까지 추적 가능해 디버깅과 로깅에 유용합니다.


7. Composition API 스타일로 Store 정의하기 - 현대적인 접근

시작하며

여러분이 Vue 3의 Composition API에 익숙해져서 setup() 안에서 ref, computed, 함수를 자유롭게 사용하는데 Pinia Store는 여전히 Options API 스타일로 작성하고 계신가요? state, getters, actions를 분리하는 것이 때로는 불편하게 느껴질 수 있습니다.

이런 문제는 특히 Composition API의 장점인 로직 재사용과 타입 추론을 Store에서도 활용하고 싶을 때 더 명확해집니다. 관련된 state와 actions를 한 곳에 모아서 작성하고 싶을 때가 있습니다.

바로 이럴 때 필요한 것이 Setup Store 패턴입니다. Composition API 문법을 그대로 사용하여 더 유연하고 직관적인 스토어를 만들 수 있습니다.

개요

간단히 말해서, Setup Store는 defineStore의 두 번째 인자로 setup 함수를 전달하여 Composition API 스타일로 스토어를 정의하는 방법입니다. 여러분이 ref는 state가 되고, computed는 getters가 되며, 일반 함수는 actions가 되는 직관적인 방식으로 스토어를 작성할 수 있습니다.

예를 들어, 카운터 스토어를 만들 때 count ref와 increment 함수를 setup 함수 안에서 정의하고 return하면 됩니다. 기존 Options Store에서는 state, getters, actions를 별도 섹션에 작성해야 했다면, Setup Store에서는 관련 로직을 가까이 두고 자유롭게 조합할 수 있습니다.

Setup Store의 핵심 특징은 Composition API와 동일한 문법, composables 재사용 가능, 그리고 더 나은 TypeScript 추론입니다. 이러한 특징들이 더 유연하고 타입 안전한 스토어 작성을 가능하게 합니다.

코드 예제

// stores/todo.js
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'

export const useTodoStore = defineStore('todo', () => {
  // ref = state
  const todos = ref([])
  const filter = ref('all') // 'all' | 'active' | 'completed'

  // computed = getters
  const filteredTodos = computed(() => {
    if (filter.value === 'active') {
      return todos.value.filter(t => !t.completed)
    }
    if (filter.value === 'completed') {
      return todos.value.filter(t => t.completed)
    }
    return todos.value
  })

  const activeCount = computed(() =>
    todos.value.filter(t => !t.completed).length
  )

  // function = actions
  function addTodo(text) {
    todos.value.push({
      id: Date.now(),
      text,
      completed: false
    })
  }

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

  async function fetchTodos() {
    const response = await fetch('/api/todos')
    todos.value = await response.json()
  }

  // 반드시 노출할 것들을 return
  return {
    todos,
    filter,
    filteredTodos,
    activeCount,
    addTodo,
    toggleTodo,
    fetchTodos
  }
})

설명

이것이 하는 일: Setup Store는 Vue 컴포넌트의 setup() 함수와 동일한 방식으로 스토어를 정의합니다. Composition API에 익숙하다면 매우 자연스럽게 느껴질 것입니다.

첫 번째로, defineStore의 두 번째 인자로 화살표 함수를 전달합니다. 이 함수 안에서 ref를 사용하여 todos와 filter를 정의하면 이들이 자동으로 스토어의 state가 됩니다.

ref를 사용하므로 .value로 접근해야 하지만, 컴포넌트에서 사용할 때는 자동으로 언래핑됩니다. 그 다음으로, computed를 사용하여 filteredTodos와 activeCount를 정의합니다.

이들은 Options Store의 getters와 동일하게 작동하며, 의존하는 ref가 변경될 때만 재계산됩니다. filter.value에 따라 동적으로 필터링된 할일 목록을 제공합니다.

세 번째로, 일반 함수로 addTodo, toggleTodo, fetchTodos를 정의합니다. 이들이 스토어의 actions가 되며, 클로저를 통해 todos와 filter에 직접 접근할 수 있습니다.

async 함수도 자유롭게 사용할 수 있어 API 호출 로직을 깔끔하게 작성할 수 있습니다. 마지막으로, return 문에서 노출하고 싶은 state, getters, actions를 객체로 반환합니다.

return하지 않은 것들은 스토어의 private 멤버가 되어 외부에서 접근할 수 없습니다. 이를 활용하면 내부 헬퍼 함수나 임시 변수를 숨길 수 있습니다.

여러분이 Setup Store를 사용하면 Composition API의 모든 장점을 스토어에서도 누릴 수 있습니다. 여러 composables를 조합하거나, 조건부로 watchers를 설정하거나, 복잡한 초기화 로직을 실행하는 등 Options Store보다 훨씬 유연한 코드를 작성할 수 있습니다.

또한 TypeScript의 타입 추론이 더 잘 작동하여 개발 경험이 향상됩니다.

실전 팁

💡 Setup Store에서는 $reset이 자동으로 제공되지 않습니다. 필요하다면 reset 함수를 직접 만들어서 return하세요.

💡 Composables를 스토어에서 재사용할 수 있습니다. useLocalStorage, useFetch 같은 composables를 import하여 활용하면 코드 중복을 줄일 수 있습니다.

💡 private한 state나 함수가 필요하면 return 객체에 포함시키지 않으면 됩니다. 캡슐화에 유용합니다.

💡 onMounted, onUnmounted 같은 생명주기 훅도 사용할 수 있습니다. 단, 스토어는 한 번만 생성되므로 주의해서 사용하세요.

💡 Options Store와 Setup Store를 혼용할 수 있습니다. 프로젝트에 맞게 선택하되, 일관성을 유지하는 것이 좋습니다.


8. $patch로 효율적인 상태 업데이트 - 성능 최적화

시작하며

여러분이 여러 개의 state 필드를 동시에 업데이트할 때 하나씩 할당하다 보니 화면이 여러 번 깜박이거나 불필요한 리렌더링이 발생한 경험이 있나요? 각 할당마다 Vue의 반응성 시스템이 트리거되어 성능이 저하될 수 있습니다.

이런 문제는 특히 폼 데이터를 한 번에 업데이트하거나, API 응답으로 여러 필드를 설정할 때 자주 발생합니다. 10개의 필드를 개별적으로 업데이트하면 10번의 업데이트 사이클이 발생할 수 있습니다.

바로 이럴 때 필요한 것이 $patch 메서드입니다. 여러 state 변경을 하나의 트랜잭션으로 묶어 한 번만 업데이트를 트리거하여 성능을 최적화할 수 있습니다.

개요

간단히 말해서, $patch는 스토어의 여러 state를 동시에 업데이트하는 메서드로, 객체 또는 함수를 인자로 받습니다. 여러분이 사용자 프로필 수정, 검색 필터 설정, 대량 데이터 업데이트 등 여러 state를 한꺼번에 변경해야 할 때 $patch를 사용합니다.

예를 들어, 사용자가 프로필 수정 폼을 제출할 때 이름, 이메일, 전화번호, 주소 등 여러 필드를 동시에 업데이트해야 하는 경우 매우 유용합니다. 기존에 store.name = 'value1'; store.email = 'value2'처럼 개별 할당했다면 각 할당마다 반응성이 트리거되었습니다.

이제는 $patch 한 번으로 모든 변경을 묶을 수 있습니다. $patch의 핵심 특징은 단일 업데이트 사이클, 객체와 함수 두 가지 사용법, 그리고 배열 조작에 최적화입니다.

이러한 특징들이 대량 업데이트 시 성능을 크게 향상시켜줍니다.

코드 예제

// stores/user.js
export const useUserStore = defineStore('user', {
  state: () => ({
    profile: {
      name: '',
      email: '',
      phone: '',
      address: ''
    },
    preferences: {
      theme: 'light',
      notifications: true
    },
    todos: []
  }),

  actions: {
    // 객체로 $patch 사용
    updateProfileBasic(data) {
      this.$patch({
        profile: {
          name: data.name,
          email: data.email
        }
      })
    },

    // 함수로 $patch 사용 - 복잡한 로직에 적합
    updateTodosOptimized(newTodos) {
      this.$patch((state) => {
        // 배열 조작은 함수 형태가 효율적
        state.todos = newTodos.filter(t => !t.completed)

        // 여러 필드 동시 변경
        state.preferences.notifications = true
        state.profile.name = 'Updated'

        // 조건부 로직도 가능
        if (state.todos.length > 10) {
          state.preferences.theme = 'dark'
        }
      })
    }
  }
})

// 컴포넌트에서 사용
const userStore = useUserStore()

// 방법 1: 객체로 직접 패치 - 간단한 업데이트
userStore.$patch({
  profile: { name: '홍길동', email: 'hong@example.com' },
  preferences: { theme: 'dark' }
})

// 방법 2: 함수로 패치 - 복잡한 로직
userStore.$patch((state) => {
  state.todos.push({ id: 1, text: '새 할일' })
  state.profile.phone = '010-1234-5678'
})

설명

이것이 하는 일: $patch는 여러 state 변경을 하나의 업데이트 사이클로 묶어서 불필요한 리렌더링을 방지합니다. Vue의 반응성 시스템을 최적화하는 핵심 메서드입니다.

첫 번째로, 객체 형태의 $patch는 가장 간단한 사용법입니다. 변경하고 싶은 state 필드들을 객체로 전달하면 Pinia가 내부적으로 병합(merge)합니다.

profile과 preferences를 동시에 변경해도 단 한 번의 업데이트만 발생합니다. 이는 마치 Object.assign을 사용하는 것과 유사하지만, 반응성을 유지하면서 효율적으로 처리됩니다.

그 다음으로, 함수 형태의 $patch는 더 복잡한 로직이 필요할 때 사용합니다. 함수의 인자로 state를 받아서 직접 조작할 수 있습니다.

배열에 아이템을 추가하거나(push), 필터링하거나(filter), 조건부 로직을 실행할 수 있어 매우 유연합니다. 특히 배열 조작 시에는 함수 형태가 객체 형태보다 훨씬 효율적입니다.

세 번째로, 중첩된 객체도 쉽게 업데이트할 수 있습니다. profile.name과 preferences.theme처럼 깊이가 있는 속성도 직접 접근하여 변경할 수 있습니다.

전체 객체를 교체할 필요 없이 필요한 부분만 수정할 수 있어 코드가 깔끔합니다. 마지막으로, 함수 형태에서는 조건부 로직을 사용할 수 있습니다.

todos 길이가 10을 넘으면 테마를 변경하는 등 복잡한 비즈니스 로직을 $patch 안에서 처리할 수 있습니다. 이 모든 변경이 단일 업데이트로 처리되어 성능이 최적화됩니다.

여러분이 $patch를 사용하면 특히 폼 제출, API 응답 처리, 대량 데이터 업데이트 시 성능 향상을 체감할 수 있습니다. 프로필 수정 폼에서 10개의 필드를 제출할 때 10번의 리렌더링 대신 1번만 발생하여 UI가 더 부드럽게 작동합니다.

또한 코드도 더 간결해져 유지보수가 쉬워집니다.

실전 팁

💡 간단한 업데이트는 객체 형태, 배열 조작이나 복잡한 로직은 함수 형태를 사용하세요. 각각의 장점을 활용할 수 있습니다.

💡 함수 형태의 $patch 안에서 비동기 작업은 피하세요. 동기적인 state 변경만 수행해야 예측 가능합니다.

💡 중첩된 객체를 부분적으로 업데이트할 때 객체 형태 $patch를 사용하면 자동으로 병합됩니다. 전체 객체를 다시 만들 필요가 없습니다.

💡 $patch는 $subscribe 콜백을 한 번만 트리거합니다. 여러 변경을 감지해야 한다면 이 점을 고려하세요.

💡 devtools에서 $patch를 사용한 변경은 하나의 mutation으로 기록됩니다. 디버깅 시 변경 이력을 추적하기 쉬워집니다.


9. $reset으로 초기 상태 복원하기 - 상태 초기화 패턴

시작하며

여러분이 사용자가 로그아웃할 때나 폼을 리셋할 때 모든 state를 초기값으로 되돌리려면 각 필드를 일일이 초기화해야 했던 경험이 있나요? state가 많아질수록 누락하기 쉽고 코드도 길어집니다.

이런 문제는 특히 복잡한 폼이나 여러 단계로 구성된 위자드에서 "처음부터 다시 시작" 기능을 구현할 때 두드러집니다. 초기값을 별도로 저장해두고 수동으로 복원하는 것은 에러가 발생하기 쉽습니다.

바로 이럴 때 필요한 것이 $reset 메서드입니다. 단 한 줄의 코드로 스토어의 모든 state를 초기 상태로 되돌릴 수 있어 안전하고 편리합니다.

개요

간단히 말해서, $reset은 스토어의 state를 정의할 때 설정한 초기값으로 완전히 되돌리는 메서드입니다. 여러분이 로그아웃, 폼 리셋, 장바구니 비우기, 검색 필터 초기화 등 상태를 처음으로 되돌려야 할 때 $reset을 사용합니다.

예를 들어, 여러 단계의 회원가입 폼에서 사용자가 "처음부터 다시"를 클릭하면 모든 입력값을 초기화해야 하는 경우 매우 유용합니다. 기존에 각 state를 수동으로 초기화했다면 새로운 state가 추가될 때마다 리셋 로직도 수정해야 했습니다.

이제는 $reset 한 번이면 자동으로 모든 state가 초기화됩니다. $reset의 핵심 특징은 완전한 초기화, Options Store에서만 사용 가능, 그리고 $subscribe로 감지 가능입니다.

이러한 특징들이 안전하고 일관된 상태 초기화를 보장합니다.

코드 예제

// stores/checkout.js
export const useCheckoutStore = defineStore('checkout', {
  state: () => ({
    step: 1,
    shippingAddress: {
      name: '',
      address: '',
      city: '',
      zipCode: ''
    },
    paymentMethod: null,
    cardInfo: {
      number: '',
      expiry: '',
      cvv: ''
    },
    agreedToTerms: false,
    couponCode: ''
  }),

  actions: {
    nextStep() {
      if (this.step < 3) this.step++
    },

    previousStep() {
      if (this.step > 1) this.step--
    },

    // 전체 초기화
    resetCheckout() {
      this.$reset() // 모든 state를 초기값으로
    },

    // 로그아웃 시 호출
    clearSensitiveData() {
      this.$patch({
        cardInfo: {
          number: '',
          expiry: '',
          cvv: ''
        }
      })
    }
  }
})

// 컴포넌트에서 사용
<script setup>
import { useCheckoutStore } from '@/stores/checkout'

const checkoutStore = useCheckoutStore()

function handleCancelCheckout() {
  if (confirm('정말 취소하시겠습니까? 모든 정보가 삭제됩니다.')) {
    checkoutStore.$reset() // 한 줄로 모든 state 초기화
    router.push('/')
  }
}

function handleLogout() {
  checkoutStore.clearSensitiveData() // 민감 정보만 삭제
  // 사용자 스토어도 리셋
  const userStore = useUserStore()
  userStore.$reset()
}
</script>

설명

이것이 하는 일: $reset은 state() 함수를 다시 호출하여 반환된 초기값으로 전체 state를 교체합니다. 이는 완전한 초기화를 보장하는 가장 안전한 방법입니다.

첫 번째로, Options Store에서 $reset을 호출하면 Pinia가 내부적으로 state() 함수를 재실행합니다. 이 함수가 반환하는 객체가 새로운 state가 되므로, 정의할 때의 초기값과 정확히 동일한 상태로 돌아갑니다.

수동으로 각 필드를 초기화하는 것보다 훨씬 안전하고 누락이 없습니다. 그 다음으로, resetCheckout action에서 this.$reset()을 호출하면 step은 1로, shippingAddress는 빈 문자열들로, agreedToTerms는 false로 모두 되돌아갑니다.

중첩된 객체도 완벽하게 초기화되므로 깊은 복사나 복잡한 로직이 필요 없습니다. 세 번째로, $reset은 반응성을 유지합니다.

즉, $reset을 호출하면 해당 스토어를 사용하는 모든 컴포넌트가 자동으로 업데이트됩니다. 또한 $subscribe로 등록한 콜백도 트리거되어 부가 작업(로깅, 분석 등)을 수행할 수 있습니다.

마지막으로, 민감한 정보만 삭제하고 싶다면 clearSensitiveData처럼 $patch를 사용할 수도 있습니다. 카드 정보는 삭제하지만 배송 주소는 유지하는 등 부분적인 초기화가 필요할 때 유용합니다.

$reset은 전체 초기화, $patch는 부분 초기화로 상황에 맞게 선택하세요. 여러분이 $reset을 사용하면 복잡한 폼이나 위자드 UI를 안전하게 관리할 수 있습니다.

사용자가 체크아웃을 취소하거나 로그아웃할 때 단 한 줄로 모든 데이터를 초기화하여 다음 사용을 위해 깨끗한 상태를 준비할 수 있습니다. 또한 state를 추가하거나 수정해도 $reset 로직을 변경할 필요가 없어 유지보수가 쉬워집니다.

실전 팁

💡 Setup Store에서는 $reset이 자동으로 제공되지 않습니다. 필요하다면 초기값을 저장해두고 reset 함수를 직접 구현하세요.

💡 $reset 전에 사용자에게 확인을 받으세요. 실수로 호출하면 모든 데이터가 날아가므로 confirm 대화상자를 사용하는 것이 좋습니다.

💡 민감한 정보(비밀번호, 카드 정보 등)는 로그아웃 시 반드시 $reset으로 제거하세요. 보안을 위해 필수적입니다.

💡 $reset은 $subscribe 콜백을 트리거합니다. localStorage에 저장하는 로직이 있다면 초기 상태가 저장될 것입니다.

💡 여러 스토어를 동시에 초기화해야 한다면 각 스토어의 $reset을 순서대로 호출하세요. 전역 리셋 함수를 만들어 관리하면 편리합니다.


10. Pinia Plugins로 기능 확장하기 - 재사용 가능한 기능 추가

시작하며

여러분이 모든 스토어에 로깅 기능을 추가하거나, 자동 저장 기능을 넣고 싶을 때 각 스토어마다 동일한 코드를 복사-붙여넣기한 경험이 있나요? 스토어가 많아질수록 중복 코드가 늘어나고 일관성을 유지하기 어려워집니다.

이런 문제는 횡단 관심사(cross-cutting concerns)라고 불리는데, 여러 스토어에 공통으로 필요한 기능을 각각 구현하면 유지보수가 매우 어렵습니다. 버그를 수정하거나 기능을 개선할 때 모든 스토어를 찾아서 수정해야 합니다.

바로 이럴 때 필요한 것이 Pinia Plugins입니다. 한 번 정의한 플러그인을 모든 스토어에 자동으로 적용하여 코드 재사용성을 극대화하고 일관성을 보장할 수 있습니다.

개요

간단히 말해서, Pinia Plugin은 모든 스토어 생성 시 자동으로 실행되는 함수로, 스토어에 속성이나 기능을 추가할 수 있습니다. 여러분이 전역 로딩 상태 추가, 에러 핸들링 통합, localStorage 자동 동기화, API 요청 캐싱 등 모든 스토어에 공통으로 필요한 기능을 플러그인으로 구현할 수 있습니다.

예를 들어, 모든 API 호출 actions에 자동으로 로딩 상태와 에러 처리를 추가하는 플러그인을 만들 수 있습니다. 기존에 각 스토어에 loading, error state를 수동으로 추가하고 try-catch를 매번 작성했다면, 이제는 플러그인 한 번으로 모든 스토어에 자동 적용됩니다.

Pinia Plugin의 핵심 특징은 모든 스토어에 자동 적용, store 객체 접근 가능, 그리고 새로운 속성 추가 가능입니다. 이러한 특징들이 DRY(Don't Repeat Yourself) 원칙을 지키면서 강력한 기능을 구현할 수 있게 해줍니다.

코드 예제

// plugins/piniaPlugins.js
import { ref } from 'vue'

// 1. localStorage 자동 동기화 플러그인
export function piniaLocalStoragePlugin({ store }) {
  // 스토어 생성 시 localStorage에서 복원
  const savedState = localStorage.getItem(store.$id)
  if (savedState) {
    store.$patch(JSON.parse(savedState))
  }

  // state 변경 시 자동 저장
  store.$subscribe((mutation, state) => {
    localStorage.setItem(store.$id, JSON.stringify(state))
  })
}

// 2. 전역 로딩/에러 상태 추가 플러그인
export function piniaLoadingPlugin({ store }) {
  // 모든 스토어에 loading, error state 추가
  store.$state.loading = false
  store.$state.error = null

  // actions를 래핑하여 자동 로딩/에러 처리
  const originalActions = { ...store }

  Object.keys(store).forEach(key => {
    const action = store[key]
    if (typeof action === 'function') {
      store[key] = async function(...args) {
        store.loading = true
        store.error = null
        try {
          const result = await action.apply(this, args)
          return result
        } catch (error) {
          store.error = error.message
          throw error
        } finally {
          store.loading = false
        }
      }
    }
  })
}

// 3. devtools 통합 플러그인
export function piniaDebugPlugin({ store }) {
  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)
    })
  })
}

// main.js에서 사용
import { createPinia } from 'pinia'
import { piniaLocalStoragePlugin, piniaLoadingPlugin, piniaDebugPlugin } from './plugins/piniaPlugins'

const pinia = createPinia()

pinia.use(piniaLocalStoragePlugin)
pinia.use(piniaLoadingPlugin)

if (import.meta.env.DEV) {
  pinia.use(piniaDebugPlugin) // 개발 환경에서만 디버그
}

app.use(pinia)

설명

이것이 하는 일: Plugin은 각 스토어가 생성될 때 자동으로 실행되어 공통 기능을 주입합니다. pinia.use()로 등록하면 모든 스토어에 일관되게 적용됩니다.

첫 번째로, piniaLocalStoragePlugin은 스토어 생성 시 localStorage에서 이전 상태를 복원하고, $subscribe로 변경을 감지하여 자동 저장합니다. store.$id로 고유한 키를 사용하므로 여러 스토어의 데이터가 섞이지 않습니다.

이 플러그인 하나로 모든 스토어에 자동 지속성(persistence) 기능이 추가됩니다. 그 다음으로, piniaLoadingPlugin은 더 고급 기술을 사용합니다.

store.$state에 loading과 error 속성을 동적으로 추가하여 모든 스토어가 이 상태를 가지게 합니다. 그 후 모든 actions를 순회하면서 원래 함수를 래핑(wrapping)하여 호출 전후에 로딩 상태를 설정하고 에러를 catch합니다.

이렇게 하면 각 스토어에서 try-catch를 작성할 필요 없이 자동으로 에러 처리가 됩니다. 세 번째로, piniaDebugPlugin은 $onAction을 사용하여 모든 action 호출을 추적합니다.

action이 시작될 때 이름과 인자를 로깅하고, 완료되면 소요 시간과 결과를 출력하며, 에러가 발생하면 에러를 기록합니다. 이는 개발 중 디버깅에 매우 유용하므로 if (import.meta.env.DEV) 조건으로 개발 환경에서만 활성화합니다.

마지막으로, main.js에서 pinia.use()로 플러그인들을 등록하면 이후 생성되는 모든 스토어에 자동으로 적용됩니다. 순서대로 실행되므로 의존성이 있다면 순서를 고려해야 합니다.

플러그인은 함수이므로 옵션을 받도록 커스터마이징할 수도 있습니다. 여러분이 Plugin을 활용하면 비즈니스 로직과 인프라 관심사를 분리할 수 있습니다.

각 스토어는 도메인 로직에만 집중하고, 로깅, 저장, 에러 처리 같은 공통 기능은 플러그인이 담당하여 코드가 훨씬 깔끔해집니다. 또한 플러그인을 수정하면 모든 스토어에 즉시 반영되므로 유지보수가 매우 쉬워집니다.

실전 팁

💡 플러그인에서 store.$state를 직접 수정하면 모든 스토어에 속성이 추가됩니다. 하지만 TypeScript 타입 안전성을 위해서는 별도로 타입 정의가 필요합니다.

💡 민감한 정보를 localStorage에 저장하지 않도록 플러그인에 제외 목록을 추가하세요. 예: 비밀번호, 토큰 등

💡 플러그인은 순서대로 실행됩니다. 의존성이 있는 플러그인은 올바른 순서로 등록하세요.

💡 성능에 민감한 플러그인(로깅 등)은 개발 환경에서만 사용하세요. 프로덕션에서는 비활성화하는 것이 좋습니다.

💡 pinia-plugin-persistedstate 같은 검증된 오픈소스 플러그인을 사용하면 직접 구현하는 것보다 안전하고 기능이 풍부합니다.


#Vue#Pinia#StateManagement#Store#CompositionAPI#중급

댓글 (0)

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