99

최근 30분간 동시 방문자 수를 표시합니다. (~)

최고 동시 방문자 수 -
어제: 0명 / 오늘: 0명

Pinia 핵심 정리

Pinia 핵심 정리

이 글은 Pinia 4.0.3 버전을 기준으로 작성되었습니다.

# 개요

Pinia는 Vue 애플리케이션의 상태 관리를 위한 라이브러리입니다.
Vuex 다음 버전의 논의 아이디어가 반영된 새로운 라이브러리로 시작해서 2022년부터 Vue의 공식 상태 관리 라이브러리가 되었습니다.
Vuex의 변이(Mutations)가 사라지면서 훨씬 쉽게 데이터 변경이 가능하고, 특히 Composition API와 TypeScript에 친화적입니다.
Vuex는 2022년 10월의 4.1.0 버전을 끝으로 새 버전이 나오지 않고 있으므로, 새로운 Vue 프로젝트를 시작한다면 Pinia 사용이 적극 권장됩니다.

# 설치 및 구성

다음과 같이 Pinia를 설치합니다.

BASH
1
npm i pinia

Pinia 4는 ESM 전용 패키지이며 Vue 3.5.11 버전과 TypeScript 5.6 버전 이상을 요구합니다.
그리고 개발자 도구 연동을 위한 @vue/devtools-api가 피어 의존성(Peer Dependency)으로 분리됐습니다.
NPM은 피어 의존성을 자동으로 설치하지만, PNPM이나 Yarn의 엄격한 설정에서는 직접 설치해야 할 수 있습니다.

프로젝트에서 사용하기 위해 다음과 같이 Vue 플러그인으로 등록합니다.

/src/main.ts
TS
1
2
3
4
5
6
7
8
9
import { createApp } from 'vue' import { createPinia } from 'pinia' import App from './App.vue' const pinia = createPinia() createApp(App) .use(pinia) .mount('#app')

# 스토어 생성

관리할 스토어는 defineStore 함수를 호출해 생성합니다.
이 함수는 스토어 인스턴스를 식별하는 고유한 문자(스토어 ID)와 스토어를 정의하는 값을 인수로 받습니다.
또한 defineStore 함수의 반환은 스토어 인스턴스를 얻을 수 있는 팩토리 함수입니다.
이 함수를 보통 훅(Hook)이라고 칭하며, use 접두사와 Store 접미사로 작명합니다.
그리고 컴포넌트나 외부에서 호출해 사용할 수 있습니다.

TS
1
2
3
import { defineStore } from 'pinia' export const use이름Store = defineStore('스토어_ID', 스토어_정의)

두 번째 인수에 무엇을 전달하는지에 따라 스토어를 정의하는 방법이 두 가지로 나뉩니다.
상태, 게터, 액션을 옵션 객체로 전달하면 옵션 스토어(Option Store)이고, Composition API 문법의 함수를 전달하면 셋업 스토어(Setup Store)입니다.
두 방식은 문법만 다를 뿐 만들어지는 스토어는 같으므로, 익숙한 쪽을 선택하면 됩니다.

# 옵션 스토어

숫자를 다루는 스토어(Count Store)를 예시로 살펴봅시다.
모듈화를 위해 /src/stores/count.ts 파일을 생성하고 state, getters, actions 세 가지 옵션으로 스토어를 생성합니다.

/src/stores/count.ts
TS
1
2
3
4
5
6
7
import { defineStore } from 'pinia' export const useCountStore = defineStore('count', { state: () => ({}), getters: {}, actions: {} })

# 상태

상태(State)는 스토어에서 정의하는 반응형 데이터입니다.
스토어 객체의 state 옵션에서 정의하며, 꼭 팩토리 함수로 작성해야 합니다.
이는 스토어 인스턴스가 여러 번 생성되더라도 상태가 불필요하게 공유 혹은 초기화되는 문제를 방지하기 위함입니다.

