이미지 로딩 중...

Pinia로 상태관리 시작하기 - Store 생성부터 활용까지 - 슬라이드 1/9
A

AI Generated

2025. 11. 8. · 3 Views

Pinia로 상태관리 시작하기 - Store 생성부터 활용까지

Vue 3의 공식 상태관리 라이브러리인 Pinia를 처음 접하는 개발자를 위한 가이드입니다. Store의 기본 개념부터 실전 활용법까지 단계별로 학습할 수 있습니다.


목차

  1. Pinia Store 기본 구조 - defineStore로 상태 저장소 만들기
  2. 컴포넌트에서 Store 사용하기 - Composition API 방식
  3. State 반응성 유지하기 - storeToRefs 활용
  4. Actions에서 비동기 처리하기 - API 호출과 상태 업데이트
  5. Getters로 계산된 값 만들기 - 파생 상태 관리
  6. 여러 Store 조합하기 - Store Composition 패턴
  7. $patch로 여러 상태 한번에 변경하기 - 성능 최적화
  8. $subscribe로 상태 변화 감지하기 - 부수 효과 처리

1. Pinia Store 기본 구조 - defineStore로 상태 저장소 만들기

시작하며

여러분이 Vue 애플리케이션을 개발할 때 여러 컴포넌트에서 동일한 사용자 정보를 사용해야 하는 상황을 겪어본 적 있나요? props를 여러 단계로 전달하거나, 이벤트를 계속 emit 해야 하는 번거로움이 있었을 겁니다.

이런 문제는 실제 개발 현장에서 자주 발생합니다. 컴포넌트 간 데이터 공유가 복잡해질수록 코드의 가독성이 떨어지고, 유지보수가 어려워집니다.

특히 전역으로 관리해야 할 상태가 많아지면 더욱 심각해집니다. 바로 이럴 때 필요한 것이 Pinia Store입니다.

Pinia는 Vue 3의 공식 상태관리 라이브러리로, 간단하면서도 강력한 방식으로 애플리케이션의 상태를 중앙에서 관리할 수 있게 해줍니다.

개요

간단히 말해서, Pinia Store는 애플리케이션의 전역 상태를 보관하는 저장소입니다. 모든 컴포넌트에서 접근 가능한 중앙 데이터 저장소라고 생각하면 됩니다.

Vuex를 사용해보셨다면 mutations, actions, getters의 복잡한 구조가 기억나실 겁니다. Pinia는 이런 복잡성을 제거하고 Composition API와 유사한 직관적인 방식을 제공합니다.

예를 들어, 사용자 인증 상태, 장바구니 데이터, 테마 설정 같은 경우에 매우 유용합니다. 기존에는 Vuex에서 mutation을 통해서만 상태를 변경했다면, 이제는 일반 함수처럼 직접 상태를 변경할 수 있습니다.

이는 코드를 훨씬 간결하고 이해하기 쉽게 만들어줍니다. Pinia의 핵심 특징은 TypeScript 완벽 지원, DevTools 통합, 플러그인 시스템, 모듈 자동 분할입니다.

이러한 특징들이 현대적인 Vue 애플리케이션 개발에 필수적인 이유는 타입 안정성과 개발 생산성을 동시에 제공하기 때문입니다.

코드 예제

// stores/user.js
import { defineStore } from 'pinia'

// 'user'는 store의 고유 ID입니다
export const useUserStore = defineStore('user', {
  // state: 저장할 데이터를 정의합니다
  state: () => ({
    name: '',
    email: '',
    isLoggedIn: false
  }),

  // actions: 상태를 변경하는 메서드입니다
  actions: {
    login(name, email) {
      this.name = name
      this.email = email
      this.isLoggedIn = true
    },

    logout() {
      this.$reset() // 초기 상태로 리셋
    }
  },

  // getters: 계산된 값을 반환합니다
  getters: {
    displayName: (state) => state.name || '게스트'
  }
})

설명

이것이 하는 일: Pinia Store는 애플리케이션의 전역 상태를 정의하고 관리하는 독립적인 모듈을 생성합니다. defineStore 함수를 사용하여 store의 구조와 동작을 선언적으로 정의할 수 있습니다.

첫 번째로, defineStore의 첫 번째 인자인 'user'는 이 store의 고유 식별자입니다. 이 ID는 DevTools에서 디버깅할 때와 여러 store를 구분할 때 사용됩니다.

반드시 고유한 값이어야 하며, 일반적으로 store의 목적을 나타내는 이름을 사용합니다. 그 다음으로, state 함수가 실행되면서 초기 상태 객체를 반환합니다.

여기서 중요한 점은 state가 함수여야 한다는 것입니다. 이는 SSR(서버 사이드 렌더링) 환경에서 각 요청마다 새로운 상태 인스턴스를 생성하기 위함입니다.

state 내부의 name, email, isLoggedIn은 반응형 데이터가 되어 변경 시 자동으로 UI가 업데이트됩니다. actions는 상태를 변경하는 메서드들의 모음입니다.

login 메서드는 this를 통해 직접 state에 접근하여 값을 변경합니다. Vuex와 달리 mutation이 없어 코드가 훨씬 간결합니다.

$reset()은 Pinia가 제공하는 내장 메서드로 state를 초기값으로 되돌립니다. getters는 Vue의 computed와 유사하게 state를 기반으로 계산된 값을 반환합니다.

displayName getter는 name이 있으면 그대로 반환하고, 없으면 '게스트'를 반환합니다. getters는 자동으로 캐싱되어 의존하는 state가 변경될 때만 재계산됩니다.

여러분이 이 코드를 사용하면 모든 컴포넌트에서 일관된 사용자 정보에 접근할 수 있습니다. 컴포넌트 간 props 전달 없이도 데이터를 공유할 수 있고, 상태 변경 로직을 한 곳에서 관리하여 유지보수성이 크게 향상됩니다.

실전 팁

💡 Store ID는 kebab-case나 camelCase를 사용하되 프로젝트 전체에서 일관성을 유지하세요. 'user-store'나 'userStore' 중 하나를 선택하여 모든 store에 동일한 네이밍 규칙을 적용하면 코드 가독성이 높아집니다.

💡 state를 객체로 직접 반환하지 말고 반드시 함수로 감싸세요. state: { count: 0 } 대신 state: () => ({ count: 0 })을 사용해야 SSR 환경에서 메모리 누수를 방지할 수 있습니다.

💡 actions 내부에서 비동기 작업을 자유롭게 사용할 수 있습니다. async/await를 사용하여 API 호출 후 상태를 업데이트하는 패턴이 매우 일반적이며, try-catch로 에러 처리도 함께 구현하세요.

💡 getters에서 다른 getters를 참조할 때는 this를 사용하세요. getters: { doubleCount: (state) => state.count * 2, quadrupleCount() { return this.doubleCount * 2 } } 형태로 getter 체이닝이 가능합니다.

