이미지 로딩 중...

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

AI Generated

2025. 11. 13. · 5 Views

Pinia 상태관리 완벽 마스터 가이드

Vue 3의 공식 상태관리 라이브러리 Pinia의 핵심 개념부터 실무 활용까지 완벽하게 다룹니다. Store 생성, State 관리, Getters, Actions, 플러그인 시스템까지 실전 예제와 함께 학습할 수 있습니다.


목차

  1. Pinia Store 기본 구조 - 상태관리의 시작점
  2. Composition API 스타일의 Setup Store - 더 유연한 상태관리
  3. Store 사용하기 - 컴포넌트에서의 활용
  4. State 변경과 $patch - 효율적인 업데이트
  5. Getters의 활용 - 계산된 상태 관리
  6. Actions와 비동기 처리 - API 호출 패턴
  7. Store 간 통신 - 여러 Store 조합하기
  8. Pinia 플러그인 - 기능 확장하기
  9. TypeScript와 Pinia - 타입 안전한 상태관리
  10. Pinia DevTools 통합 - 디버깅과 시간여행

1. Pinia Store 기본 구조 - 상태관리의 시작점

시작하며

여러분이 Vue 3 프로젝트를 진행하면서 여러 컴포넌트 간에 데이터를 공유해야 할 때 props drilling 문제로 고생한 적 있나요? 부모에서 자식으로, 또 그 자식으로 계속해서 props를 전달하다 보면 코드가 복잡해지고 유지보수가 어려워집니다.

이런 문제는 애플리케이션의 규모가 커질수록 더욱 심각해집니다. 컴포넌트 구조를 변경할 때마다 props 체인을 모두 수정해야 하고, 전역 상태를 관리하기 위해 복잡한 이벤트 버스를 사용하게 됩니다.

바로 이럴 때 필요한 것이 Pinia Store입니다. Vue 3의 공식 상태관리 라이브러리인 Pinia는 간단한 API로 전역 상태를 관리하고, TypeScript 지원도 완벽하며, DevTools와의 통합으로 디버깅까지 쉽게 만들어줍니다.

개요

간단히 말해서, Pinia Store는 Vue 애플리케이션의 중앙 집중식 데이터 저장소입니다. 모든 컴포넌트에서 접근 가능한 전역 상태를 관리하는 곳입니다.

Pinia는 Vuex의 후속 버전으로, 더 간단한 API와 뛰어난 TypeScript 지원을 제공합니다. mutations가 사라지고 actions만으로 상태를 변경할 수 있어 코드가 훨씬 간결해졌습니다.

예를 들어, 사용자 인증 정보, 쇼핑카트 데이터, 테마 설정 같은 애플리케이션 전역에서 필요한 데이터를 관리할 때 매우 유용합니다. 기존 Vuex에서는 mutations와 actions를 구분해서 사용했다면, Pinia에서는 actions만으로 동기/비동기 상태 변경을 모두 처리할 수 있습니다.

코드가 더 직관적이고 보일러플레이트 코드가 줄어듭니다. Pinia의 핵심 특징은 세 가지입니다: State(상태 데이터), Getters(계산된 값), Actions(상태 변경 로직).

이 세 가지만 이해하면 Pinia의 모든 기능을 활용할 수 있으며, 컴포넌트 간 데이터 공유가 훨씬 쉬워집니다.

코드 예제

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

// 'user'는 store의 고유 ID입니다
export const useUserStore = defineStore('user', {
  // state: 실제 데이터를 저장하는 곳
  state: () => ({
    username: '',
    email: '',
    isLoggedIn: false
  }),

  // getters: state를 기반으로 계산된 값
  getters: {
    userDisplayName: (state) => state.username || 'Guest'
  },

  // actions: state를 변경하는 메서드
  actions: {
    login(username, email) {
      this.username = username
      this.email = email
      this.isLoggedIn = true
    },
    logout() {
      this.username = ''
      this.email = ''
      this.isLoggedIn = false
    }
  }
})

설명

이것이 하는 일: Pinia Store는 애플리케이션의 전역 상태를 하나의 객체로 관리하며, 모든 컴포넌트에서 동일한 데이터에 접근하고 수정할 수 있게 해줍니다. 첫 번째로, defineStore 함수는 새로운 store를 생성합니다.

첫 번째 인자인 'user'는 store의 고유 ID로, Vue DevTools에서 이 이름으로 표시됩니다. 두 번째 인자는 store의 설정 객체입니다.

이렇게 분리된 구조 덕분에 여러 store를 독립적으로 관리할 수 있습니다. 그 다음으로, state 함수가 실행되면서 초기 상태를 설정합니다.

함수 형태로 작성하는 이유는 SSR(Server-Side Rendering) 환경에서 각 요청마다 독립적인 상태를 생성하기 위함입니다. username, email, isLoggedIn 같은 사용자 정보를 여기에 저장합니다.

getters는 Vue의 computed와 비슷하게 동작합니다. state 값을 기반으로 계산된 값을 반환하며, 캐싱되어 성능이 최적화됩니다.

userDisplayName getter는 사용자 이름이 있으면 보여주고, 없으면 'Guest'를 표시합니다. 마지막으로, actions는 state를 변경하는 메서드들입니다.

login 함수는 사용자 정보를 받아서 state를 업데이트하고, logout은 모든 정보를 초기화합니다. this 키워드로 state에 직접 접근할 수 있어 코드가 매우 직관적입니다.

여러분이 이 코드를 사용하면 모든 컴포넌트에서 useUserStore()를 호출하여 동일한 사용자 상태에 접근할 수 있습니다. 로그인 페이지에서 login을 호출하면, 헤더 컴포넌트에서 자동으로 사용자 이름이 표시되고, 네비게이션 가드에서 isLoggedIn 상태를 확인할 수 있습니다.

실전 팁

💡 Store ID는 고유해야 합니다. 여러 store를 만들 때 'user', 'cart', 'product' 처럼 명확한 이름을 사용하세요. 중복되면 예상치 못한 동작이 발생할 수 있습니다.

💡 state는 반드시 함수로 정의하세요. 객체 리터럴로 정의하면 모든 인스턴스가 같은 객체를 참조하게 되어 SSR 환경에서 문제가 발생합니다.

💡 actions 내부에서 this를 사용할 때는 화살표 함수를 사용하지 마세요. 화살표 함수는 this 바인딩이 다르게 작동하여 state에 접근할 수 없습니다.

💡 getters는 다른 getters를 참조할 수 있습니다. this.userDisplayName 같은 방식으로 다른 getter를 활용하여 복잡한 계산 로직을 구성할 수 있습니다.

💡 Store를 여러 파일로 분리하세요. stores/user.js, stores/cart.js 처럼 기능별로 나누면 유지보수가 훨씬 쉬워지고 코드 재사용성도 높아집니다.


2. Composition API 스타일의 Setup Store - 더 유연한 상태관리

시작하며

여러분이 Vue 3의 Composition API에 익숙하다면, Options API 스타일의 Pinia Store가 조금 낯설게 느껴질 수 있습니다. setup 함수에서 ref, computed를 사용하던 패턴을 store에서도 그대로 사용하고 싶지 않으신가요?

실제로 많은 개발자들이 Composition API의 유연성과 재사용성을 선호합니다. 하지만 기존 Pinia Store는 Options API 스타일로만 작성할 수 있어서, 코드 스타일이 일관되지 않고 composables를 store에서 활용하기 어려웠습니다.

바로 이럴 때 필요한 것이 Setup Store입니다. Composition API 방식으로 store를 정의할 수 있어, ref는 state가 되고, computed는 getters가 되며, function은 actions가 됩니다.

더 자유롭고 직관적인 방식으로 상태를 관리할 수 있습니다.

개요

간단히 말해서, Setup Store는 Composition API 스타일로 작성하는 Pinia Store입니다. setup 함수 안에서 작성하는 것과 동일한 문법을 사용합니다.

Setup Store는 defineStore의 두 번째 인자로 setup 함수를 전달하는 방식입니다. 내부에서 ref로 반응형 변수를 만들면 state가 되고, computed로 계산된 값을 만들면 getters가 되며, 일반 함수는 actions가 됩니다.

