이미지 로딩 중...

Pinia 상태관리 완벽 가이드 Store 생성부터 활용까지 - 슬라이드 1/8
A

AI Generated

2025. 11. 8. · 3 Views

Pinia 상태관리 완벽 가이드 Store 생성부터 활용까지

Vue 3의 공식 상태관리 라이브러리 Pinia를 처음 접하는 초급 개발자를 위한 가이드입니다. Store 생성, State 관리, Actions와 Getters 활용법을 실무 예제와 함께 단계별로 배워보세요.


목차

  1. Pinia Store 생성하기
  2. State 정의와 접근
  3. Getters로 계산된 값 만들기
  4. Actions로 상태 변경하기
  5. 컴포넌트에서 Store 사용하기
  6. 여러 Store 함께 사용하기
  7. Store 구독과 감시

1. Pinia Store 생성하기

시작하며

여러분이 Vue 애플리케이션을 개발할 때 여러 컴포넌트가 같은 데이터를 공유해야 하는 상황을 겪어본 적 있나요? 예를 들어, 사용자 로그인 정보를 헤더, 사이드바, 프로필 페이지에서 모두 사용해야 하는 경우입니다.

이런 문제는 실제 개발 현장에서 자주 발생합니다. Props를 통해 데이터를 전달하다 보면 컴포넌트 계층이 깊어질수록 Props Drilling 문제가 발생하고, 코드가 복잡해지며 유지보수가 어려워집니다.

바로 이럴 때 필요한 것이 Pinia Store입니다. Store를 사용하면 전역 상태를 중앙에서 관리하고, 어떤 컴포넌트에서든 쉽게 접근할 수 있습니다.

개요

간단히 말해서, Pinia Store는 애플리케이션의 전역 상태를 관리하는 저장소입니다. Store를 사용하면 여러 컴포넌트에서 공유해야 하는 데이터를 한 곳에 모아 관리할 수 있습니다.

예를 들어, 쇼핑몰 애플리케이션에서 장바구니 정보, 사용자 인증 상태, 테마 설정 같은 경우에 매우 유용합니다. 기존에는 Vuex를 사용했다면, 이제는 더 간단하고 타입 안전한 Pinia를 사용할 수 있습니다.

Pinia Store의 핵심 특징은 세 가지입니다: State(상태 데이터), Getters(계산된 값), Actions(상태 변경 메서드). 이러한 특징들이 명확하게 구분되어 있어 코드를 이해하고 유지보수하기 쉽습니다.

코드 예제

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

// 'counter'는 store의 고유 ID입니다
export const useCounterStore = defineStore('counter', {
  // state: 스토어의 데이터를 정의합니다
  state: () => ({
    count: 0,
    name: 'Counter Store'
  }),

  // getters: 계산된 값을 정의합니다
  getters: {
    doubleCount: (state) => state.count * 2
  },

  // actions: 상태를 변경하는 메서드를 정의합니다
  actions: {
    increment() {
      this.count++
    }
  }
})

설명

이것이 하는 일: Pinia Store를 생성하여 전역 상태를 관리할 수 있는 기반을 만듭니다. 첫 번째로, defineStore 함수를 사용하여 Store를 정의합니다.

첫 번째 매개변수인 'counter'는 Store의 고유 ID로, 개발자 도구에서 디버깅할 때 이 이름으로 Store를 구분할 수 있습니다. Store ID는 애플리케이션 전체에서 중복되지 않아야 하며, 일반적으로 Store의 역할을 나타내는 명확한 이름을 사용합니다.

두 번째 매개변수는 Store의 옵션 객체입니다. state는 함수 형태로 정의하며, 이 함수가 반환하는 객체가 실제 상태 데이터가 됩니다.

함수 형태로 작성하는 이유는 각 Store 인스턴스가 독립적인 상태를 가지도록 하기 위함입니다. getters는 state를 기반으로 계산된 값을 제공하며, Vue의 computed와 유사합니다.

actions는 state를 변경하는 메서드를 정의하는 곳입니다. actions 내부에서는 this를 통해 state에 직접 접근하고 수정할 수 있습니다.

Vuex와 달리 mutations가 없어 코드가 더 간단해졌습니다. 여러분이 이 코드를 사용하면 재사용 가능한 상태 관리 로직을 만들 수 있고, 컴포넌트 간 데이터 공유가 매우 쉬워집니다.

또한 타입스크립트를 사용할 경우 자동으로 타입 추론이 되어 개발 경험이 향상됩니다.

실전 팁

💡 Store ID는 kebab-case나 camelCase를 일관되게 사용하세요. 'user-auth', 'shopping-cart' 같은 형식이 좋습니다.

💡 Store 파일은 stores 폴더에 모아두고, 파일명은 Store의 역할을 명확히 나타내도록 작성하세요(예: userStore.js, cartStore.js).

