이미지 로딩 중...

Pinia 상태관리 실전 가이드 - 슬라이드 1/8
A

AI Generated

2025. 11. 8. · 3 Views

Pinia 상태관리 실전 가이드

Vue 3의 공식 상태관리 라이브러리인 Pinia를 실무에서 활용하는 방법을 배웁니다. Store 생성부터 Actions, Getters, 모듈화까지 단계별로 학습하여 효율적인 상태관리를 구현할 수 있습니다.


목차

  1. Pinia Store 기본 구조 - defineStore로 시작하기
  2. Actions로 비동기 작업 처리 - API 호출과 상태 업데이트
  3. Getters로 계산된 값 만들기 - 파생 상태 관리
  4. Setup Syntax로 Store 작성 - Composition API 스타일
  5. Store 모듈화 - 여러 Store 조합하기
  6. State 구독과 변경 감지 - $subscribe와 $onAction
  7. Pinia Plugins 활용 - Store 확장하기

1. Pinia Store 기본 구조 - defineStore로 시작하기

시작하며

여러분이 Vue 3 프로젝트를 진행하다가 여러 컴포넌트에서 동일한 사용자 정보를 사용해야 하는 상황을 겪어본 적 있나요? Props를 여러 단계로 전달하거나, Event Bus를 사용하면서 코드가 복잡해지고 유지보수가 어려워지는 경험 말이죠.

이런 문제는 실제 개발 현장에서 자주 발생합니다. 컴포넌트 간 데이터 공유가 필요할 때마다 코드가 스파게티처럼 얽히고, 상태 변경을 추적하기도 어려워집니다.

특히 팀 프로젝트에서는 누가 어디서 상태를 변경했는지 파악하기 힘들어 버그가 발생하기 쉽습니다. 바로 이럴 때 필요한 것이 Pinia Store입니다.

Pinia는 Vue 3를 위한 공식 상태관리 라이브러리로, 중앙화된 상태 관리를 통해 코드를 깔끔하게 유지하면서도 강력한 기능을 제공합니다.

개요

간단히 말해서, Pinia Store는 애플리케이션의 전역 상태를 관리하는 저장소입니다. defineStore 함수를 사용해 생성하며, state(상태), actions(동작), getters(계산된 값)로 구성됩니다.

왜 Pinia가 필요할까요? 대규모 애플리케이션에서는 수십 개의 컴포넌트가 동일한 데이터를 참조하고 수정합니다.

예를 들어, 로그인한 사용자 정보는 헤더, 사이드바, 프로필 페이지 등 여러 곳에서 필요합니다. Pinia를 사용하면 이 데이터를 한 곳에서 관리하고, 어느 컴포넌트에서든 쉽게 접근할 수 있습니다.

기존에는 Vuex를 사용했다면, 이제는 더 간결하고 TypeScript 친화적인 Pinia를 사용할 수 있습니다. Vuex와 달리 Pinia는 mutations가 없어 보일러플레이트 코드가 줄어들고, 자동 완성과 타입 추론이 훨씬 우수합니다.

Pinia Store의 핵심 특징은 세 가지입니다. 첫째, 모듈화가 쉬워 각 도메인별로 독립적인 Store를 만들 수 있습니다.

둘째, 개발자 도구와의 완벽한 통합으로 시간 여행 디버깅이 가능합니다. 셋째, Setup Syntax를 지원하여 Composition API와 자연스럽게 통합됩니다.

이러한 특징들이 현대적인 Vue 개발에서 필수적인 이유입니다.

코드 예제

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

export const useUserStore = defineStore('user', {
  // 상태 정의 - 컴포넌트의 data와 유사
  state: () => ({
    userId: null,
    userName: '',
    email: '',
    isAuthenticated: false
  }),

  // Getters - computed 속성과 유사
  getters: {
    fullInfo: (state) => `${state.userName} (${state.email})`
  },

  // Actions - methods와 유사, 비동기 처리 가능
  actions: {
    login(userData) {
      this.userId = userData.id
      this.userName = userData.name
      this.email = userData.email
      this.isAuthenticated = true
    },
    logout() {
      this.$reset() // 초기 상태로 리셋
    }
  }
})

설명

이것이 하는 일: 이 코드는 사용자 인증 정보를 관리하는 Pinia Store를 생성합니다. defineStore 함수를 사용해 'user'라는 이름의 Store를 정의하고, 사용자의 로그인 상태와 정보를 중앙에서 관리합니다.

첫 번째로, state 함수는 Store의 초기 상태를 반환합니다. 함수 형태로 정의하는 이유는 SSR(Server-Side Rendering) 환경에서 상태가 오염되지 않도록 하기 위함입니다.

userId, userName, email, isAuthenticated 같은 사용자 정보를 객체로 담고 있으며, 이 상태는 reactive하게 동작하여 변경 시 자동으로 UI가 업데이트됩니다. 그 다음으로, getters는 state를 기반으로 계산된 값을 제공합니다.

fullInfo getter는 사용자 이름과 이메일을 조합한 문자열을 반환하는데, Vue의 computed와 동일하게 의존하는 state가 변경될 때만 재계산됩니다. 이는 성능 최적화에 매우 중요한 역할을 합니다.

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

actions는 비동기 처리가 가능하며, async/await을 자유롭게 사용할 수 있습니다. 여러분이 이 Store를 사용하면 컴포넌트에서 const userStore = useUserStore()로 간단히 접근할 수 있습니다.

userStore.userName으로 상태를 읽고, userStore.login(data)로 상태를 변경하며, userStore.fullInfo로 계산된 값을 사용할 수 있죠. 이는 코드의 가독성을 높이고, 상태 관리 로직을 중앙화하여 유지보수를 쉽게 만들며, 컴포넌트 간 데이터 공유를 직관적으로 만드는 이점을 제공합니다.

실전 팁

💡 Store 이름은 'use'로 시작하는 것이 Vue의 Composable 네이밍 컨벤션입니다. useUserStore, useCartStore처럼 명명하면 다른 개발자들이 즉시 이해할 수 있습니다.