예를 들어, 복잡한 비즈니스 로직이나 여러 composables를 조합해야 하는 경우에 매우 유용합니다. 기존 Options API 스타일에서는 state, getters, actions가 명확히 구분되어 있었다면, Setup Store에서는 모두 setup 함수 안에서 자유롭게 정의할 수 있습니다.

더 유연하고 JavaScript의 일반적인 패턴에 가깝습니다. Setup Store의 핵심 장점은 코드 재사용성입니다.

이미 만들어둔 composables를 store에서 그대로 사용할 수 있고, 여러 store 간에 로직을 공유하기도 쉽습니다. TypeScript 타입 추론도 더 정확하게 작동하여 개발 경험이 향상됩니다.

코드 예제

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

export const useCounterStore = defineStore('counter', () => {
  // ref()는 state가 됩니다
  const count = ref(0)
  const name = ref('Counter')

  // computed()는 getters가 됩니다
  const doubleCount = computed(() => count.value * 2)
  const isPositive = computed(() => count.value > 0)

  // function()은 actions가 됩니다
  function increment() {
    count.value++
  }

  function decrement() {
    count.value--
  }

  function reset() {
    count.value = 0
  }

  // 반환된 모든 것이 store의 일부가 됩니다
  return { count, name, doubleCount, isPositive, increment, decrement, reset }
})

설명

이것이 하는 일: Setup Store는 Vue 3의 Composition API 패턴을 store에 그대로 적용하여, 더 유연하고 직관적인 방식으로 상태를 관리할 수 있게 해줍니다. 첫 번째로, defineStore의 두 번째 인자로 setup 함수를 전달합니다.

이 함수는 컴포넌트의 setup 함수와 동일하게 작동하며, 반환된 모든 값이 store의 속성이 됩니다. ref와 computed를 import하여 사용하는 것도 컴포넌트와 완전히 동일합니다.

그 다음으로, ref로 정의된 count와 name이 store의 state가 됩니다. Options API 스타일에서는 state() 함수 안에 객체로 정의했지만, Setup Store에서는 각 상태를 개별 ref로 관리합니다.

이렇게 하면 각 상태의 타입이 명확해지고, 독립적으로 관리하기 쉽습니다. computed로 정의된 doubleCount와 isPositive는 getters가 됩니다.

자동으로 캐싱되며, 의존하는 state가 변경될 때만 다시 계산됩니다. count.value를 사용하는 것에 주목하세요.

Setup Store 내부에서는 .value를 명시적으로 작성해야 합니다. 마지막으로, 일반 함수로 정의된 increment, decrement, reset이 actions가 됩니다.

이들 함수 내부에서 count.value를 직접 수정하여 상태를 변경합니다. return 문에서 필요한 모든 값과 함수를 반환하면, 이들이 store의 공개 API가 됩니다.

여러분이 이 코드를 사용하면 컴포넌트에서 useCounterStore()를 호출하여 count, doubleCount, increment 등에 접근할 수 있습니다. Composition API에 익숙한 개발자라면 별도의 학습 없이 바로 사용할 수 있으며, 기존 composables를 store에 통합하기도 쉽습니다.

실전 팁

💡 Setup Store 내부에서는 반드시 .value를 사용하세요. 컴포넌트의 setup에서와 동일하게, ref 값에 접근할 때는 .value가 필요합니다. 이를 잊으면 예상치 못한 버그가 발생합니다.

💡 반환하지 않은 변수는 private이 됩니다. 내부에서만 사용하는 헬퍼 함수나 임시 변수는 return에 포함하지 않으면 외부에서 접근할 수 없어 캡슐화가 가능합니다.

💡 기존 composables를 store에서 재사용하세요. useLocalStorage, useFetch 같은 composables를 import하여 setup 함수 내에서 바로 사용할 수 있어 코드 재사용성이 극대로 높아집니다.

💡 비동기 로직을 더 자연스럽게 작성할 수 있습니다. async/await를 일반 함수에서 바로 사용할 수 있어, API 호출이나 복잡한 비동기 처리가 Options API 스타일보다 훨씬 직관적입니다.

💡 TypeScript 타입 추론이 더 정확합니다. Setup Store는 return 타입을 기반으로 타입을 추론하여, 별도의 타입 선언 없이도 완벽한 타입 안전성을 제공합니다.


3. Store 사용하기 - 컴포넌트에서의 활용

시작하며

여러분이 Store를 정의했다면, 이제 실제 컴포넌트에서 어떻게 사용하는지가 궁금하실 겁니다. Props를 전달하는 것처럼 복잡한 과정이 필요할까요?

아니면 새로운 문법을 배워야 할까요? 실제로 많은 초보자들이 store 사용 방법에서 혼란을 겪습니다.

언제 store를 호출해야 하는지, state를 어떻게 수정하는지, computed와 함께 사용할 수 있는지 등 궁금한 점이 많습니다. 바로 이럴 때 알아야 할 것이 Store 사용 패턴입니다.

단순히 useStore() 함수를 호출하는 것만으로 모든 state, getters, actions에 접근할 수 있으며, 반응형도 자동으로 유지됩니다. 생각보다 훨씬 간단합니다.

개요

간단히 말해서, Store 사용은 컴포넌트에서 useStore() 함수를 호출하여 store 인스턴스를 가져오는 것입니다. 이 인스턴스를 통해 모든 기능에 접근합니다.

Store는 싱글톤 패턴으로 작동하여, 어디서 호출하든 동일한 인스턴스를 반환합니다. 따라서 A 컴포넌트에서 state를 변경하면 B 컴포넌트에서도 즉시 반영됩니다.

예를 들어, 헤더 컴포넌트에서 사용자가 로그인하면, 사이드바 컴포넌트의 메뉴가 자동으로 업데이트되는 식입니다. 기존 Vue의 provide/inject나 이벤트 버스와는 다르게, Store는 타입 안전성을 제공하고 DevTools와 통합되어 디버깅이 쉽습니다.

setup 함수나 script setup 어디서든 사용할 수 있습니다. Store 사용의 핵심 포인트는 세 가지입니다: 직접 접근 방식, 구조 분해 할당과 storeToRefs 활용, actions 호출 패턴.

이들을 이해하면 모든 상황에서 store를 효율적으로 사용할 수 있습니다.

코드 예제

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

// store 인스턴스 가져오기
const userStore = useUserStore()

// state와 getters는 storeToRefs로 반응형 유지
const { username, email, isLoggedIn, userDisplayName } = storeToRefs(userStore)

// actions는 직접 구조 분해 가능
const { login, logout } = userStore

// 컴포넌트 내에서 사용
function handleLogin() {
  login('John Doe', 'john@example.com')
}

function handleLogout() {
  logout()
}
</script>

<template>
  <div v-if="isLoggedIn">
    <p>Welcome, {{ userDisplayName }}!</p>
    <p>Email: {{ email }}</p>
    <button @click="handleLogout">Logout</button>
  </div>
  <div v-else>
    <button @click="handleLogin">Login</button>
  </div>
</template>

설명

이것이 하는 일: Store를 컴포넌트에서 사용하면 전역 상태에 접근하고, 이를 template에 표시하며, actions를 통해 상태를 변경할 수 있습니다. 첫 번째로, useUserStore() 함수를 호출하여 store 인스턴스를 가져옵니다.

이 함수는 처음 호출될 때 store를 생성하고, 이후에는 캐시된 인스턴스를 반환합니다. 따라서 여러 컴포넌트에서 호출해도 모두 동일한 store를 참조합니다.

그 다음으로, storeToRefs 함수를 사용하여 state와 getters를 구조 분해 할당합니다. 일반적인 구조 분해 할당을 하면 반응형이 깨지지만, storeToRefs를 사용하면 각 속성이 ref로 변환되어 반응형이 유지됩니다.

username이 변경되면 template도 자동으로 업데이트됩니다. actions는 일반 함수이므로 storeToRefs 없이 직접 구조 분해 할당해도 됩니다.

login과 logout 함수를 꺼내서 컴포넌트의 이벤트 핸들러에서 호출할 수 있습니다. this 바인딩은 Pinia가 자동으로 처리하므로 신경 쓸 필요가 없습니다.

template에서는 구조 분해한 변수들을 직접 사용합니다. isLoggedIn이 true면 사용자 정보를 보여주고, false면 로그인 버튼을 표시합니다.

