이미지 로딩 중...

Pinia 상태관리 완벽 가이드 실전편 - 슬라이드 1/9
A

AI Generated

2025. 11. 13. · 4 Views

Pinia 상태관리 완벽 가이드 실전편

Vue 3의 공식 상태관리 라이브러리인 Pinia를 실무에서 효과적으로 활용하는 방법을 배웁니다. 스토어 구조 설계부터 고급 패턴까지, 실전 예제로 배우는 Pinia 완전 정복 가이드입니다. 초급 개발자도 쉽게 따라할 수 있도록 구성했습니다.


목차

  1. Pinia 스토어 기본 구조 - 스토어 생성과 상태 정의하기
  2. 컴포넌트에서 스토어 사용하기 - 상태 읽기와 반응성 유지
  3. Actions로 비동기 작업 처리하기 - API 호출과 에러 핸들링
  4. Getters로 계산된 값 활용하기 - 파생 상태와 필터링
  5. 여러 스토어 조합하기 - 스토어 간 통신과 의존성
  6. Composition API 스타일 스토어 - Setup Store 패턴
  7. 플러그인으로 스토어 확장하기 - 로깅과 영속성
  8. 스토어 상태 초기화와 리셋 - $reset과 $patch 활용

1. Pinia 스토어 기본 구조 - 스토어 생성과 상태 정의하기

시작하며

여러분이 Vue 3 애플리케이션을 개발하면서 여러 컴포넌트에서 동일한 데이터를 공유해야 하는 상황을 겪어본 적 있나요? 예를 들어, 사용자 정보를 헤더, 사이드바, 프로필 페이지에서 모두 사용해야 한다면 어떻게 해야 할까요?

이런 문제는 실제 개발 현장에서 자주 발생합니다. Props를 여러 단계로 전달하다 보면 코드가 복잡해지고, 각 컴포넌트에서 개별적으로 API를 호출하면 불필요한 네트워크 요청이 발생합니다.

또한 데이터 동기화 문제로 인해 버그가 발생하기 쉽습니다. 바로 이럴 때 필요한 것이 Pinia 스토어입니다.

Pinia는 Vue 3의 공식 상태관리 라이브러리로, 애플리케이션 전체에서 공유되는 데이터를 중앙에서 관리할 수 있게 해줍니다. 컴포넌트 어디서든 스토어에 접근하여 데이터를 읽고 수정할 수 있어, 복잡한 Props 전달 없이도 깔끔하게 상태를 관리할 수 있습니다.

개요

간단히 말해서, Pinia 스토어는 애플리케이션의 전역 상태를 저장하고 관리하는 중앙 저장소입니다. 마치 데이터베이스처럼 필요한 데이터를 저장하고, 필요할 때 꺼내 쓸 수 있는 공간이라고 생각하면 됩니다.

왜 이 개념이 필요한지 실무 관점에서 설명하자면, 대규모 애플리케이션에서는 수십 개의 컴포넌트가 동일한 데이터를 필요로 합니다. 사용자 인증 정보, 장바구니 데이터, 알림 목록 등을 모든 컴포넌트에서 접근할 수 있어야 하는데, Pinia 없이는 이를 효율적으로 관리하기 어렵습니다.

예를 들어, 사용자가 로그인하면 헤더의 사용자 이름, 사이드바의 프로필 이미지, 설정 페이지의 사용자 정보가 모두 동시에 업데이트되어야 하는 경우에 매우 유용합니다. 전통적인 방법으로는 Vuex를 사용하거나 이벤트 버스를 활용했다면, 이제는 Pinia를 통해 더 간결하고 타입 안전한 코드를 작성할 수 있습니다.

Vuex에 비해 보일러플레이트 코드가 훨씬 적고, Composition API와 자연스럽게 통합됩니다. Pinia 스토어의 핵심 특징은 세 가지입니다.

첫째, state(상태)로 데이터를 저장하고, 둘째, getters(게터)로 계산된 값을 제공하며, 셋째, actions(액션)으로 상태를 변경합니다. 이러한 구조는 코드의 역할을 명확히 분리하여 유지보수를 쉽게 만들고, 팀 협업 시 일관된 코드 스타일을 유지할 수 있게 해줍니다.

코드 예제

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

export const useUserStore = defineStore('user', {
  // 상태 정의 - 스토어의 데이터를 저장합니다
  state: () => ({
    id: null,
    name: '',
    email: '',
    role: 'guest',
    isLoggedIn: false
  }),

  // 계산된 값 - state를 기반으로 파생된 데이터를 제공합니다
  getters: {
    fullInfo: (state) => `${state.name} (${state.email})`,
    isAdmin: (state) => state.role === 'admin'
  },

  // 액션 - 상태를 변경하는 메서드입니다
  actions: {
    login(userData) {
      this.id = userData.id
      this.name = userData.name
      this.email = userData.email
      this.role = userData.role
      this.isLoggedIn = true
    },
    logout() {
      this.$reset() // 초기 상태로 리셋
    }
  }
})

설명

이것이 하는 일: 위 코드는 사용자 정보를 관리하는 Pinia 스토어를 생성합니다. 사용자의 로그인 상태, 개인 정보, 권한 등을 중앙에서 관리하여 애플리케이션 전체에서 일관되게 사용할 수 있도록 합니다.

첫 번째로, defineStore 함수로 스토어를 정의합니다. 첫 번째 인자 'user'는 스토어의 고유 ID로, Pinia가 내부적으로 이 스토어를 식별하는 데 사용합니다.

이 ID는 개발자 도구에서도 표시되므로 디버깅에 유용합니다. 두 번째 인자는 스토어의 옵션 객체로, state, getters, actions를 정의합니다.

state는 함수 형태로 정의하며, 반환하는 객체가 실제 상태 데이터입니다. 함수로 정의하는 이유는 각 스토어 인스턴스가 독립된 상태를 가지기 위함입니다.

SSR(서버 사이드 렌더링) 환경에서 특히 중요한데, 여러 사용자의 요청이 동시에 처리될 때 상태가 섞이지 않도록 보장합니다. id, name, email 같은 기본 정보와 isLoggedIn 같은 플래그를 저장합니다.

getters는 Vue의 computed와 유사하게 동작합니다. fullInfo 게터는 이름과 이메일을 조합하여 표시용 문자열을 생성하고, isAdmin은 권한을 검사합니다.

게터는 자동으로 캐싱되므로 state가 변경되지 않으면 다시 계산하지 않아 성능이 좋습니다. 또한 게터는 다른 게터를 참조할 수도 있어 복잡한 계산 로직을 단계별로 구성할 수 있습니다.

actions는 상태를 변경하는 메서드입니다. login 액션은 사용자 데이터를 받아 state를 업데이트하고, logout 액션은 $reset()을 호출하여 초기 상태로 되돌립니다.

액션 내부에서 this를 통해 다른 state, getters, actions에 접근할 수 있습니다. 또한 액션은 비동기 작업을 수행할 수 있어 API 호출 같은 작업을 처리하기 적합합니다.

여러분이 이 코드를 사용하면 컴포넌트 어디서든 useUserStore()를 호출하여 사용자 정보에 접근할 수 있습니다. 로그인 처리, 권한 검사, 사용자 정보 표시 등을 일관되게 처리할 수 있어 코드 중복이 줄어들고 유지보수가 쉬워집니다.

또한 개발자 도구에서 상태 변화를 추적할 수 있어 디버깅이 훨씬 편리합니다.

실전 팁

💡 스토어 ID는 애플리케이션 전체에서 고유해야 합니다. 'user', 'cart', 'notification' 같이 명확하고 설명적인 이름을 사용하세요. 중복된 ID를 사용하면 런타임 에러가 발생합니다.

💡 state를 직접 객체로 정의하지 말고 반드시 함수로 정의하세요. state: { name: '' }가 아닌 state: () => ({ name: '' }) 형태로 작성해야 SSR 환경에서 안전합니다.

💡 복잡한 계산 로직은 actions가 아닌 getters에 작성하세요. getters는 자동으로 캐싱되어 성능이 좋고, 리액티브하게 업데이트됩니다.