💡 state를 직접 수정하지 말고 반드시 actions를 통해 변경하세요. 직접 수정하면 개발자 도구에서 추적이 어렵고, 나중에 유효성 검사나 로깅을 추가하기 힘듭니다.

💡 $reset()은 Options API 스타일에서만 작동합니다. Setup Syntax를 사용한다면 초기 상태를 별도로 저장해두고 수동으로 리셋해야 합니다.

💡 getters에서 다른 getter를 참조할 때는 this를 사용하세요. fullName() { return this.firstName + ' ' + this.lastName }처럼 작성하면 getter 체이닝이 가능합니다.

💡 Store를 너무 많이 나누지 마세요. 관련된 상태는 하나의 Store에 모아두는 것이 관리하기 쉽습니다. 예를 들어 사용자 정보와 사용자 설정은 같은 userStore에 두는 것이 좋습니다.


2. Actions로 비동기 작업 처리 - API 호출과 상태 업데이트

시작하며

여러분이 사용자 목록을 서버에서 가져와 화면에 표시해야 하는 상황을 생각해보세요. 로딩 상태를 표시하고, 데이터를 받아오고, 에러가 발생하면 처리하고...

이 모든 로직을 각 컴포넌트마다 작성하면 코드 중복이 심해집니다. 이런 문제는 실제 프로젝트에서 가장 흔하게 마주치는 과제입니다.

API 호출 로직이 여러 컴포넌트에 흩어져 있으면 에러 처리 방식이 일관되지 않고, 로딩 상태 관리도 제각각이 되어 사용자 경험이 나빠집니다. 게다가 같은 API를 여러 곳에서 호출하면 불필요한 네트워크 요청이 발생하기도 합니다.

바로 이럴 때 필요한 것이 Pinia의 Actions입니다. Actions를 사용하면 비동기 로직을 Store에 캡슐화하여 재사용 가능하고 테스트하기 쉬운 코드를 작성할 수 있습니다.

개요

간단히 말해서, Actions는 Store의 상태를 변경하는 메서드이며, 비동기 작업을 처리할 수 있는 유일한 곳입니다. async/await을 자유롭게 사용할 수 있고, try-catch로 에러 처리도 가능합니다.

왜 Actions가 필요할까요? API 호출, 데이터 검증, 복잡한 비즈니스 로직 등은 컴포넌트가 아닌 Store에서 관리해야 합니다.

예를 들어, 사용자 목록을 가져오는 로직을 여러 페이지에서 사용한다면, Action으로 만들어두면 한 줄의 함수 호출로 해결됩니다. 기존에는 컴포넌트의 methods나 setup 함수 안에서 API를 호출했다면, 이제는 Store의 Actions에서 중앙 관리할 수 있습니다.

이렇게 하면 로직의 재사용성이 높아지고, 테스트 작성이 쉬워지며, 코드의 구조가 명확해집니다. Actions의 핵심 특징은 세 가지입니다.

첫째, this로 state와 다른 actions에 접근할 수 있어 로직을 조합하기 쉽습니다. 둘째, 비동기 작업의 모든 단계(시작, 성공, 실패)를 한 곳에서 관리할 수 있습니다.

셋째, 개발자 도구에서 액션의 실행 과정을 추적할 수 있어 디버깅이 수월합니다. 이러한 특징들이 복잡한 애플리케이션에서 상태 관리를 단순화하는 이유입니다.

코드 예제

// stores/todo.js
import { defineStore } from 'pinia'
import axios from 'axios'

export const useTodoStore = defineStore('todo', {
  state: () => ({
    todos: [],
    loading: false,
    error: null
  }),

  actions: {
    // 비동기 액션 - async/await 사용
    async fetchTodos() {
      this.loading = true
      this.error = null

      try {
        const response = await axios.get('/api/todos')
        this.todos = response.data
      } catch (err) {
        this.error = err.message
        console.error('할일 목록 로딩 실패:', err)
      } finally {
        this.loading = false
      }
    },

    // 다른 액션 호출 가능
    async addTodo(text) {
      const newTodo = { id: Date.now(), text, done: false }
      this.todos.push(newTodo)
      // 서버에 저장
      await axios.post('/api/todos', newTodo)
    }
  }
})

설명

이것이 하는 일: 이 코드는 할일 목록을 서버에서 가져오고 추가하는 비동기 Actions를 정의합니다. loading, error 상태를 함께 관리하여 사용자에게 적절한 피드백을 제공할 수 있습니다.

첫 번째로, fetchTodos 액션은 서버에서 할일 목록을 가져오는 전체 과정을 담당합니다. 시작할 때 loading을 true로 설정하고 error를 초기화합니다.

이렇게 하면 컴포넌트에서 "로딩 중..." 메시지를 표시할 수 있고, 이전 에러가 남아있지 않게 됩니다. 그 다음으로, try 블록에서 실제 API 호출이 실행됩니다.

axios.get()은 Promise를 반환하므로 await으로 결과를 기다립니다. 성공하면 response.data를 this.todos에 할당하는데, 이 순간 todos를 참조하는 모든 컴포넌트가 자동으로 업데이트됩니다.

Pinia의 반응성 시스템 덕분에 별도의 업데이트 로직이 필요 없습니다. catch 블록에서는 에러 상황을 처리합니다.

네트워크 오류, 서버 에러, 타임아웃 등 모든 예외가 여기서 잡힙니다. error 상태에 메시지를 저장하면 컴포넌트에서 사용자에게 "데이터를 불러올 수 없습니다" 같은 메시지를 표시할 수 있습니다.

console.error는 개발 중 디버깅을 위한 것입니다. finally 블록은 성공이든 실패든 항상 실행되어 loading을 false로 되돌립니다.

addTodo 액션은 낙관적 업데이트(Optimistic Update) 패턴을 보여줍니다. 먼저 로컬 상태를 업데이트해 즉각적인 반응을 제공하고, 백그라운드에서 서버에 저장합니다.