💡 개발 중에는 Vue DevTools를 활용하여 store의 state 변화를 실시간으로 추적하세요. 시간 여행 디버깅 기능으로 과거 상태로 돌아가 버그를 재현할 수 있습니다.


2. 컴포넌트에서 Store 사용하기 - Composition API 방식

시작하며

여러분이 만든 Store를 실제 컴포넌트에서 어떻게 사용해야 할지 막막하셨나요? Store는 만들었지만 컴포넌트에서 데이터를 읽고 쓰는 방법이 헷갈릴 수 있습니다.

이런 문제는 상태관리 라이브러리를 처음 배울 때 흔히 겪는 어려움입니다. 특히 반응성을 유지하면서 store의 데이터를 컴포넌트에 연결하는 부분에서 실수가 자주 발생합니다.

바로 이럴 때 필요한 것이 Pinia의 Composition API 통합입니다. setup 함수 내에서 store를 가져와 마치 일반 객체처럼 사용할 수 있으며, 반응성도 자동으로 유지됩니다.

개요

간단히 말해서, useUserStore() 같은 composable 함수를 호출하여 store 인스턴스를 가져온 후, 그 안의 state, getters, actions를 직접 사용하는 방식입니다. Vue 3의 Composition API와 완벽하게 통합되어 setup 함수나 script setup에서 자연스럽게 사용할 수 있습니다.

예를 들어, 로그인 폼 컴포넌트에서 사용자 정보를 store에 저장하거나, 헤더 컴포넌트에서 로그인 상태를 표시하는 경우에 매우 유용합니다. 기존 Options API에서는 mapState, mapActions 같은 헬퍼 함수를 사용했다면, 이제는 직접 store를 변수에 할당하여 사용합니다.

이는 TypeScript 자동완성과 타입 추론을 완벽하게 지원합니다. 핵심 특징은 자동 반응성 유지, 구조 분해 할당 지원(storeToRefs 사용), actions 직접 호출 가능입니다.

이러한 특징들이 중요한 이유는 보일러플레이트 코드를 최소화하고 직관적인 코드 작성을 가능하게 하기 때문입니다.

코드 예제

// LoginForm.vue
<script setup>
import { ref } from 'vue'
import { useUserStore } from '@/stores/user'

// store 인스턴스를 가져옵니다
const userStore = useUserStore()

// 로그인 폼 데이터
const formName = ref('')
const formEmail = ref('')

// store의 action을 직접 호출합니다
function handleLogin() {
  userStore.login(formName.value, formEmail.value)
  // 로그인 후 리다이렉트 등 추가 로직
  console.log('로그인 완료:', userStore.displayName)
}

// store의 state에 직접 접근할 수 있습니다
function handleLogout() {
  userStore.logout()
}
</script>

<template>
  <div v-if="!userStore.isLoggedIn">
    <input v-model="formName" placeholder="이름" />
    <input v-model="formEmail" placeholder="이메일" />
    <button @click="handleLogin">로그인</button>
  </div>
  <div v-else>
    <p>환영합니다, {{ userStore.displayName }}님!</p>
    <button @click="handleLogout">로그아웃</button>
  </div>
</template>

설명

이것이 하는 일: 컴포넌트의 setup 영역에서 store를 활성화하고, store의 모든 기능(state, getters, actions)을 컴포넌트 로직과 템플릿에서 사용할 수 있게 만듭니다. 첫 번째로, useUserStore()를 호출하면 이전에 정의한 user store의 인스턴스가 반환됩니다.

이 인스턴스는 싱글톤 패턴으로 관리되어 애플리케이션 전체에서 동일한 인스턴스를 공유합니다. 즉, 어느 컴포넌트에서 상태를 변경하든 모든 컴포넌트가 동일한 데이터를 바라봅니다.

그 다음으로, userStore 객체를 통해 점 표기법으로 모든 속성에 접근합니다. userStore.isLoggedIn은 state에, userStore.displayName은 getters에, userStore.login()은 actions에 접근하는 것입니다.

이 모든 접근은 반응형으로 동작하여 값이 변경되면 자동으로 UI가 업데이트됩니다. handleLogin 함수에서는 로컬 ref 값들을 store의 action에 전달합니다.

userStore.login()을 호출하면 store 내부의 login action이 실행되고, this.name과 this.email이 업데이트됩니다. 이 변경은 즉시 모든 컴포넌트에 반영됩니다.

템플릿에서도 userStore를 직접 사용할 수 있습니다. v-if="!userStore.isLoggedIn"처럼 조건부 렌더링에 사용하거나, {{ userStore.displayName }}처럼 데이터 바인딩에 사용할 수 있습니다.

Vue의 반응성 시스템이 자동으로 추적하여 변경 시 재렌더링을 트리거합니다. 여러분이 이 코드를 사용하면 props drilling 없이 깊은 컴포넌트 트리에서도 데이터를 쉽게 공유할 수 있습니다.

또한 로직과 UI가 분리되어 테스트가 용이하고, TypeScript를 사용한다면 완벽한 타입 안정성을 얻을 수 있습니다.

실전 팁

💡 store를 구조 분해 할당할 때는 반드시 storeToRefs를 사용하세요. const { name, email } = userStore는 반응성을 잃지만, const { name, email } = storeToRefs(userStore)는 반응성을 유지합니다. actions는 storeToRefs 없이 구조 분해해도 됩니다.

💡 computed나 watch 내부에서 store 값을 사용할 때 자동으로 의존성이 추적됩니다. watch(() => userStore.isLoggedIn, (newVal) => { ... }) 형태로 store 상태 변화를 감지할 수 있습니다.

💡 여러 store를 동시에 사용할 때는 명확한 네이밍을 사용하세요. const userStore = useUserStore(), const cartStore = useCartStore() 형태로 각 store의 목적이 드러나게 변수명을 지정하면 코드 가독성이 높아집니다.

💡 store의 state를 직접 수정하는 것도 가능하지만, actions를 통해 수정하는 것이 권장됩니다. userStore.name = 'New Name'보다 userStore.updateName('New Name') 형태가 로직 추적과 디버깅에 유리합니다.

💡 onMounted나 onBeforeMount에서 store의 초기 데이터를 로드하는 패턴을 사용하세요. onMounted(async () => { await userStore.fetchUserData() }) 형태로 컴포넌트 마운트 시 필요한 데이터를 미리 가져올 수 있습니다.


3. State 반응성 유지하기 - storeToRefs 활용

시작하며

여러분이 store의 state를 구조 분해 할당했는데 화면이 업데이트되지 않는 경험을 해보셨나요? const { name, email } = userStore 형태로 코드를 작성했지만 값이 변경되어도 UI가 반응하지 않는 문제입니다.

이런 문제는 JavaScript의 구조 분해 할당이 값을 복사하기 때문에 발생합니다. 원본 객체와의 반응성 연결이 끊어져서 store의 값이 변경되어도 컴포넌트는 알 수 없게 됩니다.