💡 $reset()은 state를 초기값으로 되돌리는 편리한 메서드입니다. 로그아웃이나 폼 초기화 시 유용하게 사용할 수 있습니다.

💡 스토어 파일은 stores 폴더에 기능별로 분리하여 관리하세요. 하나의 스토어가 너무 커지면 관련 기능끼리 묶어 여러 스토어로 분리하는 것이 좋습니다.


2. 컴포넌트에서 스토어 사용하기 - 상태 읽기와 반응성 유지

시작하며

여러분이 Pinia 스토어를 만들었다면, 이제 실제 컴포넌트에서 어떻게 사용하는지 궁금하실 겁니다. 특히 Vue의 반응성 시스템이 제대로 작동하도록 하려면 어떻게 해야 할까요?

이 부분에서 많은 개발자가 실수를 합니다. 스토어의 state를 구조 분해 할당으로 가져오면 반응성이 깨지는 문제가 발생합니다.

예를 들어, const { name } = userStore 같은 코드는 스토어의 name 값이 변경되어도 컴포넌트가 업데이트되지 않습니다. 바로 이럴 때 필요한 것이 storeToRefs입니다.

Pinia가 제공하는 이 헬퍼 함수는 스토어의 state와 getters를 반응형 ref로 변환하여, 구조 분해 할당을 해도 반응성을 유지할 수 있게 해줍니다. 이를 통해 편리하게 스토어를 사용하면서도 Vue의 반응성 시스템을 완벽하게 활용할 수 있습니다.

개요

간단히 말해서, 컴포넌트에서 스토어를 사용하려면 useXxxStore 함수를 호출하고, 반응성을 유지하려면 storeToRefs를 사용해야 합니다. 이는 Vue 3의 Composition API 패턴과 자연스럽게 통합됩니다.

왜 이 개념이 필요한지 실무 관점에서 설명하자면, 대부분의 컴포넌트는 스토어의 여러 속성을 동시에 사용합니다. 매번 userStore.name, userStore.email처럼 접근하면 코드가 장황해지고 가독성이 떨어집니다.

구조 분해 할당으로 const { name, email } = ... 형태로 사용하면 훨씬 깔끔하지만, 반응성을 잃어버리는 문제가 있습니다.

예를 들어, 사용자 프로필 컴포넌트에서 여러 사용자 정보를 표시하고, 로그인 상태에 따라 다른 UI를 보여줘야 하는 경우 반응성이 필수적입니다. 전통적인 Vuex에서는 computed를 사용하여 mapState나 mapGetters로 변환했다면, Pinia에서는 storeToRefs를 통해 더 간단하고 직관적으로 처리할 수 있습니다.

TypeScript를 사용하면 타입 추론도 완벽하게 지원됩니다. 핵심 특징은 세 가지입니다.

첫째, storeToRefs는 state와 getters만 ref로 변환하고 actions는 그대로 둡니다. 둘째, 반응성을 유지하면서도 구조 분해 할당의 편리함을 제공합니다.

셋째, TypeScript 환경에서 완벽한 타입 안전성을 보장합니다. 이러한 특징들이 개발 경험을 크게 향상시키고 버그를 예방합니다.

코드 예제

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

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

// state와 getters를 반응형 ref로 변환 (반응성 유지)
const { name, email, isLoggedIn, fullInfo, isAdmin } = storeToRefs(userStore)

// actions는 구조 분해 할당으로 직접 가져오기 (함수는 반응성 불필요)
const { login, logout } = userStore

// 로그인 처리 예시
const handleLogin = async () => {
  const userData = await fetchUserData() // API 호출
  login(userData) // 액션 실행
}

// 컴포넌트에서 사용 - name.value로 접근
console.log(name.value) // 'John Doe'
console.log(isAdmin.value) // false
</script>

<template>
  <div v-if="isLoggedIn">
    <h1>Welcome, {{ name }}</h1>
    <p>{{ email }}</p>
    <p>{{ fullInfo }}</p>
    <button v-if="isAdmin">Admin Panel</button>
    <button @click="logout">Logout</button>
  </div>
</template>

설명

이것이 하는 일: 위 코드는 컴포넌트에서 Pinia 스토어를 가져와 반응성을 유지하면서 편리하게 사용하는 방법을 보여줍니다. storeToRefs를 사용하여 state와 getters를 안전하게 구조 분해하고, actions는 직접 가져와 호출합니다.

첫 번째로, useUserStore()를 호출하여 스토어 인스턴스를 가져옵니다. 이 함수는 Pinia가 자동으로 생성한 컴포저블로, 호출할 때마다 동일한 스토어 인스턴스를 반환합니다.

즉, 여러 컴포넌트에서 useUserStore()를 호출해도 모두 같은 스토어를 공유하므로 데이터가 동기화됩니다. setup 함수 또는 script setup 블록 내에서만 호출할 수 있습니다.

그 다음으로, storeToRefs(userStore)를 사용하여 state와 getters를 반응형 ref로 변환합니다. 이 함수는 내부적으로 toRef를 사용하여 각 속성을 개별 ref로 변환하되, 원본 스토어와의 연결을 유지합니다.

따라서 스토어의 state가 변경되면 이 ref들도 자동으로 업데이트됩니다. 중요한 점은 storeToRefs는 state와 getters만 변환하고, actions 같은 함수는 제외한다는 것입니다.

actions는 별도로 스토어에서 직접 구조 분해합니다. 함수는 반응성이 필요 없으므로 storeToRefs를 거치지 않아도 됩니다.

const { login, logout } = userStore 형태로 가져오면, 이 함수들은 자동으로 스토어의 컨텍스트(this)에 바인딩되어 있어 그대로 호출할 수 있습니다. handleLogin 같은 헬퍼 함수에서 login()을 호출하면 스토어의 상태가 변경됩니다.

템플릿에서 사용할 때는 ref이므로 자동으로 언래핑됩니다. {{ name }}처럼 .value 없이 바로 사용할 수 있습니다.

하지만 script 블록에서는 name.value로 접근해야 합니다. v-if="isLoggedIn"처럼 조건부 렌더링에 사용하면, isLoggedIn 값이 변경될 때 자동으로 UI가 업데이트됩니다.

여러분이 이 패턴을 사용하면 코드가 훨씬 깔끔해집니다. userStore.name 대신 name으로 접근할 수 있고, 반응성도 완벽하게 유지됩니다.

특히 여러 속성을 사용하는 복잡한 컴포넌트에서 코드 가독성이 크게 향상됩니다. TypeScript를 사용하면 자동 완성과 타입 검사도 완벽하게 지원되어 개발 생산성이 높아집니다.

실전 팁

💡 storeToRefs 없이 구조 분해하면 반응성이 깨집니다. const { name } = userStore는 초기값만 복사하므로 절대 사용하지 마세요. 항상 storeToRefs를 거쳐야 합니다.

💡 actions는 storeToRefs로 감싸면 안 됩니다. 함수는 반응성이 필요 없고, storeToRefs로 감싸면 실제로 제외되므로 직접 구조 분해하세요.

💡 script 블록에서는 name.value로 접근하지만, template에서는 name만 사용합니다. Vue가 자동으로 언래핑해주기 때문입니다.

💡 여러 스토어를 사용할 때는 각각 별도로 storeToRefs를 호출하세요. const { name } = storeToRefs(userStore)와 const { items } = storeToRefs(cartStore)처럼 분리합니다.

💡 computed나 watch에서 스토어의 값을 사용할 때도 storeToRefs로 가져온 ref를 사용하면 자동으로 의존성이 추적됩니다.


3. Actions로 비동기 작업 처리하기 - API 호출과 에러 핸들링

시작하며

여러분이 실제 애플리케이션을 개발하다 보면 대부분의 데이터는 API에서 가져옵니다. 사용자 정보를 불러오거나, 상품 목록을 조회하거나, 폼 데이터를 서버에 전송하는 등의 작업이 필수적입니다.

이런 비동기 작업을 각 컴포넌트에서 개별적으로 처리하면 문제가 발생합니다. 같은 API를 여러 곳에서 중복 호출하게 되고, 로딩 상태나 에러 처리 로직이 곳곳에 흩어져 유지보수가 어려워집니다.