버튼 클릭 시 handleLogin이나 handleLogout이 호출되어 store의 actions를 실행하고, state가 변경되면 UI가 자동으로 업데이트됩니다. 여러분이 이 코드를 사용하면 컴포넌트 간 props drilling 없이 데이터를 공유할 수 있습니다.

로그인 상태를 여러 컴포넌트에서 확인해야 한다면, 각 컴포넌트에서 useUserStore()를 호출하기만 하면 되고, 한 곳에서 변경하면 모든 곳에 자동으로 반영됩니다.

실전 팁

💡 storeToRefs는 state와 getters에만 사용하세요. actions에 사용하면 오류가 발생하거나 예상치 못한 동작을 할 수 있습니다. actions는 일반 함수이므로 직접 구조 분해 할당하면 됩니다.

💡 구조 분해 할당 없이 직접 사용해도 됩니다. userStore.username, userStore.login() 처럼 store 인스턴스를 통해 접근하면 storeToRefs 없이도 반응형이 유지됩니다.

💡 setup 밖에서 store를 호출하지 마세요. 컴포넌트의 최상위 레벨이나 모듈 스코프에서 useStore()를 호출하면 의존성 주입이 제대로 작동하지 않아 오류가 발생할 수 있습니다.

💡 computed에서 store 값을 사용할 때는 주의하세요. storeToRefs로 꺼낸 값은 이미 ref이므로, computed 내부에서 .value 없이 바로 사용할 수 있습니다.

💡 여러 store를 조합하여 사용할 수 있습니다. const userStore = useUserStore(), const cartStore = useCartStore() 처럼 여러 store를 동시에 사용하여 복잡한 비즈니스 로직을 구성할 수 있습니다.


4. State 변경과 $patch - 효율적인 업데이트

시작하며

여러분이 store의 state를 변경할 때, 한 번에 여러 속성을 업데이트해야 하는 경우가 있습니다. 예를 들어 사용자 프로필을 수정할 때 이름, 이메일, 전화번호를 동시에 변경해야 한다면 어떻게 하시겠습니까?

직접 각 속성을 하나씩 변경하면 매번 반응형 시스템이 트리거되어 성능 문제가 발생할 수 있습니다. 또한 여러 줄의 반복적인 코드를 작성해야 하고, 트랜잭션처럼 원자적으로 변경되지 않아 중간 상태가 노출될 수 있습니다.

바로 이럴 때 필요한 것이 $patch 메서드입니다. 여러 state 속성을 한 번에 효율적으로 업데이트할 수 있으며, 단 한 번의 반응형 트리거로 성능을 최적화하고, DevTools에서도 하나의 변경으로 기록됩니다.

개요

간단히 말해서, $patch는 store의 여러 state 속성을 한 번에 업데이트하는 최적화된 메서드입니다. 객체나 함수를 전달하여 state를 효율적으로 변경합니다.

$patch는 두 가지 방식으로 사용할 수 있습니다. 객체를 전달하면 해당 속성들만 병합되고, 함수를 전달하면 state를 직접 수정할 수 있습니다.

예를 들어, API 응답으로 받은 사용자 정보 전체를 한 번에 업데이트하거나, 배열에 여러 항목을 추가할 때 매우 유용합니다. 기존에 state.name = '...', state.email = '...' 처럼 하나씩 변경했다면, $patch를 사용하면 한 번에 모든 변경을 적용할 수 있습니다.

성능이 향상되고 코드도 더 간결해집니다. $patch의 핵심 장점은 세 가지입니다: 성능 최적화(단일 반응형 트리거), 코드 간결성(여러 속성을 객체로 전달), 디버깅 편의성(DevTools에서 하나의 변경으로 표시).

특히 복잡한 객체나 배열을 다룰 때 함수 형태의 $patch가 매우 강력합니다.

코드 예제

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

export const useProfileStore = defineStore('profile', {
  state: () => ({
    user: {
      name: '',
      email: '',
      age: 0
    },
    preferences: {
      theme: 'light',
      language: 'ko'
    },
    notifications: []
  }),

  actions: {
    // 객체 방식 $patch
    updateUserInfo(userData) {
      this.$patch({
        user: {
          name: userData.name,
          email: userData.email,
          age: userData.age
        }
      })
    },

    // 함수 방식 $patch - 더 복잡한 로직에 유용
    updatePreferencesAndNotifications(prefs, newNotif) {
      this.$patch((state) => {
        state.preferences.theme = prefs.theme
        state.preferences.language = prefs.language
        // 배열 조작도 가능
        state.notifications.push(newNotif)
      })
    },

    // 일반 방식 (비효율적)
    updateUserSlowWay(userData) {
      this.user.name = userData.name  // 반응형 트리거 1
      this.user.email = userData.email  // 반응형 트리거 2
      this.user.age = userData.age  // 반응형 트리거 3
    }
  }
})

설명

이것이 하는 일: $patch는 여러 state 속성을 원자적으로 업데이트하여 반응형 시스템의 트리거 횟수를 줄이고, 성능을 최적화하며, 코드를 간결하게 만듭니다. 첫 번째로, 객체 방식의 $patch를 보겠습니다.

updateUserInfo 메서드에서 this.$patch()에 객체를 전달하면, 해당 속성들이 현재 state와 병합됩니다. user 객체의 name, email, age가 한 번에 업데이트되지만, preferences나 notifications는 영향을 받지 않습니다.

이 모든 변경이 단 한 번의 반응형 트리거로 처리됩니다. 그 다음으로, 함수 방식의 $patch가 더 강력합니다.

updatePreferencesAndNotifications에서는 $patch에 콜백 함수를 전달합니다. 이 함수는 state를 인자로 받아서 직접 수정할 수 있습니다.

state.preferences를 변경하고, state.notifications 배열에 push도 할 수 있어, 복잡한 로직에 적합합니다. 비교를 위해 updateUserSlowWay를 보면, 각 속성을 하나씩 변경할 때마다 반응형 시스템이 트리거됩니다.

name을 변경하면 1번, email을 변경하면 2번, age를 변경하면 3번 트리거되어 총 3번의 업데이트가 발생합니다. computed나 watcher가 많으면 성능에 큰 영향을 줍니다.

$patch를 사용하면 동일한 작업을 단 1번의 트리거로 처리합니다. 내부적으로 모든 변경을 모아서 한 번에 적용하기 때문입니다.

Vue DevTools에서도 하나의 mutation으로 기록되어 디버깅이 쉽고, 변경 히스토리를 추적하기도 편리합니다. 여러분이 이 코드를 사용하면 특히 API 응답을 처리할 때 유용합니다.

fetch로 받은 사용자 데이터를 $patch로 한 번에 store에 반영하면, 여러 컴포넌트가 동시에 업데이트되지만 렌더링은 한 번만 발생합니다. 폼 제출, 대량 데이터 업데이트, 복잡한 상태 변경 시 필수적인 패턴입니다.

실전 팁

💡 단일 속성 변경에는 $patch를 사용하지 마세요. store.count++ 같은 단순한 변경에는 직접 접근이 더 간결하고 읽기 쉽습니다. $patch는 여러 속성을 동시에 변경할 때만 사용하세요.

💡 함수 방식은 배열 조작에 특히 유용합니다. push, splice, filter 같은 배열 메서드를 사용하거나, 조건부 로직이 필요할 때는 함수 방식의 $patch를 사용하면 더 직관적입니다.

💡 $patch는 얕은 병합(shallow merge)을 수행합니다. 중첩된 객체의 일부만 변경하고 싶다면 함수 방식을 사용하거나, 전체 객체를 새로 만들어야 합니다. 객체 방식으로는 깊은 병합이 되지 않습니다.

💡 API 응답 처리 시 $patch를 표준으로 사용하세요. async actions에서 데이터를 받아올 때 $patch로 한 번에 업데이트하면 성능과 코드 품질이 모두 향상됩니다.

💡 TypeScript에서는 타입 안전성이 보장됩니다. $patch에 전달하는 객체는 state 타입과 호환되어야 하므로, 잘못된 속성이나 타입을 사용하면 컴파일 에러가 발생하여 실수를 방지할 수 있습니다.


5. Getters의 활용 - 계산된 상태 관리