여러분이 이 Actions를 사용하면 컴포넌트에서는 todoStore.fetchTodos()만 호출하면 됩니다. 로딩 상태는 todoStore.loading으로, 에러는 todoStore.error로 확인할 수 있죠.

이는 비즈니스 로직을 컴포넌트에서 분리하여 테스트를 쉽게 만들고, 에러 처리를 일관되게 유지하며, 여러 컴포넌트에서 동일한 로직을 재사용할 수 있게 합니다.

실전 팁

💡 액션에서는 반드시 에러 처리를 해주세요. try-catch 없이 비동기 작업을 하면 예상치 못한 에러가 애플리케이션 전체를 멈출 수 있습니다. 최소한 console.error라도 남겨야 디버깅이 가능합니다.

💡 loading 상태는 반드시 finally 블록에서 false로 설정하세요. try나 catch에서 return하면 loading이 true로 남아 무한 로딩 상태가 될 수 있습니다.

💡 낙관적 업데이트를 사용할 때는 롤백 로직도 준비하세요. 서버 요청이 실패하면 로컬에 추가한 데이터를 다시 제거해야 사용자에게 혼란을 주지 않습니다.

💡 여러 액션에서 공통으로 사용하는 로직은 별도의 액션으로 분리하세요. handleError, setLoading 같은 헬퍼 액션을 만들면 코드 중복을 줄일 수 있습니다.

💡 액션에서 다른 Store의 액션을 호출할 수 있습니다. const authStore = useAuthStore(); authStore.logout() 처럼 Store 간 협력이 가능하여 복잡한 비즈니스 로직을 구현할 수 있습니다.


3. Getters로 계산된 값 만들기 - 파생 상태 관리

시작하며

여러분이 쇼핑몰 장바구니를 개발한다고 상상해보세요. 장바구니에 담긴 상품들의 총 가격, 할인 적용 후 가격, 배송비 포함 최종 금액 등을 계산해야 합니다.

이런 계산 로직을 컴포넌트마다 작성하면 어떻게 될까요? 이런 문제는 실무에서 매우 자주 발생합니다.

같은 계산 로직이 여러 곳에 중복되면 버그가 생기기 쉽고, 로직을 수정할 때 모든 곳을 찾아 고쳐야 합니다. 예를 들어 할인율 계산 방식이 바뀌면 20개 컴포넌트를 모두 수정해야 할 수도 있습니다.

게다가 매번 계산하면 성능 낭비도 발생합니다. 바로 이럴 때 필요한 것이 Pinia의 Getters입니다.

Getters는 Vue의 computed와 같은 방식으로 동작하여, 의존하는 상태가 변경될 때만 재계산되고 그 외에는 캐시된 값을 반환합니다.

개요

간단히 말해서, Getters는 state를 기반으로 계산된 값을 반환하는 함수입니다. state가 변경되지 않으면 이전 결과를 재사용하여 불필요한 계산을 방지합니다.

왜 Getters가 필요할까요? 원본 데이터(state)에서 파생된 데이터를 사용하는 경우가 많습니다.

예를 들어, 전체 사용자 목록에서 활성 사용자만 필터링하거나, 주문 목록에서 총 매출을 계산하는 경우입니다. 이런 로직을 Getters로 만들어두면 코드 한 줄로 어디서든 사용할 수 있습니다.