또한 API 응답 데이터의 형식이 변경되면 모든 컴포넌트를 수정해야 하는 번거로움이 있습니다. 바로 이럴 때 필요한 것이 Pinia actions에서의 비동기 작업 처리입니다.

actions는 async/await를 완벽하게 지원하여 API 호출, 로딩 상태 관리, 에러 처리를 한 곳에서 일관되게 처리할 수 있습니다. 이를 통해 컴포넌트는 단순히 액션을 호출하기만 하면 되고, 복잡한 로직은 스토어에서 관리할 수 있습니다.

개요

간단히 말해서, Pinia actions는 비동기 함수로 정의할 수 있으며, API 호출과 상태 업데이트를 한 곳에서 처리할 수 있습니다. async/await 문법을 사용하여 비동기 작업을 동기 코드처럼 작성할 수 있습니다.

왜 이 개념이 필요한지 실무 관점에서 설명하자면, 현대 웹 애플리케이션은 대부분 데이터를 서버에서 가져옵니다. 로그인, 데이터 조회, 폼 제출 등 거의 모든 기능이 API 통신을 필요로 합니다.

이런 비동기 작업을 스토어에서 관리하면 컴포넌트가 훨씬 간결해지고, 로딩 상태나 에러 메시지 같은 부가 정보도 체계적으로 관리할 수 있습니다. 예를 들어, 상품 목록을 불러오는 경우 로딩 스피너를 보여주고, 에러 발생 시 사용자 친화적인 메시지를 표시하고, 성공 시 데이터를 화면에 렌더링하는 모든 과정을 스토어에서 제어할 수 있습니다.

전통적인 방법으로는 컴포넌트의 onMounted에서 직접 API를 호출했다면, 이제는 스토어의 액션을 호출하기만 하면 됩니다. 이렇게 하면 같은 데이터를 필요로 하는 여러 컴포넌트에서 코드를 재사용할 수 있고, 캐싱 전략도 쉽게 구현할 수 있습니다.

핵심 특징은 세 가지입니다. 첫째, actions는 async 함수로 정의하여 자연스럽게 비동기 작업을 처리합니다.

둘째, 로딩 상태와 에러 상태를 state로 관리하여 UI에 반영할 수 있습니다. 셋째, try-catch로 에러를 안전하게 처리하고 사용자에게 피드백을 제공할 수 있습니다.

이러한 패턴들이 안정적인 애플리케이션을 만드는 기반이 됩니다.

코드 예제

// stores/product.js
import { defineStore } from 'pinia'
import { fetchProducts } from '@/api/products'

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

  actions: {
    // 비동기 액션 - API에서 상품 목록 가져오기
    async loadProducts() {
      this.isLoading = true
      this.error = null

      try {
        // API 호출 대기
        const response = await fetchProducts()
        this.products = response.data
        return response.data // 성공 시 데이터 반환
      } catch (err) {
        // 에러 처리 및 저장
        this.error = err.message || 'Failed to load products'
        console.error('Load products error:', err)
        throw err // 컴포넌트에서 추가 처리 가능하도록 재발생
      } finally {
        // 로딩 상태는 항상 false로 변경
        this.isLoading = false
      }
    }
  }
})

설명

이것이 하는 일: 위 코드는 API에서 상품 목록을 가져오는 비동기 액션을 구현합니다. 로딩 상태, 에러 처리, 데이터 저장을 모두 포함하여 실무에서 바로 사용할 수 있는 완전한 패턴을 보여줍니다.

첫 번째로, state에 세 가지 핵심 상태를 정의합니다. products는 실제 상품 데이터를 저장하고, isLoading은 API 호출 중인지 표시하며, error는 발생한 에러 메시지를 저장합니다.

이 세 가지는 비동기 작업을 다룰 때 거의 항상 필요한 상태들입니다. UI에서는 isLoading이 true일 때 스피너를 표시하고, error가 있으면 에러 메시지를 보여주고, products가 채워지면 목록을 렌더링합니다.

loadProducts 액션은 async 함수로 정의되어 있습니다. 액션이 호출되면 가장 먼저 isLoading을 true로 설정하고 기존 에러를 초기화합니다.

이렇게 하면 이전 호출의 에러가 남아있지 않고, 사용자에게 현재 로딩 중임을 알릴 수 있습니다. 여러 번 호출되어도 항상 깨끗한 상태에서 시작하는 것이 중요합니다.

try 블록에서는 실제 API 호출이 일어납니다. await fetchProducts()는 API 응답을 기다렸다가 결과를 받습니다.

성공하면 this.products에 데이터를 저장하고, 필요한 경우 가공하여 저장할 수도 있습니다. 예를 들어, response.data.filter(...) 같은 후처리를 추가할 수 있습니다.

액션에서 데이터를 반환하면 호출한 컴포넌트에서 추가 처리가 가능합니다. catch 블록은 API 호출이 실패했을 때 실행됩니다.

네트워크 오류, 서버 에러, 타임아웃 등 모든 종류의 에러를 여기서 처리합니다. err.message를 this.error에 저장하여 UI에서 사용자에게 보여줄 수 있고, console.error로 개발 중 디버깅 정보를 남깁니다.

throw err로 에러를 다시 발생시키면 컴포넌트에서 추가 처리가 가능합니다. 예를 들어, 특정 에러 코드에 따라 다른 동작을 하려면 컴포넌트에서 catch할 수 있습니다.

finally 블록은 성공하든 실패하든 항상 실행됩니다. 여기서 isLoading을 false로 설정하여 로딩 상태를 해제합니다.

finally를 사용하는 이유는 try에서 return하거나 catch에서 throw해도 반드시 실행되기 때문입니다. 이를 빠뜨리면 에러 발생 시 로딩 스피너가 계속 돌아가는 버그가 생길 수 있습니다.

여러분이 이 패턴을 사용하면 모든 비동기 작업을 일관되게 처리할 수 있습니다. 로그인, 데이터 조회, 폼 제출 등 어떤 API 호출이든 이 구조를 따르면 안정적으로 동작합니다.

컴포넌트는 단순히 loadProducts()를 호출하고 isLoading과 error를 보고 UI를 렌더링하기만 하면 됩니다. 이렇게 관심사를 분리하면 테스트도 쉬워지고, 버그도 줄어듭니다.

실전 팁

💡 항상 try-catch-finally 패턴을 사용하세요. try로 정상 흐름을 처리하고, catch로 에러를 잡고, finally로 로딩 상태를 정리합니다.

💡 에러를 throw하면 컴포넌트에서 추가 처리가 가능합니다. 단순히 로그만 남기고 싶다면 throw하지 않고, 특정 에러에 대해 추가 동작이 필요하면 throw하세요.

💡 액션이 시작될 때 error를 null로 초기화하여 이전 에러가 남아있지 않도록 하세요. 그렇지 않으면 이전 호출의 에러 메시지가 계속 표시됩니다.

💡 API 응답 데이터를 그대로 저장하기보다는 액션에서 필요한 형태로 가공하여 저장하세요. 예: this.products = response.data.map(p => ({ ...p, formattedPrice: formatPrice(p.price) }))

💡 여러 액션에서 공통으로 사용하는 에러 처리 로직은 별도 헬퍼 함수로 분리하거나, Axios 인터셉터 같은 전역 에러 핸들러를 사용하세요.


4. Getters로 계산된 값 활용하기 - 파생 상태와 필터링

시작하며

여러분이 스토어에 저장된 원본 데이터를 그대로 사용하는 것만으로는 충분하지 않을 때가 많습니다. 예를 들어, 상품 목록에서 할인 중인 상품만 보여주거나, 장바구니의 총 금액을 계산하거나, 사용자 이름을 포맷팅해야 하는 경우가 있습니다.

이런 계산 로직을 매번 컴포넌트에서 작성하면 코드가 중복되고 일관성이 깨집니다. 같은 계산을 여러 컴포넌트에서 반복하면 로직이 조금씩 달라져 버그가 발생하기 쉽고, 계산 방식을 변경할 때 모든 곳을 찾아서 수정해야 하는 문제가 있습니다.