💡 state 함수 내부에서 복잡한 로직을 실행하지 마세요. 초기값만 간단히 설정하고, 복잡한 초기화는 actions에서 처리하세요.

💡 하나의 Store는 하나의 책임만 가지도록 설계하세요. 사용자 관련 데이터와 장바구니 데이터는 별도 Store로 분리하는 것이 좋습니다.

💡 Store를 생성한 후에는 반드시 main.js에서 Pinia를 설치했는지 확인하세요. createPinia()를 app.use()로 등록해야 합니다.


2. State 정의와 접근

시작하며

여러분이 사용자 정보를 여러 컴포넌트에서 사용해야 할 때, 어떻게 데이터를 구조화하고 접근하시나요? 사용자 이름, 이메일, 권한 등 여러 정보를 개별 변수로 관리하면 코드가 산만해집니다.

이런 문제는 특히 애플리케이션이 커질수록 심각해집니다. 관련된 데이터들이 흩어져 있으면 어디서 어떤 데이터를 관리하는지 파악하기 어렵고, 버그가 발생할 가능성도 높아집니다.

바로 이럴 때 필요한 것이 Pinia의 State입니다. State를 체계적으로 정의하면 관련 데이터를 하나로 묶어 관리할 수 있고, 컴포넌트에서 직관적으로 접근할 수 있습니다.

개요

간단히 말해서, State는 Store가 관리하는 실제 데이터를 담는 공간입니다. State를 잘 정의하면 애플리케이션의 데이터 구조가 명확해지고, 여러 컴포넌트에서 일관되게 데이터를 사용할 수 있습니다.

예를 들어, 사용자 프로필 페이지, 헤더의 사용자 이름 표시, 권한 기반 메뉴 표시 같은 경우에 동일한 State를 참조하면 됩니다. 기존에는 각 컴포넌트에서 data()로 상태를 관리했다면, 이제는 Store의 State로 중앙 집중식 관리가 가능합니다.

State의 핵심 특징은 반응성(Reactivity)입니다. State 값이 변경되면 이를 사용하는 모든 컴포넌트가 자동으로 업데이트됩니다.

또한 State는 객체, 배열, 원시 타입 등 모든 JavaScript 데이터 타입을 저장할 수 있어 유연합니다.

코드 예제

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

export const useUserStore = defineStore('user', {
  state: () => ({
    // 원시 타입 상태
    userId: null,
    username: '',
    isLoggedIn: false,

    // 객체 타입 상태
    profile: {
      email: '',
      avatar: '',
      role: 'guest'
    },

    // 배열 타입 상태
    favoriteItems: [],
    notifications: []
  })
})

설명

이것이 하는 일: Store에서 관리할 데이터를 구조화하여 정의하고, 컴포넌트에서 반응적으로 접근할 수 있게 합니다. 첫 번째로, state는 반드시 함수 형태로 정의해야 합니다.

화살표 함수 () => ({})를 사용하여 객체를 반환하는 형태로 작성합니다. 이렇게 하는 이유는 SSR(Server-Side Rendering) 환경에서 Store가 여러 번 인스턴스화될 때 각각 독립적인 상태를 가지도록 하기 위함입니다.

만약 객체를 직접 반환하면 모든 인스턴스가 같은 객체를 참조하여 문제가 발생할 수 있습니다. state 객체 내부에는 원시 타입(숫자, 문자열, 불린), 객체, 배열 등 다양한 데이터 타입을 자유롭게 정의할 수 있습니다.

userId, username처럼 단순한 값도 있고, profile처럼 관련된 데이터를 하나의 객체로 묶을 수도 있습니다. 중첩된 객체나 배열도 완벽하게 반응성을 유지합니다.

컴포넌트에서 State에 접근할 때는 store.userId, store.profile.email처럼 직접 속성에 접근할 수 있습니다. Pinia는 내부적으로 Vue의 reactive()를 사용하므로, State의 모든 속성이 자동으로 반응형이 됩니다.

여러분이 이 방식으로 State를 정의하면 데이터 구조가 명확해지고, 타입스크립트를 사용할 경우 자동 완성과 타입 체크의 혜택을 받을 수 있습니다. 또한 개발자 도구에서 State의 변화를 실시간으로 추적하고 디버깅할 수 있습니다.

실전 팁

💡 State의 초기값은 실제 사용할 데이터 타입과 일치시키세요. 숫자면 0, 문자열이면 '', 배열이면 []로 초기화하여 타입 안정성을 높이세요.

💡 관련된 데이터는 객체로 묶어서 관리하세요. user.name, user.email보다 user: { name: '', email: '' } 형태가 더 관리하기 쉽습니다.