기존에는 컴포넌트의 computed에서 계산 로직을 작성했다면, 이제는 Store의 Getters에 두어 모든 컴포넌트가 공유할 수 있습니다. 한 곳에서 정의하고 여러 곳에서 사용하는 DRY(Don't Repeat Yourself) 원칙을 지킬 수 있습니다.

Getters의 핵심 특징은 세 가지입니다. 첫째, 자동 캐싱으로 성능이 뛰어납니다.

의존성이 변경되지 않으면 이전 값을 즉시 반환합니다. 둘째, 다른 Getter를 참조할 수 있어 복잡한 계산을 단계별로 나눌 수 있습니다.

셋째, 인자를 받는 Getter를 만들 수 있어 동적인 필터링과 검색이 가능합니다. 이러한 특징들이 대규모 애플리케이션에서 파생 상태를 효율적으로 관리하는 핵심입니다.

코드 예제

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

export const useCartStore = defineStore('cart', {
  state: () => ({
    items: [
      { id: 1, name: '노트북', price: 1000000, quantity: 1 },
      { id: 2, name: '마우스', price: 30000, quantity: 2 }
    ],
    discountRate: 0.1 // 10% 할인
  }),

  getters: {
    // 기본 getter - 총 상품 개수
    totalItems: (state) => {
      return state.items.reduce((sum, item) => sum + item.quantity, 0)
    },

    // 총 가격 계산
    subtotal: (state) => {
      return state.items.reduce((sum, item) => sum + item.price * item.quantity, 0)
    },

    // 다른 getter 참조 - 할인 적용 후 가격
    totalPrice() {
      return this.subtotal * (1 - this.discountRate)
    },

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

설명

이것이 하는 일: 이 코드는 쇼핑몰 장바구니의 다양한 계산 값을 Getters로 정의합니다. 상품 개수, 소계, 할인가, 특정 상품 조회 등의 기능을 제공하며, items나 discountRate가 변경될 때만 재계산됩니다.

첫 번째로, totalItems getter는 장바구니에 담긴 총 상품 개수를 계산합니다. reduce 함수로 각 상품의 quantity를 합산하는데, 화살표 함수로 정의되어 state를 매개변수로 받습니다.

이 방식은 간단한 계산에 적합하며, TypeScript의 타입 추론이 잘 동작합니다. 그 다음으로, subtotal getter는 할인 전 총 가격을 계산합니다.

각 상품의 가격과 수량을 곱한 뒤 모두 더합니다. 이 getter는 독립적으로 동작하며, 다른 getter의 기반이 됩니다.

이런 식으로 계산 로직을 단계별로 나누면 코드 이해가 쉬워지고 재사용성도 높아집니다. totalPrice getter는 일반 함수로 정의되어 this로 다른 getter와 state에 접근합니다.

this.subtotal을 참조하여 소계를 가져오고, discountRate를 적용해 최종 가격을 계산합니다. Getter 체이닝이 가능하여 복잡한 계산을 작은 단위로 나눌 수 있습니다.

이 방식은 큰 프로젝트에서 가독성을 크게 향상시킵니다. getItemById getter는 특별합니다.

함수를 반환하는 getter로, 컴포넌트에서 cartStore.getItemById(1)처럼 인자를 전달할 수 있습니다. 하지만 주의할 점이 있습니다.

인자를 받는 getter는 캐싱되지 않아 매번 실행됩니다. 따라서 무거운 계산은 피하고, 단순 조회용으로만 사용하는 것이 좋습니다.

여러분이 이 Getters를 사용하면 컴포넌트에서 {{ cartStore.totalPrice }}처럼 간단히 표시할 수 있습니다. items 배열에 상품이 추가되거나 discountRate가 변경되면 자동으로 재계산되어 화면에 반영됩니다.

이는 계산 로직을 한 곳에 모아 유지보수를 쉽게 만들고, 캐싱으로 성능을 최적화하며, 컴포넌트 코드를 간결하게 유지하는 이점을 제공합니다.

실전 팁

💡 Getter에서 무거운 계산은 피하세요. Getter는 여러 곳에서 자주 호출되므로, 복잡한 정렬이나 필터링은 Action에서 한 번만 처리하고 결과를 state에 저장하는 것이 좋습니다.

💡 인자를 받는 getter는 캐싱되지 않습니다. getItemById(1)을 10번 호출하면 10번 모두 실행됩니다. 성능이 중요하다면 computed를 사용하거나, 결과를 state에 저장하세요.

💡 Getter에서 비동기 작업을 하지 마세요. Getter는 동기적이어야 하며, API 호출이 필요하면 Action을 사용해야 합니다. 비동기 getter는 예측 불가능한 동작을 일으킵니다.

💡 다른 Store의 getter를 참조할 수 있습니다. const userStore = useUserStore(); return userStore.userName 처럼 Store 간 데이터를 조합하여 더 복잡한 getter를 만들 수 있습니다.

💡 Getter의 반환 타입을 명시하면 TypeScript에서 자동 완성이 더 잘 동작합니다. totalPrice(): number { ... } 처럼 작성하면 타입 안정성이 높아집니다.


4. Setup Syntax로 Store 작성 - Composition API 스타일

시작하며

여러분이 Vue 3의 Composition API에 익숙하다면, Options API 스타일의 Pinia Store가 조금 낯설게 느껴질 수 있습니다. ref, computed, 일반 함수를 사용하는 것이 더 자연스럽지 않나요?

이런 고민은 많은 개발자들이 겪습니다. Vue 컴포넌트는 <script setup>으로 작성하는데, Store만 다른 스타일로 작성하면 코드 일관성이 떨어집니다.

특히 Composition API의 강력한 기능들을 Store에서도 활용하고 싶어집니다. 코드를 재사용하고, 로직을 조합하는 것이 Composition API의 핵심인데, Store에서도 똑같이 하고 싶은 것이죠.

바로 이럴 때 필요한 것이 Pinia의 Setup Syntax입니다. 일반 함수 형태로 Store를 정의하면 ref, computed, watch 등 Composition API의 모든 기능을 그대로 사용할 수 있습니다.

개요

간단히 말해서, Setup Syntax는 Composition API 스타일로 Store를 작성하는 방법입니다. defineStore의 두 번째 인자로 함수를 전달하면, 그 함수 안에서 반환한 값들이 state, getters, actions가 됩니다.

왜 Setup Syntax가 필요할까요? Vue 3 컴포넌트와 동일한 방식으로 코드를 작성하면 학습 곡선이 줄어들고, Composables을 Store에서 바로 재사용할 수 있습니다.

예를 들어, useLocalStorage, useFetch 같은 유틸리티를 Store에서 직접 사용하면 코드가 매우 간결해집니다. 기존에는 state, getters, actions를 분리해서 작성했다면, 이제는 함수 안에서 자유롭게 로직을 구성할 수 있습니다.

ref로 선언한 변수는 state가 되고, computed는 getter가 되며, 일반 함수는 action이 됩니다. 이 방식은 더 직관적이고 유연합니다.

Setup Syntax의 핵심 특징은 세 가지입니다. 첫째, Composition API의 모든 기능(ref, reactive, computed, watch 등)을 사용할 수 있습니다.

둘째, 다른 Composables을 쉽게 통합하여 로직을 재사용할 수 있습니다. 셋째, 클로저를 활용한 private 변수를 만들 수 있어 캡슐화가 더 강력합니다.

이러한 특징들이 현대적인 Vue 3 개발과 완벽하게 어울리는 이유입니다.

코드 예제

// stores/counter.js
import { defineStore } from 'pinia'
import { ref, computed, watch } from 'vue'
import { useLocalStorage } from '@vueuse/core'

export const useCounterStore = defineStore('counter', () => {
  // ref = state
  const count = ref(0)
  const name = ref('Counter')

  // computed = getters
  const doubleCount = computed(() => count.value * 2)
  const isEven = computed(() => count.value % 2 === 0)

  // 일반 함수 = actions
  function increment() {
    count.value++
  }

  function decrement() {
    count.value--
  }

  // Composable 사용 가능
  const savedCount = useLocalStorage('counter', 0)

  // watch로 자동 저장
  watch(count, (newCount) => {
    savedCount.value = newCount
  })

  // 반환한 것들이 Store의 public API가 됨
  return { count, name, doubleCount, isEven, increment, decrement }
})

설명

이것이 하는 일: 이 코드는 카운터 Store를 Setup Syntax로 작성한 예제입니다. Vue 3 컴포넌트의 <script setup>과 거의 동일한 방식으로 작성되었으며, VueUse 라이브러리의 Composable까지 활용하고 있습니다.

첫 번째로, ref로 선언한 변수들은 Store의 state가 됩니다. count와 name이 반응형 상태로 동작하며, 컴포넌트에서 이 값을 변경하면 자동으로 UI가 업데이트됩니다.

Options API의 state() 함수와 동일한 역할을 하지만 문법이 더 간결하고 직관적입니다. 그 다음으로, computed로 선언한 값들은 getter가 됩니다.

doubleCount는 count의 두 배를, isEven은 짝수 여부를 계산합니다. count.value가 변경될 때만 재계산되며 그 외에는 캐시된 값을 사용합니다.

이는 Options API의 getters와 정확히 같은 동작입니다. increment와 decrement는 일반 함수로 정의되어 Store의 actions가 됩니다.

async 함수도 물론 가능합니다. 함수 안에서 count.value를 직접 수정하는데, Setup Syntax에서는 actions를 통하지 않고도 상태를 변경할 수 있습니다.

하지만 코드 구조상 함수로 만들어 두는 것이 좋습니다. 가장 흥미로운 부분은 Composables 활용입니다.

useLocalStorage를 사용하여 count를 로컬 스토리지에 자동으로 저장합니다. watch로 count가 변경될 때마다 savedCount를 업데이트하여 페이지를 새로고침해도 값이 유지됩니다.

이런 로직을 Options API 스타일로 작성하면 훨씬 복잡해집니다. 여러분이 이 Setup Syntax를 사용하면 Vue 컴포넌트와 동일한 사고방식으로 Store를 작성할 수 있습니다.

useFetch, useDebounce, useIntersectionObserver 같은 Composables을 Store에서 바로 사용하면 강력한 기능을 쉽게 구현할 수 있습니다. 이는 코드 일관성을 높이고, 학습 부담을 줄이며, 에코시스템의 다양한 라이브러리를 활용할 수 있게 합니다.

실전 팁

💡 Setup Syntax에서는 $reset()이 동작하지 않습니다. 초기값을 별도로 저장해두고 수동 리셋 함수를 만들어야 합니다. function reset() { count.value = 0; name.value = 'Counter' }

💡 반환하지 않은 변수와 함수는 private이 됩니다. 클로저를 활용하여 내부 로직을 숨길 수 있어 캡슐화가 강력해집니다. 외부에서 접근할 필요 없는 헬퍼 함수는 반환하지 마세요.

💡 Options API와 Setup Syntax를 섞어 쓰지 마세요. 프로젝트 전체에서 하나의 스타일로 통일하는 것이 중요합니다. 팀에서 Composition API를 주로 쓴다면 Setup Syntax로 가세요.

💡 TypeScript를 사용한다면 Setup Syntax가 타입 추론이 더 잘 됩니다. ref의 제네릭 타입이 자동으로 추론되고, computed의 반환 타입도 정확하게 추론됩니다.

💡 onMounted, onUnmounted 같은 라이프사이클 훅도 사용할 수 있습니다. Store가 처음 생성될 때 초기화 로직을 실행하거나, 정리 작업을 할 수 있어 매우 유용합니다.


5. Store 모듈화 - 여러 Store 조합하기

시작하며

여러분이 대규모 전자상거래 애플리케이션을 개발한다고 생각해보세요. 사용자 정보, 상품 목록, 장바구니, 주문 내역, 리뷰, 위시리스트...

이 모든 상태를 하나의 Store에 담으면 어떻게 될까요? 수천 줄의 코드가 한 파일에 몰려 유지보수가 악몽이 됩니다.

이런 문제는 실제 프로젝트가 성장하면서 반드시 마주치게 됩니다. 처음에는 작은 Store 하나로 시작했지만, 기능이 추가될수록 코드가 비대해지고 팀원들이 동시에 같은 파일을 수정하면서 충돌이 자주 발생합니다.

어떤 state가 어디서 사용되는지 추적하기도 어려워집니다. 바로 이럴 때 필요한 것이 Store 모듈화입니다.

Pinia는 처음부터 모듈화를 염두에 두고 설계되어, 각 도메인별로 독립적인 Store를 만들고 필요할 때 서로 참조할 수 있습니다.

개요

간단히 말해서, Store 모듈화는 애플리케이션의 상태를 기능별로 분리하여 여러 개의 작은 Store로 나누는 것입니다. 각 Store는 독립적으로 동작하면서도 필요할 때 다른 Store를 참조할 수 있습니다.

왜 Store 모듈화가 필요할까요? 큰 애플리케이션에서는 관심사의 분리(Separation of Concerns)가 필수입니다.

예를 들어, 사용자 인증 로직은 authStore에, 상품 관리는 productStore에, 주문 처리는 orderStore에 두면 각 팀원이 독립적으로 작업할 수 있고, 코드 충돌이 줄어듭니다. 기존에는 Vuex의 modules 시스템으로 네임스페이스를 관리했다면, 이제는 각각 독립적인 Store로 만들고 import하여 사용할 수 있습니다.

Pinia는 네임스페이스 없이도 각 Store가 완전히 격리되어 있어 이름 충돌 걱정이 없습니다. Store 모듈화의 핵심 특징은 세 가지입니다.

첫째, 각 Store는 독립적인 파일로 관리되어 코드 구조가 명확합니다. 둘째, Store 간 순환 참조를 피하면서도 자유롭게 조합할 수 있습니다.

셋째, 각 Store를 개별적으로 테스트할 수 있어 테스트 코드 작성이 쉽습니다. 이러한 특징들이 대규모 팀 프로젝트에서 필수적인 이유입니다.

코드 예제

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

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

  getters: {
    isLoggedIn: (state) => !!state.token
  },

  actions: {
    login(credentials) {
      // 로그인 로직
      this.user = credentials.user
      this.token = credentials.token
    }
  }
})

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

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

  actions: {
    async checkout() {
      // 다른 Store 참조
      const authStore = useAuthStore()

      if (!authStore.isLoggedIn) {
        throw new Error('로그인이 필요합니다')
      }

      // 주문 처리 로직
      const order = {
        userId: authStore.user.id,
        items: this.items,
        token: authStore.token
      }

      await fetch('/api/orders', {
        method: 'POST',
        headers: { 'Authorization': `Bearer ${order.token}` },
        body: JSON.stringify(order)
      })

      this.items = [] // 장바구니 비우기
    }
  }
})