바로 이럴 때 필요한 것이 storeToRefs 유틸리티입니다. 이 함수는 store의 state와 getters를 ref로 변환하여 구조 분해 할당 후에도 반응성을 유지할 수 있게 해줍니다.

개요

간단히 말해서, storeToRefs는 store 객체를 받아서 그 안의 state와 getters를 개별 ref로 변환해주는 헬퍼 함수입니다. 이를 통해 구조 분해 할당을 해도 반응성이 유지됩니다.

코드를 간결하게 작성하고 싶을 때 매우 유용합니다. 매번 userStore.name, userStore.email처럼 긴 형태로 작성하는 대신, name, email만으로 접근할 수 있습니다.

예를 들어, 템플릿에서 여러 개의 store 값을 자주 사용하는 프로필 페이지 같은 경우에 코드가 훨씬 깔끔해집니다. 기존에는 computed로 일일이 감싸서 사용했다면, 이제는 storeToRefs 한 번으로 모든 state를 ref로 변환할 수 있습니다.

이는 보일러플레이트 코드를 크게 줄여줍니다. 핵심 특징은 state와 getters만 변환(actions는 제외), 자동 ref 변환, 원본 store와 동기화 유지입니다.

이러한 특징들이 중요한 이유는 편의성과 성능을 동시에 제공하기 때문입니다. actions는 함수이므로 구조 분해해도 반응성과 무관합니다.

코드 예제

// UserProfile.vue
<script setup>
import { storeToRefs } from 'pinia'
import { useUserStore } from '@/stores/user'

const userStore = useUserStore()

// state와 getters를 반응성을 유지하며 구조 분해합니다
const { name, email, isLoggedIn, displayName } = storeToRefs(userStore)

// actions는 storeToRefs 없이 구조 분해 가능합니다
const { login, logout } = userStore

// 이제 name, email 등을 직접 사용할 수 있으며 반응성이 유지됩니다
function updateProfile() {
  console.log('현재 이름:', name.value)  // .value로 접근
  console.log('표시 이름:', displayName.value)

  // name은 ref이므로 .value를 사용하여 값을 변경합니다
  // 하지만 권장되지 않으며, action을 통해 변경하는 것이 좋습니다
}
</script>

<template>
  <div>
    <!-- ref이므로 템플릿에서는 .value 없이 사용 -->
    <p>이름: {{ name }}</p>
    <p>이메일: {{ email }}</p>
    <p>표시명: {{ displayName }}</p>
    <p>로그인 상태: {{ isLoggedIn ? '로그인됨' : '로그아웃됨' }}</p>

    <button @click="logout">로그아웃</button>
  </div>
</template>

설명

이것이 하는 일: storeToRefs는 Pinia store 객체를 받아서 내부의 모든 반응형 속성(state와 getters)을 개별 ref 객체로 변환합니다. 이렇게 변환된 ref들은 원본 store와 양방향으로 연결되어 있어 어느 쪽에서 변경하든 동기화됩니다.

첫 번째로, storeToRefs(userStore)를 호출하면 Pinia가 내부적으로 store의 속성들을 순회합니다. 이때 state로 정의된 속성과 getters로 정의된 속성만 선택적으로 ref로 변환합니다.

actions는 함수이므로 반응성과 무관하여 변환 대상에서 제외됩니다. 그 다음으로, 구조 분해 할당을 통해 필요한 속성만 추출합니다.

const { name, email } = storeToRefs(userStore)를 실행하면 name과 email이라는 두 개의 독립적인 ref가 생성됩니다. 이 ref들은 일반 ref처럼 .value를 통해 값에 접근하지만, 내부적으로는 원본 store의 state를 가리키고 있습니다.

script 영역에서 이 ref들을 사용할 때는 name.value처럼 .value를 붙여야 합니다. 하지만 템플릿에서는 Vue가 자동으로 언래핑하므로 {{ name }}처럼 직접 사용할 수 있습니다.

이는 일반 ref의 동작과 동일합니다. actions를 구조 분해할 때는 storeToRefs를 사용하지 않습니다.

const { login, logout } = userStore 형태로 직접 구조 분해하면 됩니다. 함수는 참조가 복사되므로 원본 store의 컨텍스트를 유지하며 정상적으로 동작합니다.

여러분이 이 코드를 사용하면 템플릿과 로직에서 긴 store 참조 없이 간결한 변수명으로 데이터에 접근할 수 있습니다. 특히 많은 state를 사용하는 컴포넌트에서 코드 가독성이 크게 향상되며, TypeScript 자동완성도 완벽하게 작동합니다.

실전 팁

💡 모든 state를 구조 분해하지 말고 실제로 사용하는 것만 추출하세요. const { name, email } = storeToRefs(userStore)처럼 필요한 속성만 가져오면 불필요한 반응성 추적을 줄여 성능이 향상됩니다.

💡 computed와 storeToRefs를 혼동하지 마세요. storeToRefs는 이미 반응형인 값을 ref로 변환하는 것이고, computed는 새로운 계산된 값을 만드는 것입니다. store의 값을 그대로 사용할 때는 storeToRefs를 사용하세요.

💡 구조 분해한 ref를 다른 함수에 전달할 때 주의하세요. someFunction(name.value)는 현재 값만 전달하지만, someFunction(name)은 ref 자체를 전달하여 반응성을 유지합니다. 함수 내부에서 값이 변경되는지에 따라 선택하세요.

💡 여러 store를 동시에 구조 분해할 때 이름 충돌을 피하세요. const { name: userName } = storeToRefs(useUserStore()), const { name: productName } = storeToRefs(useProductStore()) 형태로 별칭을 사용하면 명확합니다.

💡 디버깅 시 ref의 값을 확인하려면 .value를 붙여야 합니다. console.log(name)은 ref 객체를 출력하지만, console.log(name.value)는 실제 값을 출력합니다. 브라우저 개발자 도구에서는 ref 객체를 자동으로 언래핑해서 보여주기도 합니다.


4. Actions에서 비동기 처리하기 - API 호출과 상태 업데이트

시작하며

여러분이 실제 애플리케이션을 개발할 때 서버에서 데이터를 가져와 store에 저장해야 하는 상황이 대부분입니다. 로그인 API를 호출하고 응답을 받아 사용자 정보를 저장하거나, 상품 목록을 불러와야 하는 경우처럼 말이죠.

이런 문제는 모든 실무 프로젝트에서 필수적으로 다뤄야 하는 부분입니다. 비동기 요청 중 로딩 상태를 관리하고, 에러를 처리하고, 성공 시 데이터를 store에 저장하는 일련의 과정이 필요합니다.

바로 이럴 때 필요한 것이 Pinia actions의 비동기 지원입니다. actions는 async/await를 완벽하게 지원하며, 복잡한 비동기 로직을 간결하게 작성할 수 있습니다.

개요