바로 이럴 때 필요한 것이 Pinia getters입니다. Getters는 Vue의 computed처럼 state를 기반으로 파생된 값을 계산하고, 자동으로 캐싱하여 불필요한 재계산을 방지합니다.

스토어에서 계산 로직을 한 번만 정의하면 모든 컴포넌트에서 일관되게 사용할 수 있습니다.

개요

간단히 말해서, getters는 state를 기반으로 계산된 값을 반환하는 함수입니다. Vue의 computed 속성과 유사하게 동작하며, 의존하는 state가 변경될 때만 재계산됩니다.

왜 이 개념이 필요한지 실무 관점에서 설명하자면, 대부분의 애플리케이션에서 원본 데이터만으로는 UI를 구성하기 어렵습니다. 총합 계산, 필터링, 정렬, 포맷팅 등 다양한 가공이 필요합니다.

이런 로직을 getters에 집중시키면 컴포넌트가 훨씬 간결해지고, 계산 로직을 재사용할 수 있습니다. 예를 들어, 전자상거래 사이트의 장바구니에서 각 상품의 소계, 전체 금액, 할인 적용 금액, 배송비 포함 최종 금액 등을 계산하는 경우 getters로 정의하면 모든 페이지에서 일관되게 사용할 수 있습니다.

전통적인 방법으로는 컴포넌트의 computed에서 계산했다면, 이제는 스토어의 getters로 옮겨 여러 컴포넌트에서 공유할 수 있습니다. Getters는 다른 getters를 참조할 수도 있어 복잡한 계산을 단계별로 나눌 수 있습니다.

핵심 특징은 세 가지입니다. 첫째, 자동 캐싱으로 성능이 우수합니다.

의존하는 state가 변경되지 않으면 이전 결과를 재사용합니다. 둘째, 다른 getters를 참조하여 복잡한 계산을 구성할 수 있습니다.

셋째, 함수를 반환하는 getter를 만들어 인자를 받을 수도 있습니다. 이러한 특징들이 코드를 유지보수하기 쉽고 성능 좋게 만듭니다.

코드 예제

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

export const useCartStore = defineStore('cart', {
  state: () => ({
    items: [
      { id: 1, name: 'Laptop', price: 1000, quantity: 1 },
      { id: 2, name: 'Mouse', price: 50, quantity: 2 },
      { id: 3, name: 'Keyboard', price: 100, quantity: 1 }
    ],
    discountRate: 0.1 // 10% 할인
  }),

  getters: {
    // 장바구니 총 금액 계산
    totalAmount: (state) => {
      return state.items.reduce((sum, item) => sum + item.price * item.quantity, 0)
    },

    // 할인 금액 계산 - 다른 getter 참조
    discountAmount() {
      return this.totalAmount * this.discountRate
    },

    // 최종 금액 계산
    finalAmount() {
      return this.totalAmount - this.discountAmount
    },

    // 상품 개수
    totalItems: (state) => state.items.length,

    // 특정 상품 찾기 - 인자를 받는 getter
    getItemById: (state) => {
      return (id) => state.items.find(item => item.id === id)
    }
  }
})

설명

이것이 하는 일: 위 코드는 장바구니 스토어에서 다양한 계산 로직을 getters로 구현합니다. 총 금액, 할인 금액, 최종 금액 등을 계산하고, 특정 상품을 찾는 기능까지 제공합니다.

첫 번째로, totalAmount getter는 장바구니의 모든 상품 금액을 합산합니다. state 매개변수를 통해 스토어의 state에 접근하고, reduce 메서드로 각 상품의 가격과 수량을 곱한 값을 모두 더합니다.

이 getter는 items 배열이나 각 item의 price, quantity가 변경될 때만 재계산되고, 그렇지 않으면 캐싱된 값을 반환합니다. 따라서 여러 컴포넌트에서 totalAmount를 참조해도 한 번만 계산됩니다.

discountAmount getter는 할인 금액을 계산하는데, 다른 getter인 totalAmount를 참조합니다. 화살표 함수 대신 일반 함수로 정의하여 this로 스토어의 다른 getters와 state에 접근할 수 있습니다.

this.totalAmount는 totalAmount getter의 결과값이고, this.discountRate는 state의 값입니다. 이렇게 getters를 조합하면 복잡한 계산을 단계별로 나누어 가독성을 높일 수 있습니다.

finalAmount getter는 최종 결제 금액을 계산합니다. totalAmount에서 discountAmount를 빼는 간단한 로직이지만, 이를 getter로 정의하면 모든 컴포넌트에서 일관되게 사용할 수 있고, 나중에 배송비 같은 추가 계산이 필요하면 이 getter만 수정하면 됩니다.

이것이 바로 중앙 집중식 로직 관리의 장점입니다. totalItems는 단순히 상품 개수를 반환합니다.

이런 간단한 계산도 getter로 만들면 의미가 명확해지고, 나중에 로직이 복잡해져도 (예: 특정 조건의 상품만 세기) 쉽게 수정할 수 있습니다. getItemById는 인자를 받는 특별한 형태의 getter입니다.

함수를 반환하는 getter로, 호출할 때 id를 전달하면 해당 상품을 찾아줍니다. 사용할 때는 cartStore.getItemById(1) 형태로 호출합니다.

주의할 점은 이런 형태의 getter는 캐싱되지 않는다는 것입니다. 매번 호출될 때마다 새로운 함수가 생성되므로 자주 호출되는 경우 성능을 고려해야 합니다.

여러분이 이런 getters를 활용하면 컴포넌트에서 복잡한 계산 로직을 작성할 필요가 없습니다. 단순히 cartStore.totalAmount, cartStore.finalAmount 같이 접근하기만 하면 항상 최신 계산 결과를 얻을 수 있습니다.

또한 계산 로직을 변경하거나 버그를 수정할 때 스토어의 getter만 수정하면 모든 컴포넌트에 자동으로 반영됩니다. 테스트도 스토어만 테스트하면 되므로 훨씬 간단해집니다.

실전 팁

💡 다른 getters를 참조하려면 화살표 함수 대신 일반 함수로 정의하고 this를 사용하세요. 화살표 함수에서는 this가 스토어를 가리키지 않습니다.

💡 복잡한 계산은 여러 getters로 나누세요. totalAmount, discountAmount, finalAmount처럼 단계별로 나누면 코드를 이해하고 테스트하기 쉽습니다.

💡 인자를 받는 getter는 캐싱되지 않으므로 자주 호출되면 성능 문제가 있을 수 있습니다. 대신 computed에서 filter를 사용하는 것을 고려하세요.

💡 Getters는 부수 효과(side effect)가 없어야 합니다. state를 변경하거나 API를 호출하면 안 되고, 순수하게 계산만 해야 합니다.

💡 TypeScript를 사용한다면 getter의 반환 타입을 명시하면 자동 완성이 더 정확해집니다. totalAmount(): number { ... } 형태로 작성하세요.


5. 여러 스토어 조합하기 - 스토어 간 통신과 의존성

시작하며

여러분이 애플리케이션이 커지면서 하나의 스토어로는 모든 상태를 관리하기 어려워집니다. 사용자 정보, 상품 목록, 장바구니, 알림 등 각 도메인별로 스토어를 분리하는 것이 자연스럽습니다.

하지만 이렇게 스토어를 나누면 새로운 문제가 생깁니다. 예를 들어, 장바구니에 상품을 추가할 때 사용자가 로그인했는지 확인해야 하고, 주문을 생성할 때는 사용자 정보와 장바구니 정보를 모두 사용해야 합니다.

스토어들이 서로의 데이터에 접근하고 협력해야 하는 상황이 자주 발생합니다. 바로 이럴 때 필요한 것이 스토어 간 조합 패턴입니다.

Pinia는 한 스토어에서 다른 스토어를 가져와 사용할 수 있도록 설계되었습니다. 스토어의 actions나 getters 내에서 다른 스토어의 use 함수를 호출하면 됩니다.

이를 통해 각 스토어는 독립적으로 유지하면서도 필요할 때 협력할 수 있습니다.

개요