💡 State에 함수나 메서드를 저장하지 마세요. 함수는 Actions에 정의하는 것이 올바른 패턴입니다.

💡 민감한 정보(비밀번호, 토큰 등)를 State에 저장할 때는 주의하세요. 개발자 도구에서 노출될 수 있으므로, 필요한 경우에만 저장하고 적절히 암호화하세요.

💡 State의 구조를 자주 변경하지 마세요. 초기 설계를 잘 하는 것이 중요하며, 구조 변경 시 많은 컴포넌트에 영향을 줄 수 있습니다.


3. Getters로 계산된 값 만들기

시작하며

여러분이 장바구니 애플리케이션을 만들 때, 전체 상품 가격을 계산하거나 할인된 가격을 표시해야 하는 상황을 생각해보세요. 매번 컴포넌트에서 같은 계산 로직을 반복하면 코드 중복이 발생합니다.

이런 문제는 유지보수 측면에서도 심각합니다. 계산 로직이 변경되면 모든 컴포넌트를 찾아서 수정해야 하고, 실수로 일부를 빠뜨리면 버그가 발생합니다.

바로 이럴 때 필요한 것이 Getters입니다. Getters를 사용하면 State를 기반으로 계산된 값을 한 곳에 정의하고, 여러 컴포넌트에서 재사용할 수 있습니다.

개요

간단히 말해서, Getters는 State로부터 파생된 값을 계산하여 제공하는 계산 속성입니다. Getters를 사용하면 복잡한 계산 로직을 Store에 캡슐화하고, 컴포넌트는 간단히 결과만 가져다 쓸 수 있습니다.

예를 들어, 사용자 목록에서 활성 사용자만 필터링하거나, 상품 목록을 가격순으로 정렬하거나, 통계 데이터를 계산하는 경우에 매우 유용합니다. 기존에는 각 컴포넌트에서 computed 속성으로 계산했다면, 이제는 Getters로 중앙에서 관리하여 재사용성을 높일 수 있습니다.

Getters의 핵심 특징은 캐싱과 반응성입니다. Getters는 Vue의 computed처럼 동작하여 의존하는 State가 변경될 때만 다시 계산됩니다.

또한 Getters는 다른 Getters를 참조할 수 있어 복잡한 파생 상태를 효율적으로 만들 수 있습니다.

코드 예제

// 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: {
    // 전체 상품 개수
    totalItems: (state) => {
      return state.items.reduce((sum, item) => sum + item.quantity, 0)
    },

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

    // 다른 getter를 사용하는 getter
    discountedPrice() {
      return this.totalPrice * (1 - this.discountRate)
    },

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

설명

이것이 하는 일: State의 데이터를 가공하고 계산하여, 컴포넌트에서 즉시 사용할 수 있는 파생 상태를 제공합니다. 첫 번째로, 기본적인 Getter는 state를 매개변수로 받는 화살표 함수 형태로 정의합니다.

totalItems와 totalPrice가 이 형태입니다. state 매개변수를 통해 현재 Store의 모든 State에 접근할 수 있으며, 배열 메서드(reduce, map, filter 등)를 활용하여 원하는 값을 계산합니다.

이렇게 정의된 Getter는 Vue의 computed처럼 캐싱되어, items 배열이 변경될 때만 다시 계산됩니다. 두 번째로, 다른 Getter를 참조해야 하는 경우 일반 함수 형태로 정의하고 this를 사용합니다.

discountedPrice가 이 예시입니다. this.totalPrice로 다른 Getter의 값을 가져와 사용할 수 있으며, 이를 통해 Getter 간의 의존성을 만들 수 있습니다.

이 방식은 복잡한 계산을 여러 단계로 나누어 관리할 때 유용합니다. 세 번째로, 매개변수를 받는 Getter가 필요한 경우 함수를 반환하는 형태로 정의합니다.

getItemById가 이 패턴입니다. 이 경우 캐싱이 되지 않아 호출할 때마다 계산이 실행되지만, 동적인 검색이나 필터링이 필요할 때 매우 유용합니다.

예를 들어 store.getItemById(1) 형태로 사용할 수 있습니다. 여러분이 Getters를 적절히 활용하면 비즈니스 로직을 컴포넌트에서 분리하여 코드 품질이 향상되고, 같은 계산 로직을 여러 곳에서 재사용할 수 있어 유지보수가 쉬워집니다.

또한 Getters는 자동으로 타입 추론이 되어 개발 시 자동 완성의 도움을 받을 수 있습니다.

실전 팁

💡 Getters는 순수 함수로 작성하세요. 부작용(side effects)이 없어야 하며, 같은 입력에 항상 같은 결과를 반환해야 합니다.

💡 비용이 큰 계산은 Getter로 만들어 캐싱의 이점을 활용하세요. 대량의 데이터를 필터링하거나 정렬하는 로직이 좋은 예입니다.