설명

이것이 하는 일: 이 코드는 인증과 장바구니를 별도의 Store로 분리하고, cartStore에서 authStore를 참조하여 로그인 여부를 확인하는 예제입니다. 실제 전자상거래 애플리케이션의 구조를 보여줍니다.

첫 번째로, authStore는 사용자 인증과 관련된 모든 것을 담당합니다. user와 token state를 관리하고, isLoggedIn getter로 로그인 상태를 제공합니다.

이 Store는 다른 Store에 의존하지 않아 재사용성이 높고 테스트하기 쉽습니다. 인증 로직이 한 곳에 모여있어 보안 관련 수정도 용이합니다.

그 다음으로, cartStore는 장바구니 기능을 담당하지만 checkout 시 인증 정보가 필요합니다. import { useAuthStore } from './auth'로 authStore를 가져오고, 액션 안에서 const authStore = useAuthStore()로 인스턴스를 얻습니다.

이렇게 하면 다른 Store의 state와 getters에 접근할 수 있습니다. checkout 액션에서는 먼저 authStore.isLoggedIn으로 로그인 여부를 확인합니다.

로그인하지 않았다면 에러를 던져 상위 컴포넌트에서 처리하도록 합니다. 로그인한 경우에만 authStore.user.id와 authStore.token을 사용하여 주문을 생성합니다.