간단히 말해서, 한 스토어에서 다른 스토어를 사용하려면 해당 스토어의 use 함수를 호출하여 인스턴스를 가져오면 됩니다. 스토어들은 서로 참조할 수 있으며, 이를 통해 복잡한 비즈니스 로직을 구현할 수 있습니다.

왜 이 개념이 필요한지 실무 관점에서 설명하자면, 실제 애플리케이션의 비즈니스 로직은 여러 도메인에 걸쳐있습니다. 주문을 생성하려면 사용자 정보, 장바구니 데이터, 배송지 정보가 모두 필요합니다.

알림을 표시할 때 사용자의 언어 설정을 참조해야 합니다. 이런 크로스 도메인 로직을 한 곳에 모아두면 스토어가 비대해지고 관리가 어려워집니다.

예를 들어, 전자상거래 앱에서 "구매하기" 버튼을 누르면 사용자 인증 확인, 장바구니 검증, 재고 확인, 주문 생성, 결제 처리 등 여러 스토어가 협력해야 합니다. 전통적인 Vuex에서는 모듈 간 통신이 복잡하고 타입 안전성이 떨어졌다면, Pinia에서는 단순히 다른 스토어를 import하여 사용하면 됩니다.

TypeScript와 함께 사용하면 완벽한 타입 추론도 지원됩니다. 핵심 특징은 세 가지입니다.

첫째, 스토어는 독립적으로 정의하되 필요할 때 다른 스토어를 참조할 수 있습니다. 둘째, 순환 참조도 가능하지만 신중하게 사용해야 합니다.

셋째, 스토어 간 의존성이 명확하여 코드를 이해하고 테스트하기 쉽습니다. 이러한 구조가 대규모 애플리케이션에서도 유지보수 가능한 코드를 만듭니다.

코드 예제

// stores/order.js
import { defineStore } from 'pinia'
import { useUserStore } from './user'
import { useCartStore } from './cart'
import { createOrder } from '@/api/orders'

export const useOrderStore = defineStore('order', {
  state: () => ({
    orders: [],
    isProcessing: false
  }),

  actions: {
    async placeOrder() {
      // 다른 스토어 가져오기
      const userStore = useUserStore()
      const cartStore = useCartStore()

      // 사용자 로그인 확인
      if (!userStore.isLoggedIn) {
        throw new Error('Please login first')
      }

      // 장바구니 비어있는지 확인
      if (cartStore.totalItems === 0) {
        throw new Error('Cart is empty')
      }

      this.isProcessing = true

      try {
        // 주문 데이터 구성 - 여러 스토어의 데이터 조합
        const orderData = {
          userId: userStore.id,
          userEmail: userStore.email,
          items: cartStore.items,
          totalAmount: cartStore.finalAmount
        }

        // API 호출하여 주문 생성
        const order = await createOrder(orderData)
        this.orders.push(order)

        // 주문 성공 후 장바구니 비우기
        cartStore.clearCart()

        return order
      } finally {
        this.isProcessing = false
      }
    }
  }
})

설명

이것이 하는 일: 위 코드는 주문 스토어가 사용자 스토어와 장바구니 스토어를 참조하여 주문을 생성하는 과정을 보여줍니다. 여러 스토어의 데이터를 검증하고 조합하여 하나의 비즈니스 로직을 완성합니다.

첫 번째로, placeOrder 액션 내에서 useUserStore()와 useCartStore()를 호출하여 다른 스토어 인스턴스를 가져옵니다. 중요한 점은 이 호출이 액션 내부에서 이루어진다는 것입니다.

스토어 정의 밖에서 호출하면 안 되고, actions나 getters 같은 메서드 내부에서 호출해야 합니다. 이렇게 하면 각 스토어가 필요할 때만 로드되고, 올바른 Pinia 인스턴스에서 가져와집니다.

사용자 로그인 여부를 확인합니다. userStore.isLoggedIn으로 인증 상태를 체크하고, 로그인하지 않았으면 에러를 발생시킵니다.

이렇게 사용자 스토어의 state를 읽어 비즈니스 로직을 구현할 수 있습니다. 마찬가지로 cartStore.totalItems로 장바구니가 비어있는지 확인합니다.

이런 검증 로직을 주문 스토어에 집중시키면 컴포넌트는 단순히 placeOrder()만 호출하면 되므로 책임이 명확해집니다. 주문 데이터를 구성할 때 여러 스토어의 데이터를 조합합니다.

userStore.id, userStore.email로 사용자 정보를 가져오고, cartStore.items로 상품 목록을, cartStore.finalAmount로 최종 금액을 가져옵니다. 이렇게 각 도메인의 스토어에서 필요한 데이터를 꺼내 하나의 주문 객체를 만듭니다.

각 스토어는 자신의 도메인 데이터만 관리하고, 주문 스토어가 이들을 조율하는 역할을 합니다. API 호출이 성공하면 주문 목록에 추가하고, cartStore.clearCart()를 호출하여 장바구니를 비웁니다.

이것이 스토어 간 협력의 핵심입니다. 주문 스토어가 장바구니 스토어의 액션을 호출하여 상태를 변경합니다.

이렇게 하면 장바구니 비우기 로직이 장바구니 스토어에만 있으므로 중복이 없고, 나중에 로직을 변경해도 한 곳만 수정하면 됩니다. finally 블록에서 isProcessing을 false로 설정합니다.

이 패턴은 이전 예제와 동일하며, 성공하든 실패하든 처리 상태를 정리합니다. 컴포넌트에서는 isProcessing을 보고 "주문 중..." 같은 UI를 표시할 수 있습니다.

여러분이 이런 패턴을 사용하면 각 스토어는 자신의 책임에 집중하면서도 필요할 때 협력할 수 있습니다. 코드가 모듈화되어 이해하기 쉽고, 각 스토어를 독립적으로 테스트할 수 있습니다.

새로운 기능을 추가할 때도 기존 스토어를 재사용하여 빠르게 구현할 수 있습니다. 대규모 프로젝트에서 특히 이런 구조의 가치가 빛납니다.

실전 팁

💡 다른 스토어는 actions나 getters 내부에서만 호출하세요. 스토어 정의의 최상위 레벨에서 호출하면 초기화 순서 문제가 발생할 수 있습니다.

💡 순환 참조는 가능하지만 신중하게 사용하세요. A 스토어가 B를 참조하고 B가 A를 참조하는 것은 작동하지만, 무한 루프를 만들지 않도록 주의해야 합니다.

💡 스토어 간 의존성을 최소화하세요. 너무 많은 스토어가 서로 참조하면 코드를 이해하기 어려워집니다. 필요한 경우만 제한적으로 사용하세요.

💡 공통 로직은 composables나 utils로 분리하는 것도 고려하세요. 여러 스토어에서 사용하는 헬퍼 함수는 별도 파일로 추출하면 의존성이 줄어듭니다.

💡 테스트할 때는 의존하는 스토어를 모킹(mocking)할 수 있습니다. Pinia의 createPinia()와 setActivePinia()를 사용하여 테스트 환경을 설정하세요.


6. Composition API 스타일 스토어 - Setup Store 패턴

시작하며

여러분이 Vue 3의 Composition API를 즐겨 사용한다면, 스토어도 같은 스타일로 작성하고 싶으실 겁니다. 지금까지 본 Options API 스타일(state, getters, actions)도 좋지만, Composition API 스타일이 더 익숙하거나 더 유연한 로직을 원할 수 있습니다.

Options 스타일에서는 state, getters, actions가 명확히 구분되지만, 때로는 이런 구분이 제약처럼 느껴질 수 있습니다. 특히 복잡한 로직이나 커스텀 컴포저블을 재사용하고 싶을 때, Composition API의 자유도가 그리워집니다.

바로 이럴 때 필요한 것이 Setup Store 패턴입니다. Pinia는 Vue의 setup 함수처럼 스토어를 정의할 수 있는 방법을 제공합니다.

ref로 state를, computed로 getters를, 함수로 actions를 정의하면 됩니다. 이 방식은 Composition API의 모든 장점을 스토어에서도 누릴 수 있게 해줍니다.

