05. Pinia 상태 관리: store 설계와 사용법
Pinia가 필요한 시점과 state, getters, actions로 store를 설계하고 컴포넌트에서 사용하는 방법을 정리한다.
Pinia가 필요한 이유
한 컴포넌트 안에서만 사용하는 값은 ref()나 reactive()면 충분하다.
하지만 로그인 사용자와 장바구니처럼 서로 멀리 떨어진 여러 화면이 같은 상태를 읽고 변경하면 전달 과정이 길어진다.
(계속 써야하는 정보를 토스토스토스해서 전달하는 것은 비효율적이지 않은가!)
App
├── AppHeader ── 사용자 이름, 장바구니 개수
├── ProductList ── 장바구니에 상품 추가
└── ShoppingCart ── 수량 변경, 상품 제거
모든 중간 컴포넌트에 props를 계속 전달하는 방식을 props drilling 이라고 한다. Pinia는 공유 상태와 상태를 다루는 동작을 store에 모아 필요한 컴포넌트가 직접 사용하게 한다.
(굉장히 비효율적인 방식)
AppHeader ─┐
ProductList ├──> Cart Store
CartPage ──┘ state / getters / actions
먼저 판단할 것
Pinia가 모든 상태의 기본 위치는 아니다.
| 상태 | 적합한 위치 |
|---|---|
| 모달 열림, 입력 중인 값 | 사용하는 컴포넌트의 ref |
| 부모와 자식만 공유 | 부모 상태 + props / emit |
| 같은 로직을 여러 곳에서 재사용 | composable |
| 로그인 사용자, 장바구니, 앱 전역 설정 | Pinia store |
상태가 여러 페이지에서 필요하고, 페이지가 바뀌어도 유지되어야 하며, 변경 규칙을 한곳에 모을 필요가 있을 때 store를 고려한다.
설치하고 앱에 연결하기
npm install pinia
main.js에서 Pinia 인스턴스를 Vue 앱에 등록한다.
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
const app = createApp(App)
app.use(createPinia())
app.mount('#app')
store의 기본 구조
Pinia의 Option Store는 state, getters, actions 세 부분으로 이해하면 쉽다.
아주 쉽다!
| 구분 | 역할 | 컴포넌트에 비유 |
|---|---|---|
state | store가 보관하는 원본 데이터 | data, ref, reactive |
getters | state에서 계산한 파생 값 | computed |
actions | 상태를 읽고 변경하는 동작 | 함수, methods |
store의 첫 번째 인자인 id는 앱 안에서 고유해야 한다. 반환 함수는 useCartStore처럼 use와 Store를 붙이는 관례를 따른다.
// src/stores/cart.js
import { defineStore } from 'pinia'
export const useCartStore = defineStore('cart', {
state: () => ({
items: [],
lastUpdatedAt: null
}),
getters: {
itemCount: (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
)
}
},
actions: {
addItem(product) {
const item = this.items.find((item) => item.id === product.id)
if (item) {
item.quantity += 1
return
}
this.items.push({
id: product.id,
name: product.name,
price: product.price,
quantity: 1
})
},
removeItem(productId) {
this.items = this.items.filter((item) => item.id !== productId)
}
}
})
컴포넌트에서 사용하기
store 인스턴스의 state, getters, actions는 바로 접근할 수 있다.
<script setup>
import { useCartStore } from '@/stores/cart'
const cartStore = useCartStore()
</script>
<template>
<p>상품 {{ cartStore.itemCount }}개</p>
<p>합계 {{ cartStore.totalPrice.toLocaleString() }}원</p>
<button @click="cartStore.addItem(product)">
장바구니에 담기
</button>
</template>
store 자체는 reactive 객체처럼 동작한다.
state와 getters를 일반 구조 분해하면 반응성 연결이 끊어질 수 있다! 조심해야 한다.
// 피해야 할 방식
const { items, totalPrice } = cartStore
구조 분해가 필요하면 storeToRefs()를 사용한다. actions는 store에 바인딩되어 있어 직접 구조 분해해도 된다.
<script setup>
import { storeToRefs } from 'pinia'
import { useCartStore } from '@/stores/cart'
const cartStore = useCartStore()
const { items, itemCount, totalPrice } = storeToRefs(cartStore)
const { removeItem } = cartStore
</script>
<template>
<ul>
<li v-for="item in items" :key="item.id">
{{ item.name }} × {{ item.quantity }}
<button @click="removeItem(item.id)">제거</button>
</li>
</ul>
<p>{{ itemCount }}개 / {{ totalPrice.toLocaleString() }}원</p>
</template>
템플릿에서는 ref가 자동으로 언래핑되므로 .value를 붙이지 않는다.
state 변경 방법
Pinia는 state를 직접 변경할 수 있지만, 의미 있는 변경 규칙은 action에 모으면 추적과 재사용이 쉽다.
cartStore.items.push(newItem)
cartStore.addItem(product)
여러 값을 한 번에 묶어 변경할 때는 $patch()를 사용할 수 있다.
cartStore.$patch((state) => {
state.items.push(newItem)
state.lastUpdatedAt = Date.now()
})
Option Store는 $reset()으로 처음 상태로 되돌릴 수 있다.
cartStore.$reset()
store 설계 원칙
도메인 단위로 나눈다
stateStore, getterStore처럼 기술 요소별로 나누지 않고 역할이 같은 상태와 동작을 묶는다.
stores/
├── auth.js
├── cart.js
└── products.js
화면 전용 상태를 넣지 않는다
넣으면 대참사가 일어나게 될 것
특정 드롭다운의 열림 여부나 한 폼의 임시 입력값까지 전역화하면 store가 화면 구현에 강하게 결합된다.
비동기 작업은 action에 둔다
action은 비동기 함수가 될 수 있다.
로딩과 오류 상태를 함께 관리하면 화면에서는 결과만 사용하기 쉽다.
actions: {
async fetchProducts() {
this.loading = true
this.error = null
try {
const response = await fetch('/api/products')
if (!response.ok) {
throw new Error('상품을 불러오지 못했습니다.')
}
this.products = await response.json()
} catch (error) {
this.error = error.message
} finally {
this.loading = false
}
}
}
정리
Pinia는 props를 없애는 도구가 아니라 여러 화면이 공유하는 상태의 소유권과 변경 규칙을 분명하게 만드는 도구
- 지역 상태는 컴포넌트에 둔다.
- 공유 범위가 넓어질 때 store로 올린다.(로그인 값이나, 모드 같이 전역에서 쓰이는 것들)
- state는 원본 데이터, getters는 파생 값, actions는 동작으로 구분한다.
- 구조 분해할 state와 getters에는
storeToRefs()를 사용한다. - store는 사용자, 장바구니 같은 도메인 단위로 나눈다.
COMMENTS
GitHub 계정으로 로그인하여 댓글을 남길 수 있습니다. 댓글은 GitHub Discussions에 공개 저장되며, 작성 내용과 GitHub 프로필 정보가 다른 방문자에게 보일 수 있습니다.