우리 회사가 서비스 중인 플랫폼 사이트는 현재 Nuxt2로 구축되어 있는데, 유지보수를 좀 더 효율적으로 진행하기 위해 Nuxt3로의 마이그레이션을 결정했다. 이 사이트는 데이터와 관련된 대부분의 로직을 Vuex로 관리하고 있다. Nuxt2
는 상태 관리 라이브러리로 Vuex
를 기본 지원하지만, Nuxt3
는 Vuex 대신 Pinia
를 기본 지원한다. 따라서 상태 관리 라이브러리를 먼저 마이그레이션해야, 그 후에 다른 마이그레이션 작업을 진행할 수 있는 상황이었다.
상태 관리 라이브러리는 모든 컴포넌트의 상태를 중앙에서 통제할 수 있는 저장소라고 볼 수 있다. 이를 사용함으로써 애플리케이션 내 다양한 컴포넌트 간에 상태를 공유하고, 변화를 예측 가능한 방식으로 관리할 수 있게 된다는 것이 큰 장점이다. 컴포넌트 구조가 복잡해지면서 상위 컴포넌트에서 하위 컴포넌트로 props를 전달하는 과정이 점점 더 복잡해지고 관리하기 어려워지기 마련이다. 바로 이럴 때 상태 관리 라이브러리가 큰 역할을 한다.
Vuex와 Pinia 같은 라이브러리는 state, actions, getters 등의 개념을 활용해 애플리케이션의 상태를 체계적으로 관리할 수 있도록 돕는다.
Vuex는 복잡한 애플리케이션의 컴포넌트 상태를 중앙에서 효율적으로 관리한다. 상태 변경을 mutations와 actions을 통해 관리함으로써, 상태 변경을 보다 명확하고 예측 가능하게 만든다는 특징을 갖고 있다.
Nuxt2는 기본적으로 Vuex를 지원하므로 /store
폴더를 생성하여 각각의 store를 파일로 만들어 놓으면, 별도로 createStore()를 사용하여 store를 생성할 필요가 없다. Nuxt는 해당 폴더 내의 모든 .js 파일을 자동으로 Vuex 모듈로 처리하고, 이들을 하나의 Vuex 스토어로 조합한다.
아래의 Vue 코드는 사용자로부터 유저 id를 입력받아서 api로 유저 정보를 호출하는 코드이다. 만약 사용자가 입력한 유저 id가 짝수라면 유저의 상세 정보 또한 같이 호출한다.
// Vuex 스토어 설정
// store/user.js
import axios from 'axios'
const BASE_URL = 'http://localhost:3000'
export const state = () => ({
userData: null, // 사용자 정보
userId: 0, // 사용자 ID
})
export const mutations = {
setUserData(state, userData) {
state.userData = userData
},
setUserId(state, userId) {
state.userId = userId
},
}
export const actions = {
async fetchUserDataById({ commit, state }) {
if (state.userId) {
try {
const response = await axios.get(
`${BASE_URL}/api/users/${state.userId}`
)
// 유저 정보 저장 및 상세 유저 정보 가져오기 로직 실행
commit('setUserData', response.data)
if (state.userData && state.userData.id % 2 === 0) {
await this.dispatch('user/fetchUserDetail')
}
return state.data
} catch (error) {
console.error('Failed to fetch user data:', error)
}
} else {
console.error('User ID is not provided.')
}
},
async fetchUserDetail({ commit, state }) {
// 유저 정보의 id가 짝수라면 추가적인 유저 정보를 가져옴
console.log('User data fetched:', state.userData)
try {
const response = await axios.get(
`${BASE_URL}/api/users/detail?userId=${state.userData.id}`
)
commit('setUserData', {
...state.userData,
...response.data,
})
console.log('Additional user data fetched:', response.data)
} catch (error) {
console.error('Failed to fetch additional user data:', error)
}
},
}
<!-- pages/user-vuex.vue -->
<template>
<div>
<input
:value="userId"
@input="setUserId($event.target.value)"
placeholder="Enter user ID"
type="number"
/>
<button @click="fetchUserDataById">Fetch User Data</button>
<div v-if="userData">
<p>userName : {{ userData.name }}</p>
<p>userId: {{ userData.id }}</p>
<template v-if="userData.id % 2 === 0">
<p>email : {{ userData.email }}</p>
<p>gender {{ userData.gender }}</p>
</template>
</div>
</div>
</template>
<script>
import { mapState, mapMutations, mapActions } from 'vuex'
export default {
computed: {
...mapState('user', ['userId', 'userData']), // store/user.js에 있는 store의 userId
},
methods: {
// store/user.js에 있는 store의 setUserId Mutation을 등록
...mapMutations('user', ['setUserId']),
// store/user.js에 있는 store의 fetchUserDataById Action을 등록
...mapActions('user', ['fetchUserDataById']),
},
}
</script>
위 컴포넌트는 store/user.js에 정의된 store의 state, mutations, actions을 Vuex가 제공하는 mapState, mapMutations, mapActions를 통해 각각 매핑하고 있다.
위의 구조는 user 관련 데이터의 fetching, 데이터 자체(state), 그리고 파라미터(params)를 Vuex를 통해 모두 관리하는 방식이다. 이 구조에서는 다른 컴포넌트들이 Vuex를 구독하기만 하면, user 데이터에 접근하거나 이를 수정하는 것이 간편해진다는 장점이 있다.
하지만, 한 컴포넌트나 라이브러리에 모든 로직을 집중시키는 것은 기능을 추가하거나 수정 할 때, side-effect 발생률이 높아져 유지보수가 어렵다는 문제점이 존재한다.
또한 Vuex가 Vue의 반응성 시스템을 사용해 상태 변화를 감지하는 점을 고려했을 때, store 내 로직이 과도하게 많거나 상태가 자주 업데이트되는 경우, 애플리케이션의 전체적인 성능 저하를 초래할 수 있다는 점도 주목해야 한다.
꼭 모든 로직을 store에 집중할 필요는 없다. 하지만 현재 회사에서 사용하고 있는 코드 구조는 대체로 이런 방식을 따르고 있으며, 앞서 언급한 여러 문제점을 안고 있다. 이에 따라, Pinia로의 마이그레이션 과정에서 로직을 적절히 분리하는 작업이 꼭 필요한 상황이다.
Pinia는 Vuex의 대안으로 개발되었으며, 보다 단순하고 유연한 사용법을 제공한다. Vuex에서는 여러 개의 모듈을 사용할 때 상호작용을 관리하기 위해 namespace
기반의 접근 방식을 사용하는데, Pinia는 function
기반의 접근 방식을 사용하여 애플리케이션의 상태 관리를 간소화할 수 있다. 아래의 코드는 moduleA에서 moduleB의 내용을 업데이트하는 코드를 비교한 것이다.
// Vuex
// store/moduleA.js
export default {
namespaced: true,
state: {
name: 'Module A'
},
mutations: {
updateName(state, newName) {
state.name = newName;
}
},
actions: {
updateOtherModuleName({ commit }, newName) {
// namespace 사용
commit('b/updateName', newName, { root: true });
}
}
}
// store/moduleB.js
export default {
namespaced: true,
state: {
name: 'Module B'
},
mutations: {
updateName(state, newName) {
state.name = newName;
}
}
}
Vuex에서는 moduleB의 name을 변경하기 위해 commit() 함수에 b/updateName이라는 namespace를 사용하고 있다. 만약에 /store 폴더 구조가 복잡해지면 이러한 namespace는 길어질 수 밖에 없다. 또한 state의 변경을 위해선 mutations, actions을 직접적으로 호출하지 않고 별도로 commit 및 dispath를 사용하여야 하는데 이 또한 가독성을 해친다.
// Pinia
// store/moduleA.js
import { defineStore } from 'pinia'
import { useModuleBStore } from './moduleB'
export const useModuleAStore = defineStore('moduleA', {
state: () => ({
name: 'Module A'
}),
actions: {
updateOtherModuleName(newName) {
const moduleB = useModuleBStore();
moduleB.updateName(newName);
}
}
})
// store/moduleB.js
import { defineStore } from 'pinia'
export const useModuleBStore = defineStore('moduleB', {
state: () => ({
name: 'Module B'
}),
actions: {
updateName(newName) {
this.name = newName;
}
}
})
그에 반해 Pinia는 useModuleBStore를 import하고, 해당 함수를 직접 호출하므로 /store의 구조가 복잡해져도 큰 문제가 없다.
아래는 사용자로부터 유저 id를 입력받아서 api로 유저 정보를 호출하는 Vuex 코드를 Pinia로 변환하면서 로직을 분리한 결과이다.
// /api/userFetching.js
import axios from 'axios'
const BASE_URL = 'http://localhost:3000'
export const fetchUserById = (userId) => {
return axios.get(`${BASE_URL}/api/users/${userId}`)
}
export const fetchUserDetailById = (userId) => {
return axios.get(`${BASE_URL}/api/users/detail?userId=${userId}`)
}
// store/userStore.js
import { defineStore } from 'pinia'
import { fetchUserById, fetchUserDetailById } from '@/api/userFetching'
export const useUserStore = defineStore('userStore', {
state: () => ({
userData: null,
}),
actions: {
async loadUserData(userId) {
if (userId) {
try {
const response = await fetchUserById(userId)
this.userData = response.data
console.log('User data fetched:', response.data)
// 유저 정보의 id가 짝수라면 추가적인 유저 정보를 가져옴
if (userId % 2 === 0) {
await this.loadUserDetail(userId)
}
return this.userData
} catch (error) {
console.error('Failed to fetch user data:', error)
}
} else {
console.error('User ID is not provided.')
}
},
async loadUserDetail(userId) {
try {
const response = await fetchUserDetailById(userId)
this.userData = {
...this.userData,
...response.data,
}
console.log('Additional user data fetched:', response.data)
} catch (error) {
console.error('Failed to fetch additional user data:', error)
}
},
},
})
<!-- pages/user-pinia.vue -->
<template>
<div>
<input v-model="userId" placeholder="Enter user ID" type="number" />
<button @click="loadUserData">Fetch User Data</button>
<div v-if="userData">
<p>userName : {{ userData.name }}</p>
<p>userId: {{ userData.id }}</p>
<template v-if="userData.id % 2 === 0">
<p>email : {{ userData.email }}</p>
<p>gender {{ userData.gender }}</p>
</template>
</div>
</div>
</template>
<script>
import { useUserStore } from '@/store/userStore'
export default {
data() {
return {
userData: null,
userId: 0,
}
},
methods: {
async loadUserData() {
const userStore = useUserStore()
this.userData = await userStore.loadUserData(this.userId)
},
},
}
</script>
Pinia로 마이그레이션 하면서 파라미터는 로컬 상태 값으로, 데이터 fetching은 다른 컴포넌트에서도 재사용이 가능하도록 별도의 함수로 분리하였다. store에는 데이터 fetching 후 받아온 데이터만 저장하도록 하였는데, 그 이유는 다음과 같다.
이 구성을 통해, 스토어와 컴포넌트 각각의 책임이 더욱 명확해지며, 애플리케이션의 전체적인 아키텍처가 개선되었다.
Vuex 공식문서
Pinia 공식문서
Exploring Vue.js State Management: Pinia vs. Vuex
Pinia vs Vuex - Why Pinia wins