간단히 말해서, Pinia의 actions는 일반 JavaScript 함수이므로 async/await를 자유롭게 사용할 수 있습니다. Promise를 반환하면 컴포넌트에서 then/catch나 await로 처리할 수 있습니다.

Vuex의 actions와 달리 별도의 context 객체 없이 this로 직접 state에 접근하여 간결합니다. 예를 들어, 사용자 인증, 데이터 페칭, 파일 업로드 같은 비동기 작업을 store에서 중앙 관리할 수 있습니다.

기존에는 컴포넌트마다 API 호출 로직을 작성하고 에러 처리를 반복했다면, 이제는 store의 action 하나로 모든 컴포넌트에서 재사용할 수 있습니다. 이는 코드 중복을 제거하고 유지보수성을 높입니다.

핵심 특징은 async/await 완벽 지원, 에러 처리 내장 가능, 로딩 상태 자동 관리, 다른 actions 호출 가능입니다. 이러한 특징들이 중요한 이유는 복잡한 비동기 플로우를 선언적이고 읽기 쉬운 코드로 작성할 수 있기 때문입니다.

코드 예제

// stores/user.js
import { defineStore } from 'pinia'

export const useUserStore = defineStore('user', {
  state: () => ({
    user: null,
    isLoading: false,
    error: null
  }),

  actions: {
    // 비동기 action: async 키워드 사용
    async fetchUser(userId) {
      this.isLoading = true
      this.error = null

      try {
        // API 호출 (fetch, axios 등)
        const response = await fetch(`/api/users/${userId}`)

        if (!response.ok) {
          throw new Error('사용자를 찾을 수 없습니다')
        }

        const data = await response.json()

        // 성공 시 state 업데이트
        this.user = data

        return data  // 컴포넌트에서 결과를 받을 수 있습니다

      } catch (err) {
        // 에러 처리
        this.error = err.message
        throw err  // 컴포넌트에서 에러를 처리할 수 있도록 재던짐

      } finally {
        // 로딩 상태 해제
        this.isLoading = false
      }
    },

    // 다른 action 호출 가능
    async refreshUser() {
      if (this.user?.id) {
        await this.fetchUser(this.user.id)
      }
    }
  }
})

설명

이것이 하는 일: 비동기 action은 외부 API를 호출하고, 응답을 기다리는 동안 로딩 상태를 표시하며, 성공 또는 실패에 따라 store의 state를 적절히 업데이트합니다. 모든 비동기 로직을 한 곳에서 관리하여 컴포넌트는 단순히 action만 호출하면 됩니다.

첫 번째로, async fetchUser(userId) 함수가 호출되면 즉시 isLoading을 true로 설정합니다. 이는 UI에서 로딩 스피너를 표시하는 데 사용됩니다.

error를 null로 초기화하여 이전 에러를 지우는 것도 중요합니다. 이렇게 하지 않으면 이전 에러가 계속 표시될 수 있습니다.

그 다음으로, try 블록 내에서 fetch API를 사용하여 서버에 요청을 보냅니다. await 키워드로 응답이 올 때까지 기다리며, 이 동안 함수 실행이 일시 중지됩니다.

response.ok를 확인하여 HTTP 상태 코드가 200-299 범위인지 검증하고, 아니면 에러를 던집니다. 성공적으로 데이터를 받으면 this.user = data로 state를 업데이트합니다.

이 순간 모든 컴포넌트에서 user 값이 자동으로 반영됩니다. return data를 통해 컴포넌트에서 await userStore.fetchUser(1)로 결과를 직접 받을 수도 있습니다.

catch 블록에서는 네트워크 에러나 파싱 에러를 처리합니다. this.error에 에러 메시지를 저장하여 UI에서 표시할 수 있게 하고, throw err로 에러를 다시 던져서 컴포넌트에서도 에러를 감지할 수 있게 합니다.

finally 블록은 성공이든 실패든 무조건 실행되어 isLoading을 false로 되돌립니다. refreshUser action은 다른 action을 호출하는 예시입니다.

this.fetchUser()처럼 action 내부에서 다른 action을 호출할 수 있으며, await를 사용하여 순차적으로 실행할 수 있습니다. 이는 복잡한 비즈니스 로직을 여러 action으로 분리하여 재사용성을 높이는 패턴입니다.

여러분이 이 코드를 사용하면 모든 API 호출 로직을 store에 집중시킬 수 있습니다. 컴포넌트는 단지 userStore.fetchUser(1)만 호출하면 되며, 로딩과 에러 처리는 store가 알아서 관리합니다.

이는 테스트도 쉽게 만들어주고, API 엔드포인트 변경 시 한 곳만 수정하면 됩니다.

실전 팁

💡 API 호출 전에 항상 로딩 상태를 설정하고 finally에서 해제하세요. 이렇게 하면 네트워크가 느리거나 에러가 발생해도 사용자에게 일관된 피드백을 제공할 수 있습니다. 로딩 중 중복 호출을 방지하려면 if (this.isLoading) return 패턴을 사용하세요.

💡 에러 객체를 그대로 저장하지 말고 필요한 정보만 추출하세요. this.error = err는 전체 Error 객체를 저장하지만, this.error = { message: err.message, code: err.code }처럼 직렬화 가능한 정보만 저장하면 DevTools에서 디버깅이 쉽습니다.

💡 여러 API 호출을 병렬로 실행해야 할 때는 Promise.all을 사용하세요. const [user, posts] = await Promise.all([fetchUser(), fetchPosts()]) 형태로 동시에 요청하면 순차 호출보다 훨씬 빠릅니다.

💡 낙관적 업데이트(Optimistic Update) 패턴을 사용하면 UX가 향상됩니다. API 응답을 기다리지 않고 먼저 UI를 업데이트한 후, 실패 시 롤백하는 방식입니다. 예: const oldValue = this.user; this.user = newValue; try { await api() } catch { this.user = oldValue }

💡 AbortController를 사용하여 진행 중인 요청을 취소할 수 있습니다. 사용자가 빠르게 페이지를 전환할 때 불필요한 요청을 중단하여 성능과 리소스를 절약하세요. const controller = new AbortController(); fetch(url, { signal: controller.signal }) 형태로 구현합니다.


5. Getters로 계산된 값 만들기 - 파생 상태 관리

시작하며

여러분이 store의 state를 기반으로 계산된 값이 필요한 경우가 자주 있습니다. 예를 들어 장바구니의 총 금액, 완료된 할 일의 개수, 필터링된 사용자 목록 같은 값들이죠.

이런 문제는 state를 직접 변환하여 사용하면 코드가 중복되고 성능이 저하됩니다. 여러 컴포넌트에서 동일한 계산 로직을 반복하게 되면 유지보수도 어려워집니다.

바로 이럴 때 필요한 것이 Pinia의 getters입니다. Vue의 computed와 유사하게 동작하며, state를 기반으로 파생된 값을 자동으로 캐싱하고 의존성이 변경될 때만 재계산합니다.

