소개
Pinia는 2019년 11월경 Composition API를 활용해 Vue용 Store가 어떤 모습이어야 하는지를 새롭게 설계해 보려는 실험으로 시작되었습니다. 그 이후로 초기 원칙은 그대로 유지되어 왔고 2025년에 Vue 2 지원은 중단되었지만, Pinia는 Composition API 사용을 요구하지는 않습니다.
왜 Pinia를 사용해야 하나요?
Pinia는 Vue를 위한 Store 라이브러리로, 컴포넌트/페이지 사이에서 state를 공유할 수 있게 해 줍니다. Composition API에 익숙하다면 단순한 export const state = reactive({})만으로도 전역 state를 공유할 수 있다고 생각할 수 있습니다. 이것은 싱글 페이지 애플리케이션에서는 사실이지만, 서버 사이드 렌더링을 하면 애플리케이션이 보안 취약점에 노출됩니다. 하지만 작은 싱글 페이지 애플리케이션에서도 Pinia를 사용하면 많은 이점을 얻을 수 있습니다:
- 테스트 유틸리티
- 플러그인: 플러그인으로 Pinia 기능 확장
- 적절한 TypeScript 지원 또는 JS 사용자를 위한 자동완성
- 서버 사이드 렌더링 지원
- 개발자 도구 지원
- 액션과 변이를 추적하는 타임라인
- 스토어가 사용되는 컴포넌트에 표시됨
- 타임 트래블과 더 쉬운 디버깅
- 핫 모듈 교체
- 페이지를 다시 불러오지 않고 스토어 수정
- 개발 중에도 기존 상태 유지
여전히 확신이 서지 않는다면 공식 Mastering Pinia 강좌를 확인해 보세요. 초반에는 직접 defineStore() 함수를 만드는 방법을 다루고, 이후 공식 Pinia API로 넘어갑니다.
Get the Pinia Cheat Sheet from Vue Mastery 기본 예제
아래는 Pinia를 사용할 때의 API 모습입니다(전체 지침은 시작하기를 꼭 확인하세요). 먼저 스토어를 생성합니다:
// stores/counter.js
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', {
state: () => {
return { count: 0 }
},
// 이렇게도 정의할 수 있습니다
// state: () => ({ count: 0 })
actions: {
increment() {
this.count++
},
},
})그리고 컴포넌트에서 _사용_합니다:
<script setup>
import { useCounterStore } from '@/stores/counter'
const counter = useCounterStore()
counter.count++
// 자동완성 지원 ✨
counter.$patch({ count: counter.count + 1 })
// 또는 action을 사용하는 방식
counter.increment()
</script>
<template>
<!-- 스토어에서 state에 직접 접근 -->
<div>현재 카운트: {{ counter.count }}</div>
</template>더 고급 사용 사례에서는 함수(컴포넌트의 setup()과 비슷함)를 사용해 Store를 정의할 수도 있습니다:
export const useCounterStore = defineStore('counter', () => {
const count = ref(0)
function increment() {
count.value++
}
return { count, increment }
})아직도 setup()과 Composition API가 익숙하지 않더라도 걱정하지 마세요. Pinia는 map helpers like Vuex와 비슷한 도우미 집합도 지원합니다. 스토어는 똑같이 정의하고, 이후 mapStores(), mapState(), mapActions()를 사용하면 됩니다:
const useCounterStore = defineStore('counter', {
state: () => ({ count: 0 }),
getters: {
double: (state) => state.count * 2,
},
actions: {
increment() {
this.count++
},
},
})
const useUserStore = defineStore('user', {
// ...
})
export default defineComponent({
computed: {
// 다른 computed 속성들
// ...
// this.counterStore와 this.userStore에 접근할 수 있게 합니다
...mapStores(useCounterStore, useUserStore),
// this.count와 this.double을 읽을 수 있게 합니다
...mapState(useCounterStore, ['count', 'double']),
},
methods: {
// this.increment()에 접근할 수 있게 합니다
...mapActions(useCounterStore, ['increment']),
},
})각 _map helper_에 대한 더 많은 정보는 핵심 개념에서 찾을 수 있습니다.
공식 강좌
Pinia의 공식 강좌는 Mastering Pinia입니다. Pinia의 저자가 작성했으며, 기초부터 플러그인, 테스트, 서버 사이드 렌더링 같은 고급 주제까지 모두 다룹니다. Pinia를 시작하고 완전히 익히는 가장 좋은 방법입니다.
왜 _Pinia_인가요
Pinia(발음은 /piːnjʌ/, 영어로는 "피냐"처럼 들립니다)는 유효한 패키지 이름 중에서 piña(스페인어로 파인애플)와 가장 가까운 단어입니다. 파인애플은 사실 여러 개의 꽃이 모여 하나의 복합 과실을 이루는 구조입니다. 스토어와 비슷하게, 각각은 개별적으로 태어나지만 결국 모두 연결됩니다. 또한 남아메리카가 원산지인 맛있는 열대 과일이기도 합니다.
좀 더 현실적인 예제
여기에는 Pinia와 함께 사용할 API의 더 완전한 예제가 있습니다. JavaScript에서도 타입과 함께 말이죠. 어떤 사람들에게는 더 읽지 않고도 시작하기에 이것만으로 충분할 수 있지만, 그래도 나머지 문서를 확인하거나, 이 예제를 건너뛰고 _핵심 개념_을 모두 읽은 뒤 다시 돌아오는 것을 권장합니다.
import { defineStore } from 'pinia'
export const useTodos = defineStore('todos', {
state: () => ({
/** @type {{ text: string, id: number, isFinished: boolean }[]} */
todos: [],
/** @type {'all' | 'finished' | 'unfinished'} */
filter: 'all',
// 타입은 자동으로 number로 추론됩니다
nextId: 0,
}),
getters: {
finishedTodos(state) {
// 자동완성! ✨
return state.todos.filter((todo) => todo.isFinished)
},
unfinishedTodos(state) {
return state.todos.filter((todo) => !todo.isFinished)
},
/**
* @returns {{ text: string, id: number, isFinished: boolean }[]}
*/
filteredTodos(state) {
if (this.filter === 'finished') {
// 자동완성과 함께 다른 getter 호출 ✨
return this.finishedTodos
} else if (this.filter === 'unfinished') {
return this.unfinishedTodos
}
return this.todos
},
},
actions: {
// 인수 개수는 얼마든지 가능하며, 프로미스를 반환해도 되고 안 해도 됩니다
addTodo(text) {
// state를 직접 변경할 수 있습니다
this.todos.push({ text, id: this.nextId++, isFinished: false })
},
},
})Vuex와의 비교
Pinia는 Vuex의 다음 버전이 어떤 모습일지 탐구하는 과정에서 출발했으며, Vuex 5를 위한 코어 팀 논의에서 나온 많은 아이디어를 통합했습니다. 결국 우리는 Pinia가 이미 Vuex 5에서 원하던 대부분을 구현하고 있음을 깨달았고, 대신 Pinia를 새로운 권장안으로 삼기로 했습니다.
Vuex와 비교했을 때 Pinia는 더 단순하고 의례적인 코드가 적은 API를 제공하며, Composition API 스타일의 API를 제공하고, 무엇보다 TypeScript와 함께 사용할 때 탄탄한 타입 추론 지원을 갖추고 있습니다.
RFC
처음에 Pinia는 어떤 RFC 과정도 거치지 않았습니다. 저는 애플리케이션을 개발한 경험, 다른 사람의 코드를 읽은 경험, Pinia를 사용하는 클라이언트와 함께 일한 경험, Discord에서 질문에 답한 경험을 바탕으로 아이디어를 시험했습니다. 이 덕분에 다양한 사례와 애플리케이션 규모에 적응할 수 있는, 실제로 동작하는 해결책을 제공할 수 있었습니다. 저는 자주 배포했고, 핵심 API는 그대로 유지한 채 라이브러리를 발전시켰습니다.
이제 Pinia가 기본 상태 관리 솔루션이 되었으므로, Vue 생태계의 다른 코어 라이브러리와 같은 RFC 과정을 따르며 API도 안정적인 상태에 들어섰습니다.
Vuex 3.x/4.x와의 비교
Vuex 3.x는 Vue 2용 Vuex이고, Vuex 4.x는 Vue 3용입니다
Pinia API는 Vuex ≤4와 매우 다릅니다. 대표적으로 다음과 같습니다:
- _mutations_는 더 이상 존재하지 않습니다. 이것들은 종종 극도로 장황하다고 여겨졌습니다. 처음에는 devtools 통합을 제공했지만, 이제는 더 이상 문제가 되지 않습니다.
- TypeScript를 지원하기 위한 복잡한 사용자 정의 래퍼를 만들 필요가 없습니다. 모든 것이 타입화되어 있으며, API는 TS 타입 추론을 최대한 활용하도록 설계되었습니다.
- 주입할 마법 같은 문자열도 აღარ 필요합니다. 함수를 import하고, 호출하고, 자동완성을 즐기세요!
- 스토어를 동적으로 추가할 필요가 없습니다. 모두 기본적으로 동적이며, 심지어 알아차리지도 못할 것입니다. 물론 원할 때 직접 스토어를 사용해 등록할 수는 있지만, 자동이기 때문에 걱정할 필요가 없습니다.
- _modules_의 중첩 구조도 더 이상 없습니다. 다른 스토어 안에서 스토어를 import하고 _사용_하여 암묵적으로 중첩할 수는 있지만, Pinia는 설계상 평평한 구조를 제공하면서도 스토어 간 교차 조합을 가능하게 합니다. 스토어 간 순환 의존성도 가질 수 있습니다.
- _namespaced modules_도 없습니다. 스토어의 평평한 아키텍처 덕분에 스토어의 "네임스페이싱"은 정의 방식 자체에 내재되어 있으며, 모든 스토어가 네임스페이스화되어 있다고 말할 수도 있습니다.
기존 Vuex ≤4 프로젝트를 Pinia로 변환하는 더 자세한 방법은 Vuex에서 마이그레이션 가이드를 참고하세요.