💡 매개변수를 받는 Getter는 캐싱되지 않으므로 남용하지 마세요. 자주 호출되는 계산이라면 다른 방식을 고려하세요.

💡 Getter 내부에서 비동기 작업을 하지 마세요. 비동기 로직은 Actions에서 처리하고, 그 결과를 State에 저장한 후 Getter로 접근하세요.

💡 다른 Store의 Getter를 참조해야 한다면, Actions에서 해당 Store를 import하여 사용하는 것이 더 명확합니다.


4. Actions로 상태 변경하기

시작하며

여러분이 사용자가 로그인 버튼을 클릭했을 때, API를 호출하고 응답을 받아 State를 업데이트해야 하는 상황을 생각해보세요. 이런 비즈니스 로직을 컴포넌트에 직접 작성하면 컴포넌트가 복잡해집니다.

이런 문제는 테스트와 재사용성 측면에서도 불리합니다. 여러 컴포넌트에서 같은 로직이 필요할 때마다 코드를 복사하게 되고, 로직이 변경되면 모든 곳을 수정해야 합니다.

바로 이럴 때 필요한 것이 Actions입니다. Actions를 사용하면 비즈니스 로직과 State 변경 로직을 Store에 캡슐화하고, 컴포넌트는 단순히 Action을 호출하기만 하면 됩니다.

개요

간단히 말해서, Actions는 State를 변경하고 비즈니스 로직을 처리하는 메서드입니다. Actions를 사용하면 동기/비동기 작업을 모두 처리할 수 있고, 복잡한 State 업데이트 로직을 한 곳에 모을 수 있습니다.

예를 들어, API 호출, 데이터 검증, 여러 State를 동시에 업데이트하는 복잡한 트랜잭션, 에러 처리 같은 경우에 매우 유용합니다. 기존 Vuex에서는 mutations와 actions를 분리했다면, Pinia에서는 actions만 사용하여 코드가 훨씬 간단해졌습니다.

Actions의 핵심 특징은 유연성과 this 바인딩입니다. Actions 내부에서 this를 통해 State, Getters, 다른 Actions에 모두 접근할 수 있습니다.

또한 async/await를 사용한 비동기 처리가 자연스럽고, 여러 Store의 Actions를 조합할 수도 있습니다.

코드 예제

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

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

  actions: {
    // 비동기 Action - 로그인
    async login(email, password) {
      this.isLoading = true
      this.error = null

      try {
        // API 호출 시뮬레이션
        const response = await fetch('/api/login', {
          method: 'POST',
          body: JSON.stringify({ email, password })
        })

        const data = await response.json()

        // State 업데이트
        this.user = data.user
        this.token = data.token

        // 로컬 스토리지에 토큰 저장
        localStorage.setItem('token', data.token)

        return true
      } catch (error) {
        this.error = error.message
        return false
      } finally {
        this.isLoading = false
      }
    },

    // 동기 Action - 로그아웃
    logout() {
      this.user = null
      this.token = null
      localStorage.removeItem('token')
    },

    // 다른 Action 호출
    async checkAuth() {
      const token = localStorage.getItem('token')
      if (token) {
        // 토큰으로 사용자 정보 가져오기
        await this.login(null, null) // 실제로는 다른 API 호출
      }
    }
  }
})

설명

이것이 하는 일: 비즈니스 로직을 실행하고, API 호출을 처리하며, State를 안전하게 업데이트합니다. 첫 번째로, Actions는 일반 JavaScript 메서드처럼 정의합니다.

매개변수를 자유롭게 받을 수 있고, async/await를 사용하여 비동기 작업을 처리할 수 있습니다. login Action에서 볼 수 있듯이, 여러 단계의 로직을 순차적으로 실행하고 에러를 처리할 수 있습니다.

this.isLoading = true처럼 this를 통해 State에 직접 접근하여 값을 변경할 수 있으며, Vuex처럼 commit이나 dispatch를 사용할 필요가 없습니다. 두 번째로, try-catch-finally 블록을 활용하여 에러 처리와 로딩 상태 관리를 체계적으로 할 수 있습니다.

API 호출 전에 isLoading을 true로 설정하고, finally 블록에서 false로 되돌려 놓으면 성공/실패 여부와 관계없이 로딩 상태가 올바르게 관리됩니다. 에러가 발생하면 catch 블록에서 error State를 업데이트하여 컴포넌트에서 에러 메시지를 표시할 수 있습니다.

세 번째로, Actions는 다른 Actions를 호출할 수 있습니다. checkAuth Action에서 this.login()을 호출하는 것처럼, 복잡한 비즈니스 로직을 여러 작은 Actions로 나누어 조합할 수 있습니다.