개요

간단히 말해서, Setup Store는 defineStore의 두 번째 인자로 함수를 전달하여 Composition API 스타일로 스토어를 정의하는 방법입니다. ref가 state가 되고, computed가 getters가 되며, 일반 함수가 actions가 됩니다.

왜 이 개념이 필요한지 실무 관점에서 설명하자면, Composition API의 강력한 기능들을 스토어에서도 사용하고 싶은 경우가 많습니다. 커스텀 컴포저블(예: useDebounce, useLocalStorage)을 스토어에서 재사용하거나, watch로 복잡한 반응형 로직을 구현하거나, 여러 ref를 조합하여 유연한 상태 관리를 하고 싶을 때가 있습니다.

예를 들어, 검색 기능을 구현할 때 사용자 입력을 디바운싱하고, 자동으로 API를 호출하고, 결과를 캐싱하는 복잡한 로직을 Setup Store로 구현하면 훨씬 자연스럽습니다. 전통적인 Options 스타일에서는 state, getters, actions의 경계가 명확하지만 때로는 경직되어 있다면, Setup Store는 자유롭고 유연합니다.

원하는 어떤 컴포저블도 사용할 수 있고, 로직을 원하는 대로 구성할 수 있습니다. Composition API에 익숙한 개발자라면 훨씬 직관적으로 느껴질 것입니다.

핵심 특징은 세 가지입니다. 첫째, ref와 reactive로 state를 정의하고, computed로 getters를 만듭니다.

둘째, 모든 Composition API 기능(watch, watchEffect, 커스텀 컴포저블 등)을 사용할 수 있습니다. 셋째, 명시적으로 반환한 속성과 메서드만 외부에서 접근할 수 있습니다.

이러한 유연성이 복잡한 상태 관리를 더 쉽게 만듭니다.

코드 예제

// stores/search.js
import { defineStore } from 'pinia'
import { ref, computed, watch } from 'vue'
import { useDebounceFn } from '@vueuse/core'
import { searchProducts } from '@/api/products'

export const useSearchStore = defineStore('search', () => {
  // State - ref로 정의
  const query = ref('')
  const results = ref([])
  const isLoading = ref(false)
  const error = ref(null)

  // Getters - computed로 정의
  const hasResults = computed(() => results.value.length > 0)
  const resultCount = computed(() => results.value.length)

  // Actions - 일반 함수로 정의
  async function performSearch() {
    if (!query.value.trim()) {
      results.value = []
      return
    }

    isLoading.value = true
    error.value = null

    try {
      const data = await searchProducts(query.value)
      results.value = data
    } catch (err) {
      error.value = err.message
    } finally {
      isLoading.value = false
    }
  }

  // 디바운싱 적용 - Composition API의 장점
  const debouncedSearch = useDebounceFn(performSearch, 500)

  // query 변경 시 자동 검색
  watch(query, () => {
    debouncedSearch()
  })

  // 외부에 노출할 것들을 명시적으로 반환
  return {
    query,
    results,
    isLoading,
    error,
    hasResults,
    resultCount,
    performSearch
  }
})

설명

이것이 하는 일: 위 코드는 검색 기능을 Setup Store 패턴으로 구현합니다. 사용자 입력을 디바운싱하고 자동으로 검색을 실행하는 복잡한 로직을 Composition API의 강력한 기능들을 활용하여 간결하게 작성합니다.

첫 번째로, defineStore의 두 번째 인자로 객체 대신 함수를 전달합니다. 이 함수는 Vue 컴포넌트의 setup 함수처럼 동작합니다.

함수 내부에서 ref로 정의한 것들이 state가 됩니다. query, results, isLoading, error는 모두 반응형 ref이며, 스토어의 상태를 나타냅니다.

Options 스타일의 state: () => ({ ... })와 동일한 역할이지만, ref를 사용하여 더 유연하게 정의할 수 있습니다.

computed로 정의한 hasResults와 resultCount가 getters입니다. Vue의 computed와 완전히 동일하게 작동하며, 의존하는 ref가 변경될 때만 재계산됩니다.

Options 스타일의 getters와 차이점은 this를 사용하지 않고 직접 ref를 참조한다는 것입니다. results.value.length처럼 명시적으로 .value를 사용해야 합니다.

performSearch 함수가 action입니다. 일반 함수로 정의하며, async도 자유롭게 사용할 수 있습니다.

함수 내부에서 다른 ref들을 읽고 수정하여 상태를 변경합니다. query.value로 검색어를 읽고, isLoading.value = true로 상태를 업데이트하는 식입니다.

Options 스타일의 actions와 동일한 역할이지만, this 대신 직접 ref를 참조합니다. Setup Store의 진정한 강력함은 여기서 드러납니다.

useDebounceFn은 VueUse 라이브러리의 컴포저블로, 함수 호출을 디바운싱해줍니다. 이런 외부 컴포저블을 스토어에서 자유롭게 사용할 수 있습니다.

watch로 query가 변경될 때마다 debouncedSearch를 호출하도록 설정합니다. 이렇게 하면 사용자가 타이핑을 멈춘 후 500ms 후에 자동으로 검색이 실행됩니다.

Options 스타일에서는 이런 로직을 구현하기 복잡하지만, Setup Store에서는 자연스럽습니다. 마지막으로, return 문에서 외부에 노출할 것들을 명시적으로 반환합니다.

여기에 포함된 ref, computed, 함수만 컴포넌트에서 접근할 수 있습니다. 내부에서만 사용하는 헬퍼 함수나 변수는 반환하지 않으면 private으로 유지됩니다.

debouncedSearch는 내부 구현 디테일이므로 반환하지 않고, performSearch만 외부에 노출하는 식으로 캡슐화할 수 있습니다. 여러분이 Setup Store를 사용하면 Composition API의 모든 장점을 스토어에서도 누릴 수 있습니다.

복잡한 반응형 로직, 외부 컴포저블 재사용, 유연한 코드 구조 등이 가능합니다. 특히 Composition API에 익숙하다면 훨씬 자연스럽게 느껴질 것입니다.

Options 스타일과 Setup 스타일을 프로젝트 내에서 혼용할 수도 있으므로, 상황에 맞게 선택하세요.

실전 팁

💡 ref는 state, computed는 getters, 함수는 actions로 자동 변환됩니다. 명시적으로 구분하지 않아도 Pinia가 알아서 처리합니다.

💡 return에서 명시적으로 반환한 것만 외부에서 접근 가능합니다. 내부 헬퍼 함수나 임시 변수는 반환하지 않으면 private으로 유지되어 캡슐화를 강화할 수 있습니다.

💡 $reset()은 Setup Store에서 자동으로 작동하지 않습니다. 초기화가 필요하면 reset() 함수를 직접 구현하여 각 ref를 초기값으로 되돌리세요.

💡 Setup Store에서는 모든 Composition API 기능을 사용할 수 있습니다. watch, watchEffect, onMounted(컴포넌트용이 아닌 특수 케이스), 커스텀 컴포저블 등을 자유롭게 활용하세요.

💡 TypeScript에서는 반환 타입을 추론하므로 별도 타입 정의가 필요 없습니다. 하지만 명시적으로 타입을 지정하고 싶다면 반환 객체의 타입을 정의할 수 있습니다.


7. 플러그인으로 스토어 확장하기 - 로깅과 영속성

시작하며

여러분이 애플리케이션을 개발하면서 모든 스토어에 공통으로 필요한 기능이 있습니다. 예를 들어, 상태 변경을 로그로 남기거나, 특정 상태를 localStorage에 자동으로 저장하거나, 모든 액션 호출 시간을 측정하고 싶을 수 있습니다.

이런 기능을 각 스토어마다 일일이 구현하면 코드가 중복되고 일관성을 유지하기 어렵습니다. 새로운 스토어를 만들 때마다 같은 로직을 복사하는 것은 비효율적이고, 나중에 로직을 변경할 때 모든 스토어를 수정해야 하는 문제가 있습니다.

바로 이럴 때 필요한 것이 Pinia 플러그인입니다. 플러그인은 모든 스토어에 자동으로 적용되는 공통 기능을 추가할 수 있게 해줍니다.