이처럼 Store 간 데이터를 자유롭게 공유할 수 있습니다. 주의할 점은 Store의 최상위 레벨에서 다른 Store를 호출하면 안 된다는 것입니다.

const authStore = useAuthStore()는 반드시 actions, getters 내부에서 호출해야 합니다. 그래야 Pinia가 올바르게 초기화되고 순환 참조 문제를 피할 수 있습니다.

여러분이 이 모듈화 패턴을 사용하면 각 Store가 단일 책임 원칙을 따르게 됩니다. authStore는 인증만, cartStore는 장바구니만 담당하여 코드가 명확해집니다.

새로운 기능을 추가할 때도 관련된 Store만 수정하면 되어 다른 부분에 영향을 주지 않습니다. 이는 팀 협업을 원활하게 만들고, 코드 리뷰를 쉽게 하며, 버그 발생 가능성을 낮추는 효과를 제공합니다.

실전 팁

💡 Store는 도메인별로 나누되, 너무 잘게 쪼개지 마세요. userStore, userProfileStore, userSettingsStore보다는 하나의 userStore로 통합하는 것이 관리하기 쉽습니다.

💡 다른 Store를 호출할 때는 actions나 getters 내부에서만 하세요. state나 최상위 레벨에서 호출하면 초기화 순서 문제로 에러가 발생할 수 있습니다.

💡 순환 참조를 조심하세요. A Store가 B Store를 참조하고 B Store가 다시 A Store를 참조하면 문제가 생깁니다. 이런 경우 공통 로직을 별도 Store로 분리하세요.

💡 Store 간 통신이 복잡해지면 이벤트 버스나 Composable을 고려하세요. 모든 것을 Store에 넣을 필요는 없으며, 때로는 다른 패턴이 더 적합할 수 있습니다.

💡 각 Store의 파일명과 Store 이름을 일치시키세요. stores/user.js에는 useUserStore를, stores/cart.js에는 useCartStore를 두면 찾기 쉽고 일관성이 유지됩니다.


6. State 구독과 변경 감지 - $subscribe와 $onAction

시작하며

여러분이 사용자의 모든 행동을 로깅해야 하는 요구사항을 받았다고 상상해보세요. "어떤 버튼을 클릭했는지", "어떤 상태가 변경되었는지" 모두 기록해야 합니다.

각 액션마다 로깅 코드를 추가하면 코드가 지저분해지지 않을까요? 이런 문제는 실무에서 매우 흔합니다.

분석을 위한 이벤트 추적, 에러 리포팅, 상태 변경 히스토리 관리 등 횡단 관심사(Cross-cutting Concerns)는 비즈니스 로직과 섞이면 코드가 복잡해집니다. 각 액션에 추적 코드를 넣으면 나중에 추적 방식을 바꿀 때 모든 코드를 수정해야 합니다.

바로 이럴 때 필요한 것이 Pinia의 $subscribe와 $onAction입니다. 이 메서드들은 Store의 상태 변경과 액션 실행을 외부에서 감시하여, 비즈니스 로직을 건드리지 않고 부가 기능을 추가할 수 있게 합니다.

개요

간단히 말해서, $subscribe는 state가 변경될 때마다 콜백을 실행하고, $onAction은 액션이 호출될 때마다 콜백을 실행합니다. 이를 통해 로깅, 분석, 동기화 등의 부가 기능을 깔끔하게 구현할 수 있습니다.

왜 이런 구독 기능이 필요할까요? 애플리케이션 전체의 상태 변경을 한 곳에서 모니터링하면 디버깅이 쉬워지고, 사용자 행동 분석을 체계적으로 할 수 있습니다.

예를 들어, 모든 상태 변경을 로컬 스토리지에 자동 저장하거나, 특정 액션이 실행될 때마다 Google Analytics에 이벤트를 보낼 수 있습니다. 기존에는 각 액션 안에 직접 로깅 코드를 작성했다면, 이제는 외부에서 구독하여 관심사를 분리할 수 있습니다.

액션 코드는 비즈니스 로직에만 집중하고, 로깅 같은 부가 기능은 구독자가 처리하는 것이죠. 이는 클린 코드의 핵심 원칙입니다.

$subscribe와 $onAction의 핵심 특징은 세 가지입니다. 첫째, 비침투적(Non-intrusive)이어서 기존 코드를 수정하지 않고 기능을 추가할 수 있습니다.

둘째, 여러 개의 구독자를 등록할 수 있어 각 관심사를 독립적으로 관리할 수 있습니다. 셋째, 컴포넌트 언마운트 시 자동으로 구독을 해제하거나, detached 옵션으로 영구적인 구독도 가능합니다.