이렇게 하면 각 Action이 단일 책임을 가지게 되어 테스트하기 쉽고, 재사용성도 높아집니다. 여러분이 Actions를 적절히 사용하면 컴포넌트가 간결해지고, 비즈니스 로직이 Store에 집중되어 유지보수가 쉬워집니다.

또한 Actions는 개발자 도구에서 추적되어 디버깅이 용이하며, 어떤 Action이 언제 실행되었는지 히스토리를 확인할 수 있습니다.

실전 팁

💡 Actions는 항상 의미 있는 이름을 사용하세요. updateUser보다는 updateUserProfile처럼 구체적인 이름이 좋습니다.

💡 에러 처리는 Actions 내부에서 하고, 필요시 에러를 반환하거나 던지세요. 컴포넌트에서 try-catch를 반복하지 않도록 합니다.

💡 로딩 상태는 Actions에서 관리하세요. isLoading State를 추가하고 Actions 시작/종료 시 업데이트하면 UI에서 로딩 인디케이터를 쉽게 표시할 수 있습니다.

💡 Actions에서 다른 Store의 Actions를 호출할 때는 해당 Store를 import하여 사용하세요. 예: const cartStore = useCartStore(); cartStore.addItem()

💡 Actions의 반환값을 활용하세요. 성공/실패를 boolean으로 반환하거나, 처리된 데이터를 반환하면 컴포넌트에서 후속 처리를 할 수 있습니다.


5. 컴포넌트에서 Store 사용하기

시작하며

여러분이 Vue 컴포넌트를 작성할 때, 이제 Store에 정의한 State, Getters, Actions를 실제로 사용해야 합니다. 하지만 어떻게 Store에 접근하고, 어떻게 반응성을 유지하면서 데이터를 사용할 수 있을까요?

이런 질문은 Pinia를 처음 사용하는 개발자들이 가장 많이 하는 질문입니다. 잘못된 방식으로 Store를 사용하면 반응성이 깨지거나, 불필요한 리렌더링이 발생할 수 있습니다.

바로 이럴 때 필요한 것이 올바른 Store 사용 패턴입니다. Composition API와 Options API 양쪽에서 Store를 효과적으로 사용하는 방법을 배워보겠습니다.

개요

간단히 말해서, 컴포넌트에서 Store를 사용하려면 Store 함수를 호출하여 인스턴스를 얻고, 그 인스턴스를 통해 State, Getters, Actions에 접근합니다. Store를 사용하는 방식은 Composition API와 Options API에서 약간 다르지만, 기본 원리는 같습니다.

예를 들어, setup() 함수나 <script setup>에서 useCounterStore()를 호출하여 Store 인스턴스를 얻고, store.count처럼 접근합니다. 기존에는 컴포넌트의 data()에 모든 상태를 정의했다면, 이제는 필요한 상태만 Store에서 가져와 사용할 수 있습니다.

컴포넌트에서 Store를 사용할 때의 핵심은 반응성 유지입니다. storeToRefs를 사용하면 State와 Getters의 반응성을 유지하면서 구조 분해 할당을 할 수 있고, Actions는 일반 메서드처럼 호출하면 됩니다.

코드 예제

<!-- Composition API 방식 -->
<template>
  <div>
    <h2>카운터: {{ count }}</h2>
    <p>두 배: {{ doubleCount }}</p>
    <p>로딩 중: {{ isLoading }}</p>

    <button @click="increment">증가</button>
    <button @click="handleAsync">비동기 작업</button>
  </div>
</template>

<script setup>
import { storeToRefs } from 'pinia'
import { useCounterStore } from '@/stores/counter'

// Store 인스턴스 가져오기
const counterStore = useCounterStore()

// State와 Getters를 반응형으로 구조 분해
const { count, doubleCount, isLoading } = storeToRefs(counterStore)

// Actions는 직접 구조 분해 가능 (반응성 필요 없음)
const { increment } = counterStore

// 또는 직접 호출
const handleAsync = async () => {
  await counterStore.fetchData()
}

// Store의 State 직접 수정도 가능
const directUpdate = () => {
  counterStore.count = 100
}
</script>

설명

이것이 하는 일: 컴포넌트와 Store를 연결하여, 전역 상태를 로컬에서 사용하는 것처럼 편리하게 접근합니다. 첫 번째로, useCounterStore()를 호출하여 Store 인스턴스를 얻습니다.

이 함수는 같은 Store의 싱글톤 인스턴스를 반환하므로, 여러 컴포넌트에서 호출해도 모두 같은 Store를 참조합니다. Store 인스턴스를 변수에 저장하면, counterStore.count처럼 점 표기법으로 모든 속성과 메서드에 접근할 수 있습니다.