개요

간단히 말해서, getters는 store의 state를 읽기 전용으로 가공하여 반환하는 계산된 속성입니다. 결과가 자동으로 캐싱되어 동일한 입력에 대해서는 재계산하지 않습니다.

성능 최적화가 중요한 복잡한 계산에 매우 유용합니다. 예를 들어, 수천 개의 아이템을 필터링하거나 정렬하는 경우, getter를 사용하면 state가 변경될 때만 다시 계산됩니다.

매 렌더링마다 계산하는 것보다 훨씬 효율적입니다. 기존에는 컴포넌트마다 computed를 만들어 사용했다면, 이제는 store에서 getter로 정의하여 모든 컴포넌트에서 재사용할 수 있습니다.

이는 로직을 중앙화하고 테스트를 용이하게 만듭니다. 핵심 특징은 자동 캐싱, 다른 getters 참조 가능, 매개변수 받기 가능(함수 반환), TypeScript 타입 추론입니다.

이러한 특징들이 중요한 이유는 복잡한 비즈니스 로직을 선언적으로 표현하고 성능을 유지할 수 있기 때문입니다.

코드 예제

// stores/cart.js
import { defineStore } from 'pinia'

export const useCartStore = defineStore('cart', {
  state: () => ({
    items: [
      { id: 1, name: '노트북', price: 1000000, quantity: 1 },
      { id: 2, name: '마우스', price: 30000, quantity: 2 }
    ],
    taxRate: 0.1  // 10% 세율
  }),

  getters: {
    // 기본 getter: state를 받아 계산된 값 반환
    totalItems: (state) => {
      return state.items.reduce((sum, item) => sum + item.quantity, 0)
    },

    // 다른 getter 참조: this 사용
    subtotal() {
      return this.items.reduce((sum, item) => sum + item.price * item.quantity, 0)
    },

    tax() {
      return this.subtotal * this.taxRate  // 다른 getter 사용
    },

    total() {
      return this.subtotal + this.tax
    },

    // 매개변수를 받는 getter: 함수를 반환
    getItemById: (state) => {
      return (itemId) => state.items.find(item => item.id === itemId)
    },

    // 복잡한 필터링
    expensiveItems() {
      return this.items.filter(item => item.price >= 50000)
    }
  }
})

설명

이것이 하는 일: getters는 store의 state를 입력으로 받아 가공된 데이터를 반환하는 순수 함수입니다. Vue의 computed와 동일하게 의존하는 state가 변경될 때만 재계산되고, 그렇지 않으면 캐시된 값을 반환하여 성능을 최적화합니다.

첫 번째로, totalItems getter는 화살표 함수로 정의되어 state 매개변수를 받습니다. reduce를 사용하여 모든 아이템의 quantity를 합산합니다.

이 getter는 state.items가 변경될 때만 재계산되며, 여러 컴포넌트에서 cartStore.totalItems로 접근해도 한 번만 계산됩니다. 그 다음으로, subtotal, tax, total은 일반 함수로 정의되어 this를 사용합니다.

this를 통해 다른 getters나 state에 접근할 수 있습니다. tax getter는 this.subtotal을 참조하는데, 이는 자동으로 의존성이 추적되어 subtotal이 변경되면 tax도 재계산됩니다.

이런 getter 체이닝은 복잡한 계산을 작은 단위로 분리하여 가독성을 높입니다. getItemById는 특별한 패턴으로, 함수를 반환합니다.

이렇게 하면 컴포넌트에서 cartStore.getItemById(1)처럼 매개변수를 전달할 수 있습니다. 주의할 점은 이 패턴은 캐싱되지 않는다는 것입니다.

매번 호출할 때마다 find가 실행되므로, 자주 호출되는 경우 성능을 고려해야 합니다. expensiveItems getter는 배열을 필터링하여 고가 상품만 반환합니다.

filter는 새 배열을 생성하지만 getter가 캐싱하므로 state.items가 변경되지 않는 한 동일한 배열 인스턴스를 반환합니다. 이는 컴포넌트에서 v-for로 렌더링할 때 불필요한 재렌더링을 방지합니다.

컴포넌트에서 이 getters를 사용할 때는 cartStore.total처럼 속성처럼 접근합니다. 함수 호출 없이 값처럼 사용할 수 있어 매우 직관적입니다.

storeToRefs를 사용하면 const { total, tax } = storeToRefs(cartStore)처럼 구조 분해도 가능합니다. 여러분이 이 코드를 사용하면 복잡한 비즈니스 로직을 store에서 관리하여 컴포넌트를 단순하게 유지할 수 있습니다.

세금 계산 로직이 변경되어도 getter 하나만 수정하면 모든 컴포넌트에 자동으로 반영되며, 테스트도 store 단위로 쉽게 작성할 수 있습니다.

실전 팁

💡 무거운 계산이 필요한 getter는 일반 함수 형태(this 사용)로 작성하세요. 화살표 함수는 매개변수를 받을 때만 사용하고, 대부분의 경우 function 형태가 다른 getters를 참조하기 쉽고 가독성이 좋습니다.

💡 매개변수를 받는 getter는 캐싱되지 않으므로 주의하세요. 자주 호출되는 경우 computed를 컴포넌트에서 만들거나, Memoization 라이브러리(lodash.memoize)를 사용하여 수동으로 캐싱하는 것을 고려하세요.

💡 getter에서 외부 API를 호출하거나 side effect를 만들지 마세요. getters는 순수 함수여야 하며, 동일한 입력에 항상 동일한 출력을 반환해야 합니다. 비동기 작업은 반드시 actions에서 처리하세요.

💡 복잡한 getter는 여러 개의 작은 getters로 분리하세요. total: () => subtotal + tax처럼 체이닝하면 각 단계를 개별적으로 테스트하고 디버깅할 수 있습니다. 또한 중간 값들도 다른 곳에서 재사용할 수 있습니다.

💡 배열이나 객체를 반환하는 getter는 불변성을 유지하세요. state.items.filter(...)는 새 배열을 반환하므로 안전하지만, state.items.sort(...)는 원본을 변경하므로 [...state.items].sort(...)처럼 복사 후 정렬하세요.


6. 여러 Store 조합하기 - Store Composition 패턴

시작하며

여러분이 애플리케이션을 개발하다 보면 한 store에서 다른 store의 데이터가 필요한 경우가 생깁니다. 예를 들어 주문 store에서 사용자 정보와 장바구니 정보를 함께 사용해야 하는 상황이죠.

이런 문제는 도메인이 복잡해질수록 자주 발생합니다. 모든 것을 하나의 거대한 store에 넣으면 관리가 어려워지고, 완전히 분리하면 데이터 조합이 힘들어집니다.

바로 이럴 때 필요한 것이 Store Composition입니다. Pinia에서는 한 store 내부에서 다른 store를 자유롭게 import하고 사용할 수 있어 모듈화와 재사용성을 동시에 달성할 수 있습니다.