시작하며

여러분이 store의 state를 그대로 사용하는 대신, 가공된 데이터를 여러 컴포넌트에서 사용해야 하는 경우가 있습니다. 예를 들어 쇼핑카트의 총 금액, 필터링된 상품 목록, 사용자의 전체 이름 같은 계산된 값들 말이죠.

이런 로직을 각 컴포넌트에서 반복적으로 작성하면 코드 중복이 발생하고, 계산 로직이 변경될 때 모든 곳을 수정해야 합니다. 또한 동일한 계산을 여러 번 수행하여 성능도 낭비됩니다.

바로 이럴 때 필요한 것이 Getters입니다. Vue의 computed처럼 자동으로 캐싱되어 성능이 최적화되고, state가 변경될 때만 재계산되며, 다른 getters를 참조하여 복잡한 로직도 구성할 수 있습니다.

개요

간단히 말해서, Getters는 store의 state를 기반으로 계산된 값을 반환하는 속성입니다. Vue 컴포넌트의 computed와 동일한 역할을 store에서 수행합니다.

Getters는 state에 직접 접근하여 파생된 데이터를 만듭니다. 첫 번째 인자로 state를 받으며, this로도 접근할 수 있습니다.

자동으로 캐싱되어 의존하는 state가 변경되지 않으면 다시 계산하지 않습니다. 예를 들어, 상품 목록을 가격순으로 정렬하거나, 할인 적용된 최종 금액을 계산하거나, 사용자의 권한에 따른 접근 가능한 메뉴를 필터링하는 데 유용합니다.

기존에는 computed를 각 컴포넌트에 작성했다면, getters를 사용하면 로직을 중앙화하고 재사용할 수 있습니다. 모든 컴포넌트에서 동일한 계산 결과를 공유합니다.

Getters의 핵심 기능은 네 가지입니다: 자동 캐싱, 다른 getters 참조 가능, 매개변수를 받는 getters(함수 반환), 다른 store의 getters 사용. 이를 통해 복잡한 비즈니스 로직을 깔끔하게 구성할 수 있습니다.

코드 예제

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

export const useCartStore = defineStore('cart', {
  state: () => ({
    items: [
      { id: 1, name: 'Laptop', price: 1000, quantity: 1 },
      { id: 2, name: 'Mouse', price: 50, quantity: 2 }
    ],
    taxRate: 0.1
  }),

  getters: {
    // 기본 getter - state를 인자로 받음
    totalItems: (state) => {
      return state.items.reduce((sum, item) => sum + item.quantity, 0)
    },

    // 다른 getter를 참조
    subtotal: (state) => {
      return state.items.reduce((sum, item) => sum + item.price * item.quantity, 0)
    },

    tax() {
      // this로 다른 getter 참조 가능
      return this.subtotal * this.taxRate
    },

    total() {
      return this.subtotal + this.tax
    },

    // 매개변수를 받는 getter (함수 반환)
    getItemById: (state) => {
      return (itemId) => state.items.find(item => item.id === itemId)
    },

    // 다른 store 사용
    canCheckout() {
      const userStore = useUserStore()
      return userStore.isLoggedIn && this.totalItems > 0
    }
  }
})

설명

이것이 하는 일: Getters는 store의 state를 가공하여 파생 데이터를 제공하고, 캐싱을 통해 성능을 최적화하며, 비즈니스 로직을 중앙화하여 코드 재사용성을 높입니다. 첫 번째로, totalItems getter는 화살표 함수로 정의되어 state를 인자로 받습니다.

state.items 배열을 reduce로 순회하며 quantity를 모두 더해 장바구니의 총 상품 개수를 계산합니다. 이 값은 캐싱되어 items가 변경되지 않으면 다시 계산하지 않습니다.

그 다음으로, subtotal getter도 비슷하게 각 상품의 가격과 수량을 곱한 후 합산하여 세전 금액을 계산합니다. tax getter는 일반 함수로 정의되어 this를 사용할 수 있습니다.

this.subtotal로 다른 getter를 참조하여 세금을 계산하고, total getter는 subtotal과 tax를 더해 최종 금액을 계산합니다. 이처럼 getters끼리 연결하여 복잡한 계산을 단계적으로 구성할 수 있습니다.

getItemById는 특별한 패턴입니다. 함수를 반환하여 매개변수를 받을 수 있게 합니다.

컴포넌트에서 cartStore.getItemById(1)처럼 호출하면 해당 ID의 상품을 찾아 반환합니다. 단, 이 방식은 캐싱되지 않으므로 호출할 때마다 계산이 실행됩니다.

canCheckout getter는 다른 store를 참조하는 예제입니다. useUserStore()를 getter 내부에서 호출하여 사용자의 로그인 상태를 확인하고, 현재 store의 totalItems와 조합하여 결제 가능 여부를 판단합니다.

여러 store의 상태를 조합하여 복잡한 비즈니스 로직을 구현할 수 있습니다. 여러분이 이 코드를 사용하면 컴포넌트에서 복잡한 계산 로직을 작성할 필요가 없습니다.

cartStore.total을 template에 바로 표시하면 되고, 상품이 추가되거나 제거되면 자동으로 재계산되어 UI가 업데이트됩니다. 모든 컴포넌트가 동일한 계산 로직을 공유하여 일관성이 보장됩니다.

실전 팁

💡 화살표 함수와 일반 함수를 상황에 맞게 사용하세요. 다른 getter나 action을 참조해야 하면 일반 함수(this 사용), 단순히 state만 사용하면 화살표 함수가 더 간결합니다.

💡 매개변수를 받는 getter는 캐싱되지 않습니다. 성능이 중요하다면 computed를 컴포넌트에서 사용하거나, state를 배열 대신 객체(ID를 키로 사용)로 구조화하여 직접 접근하는 방식을 고려하세요.

💡 Getters에 무거운 계산을 넣지 마세요. 복잡한 정렬이나 필터링은 초기화 시 한 번만 수행하고 state에 저장하는 것이 나을 수 있습니다. Getters는 가벼운 계산에 적합합니다.

💡 다른 store를 참조할 때는 순환 참조를 주의하세요. Store A가 Store B를 참조하고, B가 다시 A를 참조하면 오류가 발생합니다. 의존성 구조를 명확히 설계하세요.

💡 TypeScript를 사용하면 getter의 반환 타입이 자동으로 추론됩니다. 명시적으로 타입을 선언할 필요가 없으며, 컴포넌트에서 사용할 때 완벽한 자동완성과 타입 체크를 제공받을 수 있습니다.


6. Actions와 비동기 처리 - API 호출 패턴

시작하며

여러분이 실무에서 store를 사용할 때, 가장 흔한 작업 중 하나가 API 호출입니다. 서버에서 데이터를 가져오거나, 수정하거나, 삭제하는 비동기 작업을 어떻게 store에서 관리하시나요?

많은 초보자들이 컴포넌트에서 직접 API를 호출하고 결과를 store에 저장하는 방식으로 작성합니다. 하지만 이렇게 하면 로직이 분산되고, 에러 처리가 일관되지 않으며, 로딩 상태 관리가 복잡해집니다.

바로 이럴 때 필요한 것이 Actions의 비동기 패턴입니다. async/await를 사용하여 API 호출을 actions에서 처리하고, 로딩과 에러 상태를 store에서 관리하며, 모든 비즈니스 로직을 중앙화할 수 있습니다.

개요

간단히 말해서, Actions는 비동기 로직을 포함할 수 있는 store의 메서드입니다. API 호출, 타이머, 복잡한 비즈니스 로직 등 모든 상태 변경 작업을 담당합니다.

Pinia의 actions는 Vuex와 달리 mutations가 없어 직접 state를 변경할 수 있고, async/await를 자유롭게 사용할 수 있습니다. 컴포넌트는 단순히 actions를 호출하기만 하면 되고, 모든 복잡한 로직은 store가 처리합니다.

예를 들어, 사용자 목록을 가져오거나, 상품을 장바구니에 추가하거나, 프로필을 수정하는 등의 작업을 actions에서 수행합니다. 기존 Vuex에서는 actions에서 mutations를 commit해야 했다면, Pinia에서는 this.state = value처럼 직접 변경하면 됩니다.