이러한 특징들이 대규모 애플리케이션의 관찰성(Observability)을 높이는 핵심입니다.

코드 예제

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

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

  actions: {
    addItem(item) {
      this.items.push(item)
      this.totalPrice += item.price
    }
  }
})

// main.js 또는 컴포넌트에서
import { useCartStore } from './stores/cart'

const cartStore = useCartStore()

// state 변경 감지
cartStore.$subscribe((mutation, state) => {
  console.log('상태 변경 타입:', mutation.type) // 'direct' | 'patch object' | 'patch function'
  console.log('변경된 Store:', mutation.storeId) // 'cart'
  console.log('새로운 상태:', state)

  // 로컬 스토리지에 자동 저장
  localStorage.setItem('cart', JSON.stringify(state))
}, { detached: true }) // 컴포넌트 언마운트 시에도 유지

// 액션 실행 감지
cartStore.$onAction(({
  name, // 액션 이름
  store, // Store 인스턴스
  args, // 액션에 전달된 인자
  after, // 액션 성공 후 콜백
  onError // 액션 에러 시 콜백
}) => {
  console.log(`액션 "${name}" 실행 시작`)
  const startTime = Date.now()

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

    // Google Analytics 전송
    gtag('event', 'action_executed', {
      action_name: name,
      duration: duration
    })
  })

  onError((error) => {
    console.error(`액션 "${name}" 실패:`, error)
    // Sentry에 에러 전송
    Sentry.captureException(error)
  })
})

설명

이것이 하는 일: 이 코드는 장바구니 Store의 모든 상태 변경과 액션 실행을 감시하여 로컬 스토리지 저장, 실행 시간 측정, 에러 추적 등을 자동으로 수행합니다. 비즈니스 로직과 완전히 분리되어 있습니다.

첫 번째로, $subscribe는 state가 변경될 때마다 콜백을 실행합니다. mutation 객체는 변경에 대한 메타데이터를 담고 있는데, type은 'direct'(직접 할당), 'patch object'($patch 사용), 'patch function'($patch에 함수 전달) 중 하나입니다.

storeId는 어떤 Store가 변경되었는지 알려주며, state는 변경 후의 전체 상태입니다. 그 다음으로, 콜백 안에서 로컬 스토리지에 상태를 저장합니다.

이렇게 하면 사용자가 페이지를 새로고침해도 장바구니 내용이 유지됩니다. detached: true 옵션은 중요한데, 기본적으로 구독은 컴포넌트가 언마운트되면 자동 해제되지만, detached를 true로 하면 앱이 종료될 때까지 유지됩니다.

로컬 스토리지 저장 같은 전역 기능은 detached로 설정해야 합니다. $onAction은 더 강력합니다.

액션이 실행되기 전에 콜백이 호출되어, 액션 이름(name), Store 인스턴스(store), 전달된 인자(args)에 접근할 수 있습니다. 여기서는 실행 시작 시간을 기록합니다.

after 콜백을 등록하면 액션이 성공적으로 완료된 후에 실행되어, 실행 시간을 계산하고 Google Analytics에 이벤트를 전송할 수 있습니다. onError 콜백은 액션에서 에러가 발생했을 때 실행됩니다.

try-catch 없이도 모든 액션의 에러를 한 곳에서 처리할 수 있어, Sentry 같은 에러 추적 서비스와 통합하기 완벽합니다. 이렇게 하면 각 액션에 에러 처리 코드를 추가할 필요가 없습니다.

여러분이 이 구독 패턴을 사용하면 횡단 관심사를 깔끔하게 분리할 수 있습니다. 로깅, 분석, 에러 추적, 자동 저장 같은 기능을 한 곳에 모아두고, 나중에 요구사항이 바뀌어도 구독 코드만 수정하면 됩니다.

이는 코드의 유지보수성을 크게 높이고, 새로운 관찰 기능을 쉽게 추가할 수 있게 하며, 비즈니스 로직을 순수하게 유지하는 이점을 제공합니다.

실전 팁

💡 $subscribe의 detached 옵션을 상황에 맞게 사용하세요. 전역 기능(로컬 스토리지 저장 등)은 detached: true로, 컴포넌트별 기능은 false(기본값)로 설정하세요.

💡 $onAction에서 비동기 작업을 하려면 after 콜백을 async로 만들어도 됩니다. 하지만 액션의 실행을 블로킹하지 않으므로, 순서가 중요한 작업은 액션 내부에서 처리해야 합니다.

💡 성능이 중요한 경우 $subscribe를 조심하세요. state가 자주 변경되면 콜백이 매번 실행되어 느려질 수 있습니다. 디바운싱이나 쓰로틀링을 고려하세요.

💡 개발 환경에서만 구독을 활성화하려면 if (import.meta.env.DEV)로 감싸세요. 프로덕션에서 불필요한 로깅이 실행되지 않아 성능이 향상됩니다.

💡 여러 Store를 동시에 구독하려면 각 Store마다 $subscribe를 호출하거나, 공통 유틸리티 함수를 만들어 모든 Store를 순회하며 구독하세요.


7. Pinia Plugins 활용 - Store 확장하기

시작하며

여러분이 모든 Store에 공통적으로 필요한 기능이 있다면 어떻게 하시겠습니까? 예를 들어, 모든 Store의 상태를 로컬 스토리지에 자동 저장하거나, 모든 액션에 로딩 상태를 추가하고 싶을 때 각 Store마다 코드를 복사-붙여넣기 하시나요?

이런 문제는 프로젝트가 커질수록 심각해집니다. 10개, 20개의 Store에 동일한 코드를 추가하면 유지보수가 악몽이 됩니다.

나중에 로직을 변경하려면 모든 Store를 찾아 수정해야 하고, 하나라도 빠뜨리면 버그가 발생합니다. 코드 중복은 개발자의 적입니다.

바로 이럴 때 필요한 것이 Pinia Plugins입니다. 플러그인은 모든 Store에 공통 기능을 자동으로 추가하는 메커니즘으로, 한 번만 작성하면 모든 Store가 그 기능을 사용할 수 있습니다.