개요

간단히 말해서, Store Composition은 한 store의 actions나 getters 내부에서 다른 store의 인스턴스를 가져와 사용하는 패턴입니다. 마치 컴포넌트에서 store를 사용하는 것과 동일한 방식입니다.

도메인을 논리적으로 분리하면서도 필요할 때 협력할 수 있게 해줍니다. 예를 들어, 인증 store, 장바구니 store, 주문 store를 각각 만들고, 주문 생성 시 세 store의 정보를 조합하여 사용할 수 있습니다.

기존 Vuex의 modules는 중첩 구조로 복잡했지만, Pinia는 평평한 구조로 각 store가 독립적이면서도 서로 참조할 수 있습니다. 이는 순환 참조만 피하면 훨씬 유연한 설계가 가능합니다.

핵심 특징은 독립적인 store들의 조합, 양방향 참조 가능(순환 제외), 자동 의존성 추적, 테스트 용이성입니다. 이러한 특징들이 중요한 이유는 대규모 애플리케이션에서 관심사의 분리와 코드 재사용을 가능하게 하기 때문입니다.

코드 예제

// stores/user.js
export const useUserStore = defineStore('user', {
  state: () => ({
    id: 1,
    name: '김개발',
    email: 'kim@example.com'
  })
})

// stores/cart.js
export const useCartStore = defineStore('cart', {
  state: () => ({
    items: [{ productId: 101, quantity: 2, price: 50000 }]
  }),
  getters: {
    total: (state) => state.items.reduce((sum, item) => sum + item.price * item.quantity, 0)
  }
})

// stores/order.js
import { useUserStore } from './user'
import { useCartStore } from './cart'

export const useOrderStore = defineStore('order', {
  state: () => ({
    orders: []
  }),

  actions: {
    async createOrder() {
      // 다른 store들을 가져와 사용합니다
      const userStore = useUserStore()
      const cartStore = useCartStore()

      // 여러 store의 데이터를 조합
      const orderData = {
        userId: userStore.id,
        userName: userStore.name,
        items: cartStore.items,
        total: cartStore.total,
        createdAt: new Date()
      }

      // API 호출 등
      const response = await fetch('/api/orders', {
        method: 'POST',
        body: JSON.stringify(orderData)
      })

      const order = await response.json()
      this.orders.push(order)

      // 주문 완료 후 장바구니 비우기
      cartStore.$reset()

      return order
    }
  }
})

설명

이것이 하는 일: Store Composition은 여러 독립적인 store들이 서로 협력하여 복잡한 기능을 구현할 수 있게 해줍니다. 각 store는 자신의 도메인에만 집중하면서도, 필요할 때 다른 store의 데이터와 로직을 활용할 수 있습니다.

첫 번째로, 세 개의 독립적인 store를 정의합니다. useUserStore는 사용자 정보만, useCartStore는 장바구니만, useOrderStore는 주문만 관리합니다.

이렇게 관심사를 분리하면 각 store의 책임이 명확해지고 테스트와 유지보수가 쉬워집니다. 그 다음으로, useOrderStore의 createOrder action 내부에서 다른 두 store를 import합니다.

const userStore = useUserStore()처럼 컴포넌트에서 사용하는 것과 동일한 방식으로 가져올 수 있습니다. 이때 각 store는 싱글톤이므로 애플리케이션 전체에서 동일한 인스턴스를 공유합니다.

orderData 객체를 생성할 때 여러 store의 데이터를 조합합니다. userStore.id와 userStore.name으로 사용자 정보를, cartStore.items와 cartStore.total로 장바구니 정보를 가져옵니다.

이 모든 데이터는 최신 상태이며 반응형으로 관리됩니다. API 호출이 성공하면 this.orders.push(order)로 자신의 state를 업데이트하고, cartStore.$reset()으로 장바구니 store의 state를 초기화합니다.

한 store에서 다른 store의 actions나 메서드를 호출하는 것이 완전히 자유롭습니다. 이 패턴의 강력한 점은 각 store가 독립적으로 테스트 가능하다는 것입니다.

userStore와 cartStore를 mock으로 교체하여 orderStore만 독립적으로 테스트할 수 있습니다. 또한 코드 재사용성이 높아져 다른 곳에서도 동일한 store들을 조합하여 사용할 수 있습니다.

여러분이 이 코드를 사용하면 대규모 애플리케이션에서도 깔끔한 아키텍처를 유지할 수 있습니다. 각 도메인별로 store를 분리하되 필요한 곳에서 자유롭게 조합하여 사용하면 됩니다.

순환 참조만 피하면 어떤 복잡한 비즈니스 로직도 표현할 수 있습니다.

실전 팁

💡 순환 참조를 절대 만들지 마세요. A store가 B를 참조하고 B가 다시 A를 참조하면 무한 루프가 발생합니다. 의존성 그래프를 그려보고 한 방향으로만 흐르도록 설계하세요. 양방향 통신이 필요하면 이벤트 버스를 고려하세요.

💡 store를 action 내부에서 가져오세요. 모듈 최상위에서 const userStore = useUserStore()처럼 가져오면 초기화 순서 문제가 발생할 수 있습니다. 반드시 actions나 getters 내부에서 호출하세요.

💡 너무 많은 store를 한 곳에서 조합하지 마세요. 3개 이상의 store를 사용한다면 코드 구조를 재고해봐야 합니다. 중간 레이어나 별도의 서비스 클래스로 분리하는 것을 고려하세요.

💡 TypeScript를 사용한다면 각 store의 타입이 자동으로 추론됩니다. const userStore = useUserStore()에서 userStore의 모든 속성과 메서드가 타입 체크되므로 오타나 잘못된 접근을 컴파일 타임에 발견할 수 있습니다.

💡 store 간 통신이 복잡해지면 subscription을 사용하세요. userStore.$subscribe((mutation, state) => { ... })로 다른 store의 변화를 감지하고 반응할 수 있습니다. 하지만 남용하면 디버깅이 어려워지므로 신중하게 사용하세요.


7. $patch로 여러 상태 한번에 변경하기 - 성능 최적화

시작하며

여러분이 store의 여러 state를 동시에 업데이트해야 할 때가 있습니다. 예를 들어 사용자 프로필 업데이트 시 이름, 이메일, 전화번호, 주소를 모두 변경해야 하는 경우죠.

이런 문제를 개별적으로 처리하면 성능 저하가 발생합니다. store.name = '새이름', store.email = '새메일' 처럼 하나씩 변경하면 각각의 변경마다 반응성 시스템이 트리거되어 컴포넌트가 여러 번 재렌더링됩니다.

바로 이럴 때 필요한 것이 $patch 메서드입니다. 여러 state 변경을 하나의 배치로 묶어서 한 번만 반응성을 트리거하므로 성능이 크게 향상됩니다.