코드가 훨씬 간결하고 직관적입니다. Actions의 핵심 패턴은 네 가지입니다: try-catch를 활용한 에러 처리, 로딩 상태 관리, 여러 actions 조합, 다른 store의 actions 호출.

이들을 마스터하면 복잡한 비동기 플로우도 깔끔하게 구현할 수 있습니다.

코드 예제

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

export const useProductStore = defineStore('products', {
  state: () => ({
    products: [],
    currentProduct: null,
    isLoading: false,
    error: null
  }),

  actions: {
    // 비동기 action - API 호출
    async fetchProducts() {
      this.isLoading = true
      this.error = null

      try {
        const response = await fetch('https://api.example.com/products')
        if (!response.ok) throw new Error('Failed to fetch products')

        const data = await response.json()
        // state를 직접 변경
        this.products = data
      } catch (error) {
        this.error = error.message
        console.error('Error fetching products:', error)
      } finally {
        this.isLoading = false
      }
    },

    // 매개변수를 받는 action
    async fetchProductById(productId) {
      this.isLoading = true
      this.error = null

      try {
        const response = await fetch(`https://api.example.com/products/${productId}`)
        if (!response.ok) throw new Error('Product not found')

        this.currentProduct = await response.json()
        return this.currentProduct  // 결과를 반환할 수도 있음
      } catch (error) {
        this.error = error.message
        throw error  // 컴포넌트에서 추가 처리를 위해 에러를 다시 던짐
      } finally {
        this.isLoading = false
      }
    },

    // 다른 action 호출
    async refreshProducts() {
      await this.fetchProducts()
      // 추가 로직 수행
      console.log(`Loaded ${this.products.length} products`)
    }
  }
})

설명

이것이 하는 일: Actions는 비동기 작업을 수행하고, 그 결과를 state에 저장하며, 에러와 로딩 상태를 관리하여 컴포넌트가 UI만 담당하도록 책임을 분리합니다. 첫 번째로, fetchProducts action은 async로 정의되어 비동기 작업을 수행합니다.

시작 시 isLoading을 true로 설정하여 컴포넌트에서 로딩 스피너를 표시할 수 있게 하고, error를 null로 초기화하여 이전 에러를 제거합니다. 이 패턴은 모든 비동기 actions의 표준입니다.

그 다음으로, try 블록에서 실제 API 호출을 수행합니다. fetch로 데이터를 가져오고, response.ok를 확인하여 HTTP 에러를 처리하며, JSON을 파싱한 후 this.products에 직접 할당합니다.

Vuex처럼 commit을 할 필요가 없어 코드가 매우 간결합니다. catch 블록에서는 발생한 에러를 this.error에 저장합니다.

컴포넌트에서 productStore.error를 확인하여 에러 메시지를 사용자에게 보여줄 수 있습니다. finally 블록은 성공하든 실패하든 항상 실행되어 isLoading을 false로 설정하고, 로딩 상태를 종료합니다.

fetchProductById는 매개변수를 받는 action입니다. productId를 받아 특정 상품을 조회하고, 결과를 return하여 컴포넌트에서 직접 사용할 수도 있습니다.

또한 throw error로 에러를 다시 던져서, 컴포넌트에서 try-catch로 추가 처리를 할 수 있게 합니다. refreshProducts는 다른 action을 호출하는 예제로, await this.fetchProducts()로 기존 action을 재사용하고 추가 로직을 수행합니다.

여러분이 이 코드를 사용하면 컴포넌트는 매우 간단해집니다. onMounted에서 productStore.fetchProducts()만 호출하고, template에서 isLoading과 error를 확인하여 UI를 표시하면 됩니다.

모든 비즈니스 로직과 에러 처리가 store에 있어 테스트도 쉽고, 여러 컴포넌트에서 동일한 로직을 재사용할 수 있습니다.

실전 팁

💡 로딩과 에러 상태는 state에 저장하세요. 컴포넌트마다 로딩 상태를 관리하는 대신, store에서 일괄 관리하면 일관된 UX를 제공할 수 있고 코드 중복도 줄어듭니다.

💡 에러를 throw할지 저장할지 결정하세요. 컴포넌트에서 특별한 처리가 필요하면 throw하고, 일반적인 에러 메시지만 표시하면 되면 state에만 저장하면 됩니다.

💡 낙관적 업데이트(Optimistic Update) 패턴을 사용하세요. 좋아요 버튼 같은 경우, API 호출 전에 state를 먼저 변경하고, 실패하면 되돌리는 방식으로 사용자 경험을 향상시킬 수 있습니다.

💡 여러 API를 동시에 호출할 때는 Promise.all을 활용하세요. await Promise.all([this.fetchProducts(), this.fetchCategories()])처럼 병렬 호출하여 성능을 최적화할 수 있습니다.

💡 API 호출은 actions에만 작성하세요. getters나 state 초기화에서 API를 호출하면 예상치 못한 부작용이 발생할 수 있습니다. 모든 비동기 로직은 actions에 집중하세요.


7. Store 간 통신 - 여러 Store 조합하기

시작하며

여러분의 애플리케이션이 커지면서 하나의 store로는 모든 상태를 관리하기 어려워집니다. 사용자 정보, 상품 목록, 장바구니, 주문 내역 등 도메인별로 store를 분리하고 싶지만, 이들 간에 데이터를 어떻게 공유하시나요?

실제로 많은 개발자들이 store를 분리하면서 어려움을 겪습니다. 사용자 정보가 장바구니에 필요하고, 주문 시 상품과 사용자 정보를 모두 사용해야 하는데, 이를 어떻게 구조화할지 고민됩니다.

바로 이럴 때 필요한 것이 Store 간 통신 패턴입니다. 한 store에서 다른 store를 import하여 사용하고, getters와 actions에서 다른 store의 상태를 참조하며, 복잡한 비즈니스 로직을 여러 store로 분산할 수 있습니다.

개요

간단히 말해서, Store 간 통신은 하나의 store에서 다른 store의 useStore() 함수를 호출하여 상태와 메서드에 접근하는 것입니다. Store들은 독립적이면서도 협력합니다.

Pinia에서는 store 간 의존성이 자유롭게 허용됩니다. useUserStore()를 useCartStore()의 actions에서 호출하여 사용자 정보를 확인하거나, useOrderStore()에서 장바구니와 상품 정보를 조합할 수 있습니다.

예를 들어, 결제 시 사용자의 배송 주소(userStore), 장바구니 상품(cartStore), 할인 쿠폰(couponStore)을 모두 조합하여 주문을 생성할 수 있습니다. 기존에 하나의 거대한 store에 모든 것을 넣었다면, 이제는 도메인별로 분리하고 필요할 때만 연결하면 됩니다.

코드가 더 모듈화되고 유지보수가 쉬워집니다. Store 간 통신의 핵심 패턴은 세 가지입니다: actions에서 다른 store 사용, getters에서 다른 store 참조, 순환 참조 방지.

이들을 이해하면 복잡한 애플리케이션 구조도 깔끔하게 설계할 수 있습니다.

코드 예제

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

export const useUserStore = defineStore('user', {
  state: () => ({
    user: null,
    isLoggedIn: false
  }),

  getters: {
    isPremiumUser: (state) => state.user?.isPremium || false
  }
})

// stores/cart.js
import { defineStore } from 'pinia'
import { useUserStore } from './user'
import { useProductStore } from './product'

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

  getters: {
    // 다른 store의 getter 사용
    totalWithDiscount() {
      const userStore = useUserStore()
      const subtotal = this.items.reduce((sum, item) => sum + item.price * item.quantity, 0)

      // 프리미엄 사용자는 10% 할인
      if (userStore.isPremiumUser) {
        return subtotal * 0.9
      }
      return subtotal
    }
  },

  actions: {
    async addToCart(productId, quantity) {
      const userStore = useUserStore()

      // 로그인 확인
      if (!userStore.isLoggedIn) {
        throw new Error('Please login to add items to cart')
      }

      // 다른 store의 action 호출
      const productStore = useProductStore()
      const product = await productStore.fetchProductById(productId)

      // 장바구니에 추가
      this.items.push({
        id: productId,
        name: product.name,
        price: product.price,
        quantity
      })
    },

    async checkout() {
      const userStore = useUserStore()

      // 여러 store의 정보를 조합
      const orderData = {
        userId: userStore.user.id,
        items: this.items,
        total: this.totalWithDiscount,
        isPremium: userStore.isPremiumUser
      }

      // API 호출
      await fetch('/api/orders', {
        method: 'POST',
        body: JSON.stringify(orderData)
      })

      // 주문 완료 후 장바구니 초기화
      this.items = []
    }
  }
})

