Pinia 완벽 가이드 | Vue 3 상태 관리·Composition API·TypeScript·실전 활용
이 글의 핵심
Pinia는 Vue의 반응성 시스템 위에 스토어를 올리고, Options/Setup 스타일 모두에서 동일한 구독·디버깅 모델을 제공합니다. storeToRefs·SSR·플러그인·영속화와 운영 시 흔한 함정을 함께 다룹니다.
이 글의 핵심
Pinia로 Vue 3 상태 관리를 구현하는 완벽 가이드입니다. Store 정의, Composition API, TypeScript, Plugins, Devtools까지 실전 예제로 정리했습니다.
실무 경험 공유: Vuex에서 Pinia로 전환하면서, 보일러플레이트가 70% 감소하고 TypeScript 지원이 완벽해진 경험을 공유합니다.
들어가며: “Vuex가 복잡해요”
실무에서 마주치는 문제들
Vuex가 너무 복잡해요
Mutations, Actions가 번거롭습니다. Pinia는 간단합니다. TypeScript 지원이 부족해요
Vuex는 타입 추론이 약합니다. Pinia는 완벽합니다. Composition API와 맞지 않아요
Vuex는 Options API 기반입니다. Pinia는 Composition API 친화적입니다.
1. Pinia란?
핵심 특징
Pinia는 Vue 3 공식 상태 관리 라이브러리입니다. 주요 장점
- 간단한 API: Mutations 없음
- TypeScript: 완벽한 지원
- Composition API: 자연스러운 통합
- Devtools: 강력한 디버깅
- 작은 크기: 1KB
2. 설치 및 설정
설치
npm install pinia
main.ts
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue';
const pinia = createPinia();
const app = createApp(App);
app.use(pinia);
app.mount('#app');
3. Store 정의
Options API 스타일
// stores/counter.ts
import { defineStore } from 'pinia';
export const useCounterStore = defineStore('counter', {
state: () => ({
count: 0,
name: 'Counter',
}),
getters: {
doubleCount: (state) => state.count * 2,
},
actions: {
increment() {
this.count++;
},
decrement() {
this.count--;
},
async fetchCount() {
const response = await fetch('/api/count');
const data = await response.json();
this.count = data.count;
},
},
});
Composition API 스타일
// stores/counter.ts
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
export const useCounterStore = defineStore('counter', () => {
const count = ref(0);
const name = ref('Counter');
const doubleCount = computed(() => count.value * 2);
function increment() {
count.value++;
}
function decrement() {
count.value--;
}
async function fetchCount() {
const response = await fetch('/api/count');
const data = await response.json();
count.value = data.count;
}
return {
count,
name,
doubleCount,
increment,
decrement,
fetchCount,
};
});
4. 컴포넌트에서 사용
Options API
<script>
import { useCounterStore } from '@/stores/counter';
export default {
setup() {
const counter = useCounterStore();
return { counter };
},
};
</script>
<template>
<div>
<p>Count: {{ counter.count }}</p>
<p>Double: {{ counter.doubleCount }}</p>
<button @click="counter.increment">+</button>
<button @click="counter.decrement">-</button>
</div>
</template>
Composition API
<script setup lang="ts">
import { useCounterStore } from '@/stores/counter';
import { storeToRefs } from 'pinia';
const counter = useCounterStore();
const { count, doubleCount } = storeToRefs(counter);
const { increment, decrement } = counter;
</script>
<template>
<div>
<p>Count: {{ count }}</p>
<p>Double: {{ doubleCount }}</p>
<button @click="increment">+</button>
<button @click="decrement">-</button>
</div>
</template>
5. 여러 Store
// stores/user.ts
export const useUserStore = defineStore('user', () => {
const user = ref<User | null>(null);
const isLoggedIn = computed(() => !!user.value);
async function login(email: string, password: string) {
const response = await fetch('/api/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
});
user.value = await response.json();
}
function logout() {
user.value = null;
}
return { user, isLoggedIn, login, logout };
});
// 다른 Store에서 사용
export const useCartStore = defineStore('cart', () => {
const userStore = useUserStore();
const items = ref([]);
async function addItem(item) {
if (!userStore.isLoggedIn) {
throw new Error('Please login first');
}
items.value.push(item);
}
return { items, addItem };
});
6. Plugins
// plugins/persistedState.ts
import { PiniaPluginContext } from 'pinia';
export function persistedStatePlugin({ store }: PiniaPluginContext) {
const stored = localStorage.getItem(store.$id);
if (stored) {
store.$patch(JSON.parse(stored));
}
store.$subscribe((mutation, state) => {
localStorage.setItem(store.$id, JSON.stringify(state));
});
}
// main.ts
import { persistedStatePlugin } from './plugins/persistedState';
const pinia = createPinia();
pinia.use(persistedStatePlugin);
7. $patch & $reset
$patch
const counter = useCounterStore();
// 객체로 업데이트
counter.$patch({
count: 10,
name: 'Updated',
});
// 함수로 업데이트
counter.$patch((state) => {
state.count++;
state.name = 'Updated';
});
$reset
counter.$reset();
8. Devtools
// 자동으로 Vue Devtools에 통합됨
// Time-travel debugging
// State inspection
// Actions tracking
9. Pinia 내부 모델과 Vue 반응성
Pinia 스토어는 Vue 3의 reactive/ref 위에 구축됩니다. Options 스타일 스토어는 내부적으로 상태·게터·액션을 Vue의 반응 시스템에 맞게 래핑하며, Setup 스타일은 작성한 ref/computed를 그대로 스토어 인스턴스로 노출합니다. 스토어 인스턴스는 effectScope 안에서 실행되어, 앱이 마운트 해제될 때 관련 이펙트가 함께 정리되는 방향으로 이해하면 디버깅이 수월합니다.
storeToRefs를 쓰는 이유: 액션·전체 스토어 객체를 그대로 구조 분해하면 반응성이 사라질 수 있습니다. 상태와 게터만 구조 분해할 때는 storeToRefs로 ref 유지를 보장합니다.
10. 프로덕션 패턴
10.1 스토어 간 의존과 순환 참조
한 스토어의 setup에서 다른 스토어를 useOtherStore()로 부르는 것은 흔하지만, 순환 의존(A→B→A)이 생기면 초기화 순서가 꼬일 수 있습니다. 해결책은 공통 도메인을 상위 스토어로 승격하거나, 이벤트 버스·컴포지션 루트에서만 교차 참조하도록 경계를 나누는 것입니다.
10.2 SSR·Nuxt
Nuxt 3에서는 @pinia/nuxt로 서버마다 깨끗한 스토어를 만들으며, 클라이언트로 상태 직렬화를 넘깁니다. localStorage에 의존하는 플러그인은 import.meta.client 가드 없이 쓰면 SSR에서 터집니다. hydration mismatch가 나면 서버와 클라이언트 초기 상태를 맞추는지 확인합니다.
10.3 지속화(persist)와 보안
앞 절의 간이 localStorage 예제는 토큰·PII를 넣기에 부적절합니다. 민감 필드는 메모리만 두거나, httpOnly 쿠키·세션과 역할을 나눕니다. $subscribe는 대량 업데이트마다 저장하면 I/O 병목이 되므로 debounce를 고려합니다.
11. 트러블슈팅
| 증상 | 원인 후보 |
|---|---|
| 구조 분해 후 화면이 갱신 안 됨 | storeToRefs 미사용 또는 액션을 잘못 분해 |
| 두 스토어가 서로 다른 인스턴스처럼 보임 | 테스트·스토리북에서 새 Pinia를 매번 만들지 않음 |
| HMR 후 상태가 꼬임 | 개발 전용 이슈 — 새로고침 또는 스토어 $reset |
| 무한 요청 루프 | watch+액션에서 동일 조건으로 다시 트리거 |
| Devtools에 액션이 안 보임 | 프로덕션 빌드에서 devtools 비활성 — 정상 |
정리 및 체크리스트
핵심 요약
- Pinia: Vue 3 상태 관리
- 간단한 API: Mutations 없음
- TypeScript: 완벽한 지원
- Composition API: 자연스러운 통합
- Plugins: 확장 가능
- Devtools: 강력한 디버깅
구현 체크리스트
- Pinia 설치
- Store 정의
- 컴포넌트 연결
- Getters 구현
- Actions 구현
- Plugins 추가
- Devtools 활용
같이 보면 좋은 글
- Vue 3 Composition API 가이드
- Zustand 완벽 가이드
- Vuex 마이그레이션 가이드
이 글에서 다루는 키워드
Pinia, Vue 3, State Management, Composition API, TypeScript, Frontend, Vuex
자주 묻는 질문 (FAQ)
Q. Vuex와 비교하면 어떤가요?
A. Pinia가 훨씬 간단하고 TypeScript 지원이 좋습니다. Vuex는 더 오래됐지만 복잡합니다.
Q. Vue 2에서도 사용할 수 있나요?
A. 네, 플러그인을 통해 Vue 2에서도 사용할 수 있습니다.
Q. Vuex에서 마이그레이션이 어려운가요?
A. 비교적 간단합니다. Mutations를 Actions로 옮기면 됩니다.
Q. 프로덕션에서 사용해도 되나요?
A. 네, Vue 공식 라이브러리로 안정적입니다.