개요

간단히 말해서, $patch는 store의 여러 속성을 동시에 업데이트하는 최적화된 방법입니다. 객체나 함수를 인자로 받아 일괄 업데이트를 수행합니다.

성능이 중요한 대량 업데이트나 폼 제출 시 매우 유용합니다. 예를 들어, 수십 개의 필드를 가진 복잡한 설정 페이지에서 '저장' 버튼을 눌렀을 때 모든 값을 한 번에 업데이트하는 경우입니다.

기존에는 각 속성을 하나씩 변경하여 불필요한 재렌더링이 발생했다면, 이제는 $patch로 한 번에 처리하여 단 한 번만 렌더링됩니다. 특히 큰 객체나 배열을 다룰 때 차이가 두드러집니다.

핵심 특징은 배치 업데이트, 두 가지 사용 방법(객체/함수), DevTools 통합, 불필요한 렌더링 방지입니다. 이러한 특징들이 중요한 이유는 사용자 경험과 애플리케이션 성능을 동시에 향상시키기 때문입니다.

코드 예제

// stores/user.js
export const useUserStore = defineStore('user', {
  state: () => ({
    name: '',
    email: '',
    phone: '',
    address: '',
    age: 0,
    preferences: {
      theme: 'light',
      language: 'ko'
    }
  })
})

// 컴포넌트에서 사용
import { useUserStore } from '@/stores/user'

const userStore = useUserStore()

// 방법 1: 객체로 패치 (부분 업데이트)
function updateBasicInfo() {
  userStore.$patch({
    name: '김개발',
    email: 'kim@example.com',
    phone: '010-1234-5678'
  })
  // 한 번의 렌더링만 발생합니다
}

// 방법 2: 함수로 패치 (복잡한 로직)
function updateWithFunction() {
  userStore.$patch((state) => {
    // state를 직접 수정할 수 있습니다
    state.name = '이개발'
    state.age += 1

    // 중첩 객체도 직접 수정 가능
    state.preferences.theme = 'dark'
    state.preferences.language = 'en'

    // 배열 메서드 사용 가능
    // state.items.push({ ... })
  })
}

// 폼 제출 예시
function handleFormSubmit(formData) {
  userStore.$patch({
    name: formData.name,
    email: formData.email,
    phone: formData.phone,
    address: formData.address
  })
}

설명

이것이 하는 일: $patch는 store의 여러 state 변경을 하나의 트랜잭션으로 묶어서 처리합니다. 내부적으로 Vue의 반응성 시스템을 한 번만 트리거하여 불필요한 재렌더링을 방지하고 성능을 크게 향상시킵니다.

첫 번째 방법은 객체를 인자로 전달하는 것입니다. userStore.$patch({ name: '김개발', email: 'kim@example.com' })처럼 변경할 속성들을 객체로 전달하면 Pinia가 자동으로 state에 병합합니다.

이는 얕은 병합(shallow merge)이므로 최상위 속성만 업데이트됩니다. 간단하고 직관적이지만 복잡한 중첩 구조나 배열 조작에는 제한적입니다.

두 번째 방법은 함수를 인자로 전달하는 것입니다. $patch((state) => { ...

}) 형태로 사용하면 state 객체가 콜백 함수의 인자로 전달됩니다. 이 state는 실제 store의 state를 직접 가리키므로 state.preferences.theme = 'dark'처럼 중첩된 속성도 직접 수정할 수 있습니다.

배열의 push, splice 같은 메서드도 자유롭게 사용할 수 있어 복잡한 업데이트에 유용합니다. 성능 측면에서 $patch의 이점은 명확합니다.

만약 store.name = 'A', store.email = 'B', store.phone = 'C'처럼 개별 할당을 하면 세 번의 반응성 트리거가 발생하여 컴포넌트가 세 번 재렌더링될 수 있습니다. 하지만 $patch를 사용하면 내부적으로 모든 변경을 모은 후 한 번만 트리거하여 단 한 번만 렌더링됩니다.

DevTools에서도 $patch는 하나의 변경으로 기록됩니다. 디버깅 시 여러 개의 개별 변경 대신 하나의 패치 이벤트로 표시되어 상태 변화를 추적하기 쉽습니다.

타임 트래블 디버깅 시에도 하나의 단위로 롤백되어 편리합니다. 실제 폼 제출 시나리오에서 handleFormSubmit 함수처럼 사용하면 매우 효과적입니다.

사용자가 입력한 모든 필드를 한 번에 store에 반영하고, 관련된 모든 컴포넌트가 새 데이터로 한 번만 업데이트됩니다. 특히 큰 폼이나 실시간 동기화가 필요한 경우 사용자 경험이 크게 개선됩니다.

여러분이 이 코드를 사용하면 불필요한 렌더링을 제거하여 애플리케이션이 훨씬 빠르게 동작합니다. 특히 모바일이나 저사양 기기에서 차이가 두드러지며, 복잡한 상태 업데이트도 간결하게 표현할 수 있습니다.

실전 팁

💡 객체 방식과 함수 방식을 적절히 선택하세요. 단순 업데이트는 객체 방식({ name: 'value' })이 간결하고, 조건문이나 배열 조작이 필요하면 함수 방식((state) => {})을 사용하세요.

💡 중첩 객체를 업데이트할 때 객체 방식은 주의하세요. $patch({ preferences: { theme: 'dark' } })는 preferences 전체를 교체하므로 language가 사라집니다. 함수 방식으로 state.preferences.theme = 'dark'를 사용하거나 스프레드 연산자를 활용하세요.

💡 $patch 내부에서 actions를 호출하지 마세요. $patch는 순수하게 state 업데이트만 해야 하며, 비즈니스 로직이나 비동기 작업은 actions에서 처리한 후 그 안에서 $patch를 호출하는 것이 좋습니다.

💡 대량의 배열 업데이트는 함수 방식을 사용하세요. $patch((state) => { state.items = newItems })가 객체 방식보다 성능이 좋습니다. 특히 수천 개의 아이템을 다룰 때 차이가 큽니다.

💡 TypeScript 사용 시 객체 방식은 타입 체크가 엄격합니다. $patch({ unknownField: 'value' })는 컴파일 에러가 발생하지만, 함수 방식은 state 객체를 직접 조작하므로 타입 안정성이 상대적으로 낮습니다. 주의해서 사용하세요.


8. $subscribe로 상태 변화 감지하기 - 부수 효과 처리

시작하며

여러분이 store의 상태가 변경될 때마다 특정 작업을 수행해야 하는 경우가 있습니다. 예를 들어 장바구니가 변경될 때마다 localStorage에 저장하거나, 사용자 설정이 바뀔 때 API로 동기화해야 하는 상황이죠.

이런 문제를 watch로 해결하려면 각 컴포넌트마다 중복 코드를 작성해야 합니다. 또한 어떤 state가 변경되었는지 정확히 추적하기도 어렵습니다.