한 번 정의하면 기존 스토어와 새로 만드는 스토어 모두에 적용되어, DRY(Don't Repeat Yourself) 원칙을 지킬 수 있습니다.

개요

간단히 말해서, Pinia 플러그인은 모든 스토어에 공통 기능을 추가하는 함수입니다. pinia.use()로 플러그인을 등록하면, 각 스토어가 생성될 때 자동으로 플러그인이 실행되어 기능을 추가합니다.

왜 이 개념이 필요한지 실무 관점에서 설명하자면, 크로스 커팅 관심사(cross-cutting concerns)를 중앙에서 관리해야 하는 경우가 많습니다. 개발 중에는 모든 상태 변경을 콘솔에 로그로 남기고 싶고, 프로덕션에서는 에러 추적 서비스에 전송하고 싶습니다.

사용자 설정이나 장바구니 같은 특정 스토어는 브라우저를 닫았다 열어도 유지되어야 합니다. 이런 요구사항을 플러그인으로 구현하면 깔끔하게 해결됩니다.

예를 들어, 모든 API 호출의 성능을 측정하여 느린 액션을 찾아내거나, 특정 패턴의 상태 변경을 분석 도구에 전송하는 등의 작업을 플러그인으로 자동화할 수 있습니다. 전통적인 방법으로는 각 스토어의 actions에 로깅 코드를 추가했다면, 플러그인을 사용하면 한 곳에서 정의하여 모든 스토어에 자동 적용됩니다.

플러그인은 스토어의 생명주기에 접근하여 초기화, 액션 호출, 상태 변경 등의 시점에 코드를 실행할 수 있습니다. 핵심 특징은 세 가지입니다.

첫째, 플러그인은 모든 스토어에 자동으로 적용됩니다. 둘째, 스토어에 새로운 속성이나 메서드를 추가할 수 있습니다.

셋째, 액션을 감싸서(wrap) 전후 처리를 추가할 수 있습니다. 이러한 기능들이 코드 재사용성을 극대화하고 유지보수를 쉽게 만듭니다.

코드 예제

// plugins/piniaLogger.js
export function piniaLogger({ store }) {
  // 스토어 초기화 시 로그
  console.log(`[Pinia] Store "${store.$id}" initialized`)

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

  // 액션 호출 감지
  store.$onAction(({ name, args, after, onError }) => {
    const startTime = Date.now()
    console.log(`[Pinia] Action "${name}" called on "${store.$id}"`, args)

    after((result) => {
      const duration = Date.now() - startTime
      console.log(`[Pinia] Action "${name}" finished in ${duration}ms`, result)
    })

    onError((error) => {
      console.error(`[Pinia] Action "${name}" failed:`, error)
    })
  })
}

// main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import { piniaLogger } from './plugins/piniaLogger'

const pinia = createPinia()

// 플러그인 등록 - 개발 환경에서만
if (import.meta.env.DEV) {
  pinia.use(piniaLogger)
}

const app = createApp(App)
app.use(pinia)
app.mount('#app')

설명

이것이 하는 일: 위 코드는 모든 스토어의 상태 변경과 액션 호출을 자동으로 로깅하는 플러그인을 구현합니다. 개발 중 디버깅에 매우 유용하며, 어떤 스토어에서 무엇이 일어나는지 실시간으로 추적할 수 있습니다.

첫 번째로, 플러그인 함수는 context 객체를 받습니다. 이 객체의 store 속성으로 현재 생성되는 스토어 인스턴스에 접근할 수 있습니다.

store.$id는 스토어의 고유 ID(예: 'user', 'cart')이고, store.$state는 현재 상태입니다. 플러그인은 각 스토어가 생성될 때마다 호출되므로, 애플리케이션에 스토어가 3개 있으면 플러그인 함수가 3번 실행됩니다.

store.$subscribe()는 상태 변경을 감지하는 메서드입니다. 콜백 함수는 mutation과 state를 받는데, mutation에는 변경 유형(direct, patch object, patch function)과 변경된 속성 정보가 들어있고, state는 변경 후의 전체 상태입니다.

이를 활용하면 어떤 상태가 어떻게 변경되었는지 상세히 로그로 남길 수 있습니다. 예를 들어, 사용자 이름이 변경되면 "userStore의 name이 'John'에서 'Jane'으로 변경되었습니다" 같은 로그를 자동으로 출력할 수 있습니다.

store.$onAction()은 액션 호출을 감지합니다. 콜백은 name(액션 이름), args(전달된 인자), after(성공 시 호출), onError(실패 시 호출) 같은 정보를 제공합니다.

이를 활용하여 액션의 시작과 종료 시점을 기록하고, 실행 시간을 측정할 수 있습니다. 위 예제에서는 액션이 시작될 때 시간을 기록하고, 종료되면 걸린 시간을 계산하여 로그로 남깁니다.

이는 성능 모니터링에 매우 유용합니다. 느린 액션을 찾아내어 최적화할 수 있습니다.

onError 핸들러는 액션에서 에러가 발생했을 때 호출됩니다. 에러를 로그로 남기거나, 에러 추적 서비스(Sentry, Bugsnag 등)에 전송할 수 있습니다.

프로덕션 환경에서는 console.log 대신 실제 로깅 서비스로 전송하도록 변경하면 됩니다. main.js에서 pinia.use(piniaLogger)로 플러그인을 등록합니다.

중요한 점은 createPinia() 후, app.use(pinia) 전에 등록해야 한다는 것입니다. 위 예제에서는 개발 환경에서만 로깅하도록 조건부로 등록했습니다.

import.meta.env.DEV는 Vite에서 제공하는 환경 변수로, 프로덕션에서는 불필요한 로그가 출력되지 않도록 합니다. 여러분이 플러그인을 활용하면 개발 생산성이 크게 향상됩니다.

디버깅이 쉬워지고, 공통 기능을 중복 없이 구현할 수 있습니다. 영속성 플러그인(pinia-plugin-persistedstate)을 사용하면 localStorage 연동도 자동화할 수 있고, 커스텀 플러그인으로 팀의 특수한 요구사항도 해결할 수 있습니다.

한 번 만들어두면 모든 프로젝트에서 재사용할 수 있어 매우 효율적입니다.

실전 팁

💡 플러그인은 createPinia() 후, app.use(pinia) 전에 등록해야 합니다. 순서가 바뀌면 스토어에 적용되지 않습니다.

💡 $subscribe와 $onAction이 반환하는 함수를 호출하면 구독을 해제할 수 있습니다. 메모리 누수를 방지하려면 컴포넌트 언마운트 시 해제하세요.

💡 개발 환경과 프로덕션 환경에 다른 플러그인을 적용하세요. 로깅은 개발에서만, 에러 추적은 프로덕션에서만 활성화하는 식으로 분리하면 성능이 좋아집니다.

💡 pinia-plugin-persistedstate 같은 오픈소스 플러그인을 활용하면 localStorage 연동을 쉽게 구현할 수 있습니다. 직접 만들지 말고 검증된 라이브러리를 사용하세요.

💡 플러그인에서 스토어에 새 속성을 추가할 수도 있습니다. store.$customMethod = () => {...} 형태로 모든 스토어에 공통 메서드를 추가할 수 있습니다.


8. 스토어 상태 초기화와 리셋 - $reset과 $patch 활용

시작하며

여러분이 사용자가 로그아웃하거나 폼을 초기화할 때, 스토어의 상태를 초기값으로 되돌려야 하는 경우가 자주 있습니다. 또는 여러 상태를 한 번에 업데이트해야 할 때도 있습니다.

상태를 하나씩 수동으로 초기화하면 코드가 장황해지고 실수하기 쉽습니다. this.name = '', this.email = '', this.isLoggedIn = false처럼 모든 속성을 일일이 되돌리는 것은 번거롭고, 나중에 새로운 상태를 추가하면 초기화 코드도 수정해야 합니다.

또한 여러 상태를 개별적으로 변경하면 각 변경마다 반응형 업데이트가 발생하여 비효율적입니다. 바로 이럴 때 필요한 것이 Pinia의 $reset과 $patch 메서드입니다.

$reset()은 스토어를 초기 상태로 되돌리고, $patch()는 여러 상태를 한 번에 효율적으로 업데이트합니다. 이 메서드들을 사용하면 코드가 간결해지고, 성능도 향상됩니다.

개요

간단히 말해서, $reset()은 스토어의 모든 state를 초기값으로 되돌리는 메서드이고, $patch()는 여러 상태를 한 번에 업데이트하는 메서드입니다. 두 메서드 모두 Pinia가 제공하는 유틸리티로, 상태 관리를 더 편리하게 만듭니다.

왜 이 개념이 필요한지 실무 관점에서 설명하자면, 상태 초기화는 매우 흔한 작업입니다. 사용자 로그아웃, 폼 리셋, 에러 상태 클리어, 필터 초기화 등 다양한 상황에서 필요합니다.

매번 모든 속성을 수동으로 초기화하는 것은 유지보수가 어렵고, 초기화를 깜빡하면 버그가 됩니다. 또한 여러 상태를 연속으로 변경할 때 각 변경마다 반응형 시스템이 동작하여 성능이 저하될 수 있습니다.

예를 들어, 사용자 프로필을 API에서 불러와 10개의 필드를 업데이트한다면, 10번의 개별 할당보다 $patch로 한 번에 처리하는 것이 훨씬 효율적입니다. 전통적인 방법으로는 각 속성을 일일이 초기값으로 할당했다면, $reset()을 사용하면 한 줄로 끝납니다.

$patch()는 객체나 함수를 받아 여러 상태를 배치로 업데이트하여, 반응형 업데이트를 최소화합니다. 핵심 특징은 세 가지입니다.

첫째, $reset()은 Options 스타일 스토어에서만 작동하며, Setup 스타일에서는 직접 구현해야 합니다. 둘째, $patch()는 객체 형태와 함수 형태 두 가지를 지원하며, 복잡한 업데이트는 함수 형태가 적합합니다.

셋째, $patch()는 여러 변경을 하나의 구독 알림으로 묶어 성능을 향상시킵니다. 이러한 메서드들이 상태 관리를 더 선언적이고 효율적으로 만듭니다.

코드 예제

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

export const useUserStore = defineStore('user', {
  state: () => ({
    id: null,
    name: '',
    email: '',
    age: 0,
    preferences: {
      theme: 'light',
      language: 'ko'
    }
  }),

  actions: {
    // $reset 사용 예시
    logout() {
      this.$reset() // 모든 state를 초기값으로
      console.log('User logged out')
    },

    // $patch 객체 형태 사용
    updateBasicInfo(data) {
      this.$patch({
        name: data.name,
        email: data.email,
        age: data.age
      })
    },

    // $patch 함수 형태 사용 - 복잡한 업데이트에 적합
    updatePreferences(newPrefs) {
      this.$patch((state) => {
        // 중첩 객체도 쉽게 업데이트
        state.preferences.theme = newPrefs.theme
        state.preferences.language = newPrefs.language
        // 배열 조작도 가능
        // state.items.push(newItem)
      })
    },

    // 여러 필드를 한 번에 업데이트
    loadUserProfile(profile) {
      // 비효율적: 여러 번의 개별 할당
      // this.id = profile.id
      // this.name = profile.name
      // this.email = profile.email
      // ... (각 할당마다 반응형 업데이트)

      // 효율적: $patch로 한 번에
      this.$patch(profile)
    }
  }
})

설명

이것이 하는 일: 위 코드는 $reset과 $patch를 활용하여 스토어 상태를 효율적으로 관리하는 방법을 보여줍니다. 로그아웃 시 초기화, 사용자 정보 업데이트, 환경설정 변경 등 다양한 시나리오를 다룹니다.

첫 번째로, logout 액션에서 this.$reset()을 호출합니다. 이 메서드는 state 함수를 다시 호출하여 반환된 객체로 현재 상태를 덮어씁니다.

즉, state: () => ({ id: null, name: '', ... })에서 정의한 초기값으로 모든 속성이 되돌아갑니다.

수동으로 this.id = null, this.name = ''을 반복할 필요가 없어 코드가 간결하고, 나중에 새로운 상태를 추가해도 자동으로 초기화됩니다. 주의할 점은 $reset()은 Options 스타일(state, getters, actions 형태)에서만 작동한다는 것입니다.

Setup 스타일에서는 초기값을 변수로 저장해두고 수동으로 초기화하는 함수를 만들어야 합니다. updateBasicInfo 액션은 $patch를 객체 형태로 사용합니다.

this.$patch({ name: ..., email: ..., age: ... })처럼 업데이트할 속성들을 객체로 전달합니다.

이렇게 하면 내부적으로 Object.assign 같은 방식으로 한 번에 병합되어, 반응형 시스템이 여러 번 트리거되지 않고 한 번만 동작합니다. 예를 들어, 이름, 이메일, 나이를 개별적으로 할당하면 3번의 반응형 업데이트가 발생하지만, $patch를 사용하면 1번만 발생하여 성능이 향상됩니다.

updatePreferences는 $patch를 함수 형태로 사용합니다. 콜백 함수는 state를 매개변수로 받아 직접 수정할 수 있습니다.

이 방식은 중첩된 객체나 배열을 다룰 때 특히 유용합니다. state.preferences.theme처럼 깊은 속성에 접근하여 변경하거나, state.items.push(...) 같은 배열 메서드를 사용할 수 있습니다.

함수 형태는 현재 상태를 기반으로 복잡한 계산을 하거나, 조건부 업데이트를 할 때 적합합니다. 내부적으로 이 함수는 한 번의 트랜잭션처럼 처리되어, 모든 변경이 완료된 후 한 번만 반응형 업데이트가 발생합니다.

loadUserProfile 액션은 API 응답 같은 전체 객체를 한 번에 스토어에 적용하는 경우를 보여줍니다. 주석 처리된 비효율적인 방식은 각 필드를 개별 할당하여 매번 반응형 업데이트가 발생합니다.

반면 this.$patch(profile)은 profile 객체의 모든 속성을 한 번에 병합하여 단 한 번의 업데이트만 발생시킵니다. 특히 많은 필드가 있는 경우 성능 차이가 크게 납니다.

$patch의 또 다른 장점은 코드 가독성입니다. 어떤 속성들이 함께 변경되는지 명확하게 보이므로, 논리적으로 관련된 상태 변경을 그룹화하여 이해하기 쉽습니다.

또한 플러그인의 $subscribe에서도 $patch로 인한 변경은 하나의 mutation으로 감지되어, 로깅이나 디버깅 시 유용합니다. 여러분이 이 메서드들을 적절히 활용하면 코드가 간결해지고 성능도 좋아집니다.

로그아웃이나 폼 리셋에는 $reset()을, 여러 필드를 동시에 업데이트할 때는 $patch()를 사용하세요. 특히 API에서 받은 데이터를 스토어에 반영할 때 $patch()를 습관화하면 불필요한 렌더링을 줄여 앱의 반응성이 향상됩니다.

실전 팁

💡 $reset()은 Options 스타일에서만 작동합니다. Setup 스타일에서는 초기값을 const INITIAL_STATE = {...}로 저장하고, reset() 함수에서 Object.assign(state, INITIAL_STATE)로 직접 구현하세요.

💡 간단한 업데이트는 객체 형태 $patch({...})를, 복잡한 로직이나 배열 조작은 함수 형태 $patch((state) => {...})를 사용하세요.

💡 $patch는 얕은 병합(shallow merge)을 수행합니다. 중첩 객체를 완전히 교체하려면 함수 형태를 사용하거나, 깊은 병합 라이브러리를 활용하세요.

💡 $patch로 여러 상태를 변경하면 $subscribe 콜백이 한 번만 호출됩니다. 로깅이나 영속성 플러그인에서 배치 업데이트로 감지되어 효율적입니다.

💡 부분 업데이트 시 $patch를 사용하고, 전체 교체 시 $state를 직접 할당하세요. this.$state = newState는 전체를 교체하지만, 일반적으로는 $patch가 더 안전합니다.


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

댓글 (0)

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