/src/stores/count.ts
TS
1
2
3
4
5
6
7
8
9
10
11
import { defineStore } from 'pinia' export const useCountStore = defineStore('count', { state: () => ({ count: 1, double: 2, negativeDouble: -2, isNegative: false, history: [] as number[] }) })

예제의 historycount가 바뀔 때마다 그 값을 차례로 쌓아 두는 배열입니다.
숫자처럼 값을 직접 담는 상태와, 배열처럼 참조를 담는 상태의 동작 차이를 함께 살펴보기 위해 추가했습니다.
두 종류를 같이 두면 뒤에서 다룰 $patch 메소드의 객체 방식과 함수 방식이 왜 나뉘는지 비교하기 좋습니다.

정의한 각 상태는 스토어 인스턴스에서 바로 조회할 수 있습니다.
스토어 인스턴스는 useCountStore()와 같이 훅을 호출해 얻을 수 있습니다.

/src/App.vue
VUE
1
2
3
4
5
6
7
8
9
10
11
12
13
<script setup lang="ts"> import { useCountStore } from '@/stores/count' const countStore = useCountStore() </script> <template> <h2>Count: {{ countStore.count }}</h2> <h2>Double: {{ countStore.double }}</h2> <h2>Negative Double: {{ countStore.negativeDouble }}</h2> <h2>Is Negative: {{ countStore.isNegative }}</h2> <h2>History: {{ countStore.history }}</h2> </template>

# 게터

게터(Getters)는 상태를 기반으로 계산된 값을 반환하는 함수로서, 게터라는 이름 그대로 읽기 전용입니다.

다음 예제에서 doubleisNegativecount 상태를 기반으로 계산된 값을 반환합니다.
이때 게터 함수의 첫 매개변수로 상태 객체를 얻을 수 있습니다.
그런데 negativeDouble의 경우는 이제 double 게터의 값을 기반으로 계산해야 하지만, 화살표 함수를 사용할 때는 다른 게터에 접근할 수 있는 방법이 없습니다.