두 번째로, storeToRefs를 사용하여 State와 Getters를 구조 분해 할당합니다. 일반적인 구조 분해 { count } = counterStore를 하면 반응성이 깨지지만, storeToRefs를 사용하면 ref로 감싸진 반응형 참조를 얻을 수 있습니다.

이렇게 하면 템플릿에서 count를 직접 사용할 수 있고, count의 값이 변경되면 자동으로 UI가 업데이트됩니다. storeToRefs는 State와 Getters만 반응형으로 만들고, Actions는 제외합니다.

세 번째로, Actions는 반응성이 필요 없으므로 Store 인스턴스에서 직접 구조 분해하거나 counterStore.increment()처럼 호출할 수 있습니다. Actions를 호출하면 Store의 State가 변경되고, 이 변경사항이 자동으로 컴포넌트에 반영됩니다.

비동기 Actions의 경우 await를 사용하여 완료를 기다릴 수 있습니다. 여러분이 이 패턴을 따르면 Store의 모든 기능을 컴포넌트에서 자연스럽게 사용할 수 있고, 코드가 간결하며 가독성이 높아집니다.

또한 Pinia의 개발자 도구와 완벽하게 통합되어, Store의 상태 변화를 실시간으로 추적하고 디버깅할 수 있습니다.

실전 팁

💡 Store 인스턴스는 setup() 최상단에서 한 번만 가져오세요. 함수나 이벤트 핸들러 내부에서 반복적으로 호출하지 마세요.

💡 storeToRefs는 State와 Getters에만 사용하고, Actions는 Store 인스턴스에서 직접 가져오세요. storeToRefs로 Actions를 감싸면 작동하지 않습니다.

💡 템플릿에서 store.count보다는 구조 분해한 count를 사용하세요. 코드가 더 깔끔하고 읽기 쉽습니다.

💡 Store의 State를 직접 수정할 수 있지만(counterStore.count++), 복잡한 로직은 Actions로 캡슐화하는 것이 좋습니다.

💡 Options API를 사용한다면 computed에서 mapStores 또는 mapState를 활용할 수 있지만, Composition API가 더 직관적이고 타입 안전성이 높습니다.


6. 여러 Store 함께 사용하기

시작하며

여러분이 실제 애플리케이션을 개발할 때는 하나의 Store만으로는 부족합니다. 사용자 인증 Store, 장바구니 Store, 알림 Store 등 여러 Store가 필요하고, 이들이 서로 상호작용해야 하는 경우가 많습니다.

이런 상황에서 어려운 점은 Store 간의 의존성을 어떻게 관리하고, 어떻게 깔끔하게 조합할 것인가입니다. 잘못 설계하면 순환 참조 문제가 발생하거나 코드가 복잡해질 수 있습니다.

바로 이럴 때 필요한 것이 Store 조합 패턴입니다. Pinia는 여러 Store를 함께 사용하기 매우 쉽게 설계되어 있으며, Store 간 통신과 조합이 자연스럽습니다.

개요

간단히 말해서, 한 Store의 Actions나 Getters에서 다른 Store를 import하고 사용할 수 있습니다. 여러 Store를 조합하면 관심사를 명확히 분리하면서도 필요한 곳에서 협력할 수 있습니다.

예를 들어, 주문 Store에서 장바구니 Store의 데이터를 가져와 주문을 생성하거나, 인증 Store에서 사용자 정보 Store를 초기화하는 경우에 유용합니다. 기존에는 Vuex의 복잡한 모듈 시스템을 사용했다면, Pinia에서는 단순히 함수를 import하여 사용하는 것처럼 Store를 조합할 수 있습니다.

여러 Store를 사용할 때의 핵심은 명확한 책임 분리와 단방향 데이터 흐름입니다. 각 Store는 자신의 도메인 데이터만 관리하고, 필요할 때 다른 Store의 데이터를 읽거나 Actions를 호출합니다.