설명

이것이 하는 일: Store 간 통신은 여러 도메인의 상태를 조합하여 복잡한 비즈니스 로직을 구현하고, 각 store의 책임을 명확히 하며, 모듈화된 구조를 유지합니다. 첫 번째로, userStore를 정의합니다.

사용자 정보와 로그인 상태를 관리하며, isPremiumUser getter로 프리미엄 회원 여부를 제공합니다. 이 store는 다른 store에 의존하지 않는 기본 store입니다.

그 다음으로, cartStore에서 userStore를 import합니다. totalWithDiscount getter 내부에서 useUserStore()를 호출하여 사용자의 프리미엄 상태를 확인하고, 이에 따라 다른 할인율을 적용합니다.

getter에서 다른 store를 사용하는 전형적인 패턴입니다. addToCart action에서는 더 복잡한 조합이 이루어집니다.

먼저 userStore의 isLoggedIn을 확인하여 비로그인 사용자의 장바구니 추가를 막습니다. 그런 다음 productStore의 fetchProductById action을 호출하여 상품 정보를 가져온 후, this.items에 추가합니다.

여러 store의 actions를 순차적으로 호출하여 복잡한 플로우를 구현합니다. checkout action은 여러 store의 정보를 조합하는 완벽한 예제입니다.

userStore에서 사용자 ID와 프리미엄 상태를, cartStore에서 장바구니 아이템과 총액을 가져와 하나의 주문 객체를 만듭니다. API 호출 후 this.items를 초기화하여 장바구니를 비웁니다.

이처럼 각 store는 자신의 데이터를 관리하면서도 필요할 때 협력합니다. 여러분이 이 코드를 사용하면 각 store의 책임이 명확해집니다.

userStore는 사용자 정보만, cartStore는 장바구니만, productStore는 상품 정보만 관리합니다. 하지만 비즈니스 로직이 필요로 하는 곳에서는 자유롭게 조합하여 사용할 수 있습니다.

코드가 모듈화되어 테스트와 유지보수가 쉬워집니다.

실전 팁

💡 Store 호출은 actions나 getters 내부에서만 하세요. state 초기화나 모듈 최상위에서 호출하면 의존성 주입이 제대로 작동하지 않을 수 있습니다.

💡 순환 참조를 절대 만들지 마세요. Store A가 B를 참조하고 B가 다시 A를 참조하면 무한 루프나 초기화 오류가 발생합니다. 의존성 방향을 명확히 설계하세요.

💡 너무 많은 store 간 의존성은 피하세요. 한 store가 5개 이상의 다른 store를 참조한다면 설계를 재검토해야 합니다. 중간 레이어 store를 만들거나 composables로 로직을 추출하는 것을 고려하세요.

💡 Store 의존성은 단방향으로 유지하세요. 예를 들어 userStore ← cartStore ← orderStore처럼 계층 구조를 만들면 이해하기 쉽고 버그가 적습니다.

💡 공통 로직은 별도의 utils나 composables로 추출하세요. 여러 store에서 동일한 로직이 반복된다면 순수 함수나 composable로 만들어 재사용하면 store 간 의존성을 줄일 수 있습니다.


8. Pinia 플러그인 - 기능 확장하기

시작하며

여러분이 모든 state 변경을 로깅하거나, localStorage에 자동으로 저장하거나, API 호출 전후에 공통 로직을 실행하고 싶다면 어떻게 하시겠습니까? 각 store마다 동일한 코드를 반복해야 할까요?

실제로 많은 애플리케이션에서 공통 기능이 필요합니다. 모든 actions의 실행 시간을 측정하거나, state를 자동으로 persist하거나, DevTools와의 통합을 강화하는 등의 작업을 모든 store에 적용하고 싶습니다.

바로 이럴 때 필요한 것이 Pinia 플러그인입니다. Store 생성 시점에 자동으로 실행되어 기능을 추가하고, 모든 store에 공통 로직을 주입하며, actions를 intercept하여 before/after 훅을 실행할 수 있습니다.

개요

간단히 말해서, Pinia 플러그인은 모든 store에 공통 기능을 추가하는 함수입니다. store가 생성될 때마다 자동으로 호출되어 state, getters, actions를 확장하거나 수정합니다.

플러그인은 context 객체를 받아서 store, app, pinia, options에 접근할 수 있습니다. 여기서 새로운 속성을 추가하거나, actions를 래핑하거나, state 변경을 감지할 수 있습니다.

예를 들어, 모든 store의 state를 localStorage에 자동 저장하거나, API 에러를 전역적으로 처리하거나, 성능 모니터링을 추가하는 데 매우 유용합니다. 기존에 각 store에서 반복적으로 작성하던 코드를 플러그인으로 한 번만 작성하면, 모든 store에 자동으로 적용됩니다.

