Props와 emit만으로 데이터를 주고받기 어려워지는 시점, 그리고 이를 해결하는 Vue 공식 상태 관리 라이브러리 Pinia에 대해 학습한 내용을 정리한다.
Props는 훌륭한 도구지만, 컴포넌트 구조가 5단계 이상 깊어진다면 문제가 생긴다. 데이터를 전달하기 위해 모든 중간 컴포넌트를 거쳐야 하는 건 너무나 복잡하다.
emit 이벤트를 낳아 유지보수를 어렵게 한다.→ 이 문제들을 해결하기 위해, 모든 컴포넌트가 공유하는 중앙 데이터 저장소(store) 를 만드는 Pinia 상태 관리 기법을 사용한다.
상태(State), 뷰(View), 기능(Actions)은 "단방향 데이터 흐름" 으로 상호작용한다.
| 구성요소 | 설명 |
|---|---|
| 상태(State) | 앱 구동에 필요한 기본 데이터 |
| 뷰(View) | 상태를 선언적으로 매핑하여 시각화 |
| 기능(Actions) | 뷰에서 사용자 입력에 대해 반응적으로 상태를 변경할 수 있게 정의된 동작 |
<template>
<!-- 뷰(View) -->
<div>{{ count }}</div>
</template>
<script setup>
import { ref } from 'vue'
// 상태(State)
const count = ref(0)
// 기능(Actions)
const increment = function () {
count.value++
}
</script>
흐름: 기능(Action) 으로 상태를 변경 → 상태(State) 가 뷰(View) 에 반영 → 뷰에서 click 등의 입력이 다시 기능을 호출
여러 컴포넌트가 상태를 공유할 때 단순성이 깨진다.
1) 여러 뷰가 동일한 상태에 종속되는 경우
props로 여러 컴포넌트에 전달하는 방법을 쓴다.
props: 부모 컴포넌트가 자식 컴포넌트에게 데이터를 전달하는 통로
2) 서로 다른 뷰의 기능이 동일한 상태를 변경시켜야 하는 경우
emit)된 이벤트를 통해 상태의 여러 복사본을 변경 및 동기화하게 된다.
emit: 자식 컴포넌트가 부모에게 알리는 신호
우리는 Setup Stores(Composition API 형식) 를 따른다.
Option Stores(Options API 형식)로는 하지 않는다.
Pinia — Vue 공식 상태 관리 라이브러리
Vite 프로젝트 빌드 시 Pinia 라이브러리를 추가한다.
$ npm create vue@latest
프로젝트 생성 과정에서 features 선택 시 Pinia (state management) 를 스페이스바로 선택한다.
◆ Select features to include in your project:
□ TypeScript
□ JSX Support
□ Router (SPA development)
■ Pinia (state management) ← 선택
□ Vitest (unit testing)
□ End-to-End Testing
□ ESLint (error prevention)
□ Prettier (code formatting)
src 하위에 stores 폴더가 신규 생성되며, 그 안에 counter.js가 들어있다.
src
├── assets
├── components
├── stores
│ └── counter.js ← 신규 생성
├── App.vue
├── main.js
├── ...
Pinia는 다음 6가지 구성 요소로 이루어진다.
defineStore()의 반환 값(store)을 담는 변수의 이름은 use...Store 패턴을 사용하는 것을 권장 (예: useCounterStore)defineStore()의 첫 번째 인자는 애플리케이션 전체에 걸쳐 사용하는 store의 고유 ID// stores/counter.js
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', () => {
const count = ref(0)
const doubleCount = computed(() => count.value * 2)
function increment() {
count.value++
}
return { count, doubleCount, increment }
})
count)을 변경하면, 이 데이터를 사용하고 있는 모든 컴포넌트의 화면은 알아서 업데이트 됨ref()와 같은 역할을 함const count = ref(0)
ref(): 기본형 데이터를 반응형으로 만드는 Vue 함수
computed()와 똑같은 역할을 함const doubleCount = computed(() => count.value * 2)
computed(): 원본 데이터가 바뀔 때만, 값을 알아서 다시 계산하는 ref
methods와 같은 역할을 함function increment() {
count.value++
}
methods: 클릭 같은 이벤트에 반응해, 특정 로직을 실행시키는 함수
return { count, doubleCount, increment }
localStorage에 저장하고 복원해주는 플러그인<!-- App.vue -->
<script setup>
import { useCounterStore } from '@/stores/counter'
const store = useCounterStore()
// state 참조 및 변경
console.log(store.count)
const newNumber = store.count + 1
</script>
<template>
<div>
<p>state : {{ store.count }}</p>
</div>
</template>
<script setup>
import { useCounterStore } from '@/stores/counter'
const store = useCounterStore()
console.log(store.doubleCount)
</script>
<template>
<div>
<p>getters : {{ store.doubleCount }}</p>
</div>
</template>
<script setup>
import { useCounterStore } from '@/stores/counter'
const store = useCounterStore()
store.increment()
</script>
<template>
<div>
<button @click="store.increment()">+++</button>
</div>
</template>
Vue devtools의 Pinia 탭에서 state, getters 값을 실시간으로 확인할 수 있다. (예: state: 1, getters: 2)
App
├── TodoForm
└── TodoList
└── TodoListItem
App.vue 제외)src/assets 내부 파일 모두 삭제main.js에서 해당 코드 삭제 (import './assets/main.css')// main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
const app = createApp(App)
app.use(createPinia())
app.mount('#app')
TodoListItem 컴포넌트와 TodoList 컴포넌트를 작성TodoListItem 컴포넌트를 TodoList 컴포넌트에 등록<!-- TodoListItem.vue -->
<template>
<div>
TodoListItem
</div>
</template>
<!-- TodoList.vue -->
<template>
<div>
<TodoListItem />
</div>
</template>
<script setup>
import TodoListItem from '@/components/TodoListItem.vue'
</script>
TodoForm 컴포넌트 작성App 컴포넌트에 TodoList, TodoForm 컴포넌트 등록<!-- TodoForm.vue -->
<template>
<div>
TodoForm
</div>
</template>
<!-- App.vue -->
<template>
<div>
<h1>Todo Project</h1>
<TodoList />
<TodoForm />
</div>
</template>
<script setup>
import TodoForm from '@/components/TodoForm.vue'
import TodoList from '@/components/TodoList.vue'
</script>
Vue devtools의 Components 탭에서 다음 트리 구조를 확인한다.
<App>
├── <TodoList>
│ └── <TodoListItem>
└── <TodoForm>
// stores/counter.js
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', () => {
let id = 0
const todos = ref([
{ id: id++, text: '할 일 1', isDone: false },
{ id: id++, text: '할 일 2', isDone: false }
])
return { todos }
})
todos state를 참조TodoListItem을 반복하면서 개별 todo를 props로 전달<!-- TodoList.vue -->
<template>
<div>
<TodoListItem
v-for="todo in store.todos"
:key="todo.id"
:todo="todo"
/>
</div>
</template>
<script setup>
import TodoListItem from '@/components/TodoListItem.vue'
import { useCounterStore } from '@/stores/counter'
const store = useCounterStore()
</script>
v-for: 배열 형태의 특정 HTML 요소를 반복해서 그리는 방법
<!-- TodoListItem.vue -->
<template>
<div>
<input type="checkbox" name="todo-text">
<label for="todo-text">{{ todo.text }}</label>
<button>삭제</button>
</div>
</template>
<script setup>
defineProps({
todo: Object
})
</script>
todos 목록에 todo를 생성 및 추가하는 addTodo 액션을 정의한다.
// stores/counter.js
const addTodo = function (todoText) {
todos.value.push({
id: id++,
text: todoText,
isDone: false
})
}
return { todos, addTodo }
TodoForm에서 실시간으로 입력되는 사용자 데이터를 양방향 바인딩하여 반응형 변수로 할당한다.
<!-- TodoForm.vue -->
<template>
<div>
<form>
<input type="text" v-model="todoText">
<input type="submit">
</form>
</div>
</template>
<script setup>
import { ref } from 'vue'
const todoText = ref('')
</script>
v-model: 폼 요소와 데이터를 양방향으로 묶어, 실시간으로 동기화
submit 이벤트가 발생했을 때 사용자 입력 텍스트를 인자로 전달하여 store에 정의한 addTodo 액션 메서드를 호출한다.
<!-- TodoForm.vue -->
<template>
<div>
<form @submit.prevent="createTodo(todoText)">
<input type="text" v-model="todoText">
<input type="submit">
</form>
</div>
</template>
<script setup>
import { useCounterStore } from '@/stores/counter'
const store = useCounterStore()
const createTodo = function (todoText) {
store.addTodo(todoText)
}
</script>
form 요소를 선택하여 todo 입력 후 input 데이터를 초기화할 수 있도록 처리한다.
<!-- TodoForm.vue -->
<template>
<form @submit.prevent="createTodo(todoText)" ref="formElem">
<input type="text" v-model="todoText">
<input type="submit">
</form>
</template>
<script setup>
const formElem = ref(null)
const createTodo = function (todoText) {
store.addTodo(todoText)
formElem.value.reset()
}
</script>
추가 버튼을 누르면 todos 배열에 새로운 항목이 추가되고 화면에 반영되는 것을 확인한다.
store의
addTodo액션을 직접 호출해도 되지만(@submit.prevent="store.addTodo(todoText)"), 굳이createTodo를 만들어서 호출하는 이유는
addTodo호출 전후로 추가 로직을 작성할 수 있기 때문이다. (예: 위의formElem.value.reset())
todos 목록에서 특정 todo를 삭제하는 deleteTodo 액션을 정의한다.
// stores/counter.js
const deleteTodo = function () {
console.log('delete')
}
return { todos, addTodo, deleteTodo }
deleteTodo 메서드 호출deleteTodo 메서드는 전달받은 인자를 store의 deleteTodo에게 전달하며 호출<!-- TodoListItem.vue -->
<template>
<div>
<input type="checkbox" name="todo-text">
<label for="todo-text">{{ todo.text }}</label>
<button @click="deleteTodo(todo.id)">삭제</button>
</div>
</template>
<script setup>
import { useCounterStore } from '@/stores/counter'
const store = useCounterStore()
const deleteTodo = function (selectedId) {
store.deleteTodo(selectedId)
}
</script>
store의 deleteTodo에 삭제 로직을 구현한다.
findIndex를 활용해 하나만 찾아서 삭제filter로 삭제하지 않아도 되는 것들만 모아서 재생성인덱스를 찾은 뒤 해당 위치에서 1개만 제거한다.
// stores/counter.js
const deleteTodo = function (selectedId) {
const index = todos.value.findIndex((todo) => todo.id === selectedId)
todos.value.splice(index, 1)
}
return { todos, addTodo, deleteTodo }
삭제하지 않아도 되는 것들만 모아서 재생성한다.
// stores/counter.js
const deleteTodo = function (selectedId) {
todos.value = todos.value.filter(todo => todo.id !== selectedId)
}
return { todos, addTodo, deleteTodo }
isDone 속성을 변경하여 todo의 완료 유무를 처리하기isDone 속성을 변경하는 updateTodo 액션 정의// stores/counter.js
const updateTodo = function () {
console.log('update')
}
return { todos, addTodo, deleteTodo, updateTodo }
흐름: 체크박스 클릭 →
isDoneref 변경 →watch감지 →store.updateTodo호출
<!-- TodoListItem.vue -->
<div>
<input type="checkbox" name="todo-text" v-model="isDone">
<label for="todo-text">{{ todo.text }}</label>
<button @click="deleteTodo(todo.id)">삭제</button>
</div>
// TodoListItem.vue
const isDone = ref(props.todo.isDone)
// TodoListItem.vue
import { ref, watch } from 'vue'
watch(isDone, () => {
store.updateTodo(props.todo.id)
})
store의 updateTodo에 수정 로직을 구현한다.
forEach를 활용해 하나만 찾아서 수정map을 활용해 수정해야 하는 항목을 찾아서 수정하고, 수정한 객체를 사용순회 도중 일치 항목을 찾으면 직접 속성을 변경한다.
// stores/counter.js
const updateTodo = function (selectedId) {
todos.value.forEach((todo) => {
if (todo.id === selectedId) {
todo.isDone = !todo.isDone
}
})
}
return { todos, addTodo, deleteTodo }
모든 항목을 순회하며, ID가 일치하면 새 객체를 리턴한다.
// stores/counter.js
const updateTodo = function (selectedId) {
todos.value = todos.value.map((todo) => {
if (todo.id === selectedId) {
todo.isDone = !todo.isDone
}
return todo
})
}
todo 객체의 isDone 속성 값에 따라 스타일 바인딩을 적용한다. (완료 시 취소선)
<!-- TodoListItem.vue -->
<label
for="todo-text"
:class="{ 'is-done': todo.isDone }"
>{{ todo.text }}</label>
<style scoped>
.is-done {
text-decoration: line-through;
}
</style>
| 방식 | 설명 |
|---|---|
| In-place 방식 (하나만 수정/삭제) | 배열 전체 재생성 없이 필요한 항목만 바로 수정 또는 제거 |
| 전체 배열 재생성 방식 | 배열을 순회하면서 특정 조건을 만족하지 않으면 누락하거나, 필요한 변경 사항만 반영해 새로운 배열을 만든 뒤 기존 배열에 재할당 |
- 두 가지 접근 모두 성능과 가독성 면에서 큰 차이가 없는 경우가 많지만, 프로젝트나 팀 컨벤션에 따라 방식이 달라질 수 있음
- 중요한 것은, 무엇을 의도하고 있는지(단일 항목 수정/삭제 vs. 전체 재생성)를 명확히 알고 선택하는 것
todos 배열의 길이 값을 반환하는 doneTodosCount 함수를 작성한다.
// stores/counter.js
const doneTodosCount = computed(() => {
const doneTodos = todos.value.filter((todo) => todo.isDone)
return doneTodos.length
})
return { todos, addTodo, deleteTodo, updateTodo, doneTodosCount }
App 컴포넌트에서 doneTodosCount getter를 참조한다.
<!-- App.vue -->
<template>
<div>
<h1>Todo Project</h1>
<h2>완료된 Todo 개수 : {{ store.doneTodosCount }}</h2>
<TodoList />
<TodoForm />
</div>
</template>
<script setup>
import { useCounterStore } from '@/stores/counter'
const store = useCounterStore()
</script>
Local Storage — 브라우저 내에 key-value 쌍을 저장하는 웹 스토리지 객체
쿠키: 서버가 사용자를 기억하려고, 브라우저에 남기는 정보
$ npm i pinia-plugin-persistedstate
// main.js
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
const app = createApp(App)
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
// app.use(createPinia())
app.use(pinia)
app.mount('#app')
defineStore()의 3번째 인자로 관련 객체를 추가한다.
// stores/counter.js
export const useCounterStore = defineStore('counter', () => {
// ...
return { todos, addTodo, deleteTodo, updateTodo, doneTodosCount }
}, { persist: true })
counter 키로 todos state가 저장되는 것을 확인pass props, emit event를 함께 사용하여 애플리케이션을 구성해야 한다.상황에 따라 적절하게 사용하는 것이 필요
SPA(Single Page Application): 하나의 페이지 안에서, 내용만 바꿔가며 보여주는 웹 앱
store / state(ref) / getters(computed) / actions(methods) / 반환 값 / pluginaddTodo(push) / deleteTodo(findIndex+splice or filter) / updateTodo(forEach or map)computed getter 활용pinia-plugin-persistedstate + { persist: true }로 Local Storage 자동 저장