목표
샘플 프로젝트의 작은 Vuex 모듈 하나를 Pinia로 실제로 옮겨보고,
컴포넌트에서는 mapXXX 대신 store hook(useAuthStore 등)을 써보는 것이 목표입니다.
Vue 2 기반 샘플 프로젝트, Vue 3 + compat build 적용,
로그인용 auth Vuex 모듈, Router 3, Axios까지 구성된 상태라고 가정합니다.
준비 상태 점검
현재 샘플의 상태 관리는 이런 느낌입니다.
2025.12.01 - [Web/Vue] - Vue 3 마이그레이션 연습용 샘플 Vue 2 프로젝트 준비하기
// src/store/index.js
import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex)
export default new Vuex.Store({
state: {
count: 0
},
mutations: {
increment(state) {
state.count++
}
},
actions: {
increment({ commit }) {
commit('increment')
}
},
getters: {
doubleCount(state) {
return state.count * 2
}
}
})
// src/store/modules/auth.js
const state = {
user: null,
token: null,
loading: false,
error: null
}
const mutations = {
LOGIN_REQUEST(state) {
state.loading = true
state.error = null
},
LOGIN_SUCCESS(state, { user, token }) {
state.loading = false
state.error = error
},
LOGOUT(state) {
state.user = null
state.token = null
}
}
const actions = {
async login({commit}, {email, password}) {
commit('LOGIN_REQUEST')
try {
const res = await this._vm.$axios.post('/login', { email, password })
const { user, token } = res.data
commit('LOGIN_SUCCESS', { user, token })
} catch (e) {
commit('LOGIN_FAILURE', '로그인에 실패했습니다.')
throw e
}
},
logout({ commit }) {
commit('LOGOUT')
}
}
const getters = {
isLoggedIn(state) {
return !!state.token
},
currentUser(state) {
return state.user
},
authError(state) {
return state.error
},
authLoading(state) {
return state.loading
}
}
export default {
namespaced: true,
state,
mutations,
actions,
getters
}
그리고 컴포넌트에서는 mapGetters, mapActions로 접근했습니다.
Pinia 설치
먼저 Pinia를 설치합니다.
npm install pinia
이제 main.js에서 Pinia를 앱에 연결합니다.
// src/main.js (Vue 3 compat 상태)
import { createApp } from 'vue'
import { createPinia } from 'pinia' // pinia
import App from './App.vue'
import router from './rotuer'
import store from './store' // 기존 Vuex
const app= createApp(App)
const pinia = createPinia() // pinia
app.use(router)
app.use(store)
app.use(pinia) // Vuex와 Pinia를 당분간 공존시켜도 됨
app.mount('#app')
이번 글에서는 Vuex를 바로 지우지 않고,
Vuex + Pinia가 같이 있는 상태에서 auth 모듈만 Pinia로 하나 옮겨볼 겁니다.
Pinia auth 스토어 만들기
src/stores 폴더를 하나 만들고, auth.js 파일을 추가합니다.
// src/stores/auth.js
import { defineStore } from 'pinia'
import axios from 'axios' // 샘플용: 실제로는 공용 axios 인스턴스 import 권장
export const useAuthStore = defineStore('auth', {
state: () => ({
user: null,
token: null,
loading: false,
error: null
}),
getters: {
isLoggedIn: (state) => !!state.token,
currentUser: (state) => state.user
},
actions: {
async login({ email, password }) {
this.loading = true
this.error = null
try {
// 샘플용 API 호출 (실제 API 엔드포인트로 교체)
const res = await axios.post('/login', {email, password})
const { user, token } = res.data
this.user = user
this.token = token
} catch (e) {
this.error = '로그인에 실패했습니다.'
throw e
} finally {
this.loading = false
}
},
logout() {
this.user = null
this.token = null
}
}
})
- state는 함수를 반환하는 형태(state: () => ({ ... }))
- getters는 state를 받아서 계산된 값 계산
- actions 안에서는 this가 store 인스턴스 → this.user, this.login()처럼 사용
이제부터 컴포넌트에서는 useAuthStore()를 호출해서 이 스토어에 접근합니다.
(Vuex 쪽 auth 모듈은 잠깐 그대로 두고, 로그인 화면을 하나씩 갈아탈 예정입니다.)
Login 컴포넌트: mapActions → useAuthStore
기존 Login.vue는 이런 형태였습니다.
<!-- src/views/Login.vue -->
<template>
<section class="login">
<h2>Login</h2>
<form @submit.prevent="onSubmit">
<div>
<label>Email</label>
<input v-model="email" type="email" required />
</div>
<div>
<label>Password</label>
<input v-model="password" type="password" required />
</div>
<p v-if="error" class="error">{{ error }}</p>
<button type="submit" :disabled="loading">
{{ loading ? '로그인 중...' : '로그인' }}
</button>
</form>
</section>
</template>
<script>
import { mapGetters, mapActions } from 'vuex'
export default {
name: 'Login',
data() {
return {
email: '',
password: ''
}
},
computed() {
...mapGetters('auth', ['authError', 'authLoading']),
error() {
return this.authError
},
loading() {
return this.authLoading
}
},
methods: {
...mapActions('auth', ['login']),
async onSubmit() {
try {
await this.login({
email: this.email,
password: this.password
})
this.$router.push({ name: 'dashboard' })
} catch (e) {
// 에러는 이미 store에 반영됨
}
}
}
}
</script>
이걸 Pinia + <script setup> 스타일로 바꾸면 아래처럼 됩니다.
<!-- src/views/Login.vue (변경 후, Pinia 사용 예시) -->
<template>
<section class="login">
<h2>Login</h2>
<form @submit.prevent="onSubmit">
<div>
<label>Email</label>
<input v-model="email" type="email" required />
</div>
<div>
<label>Password</label>
<input v-model="password" type="password" required />
</div>
<p v-if="error" class="error">{{ error }}</p>
<button type="submit" :disabled="loading">
{{ loading ? '로그인 중...' : '로그인' }}
</button>
</form>
</section>
</template>
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { storeToRefs } from 'pinia'
import { useAuthStore } from '@/stores/auth'
const router = useRouter()
const auth = useAuthStore()
// storeToRefs를 쓰면 state/getter를 반응형 참조로 꺼낼 수 있음
const { error, loading } = storeToRefs(auth)
const email = ref('')
const password = ref('')
const onSubmit = async () => {
try {
await auth.login({
email: email.value,
password: password.value
})
router.push({ name: 'dashboard' })
} catch (e) {
// 에러는 이미 store.error에 반영됨
}
}
</script>
- mapGetters, mapActions 삭제
- useAuthStore()로 스토어 인스턴스를 가져옴
- storeToRefs(auth)로 state/getter를 바로 ref로 전개
- 액션 호출은 auth.login(...)처럼 메서드 호출 느낌으로 작성
이제 이 컴포넌트는 Vuex에 전혀 의존하지 않는 상태가 됩니다.
Dashboard 컴포넌트: mapGetters → storeToRefs
Dashboard도 비슷하게 바꿀 수 있습니다.
<!-- src/views/Dashboard.vue (Pinia 버전) -->
<template>
<section class="dashboard">
<h2>Dashboard</h2>
<p v-if="user">환영합니다. {{ user.name }}님</p>
<p v-else>로그인 정보가 없습니다.</p>
<button @click="onLogout">로그아웃</button>
</section>
</section>
</template>
<script setup>
import { storeToRefs } form 'pinia'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const router = useRouter()
const auth = useAuthStore()
const { currentUser: user } = storeToRefs(auth)
const onLogout = () => {
auth.logout()
router.push({ name: 'login' })
}
</script>
이제 Dashboard.vue 역시 Pinia만 보고 동작합니다.
Vuex와 Pinia를 한동안 같이 쓰는 전략
지금 상태는 auth 기능은 Pinia store를 사용하고,
src/store/index.js 아래 나머지 Vuex 모듈이 있었다면 그대로 존재하는 상태입니다.
즉, Vuex와 Pinia가 한 프로젝트 안에서 공존하는 구조입니다.
실전에서는 보통 아래 순서대로 진행합니다.
- 새 기능/페이지부터 Pinia로 작성
- Vuex 모듈을 하나씩 defineStore로 옮김
- 더 이상 참조되지 않은 Vuex 모듈은 삭제
- 최종적으로 app.use(store)(Vue) 제거
Compat Build가 켜져 있는 동안은 이렇게 섞어서 천천히 옮겨도 됩니다.
정리
- Pinia는 defineStore('id', { state, getters, actions }) 패턴으로 Vuex 모듈을 옮기기 쉬움
- 컴포넌트에서는 mapState/mapGetters/mapActions 대신 각각 아래 패턴 사용
- const store = useXxxStore()
- const { foo } = storeToRefs(store)
- store.someAction()
- Vuex와 Pinia는 한동안 공존시켜도 되므로, 작은 모듈 하나부터 옮겨보고 점점 범위 넓히기
출처
'Framework > Vue' 카테고리의 다른 글
| Vue 2 컴포넌트를 Composition API로 바꾸는 연습 (0) | 2025.12.01 |
|---|---|
| Vue 3 Compat Build로 Vue 2 프로젝트 안전하게 버전업 (0) | 2025.12.01 |
| Vue 3 마이그레이션 연습용 샘플 Vue 2 프로젝트 준비하기 (0) | 2025.12.01 |
| Vue 2에서 Vue 3 마이그레이션 시 참고해야 할 체크리스트 (0) | 2025.12.01 |
| Vue 3 라이프사이클과 인스턴스 API 변경 정리 (0) | 2025.11.28 |