코드 예제

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

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

  getters: {
    totalPrice: (state) => {
      return state.items.reduce((sum, item) => sum + item.price * item.quantity, 0)
    }
  },

  actions: {
    addItem(product) {
      const existing = this.items.find(item => item.id === product.id)
      if (existing) {
        existing.quantity++
      } else {
        this.items.push({ ...product, quantity: 1 })
      }
    },

    clearCart() {
      this.items = []
    }
  }
})

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

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

  actions: {
    async createOrder() {
      // 다른 Store 인스턴스 가져오기
      const cartStore = useCartStore()
      const authStore = useAuthStore()

      // 다른 Store의 State와 Getters 사용
      if (!authStore.user) {
        throw new Error('로그인이 필요합니다')
      }

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

      this.isProcessing = true

      try {
        const orderData = {
          userId: authStore.user.id,
          items: cartStore.items,
          totalPrice: cartStore.totalPrice,
          createdAt: new Date()
        }

        // API 호출 시뮬레이션
        const response = await fetch('/api/orders', {
          method: 'POST',
          body: JSON.stringify(orderData)
        })

        const order = await response.json()
        this.orders.push(order)

        // 다른 Store의 Actions 호출
        cartStore.clearCart()

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

설명

이것이 하는 일: 여러 Store를 조합하여 복잡한 비즈니스 로직을 구현하고, Store 간 데이터를 공유하며 협력합니다. 첫 번째로, Store 간 통신은 매우 간단합니다.

orderStore의 createOrder Action에서 useCartStore()와 useAuthStore()를 호출하여 다른 Store의 인스턴스를 얻습니다. 이렇게 얻은 인스턴스를 통해 다른 Store의 State(authStore.user, cartStore.items)와 Getters(cartStore.totalPrice)에 자유롭게 접근할 수 있습니다.

Pinia는 내부적으로 Store 인스턴스를 싱글톤으로 관리하므로, 어디서 호출하든 같은 인스턴스를 얻게 됩니다. 두 번째로, Actions 내부에서 다른 Store의 Actions를 호출할 수 있습니다.

cartStore.clearCart()처럼 다른 Store의 메서드를 호출하면, 해당 Store의 State가 변경됩니다. 이를 통해 복잡한 트랜잭션을 구현할 수 있습니다.

예를 들어, 주문 생성이 성공하면 장바구니를 비우는 것처럼 여러 Store의 상태를 조율할 수 있습니다. 세 번째로, Store 간 의존성을 관리할 때는 순환 참조를 조심해야 합니다.

A Store가 B Store를 import하고, B Store가 다시 A Store를 import하면 문제가 발생할 수 있습니다. 이런 경우 Actions 내부에서 동적으로 Store를 가져오거나, 이벤트 기반 통신을 고려해야 합니다.

여러분이 이 패턴을 활용하면 각 Store가 단일 책임을 가지면서도 필요할 때 협력할 수 있어, 코드의 모듈성과 재사용성이 크게 향상됩니다. 또한 테스트할 때 각 Store를 독립적으로 테스트할 수 있어 테스트 코드 작성도 쉬워집니다.

실전 팁

💡 Store는 도메인별로 분리하세요. 사용자 관련은 userStore, 상품 관련은 productStore처럼 명확히 구분합니다.

💡 Store 간 의존성은 Actions에서만 만드세요. Getters에서 다른 Store를 참조하면 복잡도가 높아지고 디버깅이 어려워집니다.

💡 순환 참조를 피하세요. A → B → A 같은 의존성이 필요하다면 설계를 다시 검토하거나, 공통 Store를 만드는 것을 고려하세요.

💡 컴포넌트에서는 여러 Store를 자유롭게 사용하세요. 한 컴포넌트에서 여러 Store를 import하는 것은 전혀 문제가 없습니다.

💡 Store가 너무 많아지면 관리가 어려워지므로, 관련된 Store끼리 폴더로 그룹화하세요(stores/user/, stores/shop/ 등).


7. Store 구독과 감시

시작하며

여러분이 Store의 State가 변경될 때마다 특정 작업을 실행하고 싶은 상황을 생각해보세요. 예를 들어, 사용자가 로그아웃할 때 모든 캐시를 지우거나, 장바구니가 변경될 때마다 로컬 스토리지에 저장하거나, 디버깅을 위해 모든 State 변경을 로깅해야 할 수 있습니다.

이런 문제는 단순히 State에 접근하는 것만으로는 해결되지 않습니다. State의 변경 시점을 감지하고, 변경 전후의 값을 비교하며, 적절한 시점에 반응해야 합니다.

바로 이럴 때 필요한 것이 Store 구독(Subscribe)과 감시(Watch)입니다. Pinia는 Store의 변경사항을 추적하고 반응할 수 있는 강력한 API를 제공합니다.

개요

간단히 말해서, Store 구독은 Store의 모든 변경사항을 감지하고 콜백 함수를 실행하는 메커니즘입니다. Store 구독을 사용하면 State가 변경될 때마다 자동으로 특정 작업을 실행할 수 있습니다.

예를 들어, 영속성 처리(로컬 스토리지 저장), 로깅, 분석 이벤트 전송, 다른 시스템과의 동기화 같은 경우에 매우 유용합니다. 기존에는 watch()를 사용하여 각 State를 개별적으로 감시했다면, $subscribe를 사용하면 Store 전체의 변경사항을 한 번에 감지할 수 있습니다.

Store 구독의 핵심 특징은 자동화와 중앙 집중화입니다. 모든 State 변경을 한 곳에서 감지하고 처리할 수 있으며, 구독 해제도 간단합니다.

또한 $onAction을 사용하면 Actions의 실행을 감지하고, 성공/실패 여부도 추적할 수 있습니다.

코드 예제

// 컴포넌트나 플러그인에서 Store 구독
<script setup>
import { watch, onUnmounted } from 'vue'
import { useCartStore } from '@/stores/cart'

const cartStore = useCartStore()

// 1. $subscribe로 Store 전체 변경 감지
const unsubscribe = cartStore.$subscribe((mutation, state) => {
  // mutation: 변경 정보 객체
  // state: 변경 후의 Store state

  console.log('Store가 변경되었습니다:', mutation.type)
  console.log('변경된 State:', state)

  // 로컬 스토리지에 장바구니 저장
  localStorage.setItem('cart', JSON.stringify(state.items))
}, { detached: true }) // detached: true면 컴포넌트 언마운트 후에도 유지

// 2. $onAction으로 Actions 실행 감지
const unsubscribeAction = cartStore.$onAction(({
  name,        // Action 이름
  store,       // Store 인스턴스
  args,        // Action에 전달된 인자
  after,       // Action 성공 후 콜백
  onError      // Action 실패 시 콜백
}) => {
  console.log(`Action "${name}"이 실행되었습니다`, args)

  const startTime = Date.now()

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

  // Action 에러 시 실행
  onError((error) => {
    console.error(`Action "${name}" 실패:`, error)
  })
})

// 3. Vue의 watch로 특정 State 감시
watch(() => cartStore.items.length, (newLength, oldLength) => {
  console.log(`장바구니 상품 개수: ${oldLength}${newLength}`)

  if (newLength === 0) {
    console.log('장바구니가 비었습니다')
  }
})

// 컴포넌트 언마운트 시 구독 해제
onUnmounted(() => {
  unsubscribe()
  unsubscribeAction()
})
</script>

설명

이것이 하는 일: Store의 모든 변경사항을 자동으로 감지하고, 변경에 반응하여 부가 작업을 실행합니다. 첫 번째로, $subscribe 메서드를 사용하면 Store의 모든 State 변경을 감지할 수 있습니다.

콜백 함수는 두 개의 매개변수를 받습니다: mutation 객체는 어떤 변경이 일어났는지 정보를 담고 있고(type, storeId, payload 등), state는 변경 후의 최신 상태입니다. 이를 활용하면 State가 변경될 때마다 로컬 스토리지에 저장하거나, 서버와 동기화하거나, 로그를 남기는 등의 작업을 자동으로 처리할 수 있습니다.

detached: true 옵션을 주면 컴포넌트가 언마운트되어도 구독이 유지되어, 전역적인 모니터링이 가능합니다. 두 번째로, $onAction 메서드는 Actions의 실행을 감지합니다.

이 메서드는 Action이 실행되기 전에 호출되며, name(Action 이름), args(인자), after(성공 콜백), onError(실패 콜백)를 제공합니다. after 콜백을 등록하면 Action이 완료된 후 특정 작업을 실행할 수 있고, onError 콜백으로 에러를 중앙에서 처리할 수 있습니다.

이는 성능 모니터링, 에러 추적, 분석 이벤트 전송 등에 매우 유용합니다. 세 번째로, Vue의 watch API를 사용하여 특정 State나 Getter의 변경만 감시할 수도 있습니다.

watch(() => cartStore.items.length, ...)처럼 함수 형태로 감시 대상을 지정하면, 해당 값이 변경될 때만 콜백이 실행됩니다. 이는 $subscribe보다 더 세밀한 제어가 필요할 때 유용하며, 이전 값과 새 값을 비교할 수 있습니다.

여러분이 Store 구독을 활용하면 횡단 관심사(Cross-Cutting Concerns)를 깔끔하게 처리할 수 있고, 비즈니스 로직과 부가 기능을 분리하여 코드 품질이 향상됩니다. 또한 디버깅과 모니터링이 훨씬 쉬워져 문제를 빠르게 파악하고 해결할 수 있습니다.

실전 팁

💡 구독 함수는 반드시 정리(cleanup)하세요. 컴포넌트 언마운트 시 unsubscribe()를 호출하지 않으면 메모리 누수가 발생할 수 있습니다.

💡 $subscribe 콜백 내부에서 Store의 State를 변경하지 마세요. 무한 루프가 발생할 수 있습니다. 읽기 전용 작업만 수행하세요.

💡 전역 로깅이나 분석은 Pinia 플러그인으로 구현하는 것이 더 좋습니다. 모든 Store에 자동으로 적용됩니다.

💡 성능이 중요한 경우 debounce를 사용하세요. State가 빠르게 변경될 때 구독 콜백이 너무 자주 실행되면 성능 문제가 생길 수 있습니다.

💡 개발 환경에서만 구독이 필요한 경우 if (import.meta.env.DEV) 조건을 사용하여 프로덕션 빌드에서 제외하세요.


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

댓글 (0)

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