DRY(Don't Repeat Yourself) 원칙을 완벽하게 구현할 수 있습니다. 플러그인의 핵심 기능은 네 가지입니다: 새로운 속성 추가, actions 래핑, state 변경 감지($subscribe), 초기화 로직 실행.

이를 통해 Pinia를 프로젝트의 요구사항에 맞게 커스터마이징할 수 있습니다.

코드 예제

// plugins/pinia-logger.js
export function piniaLoggerPlugin({ store, options }) {
  // 모든 store에 새로운 속성 추가
  store.createdAt = new Date()

  // state 변경 감지
  store.$subscribe((mutation, state) => {
    console.log(`[${store.$id}] State changed:`, mutation.type)
    console.log('New state:', state)
  })

  // actions 실행 감지
  store.$onAction(({ name, args, after, onError }) => {
    const startTime = Date.now()
    console.log(`[${store.$id}] Action "${name}" called with:`, args)

    // action 성공 후
    after((result) => {
      const duration = Date.now() - startTime
      console.log(`[${store.$id}] Action "${name}" finished in ${duration}ms`)
    })

    // action 에러 시
    onError((error) => {
      console.error(`[${store.$id}] Action "${name}" failed:`, error)
    })
  })
}

// plugins/pinia-persist.js
export function piniaPersistPlugin({ store }) {
  // localStorage에서 state 복원
  const savedState = localStorage.getItem(`pinia-${store.$id}`)
  if (savedState) {
    store.$patch(JSON.parse(savedState))
  }

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

// main.js에서 플러그인 등록
import { createPinia } from 'pinia'
import { piniaLoggerPlugin } from './plugins/pinia-logger'
import { piniaPersistPlugin } from './plugins/pinia-persist'

const pinia = createPinia()
pinia.use(piniaLoggerPlugin)
pinia.use(piniaPersistPlugin)

app.use(pinia)

설명

이것이 하는 일: Pinia 플러그인은 모든 store의 생성 시점에 실행되어 공통 기능을 주입하고, state와 actions를 모니터링하며, 애플리케이션 전체의 상태관리를 통합적으로 관리합니다. 첫 번째로, piniaLoggerPlugin은 개발 환경에서 유용한 로깅 플러그인입니다.

각 store에 createdAt 속성을 추가하여 생성 시간을 기록합니다. 플러그인은 context 객체를 받으며, 여기서 store 인스턴스에 직접 접근할 수 있습니다.

그 다음으로, $subscribe 메서드로 state 변경을 감지합니다. mutation 객체에는 변경 타입(direct, patch object, patch function)과 변경된 state가 포함됩니다.

이를 콘솔에 로깅하여 디버깅을 쉽게 만듭니다. 모든 store의 state 변경이 자동으로 로깅되므로, 버그를 추적하기 훨씬 쉬워집니다.

$onAction 메서드는 actions 실행을 intercept합니다. name과 args로 어떤 action이 어떤 인자로 호출되었는지 알 수 있고, after 콜백으로 성공 시 로직을, onError 콜백으로 에러 처리를 할 수 있습니다.

실행 시간을 측정하여 성능 모니터링도 가능합니다. piniaPersistPlugin은 state를 localStorage에 자동 저장하는 플러그인입니다.

store 생성 시 localStorage에서 이전 state를 복원하고, $subscribe로 변경될 때마다 저장합니다. 이렇게 하면 페이지를 새로고침해도 state가 유지되어 사용자 경험이 향상됩니다.

pinia.$id를 키로 사용하여 각 store를 구분합니다. 여러분이 이 코드를 사용하면 main.js에서 pinia.use()로 플러그인을 등록하기만 하면 됩니다.

이후 생성되는 모든 store에 자동으로 로깅과 persist 기능이 적용됩니다. 각 store에서 반복적으로 코드를 작성할 필요가 없어 생산성이 크게 향상됩니다.

실전 팁

💡 개발 환경에서만 로깅 플러그인을 사용하세요. if (process.env.NODE_ENV === 'development') 조건으로 감싸서 프로덕션에서는 비활성화하면 성능 저하를 방지할 수 있습니다.

💡 민감한 데이터는 persist하지 마세요. 비밀번호, 토큰 같은 정보는 localStorage에 저장하면 보안 위험이 있습니다. 특정 store나 속성을 제외하는 로직을 추가하세요.

💡 플러그인 실행 순서에 주의하세요. pinia.use()로 등록한 순서대로 실행되므로, 의존성이 있는 플러그인은 순서를 고려해야 합니다.

💡 기존 플러그인을 활용하세요. pinia-plugin-persistedstate 같은 검증된 오픈소스 플러그인을 사용하면 바퀴를 재발명하지 않고 안정적인 기능을 바로 사용할 수 있습니다.

💡 플러그인에서 반환한 객체는 store에 병합됩니다. return { customMethod() {...} } 형태로 반환하면 모든 store에서 store.customMethod()로 호출할 수 있습니다.


9. TypeScript와 Pinia - 타입 안전한 상태관리

시작하며

여러분이 TypeScript를 사용하고 있다면, store의 state, getters, actions에 대한 완벽한 타입 추론을 원하실 겁니다. 자동완성이 제대로 작동하고, 잘못된 타입 사용 시 컴파일 에러가 발생하며, 리팩토링도 안전하게 할 수 있어야 하죠.

실제로 많은 TypeScript 프로젝트에서 상태관리 라이브러리의 타입 지원이 부족하면 any를 남발하게 되고, 타입 안전성을 잃어버립니다. 런타임 에러가 발생하기 전까지 버그를 발견하지 못하는 경우도 많습니다.

바로 이럴 때 알아야 할 것이 Pinia의 TypeScript 지원입니다. 별도의 타입 선언 없이도 자동으로 완벽한 타입 추론을 제공하며, interface를 사용하여 명시적인 타입을 정의할 수도 있고, Setup Store 방식에서는 더욱 강력한 타입 안전성을 제공합니다.

개요

간단히 말해서, Pinia는 TypeScript 친화적으로 설계되어 최소한의 타입 선언으로 완벽한 타입 안전성을 제공합니다. 자동 타입 추론이 뛰어나고, 필요시 명시적 타입 정의도 가능합니다.

Pinia는 처음부터 TypeScript를 염두에 두고 개발되었습니다. Options API 스타일에서는 state, getters, actions의 타입이 자동으로 추론되고, Setup Store 방식에서는 return 타입을 기반으로 더욱 정확한 추론이 이루어집니다.

예를 들어, state에 number 타입을 선언하면 actions에서 string을 할당하려 할 때 컴파일 에러가 발생합니다. 기존 Vuex는 타입 지원이 복잡하고 많은 보일러플레이트가 필요했다면, Pinia는 거의 자동으로 모든 것을 추론하여 개발 경험이 월등히 좋습니다.

TypeScript에서 Pinia 사용의 핵심은 네 가지입니다: 자동 타입 추론 활용, interface로 state 타입 정의, 제네릭으로 actions 타입 지정, 컴포넌트에서의 타입 안전한 사용. 이들을 마스터하면 런타임 에러를 대부분 컴파일 타임에 잡을 수 있습니다.

코드 예제

// stores/typed-user.ts
import { defineStore } from 'pinia'

// State 인터페이스 정의
interface User {
  id: number
  name: string
  email: string
  role: 'admin' | 'user'
}

interface UserState {
  currentUser: User | null
  users: User[]
  isLoading: boolean
}

export const useTypedUserStore = defineStore('typedUser', {
  state: (): UserState => ({
    currentUser: null,
    users: [],
    isLoading: false
  }),

  getters: {
    // 반환 타입 자동 추론
    isAdmin(): boolean {
      return this.currentUser?.role === 'admin'
    },

    // 명시적 타입 지정도 가능
    getUserById: (state) => {
      return (userId: number): User | undefined => {
        return state.users.find(user => user.id === userId)
      }
    }
  },

  actions: {
    // 매개변수와 반환 타입 지정
    async fetchUser(userId: number): Promise<User> {
      this.isLoading = true

      try {
        const response = await fetch(`/api/users/${userId}`)
        const user: User = await response.json()

        this.currentUser = user
        return user
      } finally {
        this.isLoading = false
      }
    },

    // TypeScript가 타입 오류 감지
    setUser(user: User) {
      // this.currentUser = "string"  // 컴파일 에러!
      this.currentUser = user  // 정상
    }
  }
})

// Setup Store 방식 (더 강력한 타입 추론)
export const useTypedUserStoreSetup = defineStore('typedUserSetup', () => {
  const currentUser = ref<User | null>(null)
  const users = ref<User[]>([])
  const isLoading = ref(false)

  const isAdmin = computed(() => currentUser.value?.role === 'admin')

  async function fetchUser(userId: number): Promise<User> {
    isLoading.value = true
    try {
      const response = await fetch(`/api/users/${userId}`)
      const user: User = await response.json()
      currentUser.value = user
      return user
    } finally {
      isLoading.value = false
    }
  }

  return { currentUser, users, isLoading, isAdmin, fetchUser }
})

설명

이것이 하는 일: TypeScript와 Pinia를 함께 사용하면 컴파일 타임에 타입 오류를 발견하고, IDE의 자동완성과 리팩토링 기능을 최대한 활용하며, 런타임 에러를 사전에 방지할 수 있습니다. 첫 번째로, User와 UserState interface를 정의합니다.

User는 사용자 객체의 구조를 정의하며, role은 문자열 리터럴 타입으로 'admin'과 'user'만 허용합니다. UserState는 store의 전체 state 타입을 정의하여, state 함수의 반환 타입으로 지정합니다.

그 다음으로, getters의 타입이 자동으로 추론됩니다. isAdmin getter는 boolean을 반환하는 것이 자동으로 추론되며, getUserById는 매개변수를 받는 함수를 반환하는 getter로, userId의 타입을 number로 명시하고 반환 타입은 User | undefined입니다.

존재하지 않을 수 있으므로 undefined를 포함합니다. actions에서도 타입을 명시할 수 있습니다.

fetchUser는 number 타입의 userId를 받아 Promise<User>를 반환합니다. async 함수이므로 자동으로 Promise로 래핑됩니다.

setUser에서 잘못된 타입을 할당하려 하면 TypeScript 컴파일러가 즉시 에러를 발생시킵니다. Setup Store 방식은 타입 추론이 더 강력합니다.

ref<User | null>(null)처럼 제네릭으로 타입을 지정하면, currentUser.value의 타입이 자동으로 User | null로 추론되고, IDE에서 User의 속성들이 자동완성됩니다. computed와 function도 일반 TypeScript 함수처럼 타입을 지정할 수 있어 더 자연스럽습니다.

여러분이 이 코드를 사용하면 컴포넌트에서 store를 사용할 때 완벽한 타입 지원을 받습니다. const userStore = useTypedUserStore()를 호출하면 userStore.currentUser의 타입이 User | null로 추론되고, userStore.fetchUser()는 number 타입의 인자를 요구하며, 반환 타입은 Promise<User>입니다.

잘못된 사용은 즉시 빨간 밑줄로 표시됩니다.

실전 팁

💡 state의 반환 타입을 명시적으로 지정하세요. state: (): UserState => 처럼 반환 타입을 지정하면 실수로 잘못된 속성을 추가하거나 타입을 잘못 지정하는 것을 방지할 수 있습니다.

💡 Setup Store가 타입 추론에 더 유리합니다. Options API 방식에서 this의 타입 추론이 복잡한 경우, Setup Store로 전환하면 일반 TypeScript 코드처럼 명확한 타입 추론을 받을 수 있습니다.

💡 API 응답 타입을 검증하세요. fetch로 받은 데이터는 실제로 User 타입이 아닐 수 있습니다. zod나 io-ts 같은 런타임 검증 라이브러리를 사용하여 타입 안전성을 보장하세요.

💡 strict 모드를 활성화하세요. tsconfig.json에서 "strict": true로 설정하면 null 체크, any 사용 등에 대한 엄격한 검사가 활성화되어 더 안전한 코드를 작성할 수 있습니다.

💡 타입 가드를 활용하세요. if (userStore.currentUser) 블록 안에서는 currentUser가 null이 아니라고 TypeScript가 추론하여, optional chaining 없이 속성에 접근할 수 있습니다.


10. Pinia DevTools 통합 - 디버깅과 시간여행

시작하며

여러분이 복잡한 상태 변경을 디버깅할 때, console.log만으로는 한계가 있습니다. 어떤 action이 언제 호출되었는지, state가 어떻게 변화했는지, 이전 상태로 되돌리고 싶을 때 어떻게 하시나요?

실제로 많은 개발자들이 상태관리 버그를 찾는 데 많은 시간을 소비합니다. 여러 컴포넌트와 actions가 복잡하게 얽혀 있으면 어디서 문제가 발생했는지 추적하기가 매우 어렵습니다.

바로 이럴 때 필요한 것이 Pinia DevTools 통합입니다. Vue DevTools에 Pinia 탭이 자동으로 생성되어 모든 store의 state를 실시간으로 보고, actions 실행 히스토리를 확인하며, 시간여행 디버깅으로 이전 상태로 되돌릴 수 있습니다.

개요

간단히 말해서, Pinia DevTools는 Vue DevTools 브라우저 확장에 통합된 강력한 디버깅 도구입니다. 모든 store를 시각화하고, 상태 변화를 추적하며, 시간여행 디버깅을 제공합니다.

Pinia는 Vue DevTools와 자동으로 통합되어 별도 설정 없이 바로 사용할 수 있습니다. Pinia 탭에서 모든 store의 현재 state를 트리 형태로 보고, 각 속성을 직접 수정할 수도 있습니다.

Timeline 탭에서는 actions와 state 변경이 시간순으로 기록되어 무엇이 언제 일어났는지 한눈에 파악할 수 있습니다. 예를 들어, 버튼을 클릭했을 때 어떤 순서로 actions가 실행되고 state가 변경되었는지 완벽하게 추적할 수 있습니다.

기존에 Redux DevTools를 사용해본 분들이라면 비슷한 경험을 제공한다고 생각하시면 됩니다. 하지만 Pinia는 Vue 생태계에 완벽히 통합되어 있어 더 seamless한 경험을 제공합니다.

DevTools의 핵심 기능은 네 가지입니다: State 검사 및 수정, Actions 히스토리, 시간여행 디버깅, 커스텀 디버깅 정보 추가. 이를 활용하면 개발 속도가 크게 향상되고 버그를 빠르게 찾을 수 있습니다.

코드 예제

// stores/debug-store.ts
import { defineStore } from 'pinia'

export const useDebugStore = defineStore('debug', {
  state: () => ({
    counter: 0,
    history: [] as string[]
  }),

  actions: {
    increment() {
      this.counter++
      this.history.push(`Incremented to ${this.counter}`)

      // DevTools에 커스텀 정보 추가
      if (import.meta.env.DEV) {
        console.log('[Debug Store] Counter incremented', {
          newValue: this.counter,
          timestamp: new Date().toISOString()
        })
      }
    },

    async fetchData() {
      this.history.push('Fetch started')

      try {
        // API 호출 시뮬레이션
        await new Promise(resolve => setTimeout(resolve, 1000))
        this.counter += 10
        this.history.push('Fetch completed')
      } catch (error) {
        this.history.push(`Fetch failed: ${error}`)
        throw error
      }
    },

    // DevTools에서 호출할 수 있는 디버깅 메서드
    resetState() {
      this.$reset()  // Pinia의 내장 메서드
      this.history = ['State reset']
    }
  }
})

// 개발 환경에서 디버깅 헬퍼 추가
if (import.meta.env.DEV) {
  // window에 store를 노출하여 콘솔에서 접근 가능
  import { useDebugStore } from './stores/debug-store'

  window.debugStore = () => {
    const store = useDebugStore()
    console.log('Current state:', store.$state)
    console.log('History:', store.history)
    return store
  }
}

설명

이것이 하는 일: Pinia DevTools는 애플리케이션의 상태 변화를 시각화하고, 디버깅을 쉽게 만들며, 시간여행 기능으로 버그를 재현하고 수정하는 과정을 단순화합니다. 첫 번째로, useDebugStore는 디버깅에 유용한 패턴을 보여줍니다.

counter와 history를 state로 관리하여 모든 변경 사항을 기록합니다. 이렇게 하면 DevTools에서 history 배열을 보면서 어떤 순서로 actions가 실행되었는지 확인할 수 있습니다.

그 다음으로, increment action에서 import.meta.env.DEV 조건으로 개발 환경에서만 추가 로깅을 수행합니다. 이 정보는 브라우저 콘솔에 표시되며, DevTools의 Timeline과 함께 보면 더 상세한 디버깅이 가능합니다.

프로덕션에서는 이 코드가 실행되지 않아 성능에 영향을 주지 않습니다. fetchData action은 비동기 작업의 각 단계를 history에 기록합니다.

'Fetch started', 'Fetch completed' 같은 로그를 보면 API 호출이 어느 시점에 시작되고 완료되었는지 명확히 알 수 있습니다. DevTools의 Timeline에서도 이 정보가 표시되어 비동기 플로우를 추적하기 쉽습니다.

resetState action은 $reset() 메서드를 호출하여 store를 초기 상태로 되돌립니다. 이는 Pinia가 제공하는 내장 메서드로, Options API 스타일의 store에서만 사용할 수 있습니다.

DevTools의 버튼으로도 실행할 수 있지만, action으로 정의하면 추가 로직을 함께 실행할 수 있습니다. 개발 환경에서 window.debugStore를 노출하는 패턴도 유용합니다.

브라우저 콘솔에서 debugStore()를 호출하면 현재 store의 상태와 히스토리를 즉시 확인할 수 있습니다. store 인스턴스를 반환하므로 debugStore().increment() 같은 방식으로 직접 조작도 가능합니다.

여러분이 DevTools를 사용하면 버그를 찾는 시간이 크게 줄어듭니다. Pinia 탭에서 모든 store의 state를 한눈에 보고, 잘못된 값이 있으면 직접 수정하여 UI 반응을 확인할 수 있습니다.

Timeline에서 actions를 클릭하면 해당 시점의 state로 시간여행하여 버그가 발생한 순간을 정확히 파악할 수 있습니다.

실전 팁

💡 Vue DevTools 확장을 최신 버전으로 유지하세요. Pinia 지원은 DevTools 6.0 이상에서 제공되며, 최신 버전일수록 더 많은 기능과 안정성을 제공합니다.

💡 Timeline 필터를 활용하세요. 특정 store나 특정 타입의 이벤트만 보도록 필터링하면 복잡한 애플리케이션에서도 원하는 정보를 빠르게 찾을 수 있습니다.

💡 State를 직접 수정하여 엣지 케이스를 테스트하세요. DevTools에서 state 값을 임의로 변경하면 비정상적인 상태에서 컴포넌트가 어떻게 동작하는지 쉽게 확인할 수 있습니다.

💡 프로덕션 빌드에서는 DevTools가 자동으로 비활성화됩니다. 별도 설정 없이도 성능 오버헤드가 없으므로 안심하고 개발 중에 활용하세요.

💡 $reset()은 Setup Store에서 작동하지 않습니다. Setup Store를 사용한다면 초기 state를 별도로 저장해두고 수동으로 reset 로직을 구현해야 합니다.


#Vue#Pinia#StateManagement#Store#Composition#중급

댓글 (0)

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