Pinia로 Vue 3 상태 관리하기: Store 정의, storeToRefs 반응성 함정, 플러그인, 내부 모델
이 글의 핵심
Pinia는 Vue의 반응성 시스템 위에 스토어를 올리고, Options/Setup 스타일 모두에서 동일한 구독·디버깅 모델을 제공합니다. storeToRefs·SSR·플러그인·영속화와 운영 시 흔한 함정을 함께 다룹니다.
이 글의 핵심
Pinia로 Vue 3 상태 관리를 구현하는 방법을 다룹니다. Store 정의 두 가지 스타일, 컴포넌트 연결, 스토어 간 참조, 플러그인, $patch/$reset을 예제로 보고, 각각에서 실제로 자주 막히는 반응성·SSR·초기화 문제를 함께 설명합니다.
Vuex에서 넘어오면서 겪던 문제들
Mutations와 Actions를 나눠 쓰는 게 번거로워요
Vuex는 상태 변경을 반드시 동기 mutation으로 하고, 비동기 로직은 action에서 mutation을 commit하도록 강제했습니다. 변경 이력을 Devtools에 남기기 위한 설계였지만, 결과적으로 간단한 값 하나를 바꾸는 데도 mutation 타입 상수, mutation 함수, action 함수를 모두 만들어야 했습니다. Pinia는 Vue 3의 반응성 시스템이 변경을 직접 추적할 수 있게 되면서 mutation 계층을 없앴습니다. 액션에서 상태를 바로 바꾸면 되고, Devtools 추적도 그대로 동작합니다.
TypeScript 타입 추론이 약해요
Vuex 4에서 this.$store.state.user.name의 타입을 얻으려면 모듈 타입을 직접 선언하고 useStore에 인젝션 키를 넘기는 등 설정이 많았고, commit('user/setName', ...)처럼 문자열로 호출하는 부분은 오타를 잡아 주지 못했습니다. Pinia는 스토어가 일반 함수와 객체라서 상태·게터·액션 타입이 별도 선언 없이 추론됩니다.
Composition API와 어울리지 않아요
Vuex의 모듈·네임스페이스 구조는 Options API 시절에 설계되어, <script setup>에서 쓰면 mapState 같은 헬퍼를 쓸 수 없어 computed(() => store.state.x)를 반복해야 했습니다. Pinia는 Setup 스타일 스토어로 컴포저블과 같은 문법을 그대로 씁니다.
Vue 3 공식 상태 관리로서의 Pinia
핵심 특징
Pinia는 Vue 3의 공식 상태 관리 라이브러리로, Vuex 5로 계획되던 설계가 별도 이름으로 나온 것입니다. Vue 공식 문서도 새 프로젝트에는 Vuex 대신 Pinia를 권장하며, Vuex는 유지보수 모드에 있습니다.
주요 장점:
- 간단한 API: Mutations 없음, 모듈 네임스페이스 대신 스토어를 여러 개 정의
- TypeScript: 별도 선언 없이 타입 추론
- Composition API: Setup 스타일 스토어로 자연스럽게 통합
- Devtools: 타임라인, 상태 확인·수정 지원
- 작은 크기: 약 1~2KB (gzip 기준)
구조상의 가장 큰 변화는 “하나의 큰 스토어에 모듈을 붙이는” Vuex 방식 대신 “필요한 만큼 독립된 스토어를 만드는” 방식이라는 점입니다. 각 스토어는 처음 useXxxStore()가 호출될 때 생성되므로, 사용하지 않는 페이지의 스토어는 번들 분할과 함께 아예 로드되지 않습니다.
설치와 main.ts 설정
설치
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');
app.use(pinia)는 반드시 스토어를 처음 사용하기 전에 실행되어야 합니다. 라우터 가드나 모듈 최상단처럼 앱이 설치되기 전 시점에 useCounterStore()를 호출하면 getActivePinia()was called but there was no active Pinia. Are you trying to use a store before calling "app.use(pinia)"? 에러가 납니다. 라우터 가드에서 스토어가 필요하다면 가드 함수 안에서 useXxxStore()를 호출하면 됩니다. 가드가 실행되는 시점에는 이미 Pinia가 설치되어 있기 때문입니다.
Options 스타일과 Setup 스타일 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;
},
},
});
defineStore의 첫 번째 인자 'counter'는 스토어의 고유 ID입니다. Devtools에 표시되는 이름이자 SSR에서 상태를 직렬화할 때 쓰는 키라서, 두 스토어가 같은 ID를 쓰면 서로의 상태를 덮어씁니다. 파일을 복사해 새 스토어를 만들 때 ID를 바꾸지 않는 실수가 흔하니 주의해야 합니다. Options 스타일에서 state는 반드시 객체를 반환하는 함수여야 합니다. SSR에서 요청마다 새 상태를 만들기 위해서입니다. 게터는 state를 인자로 받는 화살표 함수로 쓰면 타입이 자동 추론되고, 다른 게터를 참조하려면 일반 함수로 쓰고 this를 사용하되 반환 타입을 명시해야 TypeScript가 순환 추론 에러를 내지 않습니다.
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,
};
});
Setup 스타일에서는 ref가 state, computed가 getter, 함수가 action이 됩니다. 컴포저블처럼 watch나 다른 컴포저블(useRoute, VueUse의 useLocalStorage 등)을 스토어 안에서 바로 쓸 수 있다는 것이 가장 큰 장점입니다. 대신 규칙이 하나 있습니다. 모든 state를 return해야 합니다. 반환하지 않은 ref는 스토어 내부의 비공개 변수처럼 동작하는데, Devtools에 보이지 않고, SSR 직렬화와 하이드레이션에서도 빠지며, 플러그인도 접근할 수 없습니다. 서버에서 채운 값이 클라이언트에서 사라진다면 대부분 이 규칙을 어긴 경우입니다.
어느 스타일을 고를지는 팀 선호의 문제지만 차이는 있습니다. Options 스타일은 구조가 강제되어 일관성이 높고 $reset()이 기본 제공됩니다. Setup 스타일은 유연하고 컴포저블을 재사용하기 좋지만, $reset()을 직접 구현해야 합니다(7장 참고). 저는 단순한 CRUD 상태는 Options 스타일로, 라우터나 브라우저 API와 엮이는 스토어는 Setup 스타일로 나눠 쓰는 편이 가장 덜 헷갈렸습니다.
컴포넌트에서 Store 쓰기
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>
Options API 컴포넌트에서는 setup()에서 스토어를 반환해 쓰는 방법 외에, computed: { ...mapState(useCounterStore, ['count', 'doubleCount']) }와 methods: { ...mapActions(useCounterStore, ['increment']) } 같은 헬퍼도 쓸 수 있습니다. Vuex의 mapState와 비슷하지만 첫 인자로 네임스페이스 문자열 대신 스토어 함수를 받습니다. 기존 Options API 코드를 점진적으로 옮길 때 유용합니다.
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>
Pinia를 처음 쓸 때 가장 많이 겪는 버그가 여기서 나옵니다. 스토어는 reactive()로 감싼 객체라서 const { count } = counter처럼 구조 분해하면 그 순간의 값(숫자 0)만 복사됩니다. 에러는 나지 않고 처음 화면도 정상으로 보이는데, 버튼을 눌러도 숫자가 바뀌지 않습니다. storeToRefs는 스토어의 state와 getter만 골라 각각 ref로 감싸 주므로 구조 분해 후에도 원본 스토어와 연결이 유지됩니다. 반면 액션은 storeToRefs가 걸러 내므로 스토어에서 직접 구조 분해해야 합니다. 액션은 스토어에 바인딩된 함수라서 구조 분해해도 this를 잃지 않습니다. toRefs(counter)를 쓰면 되지 않느냐는 질문도 자주 나오는데, toRefs는 액션과 내부 속성까지 전부 ref로 만들어서 의도와 다르게 동작하므로 storeToRefs를 쓰는 것이 맞습니다.
스토어끼리 참조하기
// 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 };
});
한 스토어에서 다른 스토어를 쓰려면 그냥 useUserStore()를 호출하면 됩니다. Vuex의 rootState나 rootGetters 같은 별도 API가 필요 없습니다. 위 예제의 login은 설명을 위해 단순화한 코드라서 실제로는 headers: { 'Content-Type': 'application/json' }을 넣어야 서버가 JSON 본문을 파싱하고, response.ok를 확인하지 않으면 로그인 실패 응답({ message: 'Invalid password' } 같은 에러 객체)이 그대로 user에 들어가 isLoggedIn이 true가 되는 버그가 생깁니다. 또 ref([])는 TypeScript에서 Ref<never[]>로 추론되므로 ref<CartItem[]>([])처럼 타입을 명시해야 push에서 타입 에러가 나지 않습니다.
두 스토어가 서로를 참조할 때는 호출 위치가 중요합니다. A 스토어의 setup 함수 최상단에서 B를 호출하고, B의 setup 최상단에서도 A를 호출하면 서로가 아직 만들어지지 않은 상태에서 상대를 찾게 되어 초기화가 끝나지 않습니다. 이 경우 한쪽의 참조를 액션이나 getter 안으로 옮기면 해결됩니다. 액션이 실행되는 시점에는 두 스토어 모두 이미 생성되어 있기 때문입니다.
Plugins
플러그인은 모든 스토어가 생성될 때마다 호출되는 함수입니다. 스토어에 공통 속성을 추가하거나, 상태 변화를 구독하거나, 액션 실행을 가로채는(store.$onAction) 용도로 씁니다. 아래는 상태를 localStorage에 저장하고 복원하는 간이 영속화 플러그인입니다.
// 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);
이 플러그인은 원리를 보여 주기에는 좋지만 그대로 쓰기에는 문제가 있습니다. 모든 스토어의 전체 상태를 무조건 저장하므로 저장할 필요 없는 로딩 플래그나 큰 목록까지 기록되고, $subscribe는 상태가 바뀔 때마다 호출되어 입력창 타이핑처럼 잦은 변경에서는 매번 JSON.stringify와 동기 localStorage 쓰기가 일어납니다. 또 스토어 구조를 바꾸고 배포하면 예전 구조로 저장된 값이 $patch로 덮어씌워져 새 필드가 없는 상태로 시작하는 버그가 생깁니다. 실무에서는 스토어별로 저장 여부와 필드를 고를 수 있고 버전 관리가 되는 pinia-plugin-persistedstate를 쓰는 것이 일반적입니다. $subscribe는 기본적으로 컴포넌트가 언마운트되면 구독이 해제되는데, 플러그인에서는 컴포넌트 밖에서 호출되므로 계속 유지됩니다. 컴포넌트 안에서 호출하면서 계속 유지하려면 { detached: true } 옵션을 줍니다.
$patch와 $reset
$patch
const counter = useCounterStore();
// 객체로 업데이트
counter.$patch({
count: 10,
name: 'Updated',
});
// 함수로 업데이트
counter.$patch((state) => {
state.count++;
state.name = 'Updated';
});
객체 형태의 $patch는 여러 필드를 한 번에 바꿀 때 편하고, Devtools 타임라인에 변경이 하나의 항목으로 묶여 기록됩니다. 배열을 다룰 때는 함수 형태를 써야 합니다. 객체 형태로 { items: [...] }를 넘기면 배열 전체를 교체하게 되어, 배열에 요소 하나를 추가하려고 해도 전체 배열을 새로 만들어야 하기 때문입니다. 함수 형태에서는 state.items.push(item)처럼 직접 수정할 수 있습니다.
$reset
counter.$reset();
$reset()은 Options 스타일 스토어에서만 기본 제공됩니다. 내부적으로 state() 함수를 다시 호출해 초기 상태를 만들 수 있기 때문입니다. Setup 스타일 스토어에서 호출하면 Store "counter" is built using the setup syntax and does not implement $reset(). 에러가 납니다. Setup 스타일에서는 초기값을 만드는 함수를 두고 function $reset() { count.value = 0; name.value = 'Counter' }처럼 직접 구현해 return에 포함하면 됩니다. 로그아웃 시 모든 스토어를 초기화해야 하는 앱이라면 이 차이 때문에 스타일을 통일하는 편이 관리가 쉽습니다.
Devtools
// 자동으로 Vue Devtools에 통합됨
// Time-travel debugging
// State inspection
// Actions tracking
Vue Devtools의 Pinia 탭에서 각 스토어의 현재 상태를 보고 직접 값을 수정할 수 있고, 타임라인에서 액션 호출과 $patch를 시간순으로 확인할 수 있습니다. 별도 설정은 필요 없지만 개발 빌드에서만 동작합니다. 개발 중 스토어 코드를 고칠 때 페이지를 새로고침하지 않고 상태를 유지하려면 스토어 파일 끝에 if (import.meta.hot) import.meta.hot.accept(acceptHMRUpdate(useCounterStore, import.meta.hot))를 추가해야 합니다. 이 코드가 없으면 Vite HMR 이후 스토어가 예전 정의로 남아 수정한 액션이 반영되지 않는 것처럼 보입니다.
Pinia 내부 모델과 Vue 반응성
Pinia 스토어는 Vue 3의 reactive/ref 위에 구축됩니다. Options 스타일 스토어는 내부적으로 상태·게터·액션을 Vue의 반응 시스템에 맞게 래핑하며, Setup 스타일은 작성한 ref/computed를 그대로 스토어 인스턴스로 노출합니다. 스토어 인스턴스는 effectScope 안에서 실행되어, 앱이 마운트 해제될 때 관련 이펙트가 함께 정리되는 방향으로 이해하면 디버깅이 수월합니다.
storeToRefs를 쓰는 이유: 스토어 객체를 그대로 구조 분해하면 state와 getter는 반응성을 잃은 값으로 복사됩니다. 상태와 게터를 구조 분해할 때는 storeToRefs로 ref 유지를 보장하고, 액션은 스토어에서 직접 꺼내면 됩니다.
이 구조를 알면 다른 동작도 이해됩니다. 스토어가 reactive 객체이기 때문에 템플릿이나 스토어 인스턴스에서는 counter.count처럼 .value 없이 접근하지만(ref가 자동으로 언래핑됨), Setup 스토어 내부에서는 원래의 ref를 다루므로 count.value를 써야 합니다. 또 Pinia는 앱 하나에 인스턴스 하나를 두고 그 안의 state(pinia.state.value)에 모든 스토어 상태를 모아 둡니다. SSR에서 이 객체 하나만 직렬화하면 모든 스토어 상태가 클라이언트로 넘어가는 것도 이 구조 덕분입니다.
순환 참조, SSR, 지속화에서 조심할 것
스토어 간 의존과 순환 참조
한 스토어의 setup에서 다른 스토어를 useOtherStore()로 부르는 것은 흔하지만, 순환 의존(A→B→A)이 생기면 초기화 순서가 꼬일 수 있습니다. 해결책은 공통 도메인을 상위 스토어로 승격하거나, 이벤트 버스·컴포지션 루트에서만 교차 참조하도록 경계를 나누는 것입니다.
SSR·Nuxt
Nuxt 3에서는 @pinia/nuxt로 요청마다 깨끗한 스토어를 만들고, 클라이언트로 상태 직렬화를 넘깁니다. localStorage에 의존하는 플러그인은 import.meta.client 가드 없이 쓰면 SSR에서 터집니다. hydration mismatch가 나면 서버와 클라이언트 초기 상태를 맞추는지 확인합니다.
요청마다 새 인스턴스가 필요한 이유는 Node 서버 프로세스가 여러 사용자의 요청을 함께 처리하기 때문입니다. 모듈 최상단에 const pinia = createPinia()를 두고 모든 요청이 공유하면, A 사용자의 로그인 정보가 담긴 스토어가 B 사용자의 렌더링에 그대로 쓰이는 심각한 정보 노출이 생깁니다. @pinia/nuxt는 이 처리를 자동으로 해 주지만, 직접 SSR을 구성한다면 요청 핸들러 안에서 createPinia()를 호출해야 합니다. 같은 이유로 Setup 스토어 바깥(모듈 스코프)에 선언한 ref는 서버에서 모든 요청이 공유하므로 상태는 반드시 스토어 함수 안에 선언해야 합니다.
지속화(persist)와 보안
앞 절의 간이 localStorage 예제는 토큰·PII를 넣기에 부적절합니다. 민감 필드는 메모리만 두거나, httpOnly 쿠키·세션과 역할을 나눕니다. $subscribe는 대량 업데이트마다 저장하면 I/O 병목이 되므로 debounce를 고려합니다.
증상별 원인 후보
| 증상 | 원인 후보 |
|---|---|
| 구조 분해 후 화면이 갱신 안 됨 | storeToRefs 미사용 또는 액션을 잘못 분해 |
| 두 스토어가 서로 다른 인스턴스처럼 보임 | 테스트·스토리북에서 새 Pinia를 매번 만들지 않음 |
| HMR 후 상태가 꼬임 | 개발 전용 이슈 — 새로고침 또는 스토어 $reset |
| 무한 요청 루프 | watch+액션에서 동일 조건으로 다시 트리거 |
| Devtools에 액션이 안 보임 | 프로덕션 빌드에서 devtools 비활성 — 정상 |
$reset is not a function 계열 에러 | Setup 스타일 스토어 — $reset 직접 구현 필요 |
| 서버에서 넣은 값이 클라이언트에서 사라짐 | Setup 스토어에서 해당 ref를 return하지 않음 |
테스트에서 두 번째 행의 문제가 특히 자주 나옵니다. 스토어는 활성 Pinia 인스턴스에 묶이는데, 테스트마다 새 인스턴스를 만들지 않으면 앞 테스트에서 바뀐 상태가 다음 테스트로 새어 들어가 실행 순서에 따라 결과가 달라집니다. 단위 테스트에서는 beforeEach(() => setActivePinia(createPinia()))를, 컴포넌트 테스트에서는 @pinia/testing의 createTestingPinia()를 쓰면 매번 깨끗한 상태에서 시작하고 액션을 자동으로 스텁 처리할 수도 있습니다.
Pinia 요약
Pinia는 Vuex의 mutation 계층과 문자열 기반 호출을 걷어 내고, 스토어를 Vue 반응성 위의 평범한 객체로 만든 라이브러리입니다. 사용법 자체는 단순하지만, 실제로 문제가 되는 지점은 거의 정해져 있습니다. 구조 분해할 때 storeToRefs를 쓰는 것, Setup 스토어에서 모든 state를 반환하는 것, $reset이 Setup 스타일에 없다는 것, SSR에서 요청마다 인스턴스를 새로 만드는 것 네 가지만 기억해도 대부분의 버그를 피할 수 있습니다.
같이 보면 좋은 글
자주 묻는 질문 (FAQ)
Q. Vuex와 비교하면 어떤가요?
A. Pinia가 훨씬 간단하고 TypeScript 지원이 좋습니다. Vuex는 mutation과 모듈 네임스페이스 때문에 코드가 길어지고, 현재는 유지보수 모드라 새 기능이 추가되지 않습니다.
Q. Vue 2에서도 사용할 수 있나요?
A. Pinia 2.x는 Vue 2.7(또는 @vue/composition-api를 설치한 Vue 2.6)을 지원합니다. Pinia 3부터는 Vue 2 지원이 제거되었으므로 Vue 2 프로젝트에서는 2.x 버전을 고정해 써야 합니다.
Q. Vuex에서 마이그레이션이 어려운가요?
A. 모듈 하나를 스토어 하나로 옮기는 방식으로 점진적으로 진행할 수 있습니다. mutation은 액션으로 합치고, rootState 참조는 다른 스토어 호출로 바꾸며, 컴포넌트의 this.$store 호출을 스토어 함수 호출로 바꾸는 순서가 일반적입니다. 두 라이브러리는 한 앱에서 동시에 사용할 수 있습니다.