개요

간단히 말해서, Pinia Plugin은 Store가 생성될 때마다 실행되는 함수입니다. 이 함수 안에서 Store에 새로운 속성이나 메서드를 추가하거나, 기존 동작을 수정할 수 있습니다.

왜 플러그인이 필요할까요? DRY(Don't Repeat Yourself) 원칙을 지키기 위해서입니다.

로컬 스토리지 동기화, 에러 처리, 로딩 상태 관리, 디버그 로깅 같은 공통 기능을 플러그인으로 만들면 모든 Store가 자동으로 이 기능을 갖게 됩니다. 예를 들어, 모든 Store에 $reset 기능을 추가하거나, 모든 액션에 자동 재시도 로직을 넣을 수 있습니다.

기존에는 믹스인이나 상속으로 공통 로직을 재사용했다면, 이제는 플러그인으로 더 깔끔하게 구현할 수 있습니다. 플러그인은 각 Store의 코드를 건드리지 않고 외부에서 기능을 주입하므로, Store는 자신의 비즈니스 로직에만 집중할 수 있습니다.

Pinia Plugin의 핵심 특징은 세 가지입니다. 첫째, 모든 Store에 자동으로 적용되어 일관성을 보장합니다.

둘째, Store의 내부 구조에 접근하여 state, getters, actions를 수정하거나 추가할 수 있습니다. 셋째, 여러 플러그인을 조합하여 복잡한 기능을 모듈화할 수 있습니다.

이러한 특징들이 엔터프라이즈 애플리케이션에서 표준화와 확장성을 보장하는 핵심입니다.

코드 예제

// plugins/persistPlugin.js
export function persistPlugin({ store }) {
  // Store 생성 시 로컬 스토리지에서 복원
  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))
  })
}

// plugins/loadingPlugin.js
export function loadingPlugin({ store }) {
  // 모든 Store에 loading state 추가
  store.$state.loading = false

  // 모든 액션을 래핑하여 로딩 상태 자동 관리
  const originalActions = { ...store }

  Object.keys(store).forEach(key => {
    if (typeof store[key] === 'function') {
      const originalAction = store[key]

      store[key] = async function (...args) {
        store.loading = true
        try {
          const result = await originalAction.apply(this, args)
          return result
        } finally {
          store.loading = false
        }
      }
    }
  })
}

// main.js
import { createPinia } from 'pinia'
import { persistPlugin } from './plugins/persistPlugin'
import { loadingPlugin } from './plugins/loadingPlugin'

const pinia = createPinia()

// 플러그인 등록
pinia.use(persistPlugin)
pinia.use(loadingPlugin)

app.use(pinia)

설명

이것이 하는 일: 이 코드는 두 개의 플러그인을 정의합니다. persistPlugin은 모든 Store의 상태를 로컬 스토리지에 자동 저장하고, loadingPlugin은 모든 액션에 로딩 상태를 자동으로 추가합니다.

각 Store는 이 기능들을 무료로 얻게 됩니다. 첫 번째로, persistPlugin은 Store가 생성될 때 로컬 스토리지에서 저장된 상태를 복원합니다.

store.$id는 Store의 고유 식별자이므로 각 Store가 독립적인 키로 저장됩니다. savedState가 있으면 $patch로 상태를 덮어쓰는데, $patch는 부분 업데이트를 지원하여 새로운 state 속성이 추가되어도 안전합니다.

그 다음으로, $subscribe로 상태 변경을 감시하여 변경될 때마다 로컬 스토리지에 저장합니다. 이 플러그인 하나만 등록하면 모든 Store가 페이지 새로고침 후에도 상태를 유지합니다.

userStore, cartStore, settingsStore 등 모든 Store가 자동으로 이 기능을 갖게 되는 것이죠. loadingPlugin은 더 고급 기술을 사용합니다.

먼저 store.$state.loading = false로 모든 Store에 loading 속성을 추가합니다. 그런 다음 Store의 모든 메서드를 순회하면서 함수인 것(액션)을 찾습니다.

원본 액션을 저장해두고, 새로운 함수로 교체하는데, 이 함수는 액션 실행 전에 loading을 true로, 실행 후에 false로 설정합니다. finally 블록을 사용하여 액션이 성공하든 실패하든 항상 loading을 false로 되돌립니다.

async/await과 try-finally를 조합하여 모든 비동기 액션에서 올바르게 동작합니다. 이제 모든 Store에서 store.loading으로 로딩 상태를 확인할 수 있고, 각 액션에 로딩 로직을 작성할 필요가 없습니다.

여러분이 이 플러그인 시스템을 사용하면 횡단 관심사를 완벽하게 분리할 수 있습니다. 새로운 Store를 만들 때 공통 기능은 자동으로 추가되고, 나중에 로직을 변경할 때도 플러그인만 수정하면 모든 Store에 반영됩니다.

이는 코드 중복을 제거하고, 일관성을 보장하며, 확장 가능한 아키텍처를 만드는 이점을 제공합니다.

실전 팁

💡 플러그인에서 store.$id로 특정 Store만 대상으로 할 수 있습니다. if (store.$id === 'auth') 처럼 조건을 걸면 선택적 적용이 가능합니다.

💡 플러그인은 등록 순서대로 실행됩니다. 의존성이 있는 플러그인은 순서에 주의하세요. 예를 들어 로깅 플러그인은 다른 플러그인 이후에 등록해야 모든 변경을 추적할 수 있습니다.

💡 플러그인에서 store.$onAction과 store.$subscribe를 모두 사용할 수 있습니다. 두 가지를 조합하면 매우 강력한 관찰 시스템을 구축할 수 있습니다.

💡 TypeScript 사용 시 플러그인에서 추가한 속성의 타입을 선언해야 합니다. declare module 'pinia' { ... }로 확장하면 자동 완성이 동작합니다.

💡 플러그인은 테스트 환경에서 비활성화할 수 있습니다. if (!import.meta.env.TEST) pinia.use(plugin) 처럼 조건부로 등록하면 테스트가 간단해집니다.


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

댓글 (0)

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