이미지 로딩 중...
AI Generated
2025. 11. 8. · 6 Views
Pinia로 상태관리하기 완벽 가이드
Vue 3의 공식 상태 관리 라이브러리 Pinia를 활용하여 애플리케이션의 상태를 효율적으로 관리하는 방법을 배웁니다. Store 정의부터 Actions, Getters, 그리고 실전 패턴까지 단계별로 학습합니다.
목차
- Pinia_Store_기본_구조
- State_정의와_접근
- Getters로_파생_상태_만들기
- Actions로_비즈니스_로직_구현
- Store_컴포넌트에서_사용하기
- Multiple_Stores_패턴
- Pinia_Plugins_활용하기
1. Pinia_Store_기본_구조
시작하며
Vue 애플리케이션을 개발하다 보면 여러 컴포넌트 간에 데이터를 공유해야 하는 상황이 자주 발생합니다. Props와 Emit으로 데이터를 전달하다 보면 컴포넌트 깊이가 깊어질수록 코드가 복잡해지고 유지보수가 어려워집니다.
이런 문제는 실제 개발 현장에서 "Prop Drilling" 이라는 안티패턴으로 나타나며, 중간 컴포넌트들이 실제로 사용하지도 않는 데이터를 단순히 전달만 하는 역할을 하게 됩니다. 이는 코드의 가독성을 떨어뜨리고 리팩토링을 어렵게 만듭니다.
바로 이럴 때 필요한 것이 Pinia입니다. Pinia는 Vue 3의 공식 상태 관리 라이브러리로, 중앙 집중식 저장소를 통해 모든 컴포넌트에서 쉽게 상태를 공유하고 관리할 수 있게 해줍니다.
개요
간단히 말해서, Pinia Store는 애플리케이션의 상태를 담는 컨테이너입니다. 여러분의 앱에서 사용하는 데이터를 한 곳에 모아두고, 어디서든 접근할 수 있게 만드는 것입니다.
Vuex의 후속 라이브러리로 설계된 Pinia는 더 간단한 API와 TypeScript 지원, 그리고 Vue 3의 Composition API와 완벽한 통합을 제공합니다. 예를 들어, 사용자 인증 정보나 장바구니 데이터 같은 전역 상태를 관리할 때 매우 유용합니다.
기존에는 Vuex로 Mutations, Actions, Getters를 모두 따로 정의했다면, Pinia에서는 더 직관적인 구조로 Store를 만들 수 있습니다. Mutations가 사라지고 Actions에서 직접 상태를 변경할 수 있어 보일러플레이트 코드가 크게 줄어듭니다.
Pinia Store의 핵심 특징은 세 가지입니다: State(상태 데이터), Getters(계산된 값), Actions(비즈니스 로직). 이러한 특징들이 명확한 역할 분리를 통해 코드의 유지보수성을 높이고, 테스트하기 쉬운 구조를 만들어줍니다.
코드 예제
// stores/counter.js
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', {
// State: 상태 데이터를 반환하는 함수
state: () => ({
count: 0,
name: 'Counter Store'
}),
// Getters: 계산된 값 (computed와 유사)
getters: {
doubleCount: (state) => state.count * 2
},
// Actions: 비즈니스 로직 및 상태 변경
actions: {
increment() {
this.count++
}
}
})
설명
이것이 하는 일: Pinia Store는 애플리케이션의 상태를 중앙에서 관리하는 저장소를 정의합니다. 이 예제는 카운터 기능을 가진 기본적인 Store를 만드는 방법을 보여줍니다.
첫 번째로, defineStore('counter', {...}) 부분은 'counter'라는 고유 ID를 가진 Store를 생성합니다. 이 ID는 Pinia가 내부적으로 Store를 식별하는 데 사용되며, 개발자 도구에서도 이 이름으로 표시됩니다.
Store를 함수로 export하는 useCounterStore는 Vue의 Composable 패턴을 따르는 관례입니다. 그 다음으로, state는 반드시 함수로 정의해야 합니다.
이는 각 애플리케이션 인스턴스가 독립적인 상태를 가질 수 있도록 하기 위함입니다. SSR(Server-Side Rendering) 환경에서 특히 중요한데, 여러 사용자의 요청이 동시에 처리될 때 상태가 섞이지 않도록 보장합니다.
getters는 Vue의 computed 속성과 동일하게 동작하며, state를 기반으로 파생된 값을 계산합니다. 이 예제의 doubleCount는 count가 변경될 때마다 자동으로 재계산되며, 결과는 캐싱됩니다.
actions는 state를 변경하는 메서드로, this를 통해 state에 직접 접근할 수 있습니다. 여러분이 이 코드를 사용하면 컴포넌트 어디서든 동일한 상태를 공유할 수 있습니다.
한 컴포넌트에서 increment()를 호출하면 다른 모든 컴포넌트에서도 변경된 count 값을 즉시 확인할 수 있으며, 반응성이 자동으로 유지됩니다. 또한 Vue DevTools를 통해 상태 변화를 추적하고 디버깅할 수 있어 개발 생산성이 크게 향상됩니다.
실전 팁
💡 Store ID는 애플리케이션 전체에서 고유해야 하므로, 도메인 이름을 포함한 명확한 이름을 사용하세요 (예: 'user/auth', 'cart/items')
💡 state를 객체로 직접 반환하면 SSR에서 메모리 누수가 발생할 수 있으니 반드시 함수로 정의하세요
💡 TypeScript를 사용한다면 state의 타입이 자동으로 추론되므로 별도의 타입 정의가 필요 없습니다
💡 개발 중에는 Vue DevTools의 Pinia 탭을 활용하여 상태 변화를 실시간으로 모니터링하세요
💡 Store는 싱글톤 패턴으로 동작하므로, useCounterStore()를 여러 번 호출해도 항상 같은 인스턴스를 반환합니다
2. State_정의와_접근
시작하며
프로젝트가 커지면서 관리해야 할 상태의 종류도 다양해집니다. 사용자 정보, 설정 값, 임시 데이터 등 여러 타입의 데이터를 어떻게 구조화할지 고민하게 되죠.
많은 개발자들이 state를 잘못 설계하여 나중에 리팩토링하느라 고생합니다. 너무 많은 것을 한 state에 담거나, 반대로 너무 세분화하여 관리 포인트가 늘어나는 문제가 발생합니다.
바로 이럴 때 필요한 것이 체계적인 State 설계입니다. Pinia에서 state를 효과적으로 정의하고 접근하는 방법을 익히면 확장 가능한 애플리케이션을 만들 수 있습니다.
개요
간단히 말해서, State는 Store가 관리하는 실제 데이터입니다. 컴포넌트의 data 옵션과 비슷하지만, 전역적으로 공유된다는 점이 다릅니다.
State 설계 시 중요한 것은 정규화(Normalization)입니다. 중복 데이터를 최소화하고, 관련된 데이터를 함께 그룹화하며, 자주 변경되는 데이터와 정적인 데이터를 분리해야 합니다.
예를 들어, 사용자 프로필 Store에서는 기본 정보, 설정, 통계를 각각 별도의 객체로 관리하는 것이 좋습니다. 기존에는 Vuex에서 state를 변경하려면 반드시 mutation을 거쳐야 했다면, Pinia에서는 컴포넌트에서 직접 state를 수정하거나 action을 통해 변경할 수 있습니다.
이는 더 직관적이고 간결한 코드 작성을 가능하게 합니다. State 접근 방법에는 크게 두 가지가 있습니다: 직접 접근과 구조 분해 할당.
직접 접근은 반응성을 유지하지만, 구조 분해 시에는 storeToRefs를 사용해야 반응성이 보존됩니다. 이러한 특징을 이해하면 의도치 않은 반응성 손실을 방지할 수 있습니다.
코드 예제
// stores/user.js
import { defineStore } from 'pinia'
export const useUserStore = defineStore('user', {
state: () => ({
// 기본 정보
profile: {
id: null,
name: '',
email: ''
},
// 설정
preferences: {
theme: 'light',
language: 'ko'
},
// 동적 데이터
isLoading: false,
lastUpdated: null
}),
actions: {
// State 초기화
resetState() {
this.$reset()
}
}
})
설명
이것이 하는 일: 이 코드는 사용자 정보를 관리하는 Store의 state 구조를 정의합니다. 데이터를 논리적으로 그룹화하여 유지보수성을 높이는 패턴을 보여줍니다.
첫 번째로, state를 중첩된 객체로 구조화하는 것이 핵심입니다. profile, preferences 같은 객체로 관련 데이터를 그룹화하면, 나중에 특정 영역만 업데이트하거나 초기화하기 쉬워집니다.
예를 들어 this.profile = newProfile 같은 방식으로 전체 프로필을 한 번에 교체할 수 있습니다. 그 다음으로, isLoading, lastUpdated 같은 메타 데이터는 최상위 레벨에 배치합니다.
이는 UI 상태를 추적하는 데 사용되며, 실제 비즈니스 데이터와 분리하여 관리하는 것이 좋습니다. 로딩 상태는 API 호출 시 사용자에게 피드백을 제공하는 데 필수적입니다.
$reset() 메서드는 Pinia가 제공하는 내장 기능으로, state를 초기값으로 되돌립니다. 이는 사용자 로그아웃이나 폼 초기화 같은 상황에서 매우 유용합니다.
Pinia는 state 정의 시점의 값을 기억하고 있다가 reset 시 그 값으로 복원합니다. 여러분이 이 코드를 사용하면 데이터가 명확하게 분류되어 어떤 정보가 어디 있는지 쉽게 파악할 수 있습니다.
또한 TypeScript를 사용할 경우 IDE의 자동완성이 중첩된 구조까지 정확하게 제안하여 타이핑 실수를 방지할 수 있습니다. state 구조가 명확하면 팀원들과 협업할 때도 소통이 원활해집니다.
실전 팁
💡 state에 Date 객체나 클래스 인스턴스를 직접 저장하면 직렬화 문제가 발생할 수 있으니, 원시값이나 평범한 객체로 저장하세요
💡 민감한 정보(비밀번호, 토큰)는 state에 장기간 보관하지 말고, 필요할 때만 메모리에 두거나 암호화된 저장소를 사용하세요
💡 state의 초기값은 실제 데이터 타입과 일치시켜야 합니다 (빈 배열은 [], 빈 객체는 {}, 숫자는 0이나 null)
💡 깊게 중첩된 객체는 성능 문제를 일으킬 수 있으므로, 3단계 이상 중첩은 피하고 정규화된 구조를 고려하세요
💡 개발 모드에서는 Pinia가 state 변경을 추적하므로, 큰 배열이나 객체를 다룰 때는 프로덕션 빌드에서 성능을 테스트하세요
3. Getters로_파생_상태_만들기
시작하며
애플리케이션을 개발하다 보면 원본 데이터를 가공하거나 필터링한 값이 필요한 경우가 많습니다. 예를 들어, 전체 상품 목록에서 할인 중인 상품만 추출하거나, 사용자의 전체 주문 금액을 계산하는 등의 작업이죠.
이런 계산을 각 컴포넌트에서 반복적으로 수행하면 코드 중복이 발생하고, 계산 로직이 변경될 때 여러 곳을 수정해야 하는 문제가 생깁니다. 또한 동일한 계산을 여러 번 수행하여 성능 낭비가 발생할 수 있습니다.
바로 이럴 때 필요한 것이 Getters입니다. Getters는 state를 기반으로 계산된 값을 제공하며, 자동으로 캐싱되어 효율적인 데이터 처리를 가능하게 합니다.
개요
간단히 말해서, Getters는 Store의 computed 속성입니다. state에서 파생된 값을 계산하고, 의존하는 state가 변경될 때만 재계산됩니다.
Getters를 사용하는 주요 이유는 세 가지입니다: 코드 재사용성, 캐싱을 통한 성능 최적화, 그리고 비즈니스 로직의 중앙화. 예를 들어, 쇼핑몰 애플리케이션에서 장바구니의 총 금액을 계산하는 로직을 getter로 만들면, 여러 컴포넌트(헤더, 장바구니 페이지, 결제 페이지)에서 동일한 계산 결과를 일관성 있게 사용할 수 있습니다.
기존에는 컴포넌트의 computed에서 이런 계산을 했다면, Pinia getters로 옮기면 전역적으로 재사용 가능한 계산 로직이 됩니다. 게다가 getter에서 다른 getter를 참조할 수도 있어 복잡한 계산을 단계적으로 구성할 수 있습니다.
Getters의 핵심 특징은 반응성과 캐싱입니다. state가 변경되면 getter도 자동으로 업데이트되며, 의존성이 변경되지 않는 한 이전 결과를 재사용합니다.
또한 화살표 함수와 일반 함수 두 가지 방식으로 정의할 수 있는데, 각각 장단점이 있어 상황에 맞게 선택해야 합니다.
코드 예제
// stores/cart.js
import { defineStore } from 'pinia'
export const useCartStore = defineStore('cart', {
state: () => ({
items: [
{ id: 1, name: '노트북', price: 1200000, quantity: 1 },
{ id: 2, name: '마우스', price: 30000, quantity: 2 }
],
discount: 0.1 // 10% 할인
}),
getters: {
// 기본 getter: 화살표 함수
subtotal: (state) => {
return state.items.reduce((sum, item) => sum + item.price * item.quantity, 0)
},
// 다른 getter 참조: 일반 함수 사용
total() {
return this.subtotal * (1 - this.discount)
},
// 매개변수를 받는 getter: 함수를 반환
getItemById: (state) => {
return (id) => state.items.find(item => item.id === id)
}
}
})
설명
이것이 하는 일: 이 코드는 장바구니 Store에서 상품 금액을 계산하는 다양한 getter 패턴을 보여줍니다. 각 getter는 서로 다른 방식으로 정의되어 있어 상황별 활용법을 이해할 수 있습니다.
첫 번째로, subtotal getter는 화살표 함수로 정의되어 있습니다. 화살표 함수는 state 매개변수를 받아 사용하며, TypeScript의 타입 추론이 잘 작동합니다.
reduce 메서드로 모든 상품의 가격과 수량을 곱한 값을 합산하여 소계를 계산합니다. 이 계산은 items 배열이 변경될 때마다 자동으로 재실행되지만, 결과는 캐싱되어 동일한 요청에 대해서는 재계산하지 않습니다.
그 다음으로, total getter는 일반 함수로 정의되어 this를 통해 다른 getter에 접근합니다. this.subtotal로 앞서 계산한 소계를 가져와 할인율을 적용한 최종 금액을 계산합니다.
일반 함수를 사용하면 다른 getter, state, 심지어 action까지 this로 접근할 수 있어 유연성이 높습니다. getItemById는 매개변수를 받는 특별한 형태의 getter입니다.
함수를 반환하는 패턴을 사용하여, 컴포넌트에서 store.getItemById(1) 같은 방식으로 호출할 수 있습니다. 다만 이 방식은 캐싱이 되지 않으므로, 호출할 때마다 find 연산이 실행됩니다.
성능이 중요한 경우 computed를 별도로 만들거나 메모이제이션을 고려해야 합니다. 여러분이 이 코드를 사용하면 복잡한 계산 로직을 컴포넌트에서 분리하여 Store에서 관리할 수 있습니다.
여러 컴포넌트에서 동일한 계산이 필요할 때 getter를 참조하기만 하면 되므로, 코드 중복이 사라지고 유지보수가 쉬워집니다. 또한 계산 로직이 변경되어도 getter만 수정하면 모든 컴포넌트에 자동으로 반영됩니다.
실전 팁
💡 getter에서 비동기 작업을 수행하면 안 됩니다. API 호출이 필요하면 action을 사용하세요
💡 다른 Store의 getter를 참조할 때는 해당 Store를 import하여 사용할 수 있습니다: const otherStore = useOtherStore(); return otherStore.someGetter
💡 매개변수를 받는 getter는 캐싱되지 않으므로, 자주 호출된다면 컴포넌트의 computed에서 결과를 캐싱하는 것을 고려하세요
💡 getter에서 복잡한 배열 필터링을 한다면, state를 정규화하여 인덱싱된 구조로 변경하는 것이 성능상 유리할 수 있습니다
💡 TypeScript 사용 시 getter의 반환 타입은 자동 추론되지만, 명시적으로 지정하면 더 안전한 코드를 작성할 수 있습니다
4. Actions로_비즈니스_로직_구현
시작하며
실제 애플리케이션에서는 단순히 데이터를 읽는 것만으로는 부족합니다. API를 호출하고, 데이터를 검증하고, 여러 state를 조율하며, 에러를 처리하는 등의 복잡한 작업이 필요하죠.
많은 개발자들이 이런 로직을 컴포넌트에 직접 작성하다가 코드가 비대해지고 테스트하기 어려워지는 문제를 겪습니다. 컴포넌트는 UI 표현에 집중해야 하는데, 비즈니스 로직까지 떠안게 되면 재사용성과 유지보수성이 떨어집니다.
바로 이럴 때 필요한 것이 Actions입니다. Actions는 비즈니스 로직을 캡슐화하고, 동기/비동기 작업을 모두 처리할 수 있으며, state를 안전하게 변경하는 중앙화된 방법을 제공합니다.
개요
간단히 말해서, Actions는 Store의 메서드입니다. state를 변경하고, 비동기 작업을 수행하며, 다른 action을 호출할 수 있는 모든 것이 가능한 함수입니다.
Vuex와 달리 Pinia의 actions는 동기/비동기 구분 없이 사용할 수 있습니다. mutation이 없어졌기 때문에 actions에서 직접 state를 변경할 수 있으며, this를 통해 state, getters, 다른 actions에 모두 접근 가능합니다.
예를 들어, 사용자 로그인 action에서 API를 호출하고, 응답 데이터로 state를 업데이트하며, 토큰을 로컬 스토리지에 저장하는 모든 작업을 한 곳에서 처리할 수 있습니다. 기존에는 Vuex에서 action → mutation → state 변경이라는 단계를 거쳤다면, Pinia에서는 action → state 변경으로 간소화됩니다.
이는 보일러플레이트 코드를 줄이고 더 직관적인 코드 작성을 가능하게 합니다. Actions의 핵심 특징은 유연성과 조합성입니다.
async/await를 자유롭게 사용할 수 있고, try-catch로 에러를 처리하며, 여러 actions를 조합하여 복잡한 워크플로우를 만들 수 있습니다. 또한 actions는 일반 JavaScript 함수이므로 단위 테스트하기도 쉽습니다.
코드 예제
// stores/todo.js
import { defineStore } from 'pinia'
export const useTodoStore = defineStore('todo', {
state: () => ({
todos: [],
isLoading: false,
error: null
}),
actions: {
// 비동기 action: API 호출
async fetchTodos() {
this.isLoading = true
this.error = null
try {
const response = await fetch('/api/todos')
const data = await response.json()
this.todos = data // state 직접 변경
} catch (error) {
this.error = error.message
console.error('Failed to fetch todos:', error)
} finally {
this.isLoading = false
}
},
// 동기 action: 로컬 state 변경
addTodo(text) {
this.todos.push({
id: Date.now(),
text,
completed: false
})
},
// 다른 action 호출
async initializeStore() {
await this.fetchTodos()
// 추가 초기화 로직...
}
}
})
설명
이것이 하는 일: 이 코드는 Todo 애플리케이션에서 데이터를 가져오고 관리하는 actions를 보여줍니다. 비동기 API 호출, 에러 처리, 그리고 로컬 state 변경의 실전 패턴을 담고 있습니다.
첫 번째로, fetchTodos action은 전형적인 비동기 패턴을 보여줍니다. action이 시작될 때 isLoading을 true로 설정하여 UI에 로딩 상태를 표시할 수 있게 합니다.
error를 null로 초기화하는 것도 중요한데, 이전 에러를 지우지 않으면 오래된 에러 메시지가 계속 표시될 수 있습니다. try-catch-finally 구조는 비동기 작업의 표준 패턴으로, 성공/실패 여부와 관계없이 finally에서 항상 로딩 상태를 해제합니다.
그 다음으로, API 응답을 받으면 this.todos = data로 state를 직접 업데이트합니다. Vuex의 mutation 없이도 Pinia는 이 변경을 추적하고 반응성을 유지합니다.
에러가 발생하면 catch 블록에서 에러 메시지를 state에 저장하고, console.error로 디버깅 정보도 출력합니다. 이렇게 하면 사용자에게는 친절한 메시지를 보여주면서 개발자는 상세한 에러 정보를 확인할 수 있습니다.
addTodo는 동기 action의 예시로, 로컬에서 즉시 새 할일을 추가합니다. 낙관적 업데이트(Optimistic Update) 패턴에서는 이렇게 먼저 로컬 state를 변경하고, 백그라운드에서 API를 호출하여 서버와 동기화합니다.
사용자는 즉각적인 피드백을 받아 더 나은 경험을 얻습니다. initializeStore는 여러 actions를 조합하는 패턴을 보여줍니다.
await this.fetchTodos()로 다른 action을 호출하고, 그 결과를 기다린 후 추가 로직을 실행할 수 있습니다. 이렇게 actions를 조합하면 복잡한 초기화 로직을 깔끔하게 구조화할 수 있습니다.
여러분이 이 코드를 사용하면 컴포넌트는 단순히 store.fetchTodos()만 호출하면 되고, 모든 복잡한 로직은 Store에서 처리됩니다. 로딩 상태와 에러 처리가 중앙화되어 일관성 있는 UX를 제공할 수 있으며, 테스트 시에도 action만 모킹하면 되므로 테스트 코드가 간결해집니다.
또한 여러 컴포넌트에서 동일한 데이터 fetching 로직을 재사용할 수 있어 코드 중복이 사라집니다.
실전 팁
💡 action에서 여러 state를 변경할 때는 $patch를 사용하면 성능이 향상됩니다: this.$patch({ todos: data, isLoading: false })
💡 에러 처리 시 사용자에게 보여줄 메시지와 개발자를 위한 로그를 분리하세요. 민감한 정보는 console.error에만 출력하세요
💡 action이 실패했을 때 throw를 사용하여 에러를 전파하면, 컴포넌트에서 추가 에러 처리를 할 수 있습니다
💡 낙관적 업데이트를 사용할 때는 API 실패 시 롤백 로직을 반드시 구현하세요. 이전 state를 백업해두었다가 복원하는 패턴을 사용하세요
💡 긴 작업은 AbortController를 활용하여 취소 가능하게 만들면, 사용자가 페이지를 떠날 때 불필요한 네트워크 요청을 방지할 수 있습니다
5. Store_컴포넌트에서_사용하기
시작하며
Store를 만들었다면 이제 컴포넌트에서 실제로 사용할 차례입니다. 하지만 여러 가지 접근 방식이 있고, 각각 미묘한 차이가 있어 혼란스러울 수 있습니다.
특히 Composition API와 Options API를 혼용하는 프로젝트에서는 일관성 있는 패턴을 선택하는 것이 중요합니다. 잘못된 방식으로 Store를 사용하면 반응성을 잃거나 불필요한 재렌더링이 발생할 수 있습니다.
바로 이럴 때 필요한 것이 올바른 Store 사용 패턴입니다. storeToRefs, computed, watch 등의 도구를 적재적소에 활용하면 효율적이고 안전한 코드를 작성할 수 있습니다.
개요
간단히 말해서, 컴포넌트에서 Store를 사용하는 방법은 직접 참조, 구조 분해 할당, 그리고 헬퍼 함수 활용의 세 가지입니다. 각각의 방법은 상황에 따라 장단점이 있습니다.
가장 기본적인 방법은 const store = useStore()로 Store 인스턴스를 가져와 store.count, store.increment() 같은 방식으로 사용하는 것입니다. 이 방법은 항상 반응성을 유지하지만, 코드가 길어질 수 있습니다.
예를 들어, template에서 {{ store.user.profile.name }}처럼 깊게 중첩된 접근이 필요할 때 가독성이 떨어집니다. 기존의 Vuex mapState, mapGetters를 사용했다면, Pinia에서는 storeToRefs가 그 역할을 대신합니다.
구조 분해 할당으로 state와 getters를 추출하면서도 반응성을 유지하는 핵심 도구입니다. 하지만 actions는 storeToRefs가 필요 없이 바로 구조 분해해도 됩니다.
Store 사용의 핵심 특징은 반응성 유지와 성능 최적화의 균형입니다. 필요한 데이터만 선택적으로 참조하면 불필요한 재렌더링을 방지할 수 있고, watch를 활용하면 특정 state 변경에 반응하는 사이드 이펙트를 구현할 수 있습니다.
Composition API에서는 이러한 패턴이 더욱 자연스럽고 유연하게 작동합니다.
코드 예제
<script setup>
import { computed, watch } from 'vue'
import { storeToRefs } from 'pinia'
import { useUserStore } from '@/stores/user'
// Store 인스턴스 가져오기
const userStore = useUserStore()
// ✅ 올바른 방법: storeToRefs로 state와 getters 구조 분해
const { profile, isLoading } = storeToRefs(userStore)
// ✅ actions는 storeToRefs 없이 직접 구조 분해 가능
const { fetchUser, updateProfile } = userStore
// ❌ 잘못된 방법: 반응성을 잃음
// const { profile } = userStore
// Computed로 파생 데이터 만들기
const displayName = computed(() => {
return profile.value.name || '게스트'
})
// Store state 변경 감지
watch(() => userStore.profile.email, (newEmail) => {
console.log('이메일 변경됨:', newEmail)
})
// 컴포넌트 마운트 시 데이터 로드
fetchUser()
</script>
<template>
<div v-if="isLoading">로딩 중...</div>
<div v-else>
<h1>{{ displayName }}</h1>
<p>{{ profile.email }}</p>
</div>
</template>
설명
이것이 하는 일: 이 코드는 Composition API에서 Pinia Store를 올바르게 사용하는 실전 패턴을 보여줍니다. 반응성을 유지하면서도 깔끔한 코드를 작성하는 방법을 담고 있습니다.
첫 번째로, useUserStore()로 Store 인스턴스를 가져옵니다. 이 함수는 항상 같은 싱글톤 인스턴스를 반환하므로, 여러 컴포넌트에서 호출해도 동일한 상태를 공유합니다.
storeToRefs(userStore)는 Pinia가 제공하는 특별한 헬퍼로, state와 getters를 ref로 변환하여 구조 분해해도 반응성이 유지되게 합니다. 일반적인 toRefs와 달리 methods는 제외하고 반응형 속성만 처리합니다.
그 다음으로, actions는 일반 메서드이므로 storeToRefs 없이 직접 userStore에서 구조 분해할 수 있습니다. { fetchUser, updateProfile } = userStore처럼 작성하면 됩니다.
이렇게 하면 template이나 script에서 store.fetchUser() 대신 fetchUser()로 간결하게 호출할 수 있습니다. 주석으로 표시된 잘못된 방법(const { profile } = userStore)을 사용하면, profile은 일반 객체가 되어 변경사항이 UI에 반영되지 않습니다.
computed로 displayName을 만드는 부분은 Store의 getter를 사용하는 대신 컴포넌트 레벨에서 추가 가공이 필요할 때의 패턴입니다. profile.value로 ref의 값에 접근하고, 이름이 없으면 기본값을 표시합니다.
watch는 Store state의 변경을 감지하는 데 유용한데, 예를 들어 이메일이 변경되면 검증 로직을 실행하거나 분석 이벤트를 보낼 수 있습니다. 템플릿에서는 isLoading과 profile을 직접 사용합니다.
ref로 감싸져 있지만 template 내에서는 자동으로 언래핑되므로 .value 없이 바로 접근 가능합니다. 조건부 렌더링(v-if)으로 로딩 상태를 처리하면 사용자 경험이 향상됩니다.
여러분이 이 코드를 사용하면 Store의 state 변경이 자동으로 UI에 반영되며, 불필요한 재렌더링 없이 효율적으로 작동합니다. setup 함수의 최상위 레벨에서 Store를 초기화하면 컴포넌트 생명주기 동안 일관성 있게 사용할 수 있고, watch와 computed를 조합하여 복잡한 반응형 로직도 깔끔하게 표현할 수 있습니다.
실전 팁
💡 Options API를 사용한다면 mapStores, mapState, mapActions 헬퍼를 활용할 수 있지만, Composition API가 더 직관적이고 TypeScript 지원이 우수합니다
💡 여러 Store를 사용할 때는 변수명을 명확히 구분하세요: const userStore = useUserStore(); const cartStore = useCartStore()
💡 computed 내에서 Store의 action을 호출하지 마세요. computed는 순수 함수여야 하며, 부작용이 있는 로직은 watch나 이벤트 핸들러에서 처리하세요
💡 Store state를 v-model에 직접 바인딩할 때는 computed의 get/set을 사용하거나, 로컬 ref를 만들어 동기화하는 패턴을 고려하세요
💡 큰 배열이나 객체를 다룰 때는 shallowRef를 사용하여 성능을 최적화할 수 있지만, 중첩된 속성의 변경은 감지되지 않으므로 주의하세요
6. Multiple_Stores_패턴
시작하며
애플리케이션이 성장하면서 하나의 Store에 모든 상태를 담기 어려워집니다. 사용자 정보, 상품 목록, 장바구니, 알림 등 도메인별로 분리된 상태 관리가 필요하죠.
하나의 거대한 Store를 만들면 코드 탐색이 어렵고, 여러 개발자가 동시에 작업할 때 충돌이 발생하며, 번들 크기도 불필요하게 커집니다. 관심사의 분리(Separation of Concerns) 원칙을 지키기 위해서는 Store를 적절히 나누는 것이 중요합니다.
바로 이럴 때 필요한 것이 Multiple Stores 패턴입니다. 도메인별로 Store를 분리하고, 필요할 때 서로 참조하며, 각 Store가 독립적으로 동작하면서도 협력할 수 있는 구조를 만듭니다.
개요
간단히 말해서, Multiple Stores는 애플리케이션의 상태를 논리적 단위로 분할하는 아키텍처 패턴입니다. 각 Store는 하나의 책임만 가지며, 필요시 다른 Store를 import하여 사용합니다.
Store 분리의 기준은 도메인 주도 설계(Domain-Driven Design)를 따르는 것이 좋습니다. 사용자 관련 기능은 useUserStore, 상품 관련은 useProductStore, 주문 관련은 useOrderStore처럼 비즈니스 도메인별로 나눕니다.
예를 들어, 전자상거래 앱에서는 인증, 카탈로그, 장바구니, 결제를 각각 독립된 Store로 관리하면 코드베이스가 명확해집니다. 기존의 모놀리식 Store 구조와 달리, Multiple Stores는 각 모듈이 독립적으로 개발, 테스트, 배포될 수 있습니다.
한 Store의 변경이 다른 Store에 의도치 않은 영향을 주지 않으며, 코드 리뷰와 유지보수가 훨씬 쉬워집니다. Multiple Stores의 핵심 특징은 느슨한 결합과 높은 응집도입니다.
각 Store는 자신의 도메인 로직에만 집중하지만, 다른 Store를 import하여 필요한 데이터나 기능을 활용할 수 있습니다. 순환 참조만 주의하면 Store 간 협력이 자유롭게 가능합니다.
코드 예제
// stores/auth.js
import { defineStore } from 'pinia'
export const useAuthStore = defineStore('auth', {
state: () => ({
user: null,
token: null
}),
getters: {
isAuthenticated: (state) => !!state.token
},
actions: {
async login(credentials) {
// 로그인 로직...
this.user = { id: 1, name: '홍길동' }
this.token = 'dummy-token'
},
logout() {
this.user = null
this.token = null
}
}
})
// stores/cart.js
import { defineStore } from 'pinia'
import { useAuthStore } from './auth'
export const useCartStore = defineStore('cart', {
state: () => ({
items: []
}),
actions: {
addItem(product) {
// ✅ 다른 Store 참조하기
const authStore = useAuthStore()
if (!authStore.isAuthenticated) {
throw new Error('로그인이 필요합니다')
}
this.items.push(product)
},
clearCart() {
this.items = []
}
}
})
설명
이것이 하는 일: 이 코드는 인증 Store와 장바구니 Store를 분리하고, 장바구니에서 인증 상태를 확인하는 Store 간 협력 패턴을 보여줍니다. 첫 번째로, useAuthStore는 사용자 인증과 관련된 모든 로직을 담당합니다.
user와 token state, 로그인 여부를 확인하는 isAuthenticated getter, 그리고 login/logout actions가 있습니다. 이 Store는 다른 Store에 의존하지 않고 독립적으로 동작하며, 인증이라는 하나의 책임만 가집니다.
SRP(Single Responsibility Principle)를 따르는 좋은 예시입니다. 그 다음으로, useCartStore는 장바구니 관리를 담당하지만, 인증이 필요한 기능에서는 useAuthStore를 참조합니다.
addItem action 내부에서 const authStore = useAuthStore()로 인증 Store를 가져오고, authStore.isAuthenticated로 로그인 상태를 확인합니다. 로그인하지 않은 사용자가 상품을 추가하려 하면 에러를 throw하여 적절히 처리할 수 있습니다.
이 패턴의 중요한 점은 action 내부에서 다른 Store를 호출한다는 것입니다. setup 함수나 컴포넌트 최상위에서만 사용하는 것이 아니라, Store의 actions와 getters 안에서도 자유롭게 다른 Store를 참조할 수 있습니다.
다만 순환 참조(A가 B를 참조하고 B도 A를 참조)는 피해야 하며, 의존성 방향을 명확히 유지해야 합니다. Store 분리의 이점은 명확합니다.
authStore를 변경해도 cartStore의 코드는 수정할 필요가 없고(인터페이스만 유지되면), 각 Store를 독립적으로 테스트할 수 있으며, 필요한 Store만 선택적으로 로드하여 번들 크기를 최적화할 수도 있습니다. 여러분이 이 코드를 사용하면 대규모 애플리케이션에서도 상태 관리 코드를 깔끔하게 유지할 수 있습니다.
새로운 기능을 추가할 때 어느 Store에 코드를 넣어야 할지 명확하고, 팀원들과 협업할 때도 각자 다른 Store를 담당하여 병렬로 작업할 수 있습니다. 또한 특정 Store만 핫 리로드되므로 개발 경험도 향상됩니다.
실전 팁
💡 Store 간 의존성은 단방향으로 유지하세요. 계층 구조를 만들어 하위 Store가 상위 Store를 참조하는 패턴이 안전합니다
💡 여러 Store에서 공통으로 사용하는 유틸리티는 별도의 composable이나 helper 함수로 분리하세요
💡 Store가 너무 많아지면 폴더 구조로 그룹화하세요: stores/user/, stores/product/, stores/order/
💡 Store 간 통신이 복잡해지면 이벤트 버스나 pub-sub 패턴을 고려하되, 과도한 추상화는 피하세요
💡 TypeScript를 사용한다면 각 Store의 타입을 export하여 다른 Store에서 타입 안정성을 확보하세요: export type CartStore = ReturnType<typeof useCartStore>
7. Pinia_Plugins_활용하기
시작하며
Pinia의 기본 기능만으로도 충분하지만, 실무에서는 추가적인 요구사항이 생깁니다. 상태를 로컬 스토리지에 자동 저장하거나, 모든 action 호출을 로깅하거나, API 응답을 자동으로 캐싱하는 등의 횡단 관심사(Cross-cutting Concerns)를 처리해야 하죠.
이런 기능을 각 Store마다 반복적으로 구현하면 코드 중복이 심해지고, 일관성을 유지하기 어렵습니다. 또한 나중에 로깅 방식을 변경하려면 모든 Store를 수정해야 하는 번거로움이 있습니다.
바로 이럴 때 필요한 것이 Pinia Plugins입니다. Plugin은 모든 Store에 자동으로 적용되는 확장 기능으로, 공통 로직을 중앙화하고 재사용 가능한 기능을 만들 수 있게 해줍니다.
개요
간단히 말해서, Pinia Plugin은 모든 Store에 추가 기능을 주입하는 함수입니다. Store가 생성될 때 자동으로 실행되어 새로운 속성이나 메서드를 추가하거나, 기존 동작을 수정할 수 있습니다.
Plugin의 주요 사용 사례는 지속성(Persistence), 디버깅, 동기화, 그리고 부가 기능 추가입니다. 예를 들어, 상태를 localStorage에 자동 저장하는 plugin을 만들면, 각 Store에서 별도로 저장 로직을 구현하지 않아도 됩니다.
페이지를 새로고침해도 상태가 유지되어 사용자 경험이 크게 향상됩니다. 기존에는 각 Store의 action에서 localStorage.setItem을 호출했다면, plugin을 사용하면 state 변경을 자동 감지하여 저장합니다.
이는 DRY(Don't Repeat Yourself) 원칙을 따르며, 코드베이스 전체의 유지보수성을 높입니다. Plugin의 핵심 특징은 확장성과 재사용성입니다.
한 번 작성한 plugin을 여러 프로젝트에서 사용할 수 있고, npm 패키지로 배포하여 커뮤니티와 공유할 수도 있습니다. pinia-plugin-persistedstate 같은 유명한 plugin들이 이미 존재하며, 직접 커스텀 plugin을 만들 수도 있습니다.
코드 예제
// plugins/piniaLogger.js
export function piniaLogger() {
return (context) => {
const { store } = context
// Store가 생성될 때 실행
console.log(`✅ Store '${store.$id}' 초기화됨`)
// 모든 action 호출을 로깅
store.$onAction(({ name, args, after, onError }) => {
const startTime = Date.now()
console.log(`🚀 Action '${name}' 시작, 인자:`, args)
after(() => {
const duration = Date.now() - startTime
console.log(`✅ Action '${name}' 완료 (${duration}ms)`)
})
onError((error) => {
console.error(`❌ Action '${name}' 실패:`, error)
})
})
// 모든 state 변경을 감지
store.$subscribe((mutation, state) => {
console.log('📝 State 변경:', mutation.type, mutation.storeId)
})
}
}
// main.js에서 plugin 등록
import { createPinia } from 'pinia'
import { piniaLogger } from './plugins/piniaLogger'
const pinia = createPinia()
pinia.use(piniaLogger())
설명
이것이 하는 일: 이 코드는 모든 Store의 action 호출과 state 변경을 자동으로 로깅하는 커스텀 plugin을 만듭니다. 디버깅과 모니터링에 유용한 실전 패턴입니다.
첫 번째로, plugin 함수는 context 객체를 받아 처리합니다. context에는 store, app, pinia, options 같은 유용한 정보가 담겨 있습니다.
store.$id는 Store의 고유 식별자로, 어떤 Store가 초기화되었는지 확인할 수 있습니다. 이 로그를 통해 애플리케이션 시작 시 어떤 Store들이 생성되는지 파악할 수 있습니다.
그 다음으로, store.$onAction은 Store의 모든 action 호출을 가로챕니다. action이 시작될 때의 시간을 기록하고, after 콜백에서 완료 시간을 계산하여 성능을 측정합니다.
이는 느린 action을 찾아 최적화하는 데 매우 유용합니다. onError 콜백은 action에서 발생한 에러를 캐치하여 로그로 남기며, 에러 추적 서비스(Sentry, LogRocket)와 통합할 수도 있습니다.
store.$subscribe는 state의 모든 변경을 감지합니다. mutation.type은 변경 방식(direct, patch object, patch function)을 나타내며, 어떻게 state가 변경되었는지 추적할 수 있습니다.
이 정보를 활용하면 undo/redo 기능을 구현하거나, 변경 이력을 서버에 전송하여 동기화하는 기능을 만들 수 있습니다. plugin을 등록하는 방법은 간단합니다.
createPinia()로 Pinia 인스턴스를 만들고, pinia.use()로 plugin을 추가한 후, Vue 앱에 install하면 됩니다. 여러 plugin을 등록할 수 있으며, 등록된 순서대로 실행됩니다.
여러분이 이 코드를 사용하면 개발 중에 모든 상태 변경과 action 호출을 콘솔에서 확인할 수 있어 디버깅이 쉬워집니다. 프로덕션에서는 조건부로 plugin을 비활성화하거나, 실제 모니터링 서비스로 데이터를 전송하도록 수정할 수 있습니다.
또한 이 패턴을 확장하여 localStorage 저장, API 동기화, 성능 측정 등 다양한 기능을 추가할 수 있습니다.
실전 팁
💡 개발 환경에서만 plugin을 활성화하려면 if (import.meta.env.DEV) pinia.use(...) 조건을 추가하세요
💡 지속성이 필요한 Store만 선택적으로 저장하려면 Store options에 persist: true 같은 플래그를 추가하고 plugin에서 확인하세요
💡 $subscribe의 두 번째 인자로 { detached: true }를 전달하면 컴포넌트가 언마운트되어도 구독이 유지됩니다
💡 plugin에서 추가한 속성은 TypeScript에서 타입이 자동 추론되지 않으므로, 모듈 보강(module augmentation)으로 타입을 선언해야 합니다
💡 이미 검증된 plugin을 사용하는 것도 좋은 선택입니다: pinia-plugin-persistedstate(지속성), pinia-plugin-history(undo/redo) 등을 활용하세요