이미지 로딩 중...
AI Generated
2025. 11. 8. · 3 Views
Pinia 상태관리 완벽 가이드 Store 활용편
Pinia를 사용한 실전 상태관리 기법을 배웁니다. Store 구조화부터 Getters, Actions 활용까지 Vue 3 프로젝트에서 효율적으로 상태를 관리하는 방법을 다룹니다.
목차
- Store 정의하기 - defineStore로 상태 저장소 만들기
- Getters 활용하기 - 계산된 상태 값 만들기
- Actions로 상태 변경하기 - 비즈니스 로직 구현
- Setup Syntax로 Store 사용하기 - Composition API 스타일
- Store 구독하기 - 상태 변화 감지하기
- 여러 Store 연동하기 - Store 간 통신 패턴
- Pinia Plugin 만들기 - 기능 확장하기
1. Store 정의하기 - defineStore로 상태 저장소 만들기
시작하며
여러분이 Vue 3 프로젝트에서 여러 컴포넌트가 사용자 정보를 공유해야 하는 상황을 겪어본 적 있나요? Props로 데이터를 전달하다 보면 컴포넌트 트리가 깊어질수록 Props Drilling이 발생하고, 코드가 복잡해집니다.
이런 문제는 실제 개발 현장에서 자주 발생합니다. 여러 컴포넌트에서 동일한 데이터를 필요로 할 때마다 중간 컴포넌트들이 불필요하게 props를 전달해야 하고, 상태 업데이트 로직이 분산되어 유지보수가 어려워집니다.
바로 이럴 때 필요한 것이 Pinia의 Store입니다. Store를 사용하면 전역 상태를 한 곳에서 관리하고, 어떤 컴포넌트에서든 쉽게 접근할 수 있습니다.
개요
간단히 말해서, Store는 애플리케이션의 상태를 중앙에서 관리하는 저장소입니다. defineStore 함수를 사용해 Store를 정의하고, 컴포넌트에서 필요할 때 가져다 쓸 수 있습니다.
Store가 필요한 이유는 상태 관리의 일관성과 효율성 때문입니다. 예를 들어, 사용자 인증 정보를 여러 페이지에서 사용해야 한다면, Store에 한 번만 저장해두고 어디서든 꺼내 쓸 수 있습니다.
로그아웃 시에도 Store의 상태만 초기화하면 모든 컴포넌트에 자동으로 반영됩니다. 기존 Vuex와 비교하면, Pinia는 훨씬 간단한 문법을 제공합니다.
Mutations를 제거하고 Actions만으로 상태를 변경할 수 있어 코드가 직관적이고, TypeScript 지원도 훨씬 뛰어납니다. Pinia Store의 핵심 특징은 다음과 같습니다.
첫째, Options API와 Composition API 스타일 모두 지원하여 팀의 코딩 스타일에 맞게 선택할 수 있습니다. 둘째, 모듈화가 쉬워서 도메인별로 Store를 분리하여 관리할 수 있습니다.
셋째, Vue DevTools와 완벽하게 통합되어 상태 변화를 실시간으로 추적할 수 있습니다.
코드 예제
// stores/user.js
import { defineStore } from 'pinia'
// 첫 번째 인자: Store의 고유 ID
// 두 번째 인자: Store 설정 객체
export const useUserStore = defineStore('user', {
// state: 상태를 정의하는 함수
state: () => ({
name: '',
email: '',
isLoggedIn: false,
loginAttempts: 0
}),
// getters와 actions는 다음 섹션에서 다룹니다
})
설명
이것이 하는 일: defineStore는 Pinia의 핵심 함수로, 애플리케이션의 상태를 관리할 Store를 생성합니다. 컴포넌트에서 이 Store를 가져다 사용하면 어디서든 동일한 상태에 접근할 수 있습니다.
첫 번째 단계로, defineStore의 첫 번째 인자에는 Store의 고유 ID를 문자열로 전달합니다. 이 예제에서는 'user'라는 ID를 사용했는데, 이는 Vue DevTools에서 Store를 식별할 때 사용되므로 명확하고 의미 있는 이름을 사용해야 합니다.
같은 ID를 가진 Store는 하나의 인스턴스를 공유하므로, 여러 컴포넌트에서 useUserStore를 호출해도 모두 동일한 상태를 참조합니다. 두 번째 단계로, 설정 객체의 state 속성에는 함수를 전달합니다.
이 함수는 Store의 초기 상태를 담은 객체를 반환해야 합니다. 함수 형태로 작성하는 이유는 SSR(Server-Side Rendering) 환경에서 각 요청마다 독립적인 상태를 생성하기 위함입니다.
예제에서는 사용자 이름, 이메일, 로그인 상태, 로그인 시도 횟수를 상태로 정의했습니다. 세 번째 단계로, 이렇게 정의한 Store를 export하여 다른 파일에서 import해서 사용할 수 있게 합니다.
컴포넌트에서는 const userStore = useUserStore()처럼 호출하면 되고, 이후 userStore.name이나 userStore.isLoggedIn 같은 방식으로 상태에 접근할 수 있습니다. 여러분이 이 패턴을 사용하면 상태 관리 로직을 컴포넌트에서 완전히 분리할 수 있어 테스트가 쉬워지고, 코드 재사용성이 높아집니다.
또한 여러 Store를 도메인별로 나누어 관리할 수 있어, 예를 들어 useUserStore, useProductStore, useCartStore처럼 관심사를 명확하게 분리할 수 있습니다. 실무에서는 Store 파일을 stores 폴더에 모아두고, 각 도메인별로 파일을 분리하는 것이 좋습니다.
이렇게 하면 팀원들이 어디서 어떤 상태를 관리하는지 쉽게 파악할 수 있고, 코드 충돌도 줄일 수 있습니다.
실전 팁
💡 Store ID는 kebab-case나 camelCase로 일관되게 작성하세요. 'user-profile'이나 'userProfile' 같은 명명 규칙을 팀 내에서 통일하면 협업이 수월합니다.
💡 state는 반드시 함수로 작성하세요. 객체를 직접 반환하면 SSR 환경에서 상태가 공유되는 버그가 발생할 수 있습니다.
💡 초기 상태 값은 타입을 명확하게 하세요. 빈 문자열('')이나 null을 사용할지, 배열은 []로 초기화할지 미리 정하면 예상치 못한 타입 에러를 방지할 수 있습니다.
💡 Store 파일명은 use로 시작하는 함수명과 일치시키세요. useUserStore는 user.js 파일에 정의하면 찾기 쉽습니다.
💡 하나의 Store는 하나의 책임만 가지도록 설계하세요. 사용자 관련 상태와 제품 관련 상태는 별도 Store로 분리하는 것이 유지보수에 유리합니다.
2. Getters 활용하기 - 계산된 상태 값 만들기
시작하며
여러분이 사용자의 전체 이름을 여러 컴포넌트에서 표시해야 하는데, firstName과 lastName이 분리되어 저장된 상황을 떠올려보세요. 매번 컴포넌트에서 ${firstName} ${lastName}처럼 조합하다 보면 코드 중복이 발생하고, 형식을 바꾸고 싶을 때 모든 곳을 수정해야 합니다.
이런 문제는 파생된 상태(derived state)를 다룰 때 흔히 발생합니다. 기존 상태를 기반으로 계산되는 값들이 여러 곳에 흩어져 있으면, 로직이 일관되지 않고 버그가 생기기 쉽습니다.
특히 복잡한 필터링이나 정렬 로직이 필요한 경우 더욱 그렇습니다. 바로 이럴 때 필요한 것이 Pinia의 Getters입니다.
Getters는 Vue의 computed와 유사하게 동작하며, 상태를 기반으로 계산된 값을 캐싱하여 성능을 최적화합니다.
개요
간단히 말해서, Getters는 Store의 상태로부터 파생된 값을 계산하는 함수입니다. Vue의 computed 속성처럼 의존하는 상태가 변경될 때만 다시 계산되며, 그렇지 않으면 캐시된 값을 반환합니다.
Getters가 필요한 이유는 로직 재사용과 성능 최적화 때문입니다. 예를 들어, 장바구니에서 총 금액을 계산하거나, 사용자 목록을 필터링하거나, 복잡한 데이터 변환이 필요할 때 Getters를 사용하면 계산 로직을 한 곳에서 관리하고 자동으로 캐싱할 수 있습니다.
기존에 컴포넌트마다 computed를 중복 작성했다면, 이제는 Store의 Getters로 한 번만 정의하고 여러 컴포넌트에서 재사용할 수 있습니다. 또한 Getters는 다른 Getters를 참조할 수 있어 복잡한 계산도 단계적으로 구성할 수 있습니다.
Getters의 핵심 특징은 다음과 같습니다. 첫째, 자동 캐싱으로 동일한 입력에 대해 불필요한 재계산을 방지합니다.
둘째, this를 통해 state와 다른 getters에 접근할 수 있어 유연한 조합이 가능합니다. 셋째, 인자를 받을 수 있는 함수를 반환하여 동적인 계산도 지원합니다.
이러한 특징들이 복잡한 상태 로직을 효율적으로 관리하는 데 핵심적입니다.
코드 예제
// stores/user.js
export const useUserStore = defineStore('user', {
state: () => ({
firstName: 'John',
lastName: 'Doe',
age: 25,
purchaseHistory: [
{ id: 1, amount: 15000 },
{ id: 2, amount: 32000 }
]
}),
getters: {
// this로 state 접근
fullName: (state) => `${state.firstName} ${state.lastName}`,
// 다른 getter 참조
isAdult: (state) => state.age >= 18,
greeting() {
return `안녕하세요, ${this.fullName}님!`
},
// 총 구매 금액 계산
totalPurchase: (state) => {
return state.purchaseHistory.reduce((sum, item) => sum + item.amount, 0)
},
// 인자를 받는 getter (함수를 반환)
getPurchaseById: (state) => {
return (purchaseId) => state.purchaseHistory.find(p => p.id === purchaseId)
}
}
})
설명
이것이 하는 일: Getters는 Store의 상태를 가공하거나 조합하여 새로운 값을 만들어냅니다. 원본 상태는 변경하지 않고, 읽기 전용으로 계산된 결과만 제공하는 순수 함수입니다.
첫 번째로, 기본 Getter는 state를 매개변수로 받는 화살표 함수입니다. fullName getter는 firstName과 lastName을 조합하여 전체 이름을 반환합니다.
이렇게 정의하면 컴포넌트에서 userStore.fullName으로 접근할 수 있고, firstName이나 lastName이 변경될 때만 자동으로 재계산됩니다. 이 방식의 장점은 간결하고 명확하다는 것입니다.
두 번째로, 일반 함수 형태로 정의하면 this로 state와 다른 getters에 접근할 수 있습니다. greeting getter는 this.fullName을 참조하여 인사말을 만듭니다.
이처럼 Getter끼리 조합하면 복잡한 로직을 단계적으로 구성할 수 있어 가독성이 높아집니다. totalPurchase getter는 배열의 reduce 메서드를 사용해 구매 이력의 총합을 계산하는데, 이런 계산 로직을 컴포넌트에 두지 않고 Store에서 관리하면 일관성이 유지됩니다.
세 번째로, 동적인 조회가 필요할 때는 함수를 반환하는 Getter를 사용합니다. getPurchaseById는 purchaseId를 받아서 해당 구매 내역을 찾는 함수를 반환합니다.
컴포넌트에서는 userStore.getPurchaseById(1)처럼 호출할 수 있습니다. 단, 함수를 반환하는 Getter는 캐싱되지 않으므로 호출할 때마다 계산이 실행된다는 점을 유의해야 합니다.
여러분이 Getters를 활용하면 복잡한 비즈니스 로직을 Store에 캡슐화할 수 있습니다. 예를 들어, 할인율을 적용한 가격 계산, 사용자 권한에 따른 필터링, 다국어 지원을 위한 텍스트 변환 등 다양한 상황에서 유용합니다.
특히 여러 컴포넌트에서 동일한 계산을 해야 할 때 Getters로 중앙화하면 코드 중복을 제거하고 유지보수가 쉬워집니다. Getters는 읽기 전용이므로 상태를 변경하는 로직은 포함해서는 안 됩니다.
상태를 변경하려면 다음 섹션에서 다룰 Actions를 사용해야 합니다. 이런 분리는 코드의 예측 가능성을 높이고 디버깅을 쉽게 만듭니다.
실전 팁
💡 복잡한 계산은 여러 Getters로 나누세요. 한 Getter가 너무 많은 일을 하면 재사용이 어렵고 테스트도 복잡해집니다.
💡 함수를 반환하는 Getter는 캐싱되지 않습니다. 자주 호출되는 경우라면 상태를 추가하거나 memoization 라이브러리를 고려하세요.
💡 Getter에서 비동기 로직은 피하세요. API 호출이나 타이머 같은 비동기 작업은 Actions에서 처리해야 합니다.
💡 다른 Store의 Getter를 참조할 수 있습니다. const otherStore = useOtherStore()로 가져온 후 otherStore.someGetter처럼 사용하면 됩니다.
💡 TypeScript를 사용한다면 Getter의 반환 타입을 명시하세요. 타입 추론이 복잡한 경우 명시적으로 작성하면 IDE의 자동완성이 정확해집니다.
3. Actions로 상태 변경하기 - 비즈니스 로직 구현
시작하며
여러분이 사용자 로그인 기능을 구현하는데, API 호출 후 응답을 받아 상태를 업데이트하고, 에러 처리도 해야 하는 상황을 생각해보세요. 이런 로직을 컴포넌트에 직접 작성하면 컴포넌트가 비대해지고, 같은 로직을 다른 곳에서 재사용하기 어렵습니다.
이런 문제는 상태 변경 로직이 컴포넌트에 분산될 때 발생합니다. 로그인을 여러 페이지에서 처리해야 한다면 코드가 중복되고, 로그인 로직을 수정할 때마다 모든 컴포넌트를 찾아 업데이트해야 합니다.
또한 비동기 작업의 에러 처리나 로딩 상태 관리가 일관되지 않으면 사용자 경험이 나빠집니다. 바로 이럴 때 필요한 것이 Pinia의 Actions입니다.
Actions는 상태를 변경하고 비즈니스 로직을 실행하는 메서드로, 동기/비동기 작업을 모두 처리할 수 있습니다.
개요
간단히 말해서, Actions는 Store의 상태를 변경하는 메서드입니다. 컴포넌트에서 버튼 클릭이나 폼 제출 같은 이벤트가 발생했을 때 Action을 호출하여 상태를 업데이트할 수 있습니다.
Actions가 필요한 이유는 상태 변경 로직의 중앙화와 재사용성 때문입니다. 예를 들어, 사용자 로그인, 장바구니 상품 추가, 프로필 수정 같은 작업은 여러 곳에서 일어날 수 있는데, Action으로 정의하면 한 곳에서 관리하고 어디서든 호출할 수 있습니다.
API 호출, 데이터 유효성 검증, 에러 처리 등 복잡한 로직도 Action 안에서 처리할 수 있습니다. 기존 Vuex의 Mutations와 Actions를 비교하면, Pinia는 Mutations를 제거하고 Actions만으로 모든 상태 변경을 처리합니다.
이는 코드를 단순화하고, 동기/비동기 구분 없이 일관된 방식으로 작성할 수 있게 합니다. Actions는 async/await를 자연스럽게 사용할 수 있어 비동기 코드가 훨씬 읽기 쉽습니다.
Actions의 핵심 특징은 다음과 같습니다. 첫째, this로 state, getters, 다른 actions에 접근하여 자유롭게 상태를 변경할 수 있습니다.
둘째, Promise를 반환할 수 있어 컴포넌트에서 async/await로 결과를 기다릴 수 있습니다. 셋째, 다른 Store의 actions를 호출하여 복잡한 워크플로우를 구성할 수 있습니다.
이러한 특징들이 실무에서 유연하고 강력한 상태 관리를 가능하게 합니다.
코드 예제
// stores/user.js
export const useUserStore = defineStore('user', {
state: () => ({
user: null,
isLoading: false,
error: null
}),
actions: {
// 비동기 action: 로그인
async login(email, password) {
this.isLoading = true
this.error = null
try {
// API 호출 시뮬레이션
const response = await fetch('/api/login', {
method: 'POST',
body: JSON.stringify({ email, password })
})
if (!response.ok) throw new Error('로그인 실패')
const data = await response.json()
// 상태 업데이트
this.user = data.user
return data
} catch (error) {
this.error = error.message
throw error
} finally {
this.isLoading = false
}
},
// 동기 action: 로그아웃
logout() {
this.user = null
this.error = null
localStorage.removeItem('token')
},
// 다른 action 호출
async refreshUserData() {
if (!this.user) return
try {
const data = await this.login(this.user.email, this.user.password)
console.log('사용자 데이터 갱신 완료')
} catch (error) {
console.error('갱신 실패:', error)
}
}
}
})
설명
이것이 하는 일: Actions는 Store의 상태를 실제로 변경하고, API 호출 같은 부수 효과(side effects)를 처리하는 곳입니다. 컴포넌트는 사용자 이벤트를 받아 Action을 호출하고, Action이 모든 비즈니스 로직을 처리합니다.
첫 번째로, 비동기 Action인 login을 살펴보겠습니다. async/await를 사용하여 API 호출을 처리하고, 로딩 상태와 에러 상태를 함께 관리합니다.
this.isLoading을 true로 설정하여 컴포넌트에서 로딩 UI를 표시할 수 있게 하고, try-catch로 에러를 잡아 this.error에 저장합니다. 성공하면 this.user에 사용자 정보를 저장하여 상태를 업데이트합니다.
finally 블록에서 isLoading을 false로 되돌려 로딩이 끝났음을 알립니다. 두 번째로, 동기 Action인 logout은 즉시 상태를 초기화합니다.
사용자 정보를 null로 설정하고, localStorage에서 토큰을 제거하는 등의 정리 작업을 수행합니다. 이처럼 간단한 상태 변경도 Action으로 만들어두면 로그아웃 로직이 필요한 모든 곳에서 일관되게 사용할 수 있습니다.
세 번째로, Action 안에서 다른 Action을 호출할 수도 있습니다. refreshUserData는 this.login을 호출하여 사용자 데이터를 갱신합니다.
이런 패턴은 복잡한 워크플로우를 단계별로 나누어 구성할 때 유용합니다. 예를 들어, 결제 프로세스에서 재고 확인 -> 결제 처리 -> 주문 생성 같은 여러 단계를 각각 Action으로 만들고 조합할 수 있습니다.
여러분이 Actions를 사용하면 컴포넌트는 UI에만 집중하고, 비즈니스 로직은 Store가 담당하는 명확한 책임 분리가 가능합니다. 컴포넌트에서는 await userStore.login(email, password)처럼 간단하게 호출하고, 결과에 따라 UI만 업데이트하면 됩니다.
또한 Actions는 테스트하기 쉽습니다. 컴포넌트를 렌더링하지 않고도 Store의 Action만 독립적으로 테스트할 수 있어 단위 테스트 작성이 간편합니다.
실무에서는 Action에 에러 처리, 재시도 로직, 낙관적 업데이트(optimistic update) 같은 고급 패턴을 추가할 수 있습니다. 예를 들어, 네트워크 요청 실패 시 자동으로 3번까지 재시도하거나, 사용자가 좋아요 버튼을 누르면 서버 응답을 기다리지 않고 즉시 UI를 업데이트한 후 나중에 서버 응답을 반영하는 방식입니다.
실전 팁
💡 Action에서 에러를 throw하면 컴포넌트에서 try-catch로 잡을 수 있습니다. 에러를 상태에 저장하는 것과 throw하는 것을 병행하면 유연한 에러 처리가 가능합니다.
💡 로딩 상태는 각 도메인별로 관리하세요. isUserLoading, isProductLoading처럼 분리하면 특정 작업만 로딩 UI를 표시할 수 있습니다.
💡 Action의 매개변수는 객체로 받으면 확장성이 좋습니다. login({ email, password, rememberMe })처럼 작성하면 나중에 옵션을 추가하기 쉽습니다.
💡 여러 상태를 한 번에 업데이트할 때는 $patch를 사용하세요. this.$patch({ user: data.user, isLoading: false })처럼 작성하면 성능이 최적화됩니다.
💡 다른 Store의 action을 호출할 때는 순환 참조를 주의하세요. A Store가 B Store를 호출하고 B가 다시 A를 호출하면 무한 루프가 발생할 수 있습니다.
4. Setup Syntax로 Store 사용하기 - Composition API 스타일
시작하며
여러분이 Vue 3의 Composition API에 익숙하다면, setup 함수 안에서 ref와 computed를 사용하는 것이 자연스러울 것입니다. 하지만 Options API 스타일의 Store 정의는 이런 패턴과 다르게 느껴져서 혼란스러울 수 있습니다.
이런 문제는 두 가지 API 스타일을 섞어 사용할 때 발생합니다. 컴포넌트는 Composition API로 작성하는데 Store는 Options API 스타일로 정의하면, 코드 스타일이 일관되지 않고 팀원들이 혼란스러워할 수 있습니다.
특히 Composition API의 유연성에 익숙한 개발자라면 더 직관적인 방법을 원할 것입니다. 바로 이럴 때 필요한 것이 Pinia의 Setup Syntax입니다.
Setup 함수를 사용하면 Vue 3의 Composition API처럼 Store를 정의할 수 있어, 일관된 코드 스타일을 유지할 수 있습니다.
개요
간단히 말해서, Setup Syntax는 Store를 Composition API 스타일로 정의하는 방법입니다. ref로 state를, computed로 getters를, 일반 함수로 actions를 정의할 수 있습니다.
Setup Syntax가 필요한 이유는 코드 일관성과 유연성 때문입니다. 예를 들어, Vue 3 프로젝트를 Composition API로 작성한다면 Store도 같은 스타일로 작성하는 것이 자연스럽습니다.
또한 composables를 Store 안에서 직접 사용할 수 있어, useRouter, useFetch 같은 함수를 쉽게 통합할 수 있습니다. 기존 Options API 스타일과 비교하면, Setup Syntax는 더 자유롭습니다.
state, getters, actions를 명확하게 구분하지 않고, 필요한 것을 선언하고 반환하기만 하면 됩니다. TypeScript 타입 추론도 더 잘 작동하여 IDE의 자동완성이 정확해집니다.
Setup Syntax의 핵심 특징은 다음과 같습니다. 첫째, Vue의 reactivity API를 그대로 사용할 수 있어 학습 곡선이 낮습니다.
둘째, composables를 쉽게 통합할 수 있어 코드 재사용이 간편합니다. 셋째, 반환하는 것만 외부에 노출되므로 private 로직을 쉽게 숨길 수 있습니다.
이러한 특징들이 현대적인 Vue 3 프로젝트에 완벽하게 들어맞습니다.
코드 예제
// stores/counter.js
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', () => {
// State: ref로 정의
const count = ref(0)
const name = ref('Counter Store')
// Getters: computed로 정의
const doubleCount = computed(() => count.value * 2)
const isEven = computed(() => count.value % 2 === 0)
// Actions: 일반 함수로 정의
function increment() {
count.value++
}
async function incrementAsync() {
await new Promise(resolve => setTimeout(resolve, 1000))
count.value++
}
function reset() {
count.value = 0
}
// 외부에 노출할 것들을 반환
return {
count,
name,
doubleCount,
isEven,
increment,
incrementAsync,
reset
}
})
설명
이것이 하는 일: Setup Syntax는 defineStore의 두 번째 인자로 함수를 전달하여 Store를 정의합니다. 이 함수는 Vue 컴포넌트의 setup 함수처럼 동작하며, 반환한 값들이 Store의 속성과 메서드가 됩니다.
첫 번째로, State는 ref로 정의합니다. const count = ref(0)처럼 작성하면 이것이 Store의 반응형 상태가 됩니다.
컴포넌트에서는 counterStore.count로 접근할 수 있고, counterStore.count++처럼 직접 변경할 수도 있습니다. 여러 개의 ref를 정의하여 필요한 만큼 상태를 만들 수 있으며, 각각 독립적으로 반응성을 가집니다.
두 번째로, Getters는 computed로 정의합니다. doubleCount는 count의 두 배 값을 계산하고, isEven은 count가 짝수인지 판단합니다.
이들은 자동으로 캐싱되며, 의존하는 상태(count)가 변경될 때만 다시 계산됩니다. 컴포넌트에서는 counterStore.doubleCount처럼 접근하고, 일반 속성처럼 사용할 수 있습니다.
세 번째로, Actions는 일반 함수로 정의합니다. increment 함수는 count를 1 증가시키고, incrementAsync는 1초 후에 증가시키는 비동기 함수입니다.
reset 함수는 count를 0으로 초기화합니다. 이 함수들 안에서 ref를 변경할 때는 .value를 사용해야 한다는 점을 주의하세요.
컴포넌트에서는 counterStore.increment()처럼 호출합니다. 마지막으로, return 문에서 외부에 노출할 것들을 객체로 반환합니다.
반환하지 않은 변수나 함수는 Store 내부에서만 사용되므로, private 로직을 쉽게 캡슐화할 수 있습니다. 예를 들어, 내부적으로만 사용하는 헬퍼 함수나 임시 변수는 반환하지 않으면 됩니다.
여러분이 Setup Syntax를 사용하면 composables를 Store에서 직접 활용할 수 있습니다. 예를 들어, const router = useRouter()로 라우터를 가져와 action에서 페이지 이동을 처리하거나, const { data, error } = useFetch('/api/data')로 데이터를 가져와 상태를 초기화할 수 있습니다.
이런 통합은 Options API 스타일에서는 어렵거나 불가능합니다. TypeScript 사용 시 Setup Syntax는 타입 추론이 매우 잘 작동합니다.
ref와 computed의 타입이 자동으로 추론되고, 함수의 매개변수와 반환 타입도 명시적으로 작성할 수 있어 타입 안전성이 높아집니다. IDE에서도 자동완성과 타입 체크가 정확하게 작동하여 개발 경험이 향상됩니다.
실전 팁
💡 Setup Syntax와 Options API 스타일을 섞어 쓰지 마세요. 프로젝트 전체에서 하나의 스타일로 통일하면 코드 리뷰와 유지보수가 쉬워집니다.
💡 State를 변경할 때는 항상 .value를 사용하세요. count.value++처럼 작성해야 하며, count++는 작동하지 않습니다.
💡 외부에 노출하지 않을 헬퍼 함수는 return에 포함하지 마세요. 이렇게 하면 Store의 공개 API가 명확해집니다.
💡 composables를 사용할 때는 setup 함수 최상위에서 호출하세요. 조건문이나 반복문 안에서 호출하면 반응성이 깨질 수 있습니다.
💡 대규모 Store는 여러 composables로 분리하여 조합하세요. useUserState, useUserActions처럼 나눈 후 setup 함수에서 조합하면 관리가 쉽습니다.
5. Store 구독하기 - 상태 변화 감지하기
시작하며
여러분이 사용자가 장바구니를 변경할 때마다 자동으로 localStorage에 저장하거나, 특정 상태가 변경되면 분석 이벤트를 전송해야 하는 상황을 떠올려보세요. 이런 부수 효과를 컴포넌트마다 watch로 감지하면 코드가 중복되고, 놓치는 경우가 생길 수 있습니다.
이런 문제는 상태 변화에 대한 전역적인 반응이 필요할 때 발생합니다. 장바구니 상태가 어디서든 변경될 수 있는데, 모든 변경 지점에서 저장 로직을 실행하는 것은 비효율적이고 실수하기 쉽습니다.
또한 디버깅을 위해 모든 상태 변화를 로깅하고 싶을 때도 각 컴포넌트에 로깅 코드를 추가하는 것은 현실적이지 않습니다. 바로 이럴 때 필요한 것이 Pinia의 구독(Subscription) 기능입니다.
Store의 상태 변화나 action 호출을 중앙에서 감지하고, 원하는 부수 효과를 자동으로 실행할 수 있습니다.
개요
간단히 말해서, 구독은 Store의 변화를 감지하여 콜백 함수를 실행하는 메커니즘입니다. $subscribe로 상태 변화를 감지하고, $onAction으로 action 호출을 감지할 수 있습니다.
구독이 필요한 이유는 횡단 관심사(cross-cutting concerns)를 처리하기 위함입니다. 예를 들어, 모든 상태 변화를 localStorage에 저장하거나, 특정 action이 호출될 때 로그를 남기거나, 상태가 변경되면 서버와 동기화하는 작업은 여러 Store에 걸쳐 있는 공통 관심사입니다.
이를 각 Store나 컴포넌트에 중복 작성하지 않고 한 곳에서 관리할 수 있습니다. 기존에 Vue의 watch를 사용해 상태를 감시했다면, 이제는 Store의 구독 기능으로 더 명확하게 관리할 수 있습니다.
watch는 특정 컴포넌트의 생명주기에 종속되지만, Store 구독은 애플리케이션 수준에서 작동하여 더 일관된 동작을 보장합니다. 구독의 핵심 특징은 다음과 같습니다.
첫째, 모든 상태 변화를 자동으로 감지하여 누락 없이 처리할 수 있습니다. 둘째, action의 실행 전후를 감지하여 로깅, 타이밍 측정, 에러 처리 같은 미들웨어 패턴을 구현할 수 있습니다.
셋째, 구독을 취소할 수 있는 함수를 반환하여 메모리 누수를 방지할 수 있습니다. 이러한 특징들이 복잡한 애플리케이션에서 상태 관리를 체계적으로 만듭니다.
코드 예제
// main.js 또는 plugin 파일
import { useCartStore } from './stores/cart'
const cartStore = useCartStore()
// 상태 변화 구독
const unsubscribe = cartStore.$subscribe((mutation, state) => {
// mutation.type: 'direct' | 'patch object' | 'patch function'
console.log('상태 변경 타입:', mutation.type)
console.log('변경된 이벤트:', mutation.events)
// localStorage에 자동 저장
localStorage.setItem('cart', JSON.stringify(state.items))
}, { detached: true }) // detached: true면 컴포넌트 언마운트 시에도 유지
// Action 호출 구독
cartStore.$onAction(({
name, // action 이름
store, // store 인스턴스
args, // action에 전달된 인자
after, // action 성공 후 실행
onError // action 에러 시 실행
}) => {
console.log(`Action "${name}" 시작, 인자:`, args)
const startTime = Date.now()
after((result) => {
const elapsed = Date.now() - startTime
console.log(`Action "${name}" 완료 (${elapsed}ms), 결과:`, result)
})
onError((error) => {
console.error(`Action "${name}" 실패:`, error)
// 에러 리포팅 서비스에 전송
})
})
// 구독 취소 (필요 시)
// unsubscribe()
설명
이것이 하는 일: 구독 기능은 Store의 변화를 감시하는 옵저버 패턴을 제공합니다. 상태가 변경되거나 action이 호출될 때마다 등록한 콜백 함수가 자동으로 실행되어, 부수 효과를 일관되게 처리할 수 있습니다.
첫 번째로, $subscribe는 상태 변화를 감지합니다. 콜백 함수는 mutation과 state를 매개변수로 받는데, mutation.type으로 변경 방식을 알 수 있습니다.
'direct'는 store.count++처럼 직접 변경한 경우, 'patch object'는 $patch({ count: 1 })로 변경한 경우, 'patch function'은 $patch(state => state.count++)로 변경한 경우입니다. state는 변경 후의 전체 상태를 담고 있어, localStorage에 저장하거나 서버에 전송할 수 있습니다.
두 번째로, options 객체의 detached: true 설정은 중요합니다. 기본적으로 컴포넌트 안에서 구독하면 컴포넌트가 언마운트될 때 구독도 자동으로 해제됩니다.
하지만 detached를 true로 설정하면 컴포넌트 생명주기와 무관하게 유지되어, 애플리케이션 전역에서 작동하는 기능을 구현할 수 있습니다. 예를 들어, main.js에서 설정한 localStorage 동기화는 어떤 페이지에 있든 계속 작동해야 하므로 detached를 true로 설정합니다.
세 번째로, $onAction은 action 호출을 감지합니다. name으로 어떤 action이 호출되었는지 알 수 있고, args로 전달된 인자를 확인할 수 있습니다.
after 콜백을 등록하면 action이 성공적으로 완료된 후에 실행되며, 반환값을 받을 수 있습니다. onError 콜백은 action에서 에러가 발생했을 때 실행됩니다.
이를 활용하면 모든 action의 실행 시간을 측정하거나, 에러를 중앙에서 처리하는 미들웨어를 만들 수 있습니다. 여러분이 구독을 활용하면 다양한 패턴을 구현할 수 있습니다.
예를 들어, 장바구니 상태를 localStorage에 자동 저장하여 페이지를 새로고침해도 유지되게 하거나, 사용자의 모든 action을 로깅하여 사용 패턴을 분석하거나, 특정 상태 변화를 감지하여 알림을 표시하는 등의 기능을 쉽게 추가할 수 있습니다. 실무에서는 plugin을 만들어 여러 Store에 공통 구독 로직을 적용할 수 있습니다.
Pinia의 plugin 시스템을 사용하면 모든 Store에 자동으로 구독을 등록하고, 상태 지속성, 로깅, 에러 추적 같은 기능을 프로젝트 전체에 일관되게 적용할 수 있습니다. 이는 코드 중복을 줄이고 유지보수를 쉽게 만듭니다.
구독을 사용할 때는 메모리 누수에 주의해야 합니다. 구독 함수는 취소 함수를 반환하는데, 더 이상 필요 없을 때는 반드시 호출하여 구독을 해제해야 합니다.
특히 동적으로 생성되는 Store나 임시 구독의 경우 정리를 잊지 말아야 합니다.
실전 팁
💡 전역 기능은 main.js에서 detached: true로 구독하세요. 컴포넌트 생명주기와 무관하게 항상 작동해야 하는 기능에 적합합니다.
💡 $subscribe에서 비용이 큰 작업은 debounce나 throttle을 적용하세요. 상태가 빠르게 변할 때 localStorage 저장 같은 작업이 과도하게 실행되는 것을 방지합니다.
💡 $onAction의 after와 onError는 비동기 action에서 특히 유용합니다. API 호출의 성공/실패를 중앙에서 처리하여 일관된 에러 핸들링을 구현할 수 있습니다.
💡 개발 환경에서만 상세 로깅을 활성화하세요. if (import.meta.env.DEV) 조건으로 감싸면 프로덕션 빌드에서 불필요한 로그를 제거할 수 있습니다.
💡 구독 취소 함수는 컴포넌트의 onUnmounted나 beforeUnmount에서 호출하세요. 메모리 누수를 방지하고 깔끔한 정리를 보장합니다.
6. 여러 Store 연동하기 - Store 간 통신 패턴
시작하며
여러분이 사용자가 로그아웃할 때 장바구니와 주문 내역을 함께 초기화해야 하는 상황을 생각해보세요. 각 Store가 독립적으로 관리되는데, 한 Store의 변화가 다른 Store에 영향을 미쳐야 할 때 어떻게 해야 할까요?
컴포넌트에서 여러 Store를 일일이 초기화하는 것은 번거롭고 실수하기 쉽습니다. 이런 문제는 도메인 간 의존성이 생길 때 발생합니다.
실제 애플리케이션에서는 사용자 인증, 장바구니, 알림, 설정 같은 여러 도메인이 서로 영향을 주고받습니다. 이를 각 컴포넌트에서 처리하면 로직이 분산되고, 의존 관계가 불명확해집니다.
바로 이럴 때 필요한 것이 Store 간 연동 패턴입니다. 한 Store에서 다른 Store를 가져와 상태를 읽거나 action을 호출하여, 복잡한 워크플로우를 체계적으로 구성할 수 있습니다.
개요
간단히 말해서, Store 간 연동은 한 Store 내부에서 다른 Store의 인스턴스를 가져와 사용하는 것입니다. useOtherStore()를 호출하여 다른 Store에 접근하고, 그 상태나 메서드를 활용할 수 있습니다.
Store 간 연동이 필요한 이유는 복잡한 비즈니스 로직을 구조화하기 위함입니다. 예를 들어, 결제를 처리할 때 사용자 인증을 확인하고, 장바구니 상품을 가져오고, 재고를 확인하는 등 여러 도메인의 상태와 로직이 함께 작동해야 합니다.
각 도메인을 독립적인 Store로 관리하면서도, 필요할 때 서로 통신할 수 있어야 합니다. 기존에 전역 이벤트 버스나 복잡한 props 전달로 해결했던 문제를, Pinia의 Store 연동으로 더 명확하게 처리할 수 있습니다.
각 Store는 독립적으로 테스트 가능하면서도, 실제 동작 시에는 유기적으로 협력할 수 있습니다. Store 간 연동의 핵심 특징은 다음과 같습니다.
첫째, 직접 참조 방식으로 타입 안전성이 보장됩니다. 둘째, 순환 참조를 주의해야 하지만, getter나 action 내부에서 호출하면 대부분 문제없이 작동합니다.
셋째, 각 Store의 책임은 유지하면서도 협력할 수 있어 모듈화와 통합의 균형을 이룹니다. 이러한 특징들이 대규모 애플리케이션의 복잡성을 관리하는 핵심입니다.
코드 예제
// stores/user.js
import { defineStore } from 'pinia'
export const useUserStore = defineStore('user', {
state: () => ({
user: null,
isAuthenticated: false
}),
actions: {
logout() {
// 다른 Store를 가져와서 사용
const cartStore = useCartStore()
const notificationStore = useNotificationStore()
// 여러 Store를 함께 초기화
this.user = null
this.isAuthenticated = false
cartStore.clearCart()
notificationStore.clearNotifications()
console.log('모든 사용자 데이터 초기화 완료')
}
}
})
// stores/cart.js
import { useUserStore } from './user'
export const useCartStore = defineStore('cart', {
state: () => ({
items: []
}),
getters: {
// 다른 Store의 상태 참조
canCheckout() {
const userStore = useUserStore()
return userStore.isAuthenticated && this.items.length > 0
}
},
actions: {
clearCart() {
this.items = []
},
async checkout() {
const userStore = useUserStore()
if (!userStore.isAuthenticated) {
throw new Error('로그인이 필요합니다')
}
// 결제 처리 로직
console.log('결제 처리 중...', this.items)
}
}
})
// stores/notification.js
export const useNotificationStore = defineStore('notification', {
state: () => ({
notifications: []
}),
actions: {
clearNotifications() {
this.notifications = []
}
}
})
설명
이것이 하는 일: Store 간 연동은 도메인을 분리하면서도 필요할 때 협력하게 만드는 패턴입니다. 각 Store는 자신의 책임에 집중하면서, action이나 getter 안에서 다른 Store를 참조하여 복잡한 로직을 구성합니다.
첫 번째로, action 안에서 다른 Store를 가져와 사용하는 패턴을 살펴보겠습니다. useUserStore의 logout action에서는 useCartStore()와 useNotificationStore()를 호출하여 각 Store의 인스턴스를 얻습니다.
그 후 각 Store의 초기화 메서드를 호출하여, 로그아웃 시 모든 관련 상태를 함께 정리합니다. 이렇게 하면 컴포넌트는 단순히 userStore.logout()만 호출하면 되고, 복잡한 정리 로직은 Store가 담당합니다.
두 번째로, getter 안에서 다른 Store의 상태를 참조할 수 있습니다. useCartStore의 canCheckout getter는 useUserStore의 isAuthenticated 상태를 확인하여, 사용자가 로그인했고 장바구니에 상품이 있을 때만 true를 반환합니다.
이런 파생 상태는 여러 도메인의 정보를 조합해야 하는데, getter로 정의하면 자동으로 캐싱되고 의존하는 상태가 변할 때만 다시 계산됩니다. 세 번째로, action에서 다른 Store의 상태를 검증하고 비즈니스 로직을 실행할 수 있습니다.
checkout action은 먼저 userStore.isAuthenticated를 확인하여 로그인 여부를 검증합니다. 인증되지 않았다면 에러를 던지고, 인증되었다면 결제 처리를 진행합니다.
이처럼 도메인 간 의존성을 명시적으로 표현하고 처리할 수 있습니다. 여러분이 이 패턴을 사용하면 각 Store는 단일 책임을 유지하면서도 협력할 수 있습니다.
userStore는 사용자 관리만, cartStore는 장바구니 관리만, notificationStore는 알림 관리만 담당하지만, 필요할 때는 서로 통신하여 복잡한 워크플로우를 완성합니다. 이는 코드를 모듈화하고 테스트 가능하게 만들면서도, 실제 기능은 유기적으로 동작하게 합니다.
주의할 점은 순환 참조입니다. A Store가 B Store를 참조하고, B Store가 다시 A Store를 참조하면 문제가 발생할 수 있습니다.
하지만 getter나 action 내부에서 필요할 때만 참조하면 대부분 안전합니다. Store 정의 시점에 즉시 참조하는 것은 피하고, 함수 내부에서 호출하는 방식을 사용하세요.
실무에서는 Store 간 의존성을 문서화하고, 의존 방향을 일관되게 유지하는 것이 좋습니다. 예를 들어, 하위 도메인(cart, notification)은 상위 도메인(user)을 참조하지만, 반대는 피하는 식으로 규칙을 정하면 순환 참조를 방지하고 코드 구조를 명확하게 유지할 수 있습니다.
실전 팁
💡 Store를 가져올 때는 항상 함수 내부에서 호출하세요. getter나 action 안에서 const otherStore = useOtherStore()처럼 사용하면 순환 참조 문제를 피할 수 있습니다.
💡 의존성이 복잡해지면 도메인을 재설계하세요. 너무 많은 Store가 서로 참조한다면 도메인 경계를 잘못 나눈 것일 수 있습니다.
💡 단방향 의존성을 유지하세요. 상위 도메인 → 하위 도메인으로만 참조하도록 규칙을 정하면 코드 구조가 명확해집니다.
💡 Store 간 이벤트 통신이 필요하다면 구독 기능을 활용하세요. 직접 참조보다 느슨한 결합이 필요할 때 유용합니다.
💡 TypeScript 사용 시 순환 의존성 타입 에러가 발생하면 타입만 별도 파일로 분리하세요. types.ts에 인터페이스를 정의하고 import하면 해결됩니다.
7. Pinia Plugin 만들기 - 기능 확장하기
시작하며
여러분이 모든 Store에 localStorage 자동 저장 기능을 추가하고 싶은데, 각 Store마다 동일한 구독 코드를 복사 붙여넣기하고 있다면 뭔가 잘못된 것입니다. 코드가 중복되고, 나중에 로직을 변경할 때 모든 Store를 수정해야 합니다.
이런 문제는 횡단 관심사를 처리할 때 흔히 발생합니다. 로깅, 지속성, 에러 추적, 성능 측정 같은 기능은 모든 Store에 공통적으로 필요한데, 각각 구현하면 유지보수가 어렵고 일관성도 떨어집니다.
DRY(Don't Repeat Yourself) 원칙을 위반하는 것이죠. 바로 이럴 때 필요한 것이 Pinia의 Plugin 시스템입니다.
Plugin을 만들면 모든 Store에 자동으로 기능을 추가하고, 공통 로직을 한 곳에서 관리할 수 있습니다.
개요
간단히 말해서, Pinia Plugin은 모든 Store에 자동으로 적용되는 확장 기능입니다. Plugin 함수를 정의하고 Pinia 인스턴스에 등록하면, 각 Store가 생성될 때마다 Plugin이 실행되어 기능을 추가합니다.
Plugin이 필요한 이유는 공통 기능의 재사용과 관심사의 분리 때문입니다. 예를 들어, 모든 Store의 상태를 localStorage에 자동 저장하거나, 모든 action 호출을 로깅하거나, 개발 환경에서 상태 변화를 추적하는 기능은 Store마다 반복 구현할 필요가 없습니다.
Plugin으로 한 번 만들면 프로젝트 전체에 일관되게 적용됩니다. 기존에 각 Store에서 개별적으로 구독을 등록했다면, 이제는 Plugin으로 중앙화할 수 있습니다.
Plugin은 Store의 생성 시점에 실행되므로, 모든 Store에 자동으로 기능이 추가되고 설정도 통일됩니다. Plugin의 핵심 특징은 다음과 같습니다.
첫째, context 객체를 통해 store, app, options, pinia 인스턴스에 접근할 수 있어 강력한 확장이 가능합니다. 둘째, Store에 새로운 속성이나 메서드를 추가할 수 있어 기능을 확장할 수 있습니다.
셋째, 여러 Plugin을 조합하여 모듈화된 기능을 구성할 수 있습니다. 이러한 특징들이 Pinia를 매우 유연하고 확장 가능한 상태 관리 라이브러리로 만듭니다.
코드 예제
// plugins/persistPlugin.js
export function persistPlugin(context) {
const { store, options } = context
// 옵션에서 persist 설정 확인
if (options.persist) {
const storageKey = `pinia-${store.$id}`
// 저장된 상태 복원
const savedState = localStorage.getItem(storageKey)
if (savedState) {
store.$patch(JSON.parse(savedState))
}
// 상태 변화 구독하여 자동 저장
store.$subscribe((mutation, state) => {
localStorage.setItem(storageKey, JSON.stringify(state))
}, { detached: true })
}
// Store에 새로운 메서드 추가
store.clearPersistedState = () => {
localStorage.removeItem(`pinia-${store.$id}`)
}
}
// main.js
import { createPinia } from 'pinia'
import { persistPlugin } from './plugins/persistPlugin'
const pinia = createPinia()
pinia.use(persistPlugin)
app.use(pinia)
// stores/cart.js - persist 옵션으로 활성화
export const useCartStore = defineStore('cart', {
state: () => ({
items: []
}),
actions: {
addItem(item) {
this.items.push(item)
}
},
// Plugin이 이 옵션을 확인함
persist: true
})
// 컴포넌트에서 사용
const cartStore = useCartStore()
cartStore.clearPersistedState() // Plugin이 추가한 메서드
설명
이것이 하는 일: Pinia Plugin은 Store가 생성될 때마다 자동으로 실행되는 함수로, Store를 확장하거나 부수 효과를 추가합니다. Plugin 함수는 context 객체를 받아 store, options, pinia 인스턴스에 접근하고, 원하는 기능을 구현합니다.
첫 번째로, Plugin 함수의 기본 구조를 살펴보겠습니다. persistPlugin 함수는 context에서 store와 options를 추출합니다.
store는 현재 생성된 Store의 인스턴스이고, options는 defineStore에 전달된 설정 객체입니다. options.persist를 확인하여 이 Store가 지속성 기능을 원하는지 판단합니다.
이런 옵트인 방식은 모든 Store에 강제로 적용하지 않고, 필요한 Store만 선택적으로 사용하게 합니다. 두 번째로, localStorage에서 저장된 상태를 복원하는 로직입니다.
localStorage.getItem(storageKey)로 이전에 저장된 데이터를 가져오고, JSON.parse로 파싱한 후 store.$patch로 상태에 반영합니다. 이렇게 하면 페이지를 새로고침하거나 다시 방문해도 이전 상태가 유지됩니다.
그 후 store.$subscribe로 상태 변화를 구독하여, 변경이 일어날 때마다 자동으로 localStorage에 저장합니다. detached: true 옵션으로 컴포넌트와 무관하게 항상 작동하도록 합니다.
세 번째로, Store에 새로운 메서드를 추가할 수 있습니다. store.clearPersistedState는 localStorage의 데이터를 삭제하는 헬퍼 메서드입니다.
컴포넌트에서 cartStore.clearPersistedState()처럼 호출하여 저장된 데이터를 지울 수 있습니다. 이처럼 Plugin은 Store의 API를 확장하여 추가 기능을 제공할 수 있습니다.
네 번째로, Plugin을 Pinia 인스턴스에 등록하는 방법입니다. main.js에서 pinia.use(persistPlugin)처럼 호출하면 됩니다.
이후 생성되는 모든 Store에 이 Plugin이 자동으로 적용됩니다. 여러 Plugin을 등록하려면 pinia.use(plugin1).use(plugin2)처럼 체이닝하거나, 각각 호출하면 됩니다.
Plugin은 등록 순서대로 실행됩니다. 여러분이 Plugin을 활용하면 다양한 고급 기능을 구현할 수 있습니다.
예를 들어, 개발 환경에서 모든 action 호출을 콘솔에 로깅하는 Plugin, 특정 상태 변화를 서버에 전송하는 Plugin, 에러가 발생하면 Sentry 같은 모니터링 서비스에 리포팅하는 Plugin 등을 만들 수 있습니다. 이런 기능들을 각 Store에 중복 작성하지 않고 Plugin으로 한 번만 구현하면 됩니다.
실무에서는 pinia-plugin-persistedstate 같은 검증된 오픈소스 Plugin을 사용하는 것도 좋습니다. 이미 많은 개발자들이 사용하고 테스트한 Plugin은 안정적이고 기능도 풍부합니다.
하지만 프로젝트에 특화된 요구사항이 있다면 직접 Plugin을 만드는 것이 더 적합할 수 있습니다.
실전 팁
💡 Plugin에서 options 객체를 활용하여 옵트인 방식으로 만드세요. 모든 Store에 강제 적용하지 말고, 필요한 Store만 옵션을 설정하게 하면 유연합니다.
💡 context.app으로 Vue 앱 인스턴스에 접근할 수 있습니다. router, i18n 같은 전역 플러그인을 Plugin 안에서 사용할 수 있습니다.
💡 여러 Plugin을 조합할 때는 실행 순서에 주의하세요. 한 Plugin이 다른 Plugin의 결과에 의존한다면 등록 순서가 중요합니다.
💡 Plugin에서 비동기 작업은 await하지 말고 백그라운드에서 실행하세요. Plugin 함수가 Promise를 반환하면 Store 생성이 지연됩니다.
💡 TypeScript를 사용한다면 Store 타입을 확장하세요. declare module 'pinia'로 추가한 속성의 타입을 선언하면 자동완성이 작동합니다.