/src/stores/count.ts
TS
1
2
3
4
5
6
7
8
9
10
11
12
13
import { defineStore } from 'pinia' export const useCountStore = defineStore('count', { state: () => ({ count: 1, history: [] as number[] }), getters: { double: state => state.count * 2, isNegative: state => state.count < 0 // negativeDouble: () => this.double * -1 // 정상적으로 동작하지 않음! } })

그래서 만약 게터가 다른 게터의 값을 기반으로 계산하려면, 화살표 함수 대신 일반 함수를 사용하고 this 키워드를 통해 상태나 다른 게터에 접근할 수 있습니다.
그런데 이렇게 this 키워드를 사용할 때는 게터의 반환 타입을 꼭 명시해야 합니다.

일반 함수와 화살표 함수의 this 키워드 차이를 이해하면, 현재 구조에서 화살표 함수로 this 키워드를 사용할 수 없는 이유를 알 수 있습니다.

/src/stores/count.ts
TS
1
2
3
4
5
6
7
8
9
10
11
12
import { defineStore } from 'pinia' export const useCountStore = defineStore('count', { // ... getters: { double: state => state.count * 2, isNegative: state => state.count < 0, negativeDouble(): number { return this.double * -1 } } })

# 액션

액션(Actions)은 상태나 게터를 활용해 실행할 수 있는 함수입니다.
this 키워드로 상태나 게터 그리고 다른 액션에 접근할 수 있으며, 상태를 변경할 수도 있습니다.

/src/stores/count.ts
TS
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import { defineStore } from 'pinia' export const useCountStore = defineStore('count', { state: () => ({ count: 1, history: [] as number[] }), getters: { double: state => state.count * 2, isNegative: state => state.count < 0, negativeDouble(): number { return this.double * -1 } }, actions: { increase(value = 1) { this.count += value this.history.push(this.count) }, decrease(value = 1) { this.count -= value this.history.push(this.count) } } })

다음과 같이 스토어 인스턴스에서 바로 접근해서 호출할 수 있습니다.

/src/App.vue
VUE
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<script setup lang="ts"> import { useCountStore } from '@/stores/count' const countStore = useCountStore() </script> <template> <button @click="countStore.increase">증가!</button> <button @click="countStore.decrease">감소!</button> <h2>Count: {{ countStore.count }}</h2> <h2>Double: {{ countStore.double }}</h2> <h2>Negative Double: {{ countStore.negativeDouble }}</h2> <h2>Is Negative: {{ countStore.isNegative }}</h2> <h2>History: {{ countStore.history }}</h2> </template>

다음과 같이 비동기 액션을 사용할 수도 있습니다.

/src/stores/count.ts
TS
1
2
3
4
5
6
7
8
9
10
11
12
13
import { defineStore } from 'pinia' export const useCountStore = defineStore('count', { // ... actions: { // ... async fetchCount() { const res = await fetch('https://api.heropy.dev/v0/count') const count = await res.json() this.count = count } } })

# 셋업 스토어

defineStore의 두 번째 인수로 함수를 전달하면, Vue 컴포넌트의 setup 함수처럼 Composition API 문법으로 스토어를 정의할 수 있습니다.
ref가 상태, computed가 게터, 일반 함수가 액션에 해당하며, 외부에서 사용할 것들을 객체로 반환합니다.

/src/stores/count.ts
TS
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import { ref, computed } from 'vue' import { defineStore } from 'pinia' export const useCountStore = defineStore('count', () => { const count = ref(1) const history = ref<number[]>([]) const double = computed(() => count.value * 2) const negativeDouble = computed(() => double.value * -1) function increase(value = 1) { count.value += value history.value.push(count.value) } function decrease(value = 1) { count.value -= value history.value.push(count.value) } return { count, history, double, negativeDouble, increase, decrease } })

컴포넌트에서 사용하는 방법은 옵션 스토어와 같습니다.
게터가 다른 게터를 참조할 때 this 키워드나 반환 타입 명시가 필요 없다는 점이 셋업 스토어의 장점입니다.
그 밖에도 스토어 안에서 watch를 사용하거나 컴포저블(Composable)을 호출할 수 있어서 옵션 스토어보다 유연합니다.

# 구조 분해와 반응성

스토어 인스턴스는 반응형 객체이므로, 다음과 같이 구조 분해하면 반응성이 사라집니다.
count는 구조 분해하는 시점의 값으로 고정되어, 이후 상태가 변경돼도 화면이 갱신되지 않습니다.

/src/App.vue
VUE
1
2
3
4
5
6
7
8
9
10
<script setup lang="ts"> import { useCountStore } from '@/stores/count' const countStore = useCountStore() const { count } = countStore // 반응성이 사라집니다! </script> <template> <h2>Count: {{ count }}</h2> </template>

이때 storeToRefs 함수를 사용하면 각 상태와 게터를 반응형 참조(Ref)로 변환해서 구조 분해할 수 있습니다.
액션은 반응성과 무관하므로 스토어 인스턴스에서 바로 구조 분해합니다.

/src/App.vue
VUE
1
2
3
4
5
6
7
8
9
10
11
12
13
14
<script setup lang="ts"> import { storeToRefs } from 'pinia' import { useCountStore } from '@/stores/count' const countStore = useCountStore() const { count, double } = storeToRefs(countStore) const { increase, decrease } = countStore </script> <template> <button @click="increase">증가!</button> <h2>Count: {{ count }}</h2> <h2>Double: {{ double }}</h2> </template>

# 인스턴스 멤버

스토어 인스턴스에서 제공하는 여러 멤버를 통해 스토어를 더욱 다양하게 활용할 수 있습니다.

이어지는 예제는 옵션 스토어를 기준으로 작성했습니다.
뒤에서 살펴볼 $reset 메소드를 제외하면, 모든 인스턴스 멤버는 셋업 스토어에서도 동일하게 동작합니다.

# 스토어 ID 확인 ($id)

defineStore의 첫 번째 인수로 제공한 스토어 인스턴스를 식별하는 고유한 문자를 $id 속성으로 얻을 수 있습니다.

/src/App.vue
VUE
1
2
3
4
5
6
<script setup lang="ts"> import { useCountStore } from '@/stores/count' const countStore = useCountStore() console.log(countStore.$id) // 'count' </script>

# 상태 객체 확인 ($state)

$state 속성으로 스토어 인스턴스의 상태 객체에 접근할 수 있습니다.
게터와 액션은 빠지고 state 옵션에 정의한 상태만 담긴 객체입니다.
이 객체도 반응형이라 countStore.$state.count = 100처럼 값을 바꾸면 countStore.count로 바꿀 때와 똑같이 동작합니다.

/src/App.vue
VUE
1
2
3
4
5
6
<script setup lang="ts"> import { useCountStore } from '@/stores/count' const countStore = useCountStore() console.log(countStore.$state) // { count: 1, history: [] } </script>

# 상태 변경 ($patch)

컴포넌트에서는 상태의 읽기와 쓰기가 모두 가능합니다.
만약 여러 상태를 각각 변경하면, 개발자 도구에 각각 별개의 항목으로 기록되어 변경 이력을 추적하기 어렵습니다.
이때 $patch 메소드를 사용하면 여러 상태 변경을 단일 작업으로 처리할 수 있습니다.
개발자 도구에 항목 하나로 기록되고, 뒤에서 살펴볼 $subscribe 메소드의 콜백도 한 번만 실행됩니다.
그리고 명시적으로 상태 변경을 처리하므로 가독성도 높아집니다.

다음 예제에서 handleClick 함수는 여러 상태를 개별적으로 변경하고 있습니다.

/src/App.vue
VUE
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<script setup lang="ts"> import { useCountStore } from '@/stores/count' const countStore = useCountStore() function handleClick() { if (countStore.count > 100) { countStore.count = 100 countStore.history.push(100) } else { countStore.count += 1 countStore.history.push(countStore.count) } } </script> <template> <button @click="handleClick">트리거!</button> <h2>Count: {{ countStore.count }}</h2> <h2>History: {{ countStore.history }}</h2> </template>

이때 다음과 같이 $patch 메소드를 사용해 여러 상태를 한 번에 변경할 수 있습니다.
이 방식은 간단한 데이터 변경에는 편리하지만, 기존 값을 참조하거나 값의 일부만 변경(참조형)하는 데는 추가 비용이 듭니다.

TS
1
2
3
4
store.$patch({ 상태1:, 상태2: })
객체로 변경
/src/App.vue
VUE
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<script setup lang="ts"> // ... function handleClick() { if (countStore.count > 100) { countStore.$patch({ count: 100, history: [...countStore.history, 100] }) } else { countStore.$patch({ count: countStore.count + 1, history: [...countStore.history, countStore.count + 1] }) } } </script>

이를 보완하기 위해 $patch 메소드는 다음과 같이 함수 형태로도 사용할 수 있습니다.
콜백의 매개변수로 상태 객체를 전달받아 각 세부 상태를 자유롭게 변경할 수 있습니다.

TS
1
2
3
4
store.$patch(state => { state.상태1 = state.상태2 = })
함수로 변경
/src/App.vue
VUE
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<script setup lang="ts"> // ... function handleClick() { countStore.$patch(state => { if (countStore.count > 100) { state.count = 100 state.history.push(100) } else { state.count += 1 state.history.push(state.count) } }) } </script>

스토어 멤버는 액션 내에서도 사용할 수 있습니다.
this 키워드로 접근합니다.

/src/stores/count.ts
TS
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { defineStore } from 'pinia' export const useCountStore = defineStore('count', { // ... actions: { increase(value = 1) { this.$patch(state => { state.count += value state.history.push(state.count) }) }, decrease(value = 1) { this.$patch(state => { state.count -= value state.history.push(state.count) }) } } })

# 상태 구독 ($subscribe)

$subscribe 메소드로 스토어의 상태가 변경될 때마다 콜백을 실행할 수 있습니다.
주로 상태의 변화에 맞게 로컬 스토리지에 저장하거나, 외부로 동기화할 때 사용합니다.

TS
1
const 구독해제함수 = store.$subscribe(콜백, 옵션?)

콜백은 변경 정보(mutation)와 변경된 상태 객체(state)를 인수로 받습니다.
대표적으로 변경 정보의 type 속성을 통해서 상태가 직접 수정('direct')되었는지, $patch 메소드로 변경('patch object' | 'patch function')되었는지 구분할 수 있습니다.

TS
1
2
3
4
5
6
7
countStore.$subscribe((mutation, state) => { console.log(mutation, state) // 변경 정보, 변경된 상태 객체 console.log(mutation.type) // 'direct' | 'patch object' | 'patch function' console.log(mutation.storeId) // 스토어 ID console.log(mutation.events.oldValue) // 변경 전 값 console.log(mutation.events.newValue) // 변경 후 값 })

events 속성은 개발자 도구를 위한 정보로 개발 모드에서만 제공됩니다.
프로덕션 빌드에서는 undefined이고 변경 방식에 따라 단일 객체나 배열로 모양이 달라지므로, 실제 로직에서 사용하면 안 됩니다.

$subscribe 메소드의 두 번째 인수로는 Pinia의 detached 옵션과 Vue watch() 옵션을 전달할 수 있습니다.
컴포넌트에서 $subscribe 메소드를 호출하면 해당 컴포넌트가 언마운트될 때 구독이 자동으로 해제되는데, detached 옵션을 true로 설정하면 컴포넌트와 무관하게 구독을 유지합니다.
참고로 deep 옵션은 기본값이 true이므로 따로 지정하지 않아도 중첩된 상태의 변화를 감지합니다.
그리고 $subscribe 메소드의 반환 값은 구독을 해제할 수 있는 함수입니다.

TS
1
2
3
4
5
6
7
8
9
10
11
12
const unsubscribe = countStore.$subscribe( (mutation, state) => { // ... }, { detached: true, flush: 'sync' // ... } ) unsubscribe() // 구독 해제!

# 액션 구독 ($onAction)

스토어의 액션 호출을 구독하여 액션이 실행될 때 콜백을 실행합니다.
액션 실행 전후의 동작을 감지하거나 로깅할 때 유용합니다.

TS
1
2
3
4
5
6
7
8
const unsubscribe = countStore.$onAction(payload => { console.log(payload.name) // 액션 이름 console.log(payload.args) // 액션 호출의 인수 console.log(payload.after) // 액션 호출의 return 혹은 resolve 후 실행할 함수 console.log(payload.onError) // 액션 호출의 throw 혹은 reject 시 실행할 함수 }) unsubscribe() // 구독 해제!

$subscribe 메소드와 마찬가지로 컴포넌트에서 호출하면 언마운트될 때 구독이 자동으로 해제됩니다.
이를 유지하려면 두 번째 인수로 true를 전달합니다.
$subscribe 메소드와 달리 옵션 객체가 아닌 불리언 값을 전달한다는 점에 주의하세요.

TS
1
2
3
countStore.$onAction(payload => { // ... }, true) // 컴포넌트와 무관하게 구독 유지!

# 상태 초기화 ($reset)

$reset 메소드를 호출하면 변경된 모든 상태를 초깃값으로 되돌립니다.
state 옵션의 팩토리 함수를 다시 호출해 새로운 상태 객체를 만들고, 현재 상태를 그 값으로 덮어씁니다.

/src/App.vue
VUE
1
2
3
<template> <button @click="countStore.$reset">모든 상태 초기화!</button> </template>

만약 개별 상태를 초기화해야 하는 경우, 별도의 액션을 작성해야 합니다.

/src/stores/count.ts
TS
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import { defineStore } from 'pinia' export const useCountStore = defineStore('count', { state: () => ({ count: 1, history: [] as number[] }), // ... actions: { // ... resetCount() { this.count = 0 }, resetHistory() { this.history = [] } } })

$reset 메소드는 옵션 스토어에서만 제공됩니다.
셋업 스토어에서 호출하면 에러가 발생하므로, 상태를 초깃값으로 되돌리는 함수를 직접 작성해서 반환해야 합니다.
자세한 내용은 Pinia 공식 문서의 Resetting the state 문단을 참고하세요.

옵션 스토어는 state 옵션 자체가 초기 상태를 만들어 내는 팩토리 함수라서, Pinia가 그 함수를 다시 호출하기만 하면 초깃값을 얻을 수 있습니다.
반면 셋업 스토어에는 상태만 따로 만들어 주는 함수가 없습니다.
setup 함수를 다시 실행하면 상태뿐 아니라 게터, 액션, 감시자까지 전부 새로 만들어지므로, Pinia는 초기화 방법을 추측하지 않고 에러를 발생시킵니다.

그래서 셋업 스토어에서는 모든 상태를 초깃값으로 되돌리는 함수를 직접 만들어 반환합니다.
이름을 $reset으로 지으면, 사용하는 쪽에서는 옵션 스토어와 똑같이 countStore.$reset()으로 호출할 수 있습니다.

/src/stores/count.ts
TS
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { ref, computed } from 'vue' import { defineStore } from 'pinia' export const useCountStore = defineStore('count', () => { const count = ref(1) const history = ref<number[]>([]) const double = computed(() => count.value * 2) function increase(value = 1) { count.value += value history.value.push(count.value) } function $reset() { count.value = 1 history.value = [] } return { count, history, double, increase, $reset } })

# 스토어 폐기 ($dispose)

스토어의 이펙트 스코프(Effect Scope)를 정지하고 모든 구독을 해제한 뒤, 스토어 레지스트리에서 제거합니다.
이펙트 스코프는 Vue의 effectScope() API로 만드는 반응형 효과의 묶음입니다.
Pinia는 스토어마다 스코프를 하나씩 만들어 게터와 감시자를 담아 두므로, 스코프를 정지하면 그 스토어의 반응형 효과가 한 번에 멈춥니다.
스토어가 더 이상 필요 없을 때 사용할 수 있습니다.

/src/App.vue
VUE
1
2
3
<template> <button @click="countStore.$dispose">스토어 폐기!</button> </template>

폐기한 뒤에도 스토어가 필요하면, useCountStore()처럼 훅을 다시 호출해 새로운 인스턴스를 얻어야 합니다.
폐기한 인스턴스는 레지스트리에서 제거된 상태여서 옵션 스토어의 게터가 에러를 발생시키므로, 그대로 재사용하면 안 됩니다.

$dispose 메소드는 상태까지 삭제하지는 않습니다.
따라서 훅을 다시 호출하면 인스턴스는 새로 만들어지지만, 상태는 초깃값이 아닌 폐기 직전의 값을 그대로 이어받습니다.
상태까지 비우려면 delete pinia.state.value[store.$id]와 같이 직접 삭제해야 합니다.

TS
1
2
3
4
5
6
7
const countStore = useCountStore() countStore.increase() countStore.$dispose() const newStore = useCountStore() console.log(newStore === countStore) // false, 새로운 인스턴스입니다. console.log(newStore.count) // 2, 초깃값 1이 아닙니다!