Engineering note · Vue / Pinia

Pinia Store를 구조 분해할 때 반응성을 유지하는 방법

2026년 08월 14일이현수
태그vue3piniareactivity

Pinia Store의 state와 getter를 바로 구조 분해했을 때 값이 갱신되지 않는 이유와 storeToRefs로 반응성을 유지하는 방법을 정리한다.

Pinia Store의 state와 getter를 구조 분해하면 action을 호출한 뒤에도 로컬 변수는 처음 값에 머문다.

ts
// stores/counter.ts
import { defineStore } from 'pinia'

export const useCounterStore = defineStore('counter', {
  state: () => ({ count: 0 }),
  getters: {
    doubleCount: state => state.count * 2,
  },
  actions: {
    increment() {
      this.count++
    },
  },
})

count를 바꾸는 action과 현재 값의 두 배를 반환하는 getter가 있는 간단한 Store다. 컴포넌트에서 Store 객체를 바로 구조 분해하면 아래처럼 값이 갈라진다.

vue
<script setup lang="ts">
import { useCounterStore } from '@/stores/counter'

const counterStore = useCounterStore()
const { count, doubleCount } = counterStore

counterStore.increment()

console.log(counterStore.count) // 1
console.log(count) // 0
console.log(doubleCount) // 0
</script>

Store 객체를 통하면 최신 값을 읽지만 count와 doubleCount는 갱신되지 않는다. Pinia가 반환하는 Store는 reactive로 감싼 객체이고, 구조 분해는 그 시점의 속성값을 별도 변수에 할당하기 때문이다.

Store 객체를 벗어나면서 추적이 끊긴다

ts
const counterStore = useCounterStore()

console.log(counterStore.count)
counterStore.increment()
console.log(counterStore.count)

counterStore.count로 접근할 때는 매번 Store의 Proxy를 거친다. Vue는 속성을 읽을 때 의존성을 추적하고, 값이 바뀌면 해당 속성을 사용한 화면이나 계산을 다시 갱신한다.

ts
const { count } = counterStore

구조 분해한 count는 Proxy의 속성이 아니라 일반 숫자 변수다. 최초 할당 이후에는 counterStore.count와 연결되지 않는다. Vue 공식 문서에서도 reactive() 객체의 원시값 속성을 구조 분해하면 반응형 연결을 잃는다고 설명한다.

Vue - Limitations of reactive()

객체나 배열 state는 바로 구조 분해해도 내부 변경이 화면에 반영되는 것처럼 보일 수 있다. 구조 분해한 값 자체가 중첩 Proxy이기 때문이다. 하지만 Store 속성을 새로운 객체로 교체하면 로컬 변수는 이전 객체를 계속 가리킨다. 값의 형태에 따라 우연히 동작하는 코드에 기대기보다 연결 방식을 분명하게 두는 편이 낫다.

state와 getter는 storeToRefs로 꺼낸다

vue
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { useCounterStore } from '@/stores/counter'

const counterStore = useCounterStore()
const { count, doubleCount } = storeToRefs(counterStore)

counterStore.increment()

console.log(count.value) // 1
console.log(doubleCount.value) // 2
</script>

<template>
  <p>{{ count }} / {{ doubleCount }}</p>
</template>

storeToRefs(counterStore)는 state와 getter를 Store 속성에 연결된 ref로 만든다. <script setup> 안에서는 .value로 접근하고, 템플릿에서는 Vue가 자동으로 값을 꺼내므로 .value를 붙이지 않는다.

Store 객체를 그대로 사용해도 된다. 구조 분해가 코드 가독성에 도움이 되지 않는다면 counterStore.count와 counterStore.doubleCount로 접근하는 방식이 가장 단순하다. 일부 getter만 다른 계산에 연결하려면 computed(() => counterStore.doubleCount)처럼 감쌀 수도 있다.

action은 Store에서 바로 구조 분해한다

vue
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { useCounterStore } from '@/stores/counter'

const counterStore = useCounterStore()
const { count, doubleCount } = storeToRefs(counterStore)
const { increment } = counterStore
</script>

<template>
  <button @click="increment">{{ count }} / {{ doubleCount }}</button>
</template>

action은 반응형으로 추적할 값이 아니며 Store에 바인딩되어 있어 바로 구조 분해할 수 있다. state와 getter는 storeToRefs(), action은 Store 객체에서 나누어 꺼내면 각 값의 역할도 코드에 드러난다.

toRefs보다 storeToRefs가 맞다

ts
import { toRefs } from 'vue'

const { count, increment } = toRefs(counterStore)

increment.value()

Vue의 toRefs()를 쓰면 Store의 열거 가능한 속성을 일괄 변환하므로 action까지 ref로 다루게 될 수 있다. 호출할 때도 increment.value()처럼 불필요한 형태가 된다.

ts
import { storeToRefs } from 'pinia'

const { count } = storeToRefs(counterStore)
const { increment } = counterStore

storeToRefs()는 Pinia Store 전용 함수다. state, getter, 플러그인이 추가한 반응형 state는 ref로 만들고 action과 반응형이 아닌 속성은 제외한다.

Pinia - storeToRefs() API

Store 전체를 그대로 쓸지, 필요한 속성만 꺼낼지를 먼저 정하면 된다. 필요한 state와 getter를 구조 분해할 때만 storeToRefs()를 사용하고, action은 Store에서 바로 꺼내면 각 속성의 역할이 분명해진다.

좋아요와 댓글

댓글 남기기

댓글 0개

댓글을 불러오는 중입니다.