바로 이럴 때 필요한 것이 $subscribe 메서드입니다. store 수준에서 모든 state 변화를 감지하고, 어떤 값이 어떻게 변경되었는지 상세 정보를 제공합니다.

개요

간단히 말해서, $subscribe는 store의 state가 변경될 때마다 실행되는 콜백 함수를 등록하는 메서드입니다. mutation 정보와 현재 state를 인자로 받아 부수 효과를 처리할 수 있습니다.

로컬 스토리지 동기화, 로깅, 분석, 외부 시스템 연동 등 다양한 용도로 활용됩니다. 예를 들어, 사용자의 장바구니 상태를 브라우저에 저장하여 새로고침 후에도 유지하거나, 중요한 설정 변경을 서버에 자동으로 백업하는 경우입니다.

기존에는 각 컴포넌트에서 watch를 사용하거나 actions 내부에 부가 로직을 넣었다면, 이제는 store 초기화 시점에 한 번만 subscribe를 설정하면 됩니다. 이는 관심사의 분리와 코드 재사용성을 향상시킵니다.

핵심 특징은 모든 state 변화 감지, mutation 타입과 페이로드 제공, 컴포넌트 언마운트 시 자동 해제(옵션), 직접 state 수정도 감지입니다. 이러한 특징들이 중요한 이유는 디버깅과 데이터 영속화를 쉽게 만들어주기 때문입니다.

코드 예제

// stores/cart.js
import { defineStore } from 'pinia'

export const useCartStore = defineStore('cart', {
  state: () => ({
    items: [],
    lastUpdated: null
  }),

  actions: {
    addItem(item) {
      this.items.push(item)
      this.lastUpdated = new Date()
    }
  }
})

// main.js 또는 플러그인에서
import { useCartStore } from '@/stores/cart'

// store 초기화 후 subscription 설정
const cartStore = useCartStore()

// 기본 사용: 모든 변화 감지
cartStore.$subscribe((mutation, state) => {
  // mutation: { type, storeId, payload, events }
  console.log('장바구니 변경됨:', mutation.type)

  // localStorage에 저장
  localStorage.setItem('cart', JSON.stringify(state.items))
})

// 옵션 사용: 컴포넌트 언마운트 후에도 유지
cartStore.$subscribe(
  (mutation, state) => {
    // 변경 타입 확인
    if (mutation.type === 'direct') {
      console.log('직접 state 수정:', mutation.events)
    } else if (mutation.type === 'patch object') {
      console.log('$patch 객체 사용:', mutation.payload)
    } else if (mutation.type === 'patch function') {
      console.log('$patch 함수 사용')
    }

    // API로 동기화 (디바운스 권장)
    syncToServer(state.items)
  },
  { detached: true }  // 컴포넌트 생명주기와 분리
)

// 구독 취소
const unsubscribe = cartStore.$subscribe((mutation, state) => {
  // ...
})

// 필요시 수동으로 구독 해제
unsubscribe()

설명

이것이 하는 일: $subscribe는 store의 state에 발생하는 모든 변경을 실시간으로 감지하여 등록된 콜백 함수를 실행합니다. 변경의 원인과 방법, 영향받은 속성 등 상세 정보를 제공하여 정교한 부수 효과 처리가 가능합니다.

첫 번째로, cartStore.$subscribe()를 호출하면 콜백 함수를 등록합니다. 이 함수는 두 개의 인자를 받습니다: mutation 객체와 현재 state입니다.

mutation 객체에는 type(변경 방법), storeId(어떤 store인지), payload($patch로 전달된 데이터), events(실제 변경 이벤트) 정보가 포함됩니다. 그 다음으로, mutation.type을 확인하여 어떤 방식으로 state가 변경되었는지 알 수 있습니다.

'direct'는 store.items.push()처럼 직접 수정한 경우, 'patch object'는 $patch({ items: [...] })를 사용한 경우, 'patch function'은 $patch((state) => {})를 사용한 경우입니다. 이를 통해 변경 원인에 따라 다르게 반응할 수 있습니다.

localStorage.setItem('cart', JSON.stringify(state.items))는 매우 일반적인 패턴입니다. store의 state가 변경될 때마다 자동으로 브라우저 저장소에 동기화하여 페이지를 새로고침해도 데이터가 유지됩니다.

앱 초기화 시 localStorage에서 읽어와 store를 복원하면 완전한 영속화가 구현됩니다. detached: true 옵션은 구독을 컴포넌트의 생명주기와 분리합니다.

기본적으로 컴포넌트 내부에서 $subscribe를 호출하면 컴포넌트가 언마운트될 때 자동으로 구독이 해제됩니다. 하지만 detached를 true로 설정하면 컴포넌트가 사라져도 구독이 유지되어 전역적인 효과를 만들 수 있습니다.

$subscribe는 함수를 반환하는데 이것이 구독 해제 함수입니다. const unsubscribe = cartStore.$subscribe(...)로 저장해두었다가 unsubscribe()를 호출하면 더 이상 콜백이 실행되지 않습니다.

메모리 누수를 방지하기 위해 필요 없어진 구독은 반드시 해제해야 합니다. 여러분이 이 코드를 사용하면 데이터 영속화, 로깅, 분석 등을 store 로직과 분리하여 관리할 수 있습니다.

actions마다 localStorage 코드를 넣는 대신 한 곳에서 모든 변경을 감지하므로 코드가 깔끔해지고 버그 가능성이 줄어듭니다.

실전 팁

💡 고빈도 업데이트 시 디바운스나 쓰로틀을 사용하세요. 매 변경마다 API를 호출하면 서버에 부담이 됩니다. lodash.debounce나 setTimeout을 활용하여 일정 시간 동안 변경이 없을 때만 동기화하세요.

💡 $subscribe 내부에서 같은 store를 수정하면 무한 루프가 발생할 수 있습니다. 콜백 내에서 state를 직접 변경하지 말고, 필요하다면 nextTick이나 setTimeout으로 다음 틱에 실행하세요.

💡 민감한 정보는 localStorage에 저장하지 마세요. 토큰이나 개인정보는 sessionStorage나 httpOnly 쿠키를 사용하고, 암호화를 고려하세요. localStorage는 XSS 공격에 취약합니다.

💡 여러 subscribe를 등록할 수 있습니다. 로깅용 subscribe, localStorage용 subscribe, 분석용 subscribe를 각각 분리하여 관심사별로 관리하면 코드가 명확해집니다.

💡 개발 환경에서만 활성화되는 subscribe를 만들어 디버깅에 활용하세요. if (import.meta.env.DEV) { store.$subscribe((m, s) => console.log(m)) }처럼 개발 중에만 상태 변화를 로깅하면 프로덕션 빌드 크기가 줄어듭니다.


#Vue#Pinia#StateManagement#Store#Composition#중급

댓글 (0)

댓글을 작성하려면 로그인이 필요합니다.