Vue.js의 새로운 상태 관리 라이브러리 Pinia

호박고구마·2022년 10월 19일
1
post-thumbnail

현재 Vue2를 기반으로 만들어진 기존 레거시 프로젝트를 Vue3로 마이그레이션 하는 작업을 진행중이다. 이 과정에서 자연스럽게 기존에 사용하던 상태 관리 라이브러리도 Vuex에서 Pinia로 변경하는 작업을 진행중이다. 그 과정에서 Pinia의 핵심을 정리해보고 Vuex와 간단하게 비교해보고자 한다.


Pinia의 등장

Pinia는 Vue3의 핵심인 Composition API에 적합한 형태로 만들어진 Vue의 상태 관리 라이브러리이다.
기존에 Vue.js의 상태 관리 라이브러리 하면 자연스레 Vuex였는데, 이제 공식적으로 추천하는 상태 관리 라이브러리는 Pinia로 변경됐다.
이제는 Vue3가 디폴트가 된 것 처럼 비슷한 컨셉을 공유하는 Pinia가 공식적인 상태 관리 라이브러리가 된 것은 어쩌면 당연한 흐름인 것 같다.
Vuex와 Pinia 중 어떤 걸 사용해야 할지 고민이 들텐데, 여기저기 공식 문서를 본 결과 기존 Vue2 사용자라면 Vuex를 사용하고, Vue3 사용자라면 Pinia를 사용하는 게 궁합이 좋을 것 같다.
물론 Pinia가 Vue3만 서포트하는 건 아니고 당연히 Vue2와도 함께 사용할 수 있다. 하지만 전반적인 컨셉을 유지하고 코드를 통일성 있게 작성하기 위해서는 궁합이 잘 맞는 걸 선택하는 게 베스트일 것 같다.


Pinia의 강점

공식 문서를 보고 느낀 Pinia의 강점은 크게 다음과 같다.

1. Intuitive
Vue3 기반의 setup() 함수를 사용하는데 친숙하다면 코드 작성이 보다 직관적으로 느껴진다.

2. Type Safe
Typescript 기반으로 Type에 대한 안정성이 보장된다.

3. Extensibility
localstorage 등을 사용해 확장할 수 있다.

이외에도 Devtool 지원, 적은 용량, 모듈화 등의 장점이 돋보인다.


핵심 컨셉

Store

Store라는 단위 안에서 상태를 관리한다는 핵심 컨셉은 기존 Vuex와 동일하다.
defineStore() 메소드를 통해 해당 store의 유니크한 id와 옵션(state, getters, actions)를 정의한다. 이 메소드로 만들어진 Store 객체는 use 키워드 + id + Store 키워드의 조합으로 이름을 붙여 export 하는 방식이 권장된다.
Pinia는 기본적으로 Vue2(Options API)와 Vue3(Setup API) 둘 다 지원하기 때문에 각 API의 문법 대로 Store를 만들 수 있다.

Options API

<script>
import { defineStore } from 'pinia'

export const useCounterStore = defineStore('counter', {
  state: () => ({ count: 0, name: 'Eduardo' }),
  getters: {
    doubleCount: (state) => state.count * 2,
  },
  actions: {
    increment() {
      this.count++
    },
  },
})
</script>

코드에서 알 수 있듯 Options API로 Store를 정의하는 방식은 기존 Vuex와 동일하다.
다만 Vuex와 비교했을 때 state를 실제 변경하는 mutations가 따로 없다. actions에서 mutations, 즉 state 변경까지 함께 작성된다.

Setup API

<script>
export const useCounterStore = defineStore('counter', () => {
  const count = ref(0)
  const name = ref('Eduardo')
  const doubleCount = computed(() => count.value * 2)
  function increment() {
    count.value++
  }

  return { count, name, doubleCount, increment }
})
</script>

Setup API는 Vue3의 Setup API와 비슷하게 생겼다.
Options API 와 비교했을 때 다음과 같은 형식으로 state, getters, actions를 정의할 수 있다.

1. _ref()_ => state 정의
2. _computed()_ => getters 정의
3. _function()_ => actions 정의

그리고 정의한 state, getters, actions 함수를 마지막에 return 해주면 된다.

이렇게 만들어진 Store는 아래 코드 처럼 Vue 컴포넌트의 setup 함수 등에서 사용할 수 있다.

<script>
import { useCounterStore } from '~/store/counter'

export default defineComponent({
  setup() {
  	// counter store 가져오기
    const store = useCounterStore()
    const { name, doubleCount } = store

    return {
      name,
      doubleCount,
    }
  },
})
</script>

Options API vs Setup API

공식 문서에 따르면, 두 API 문법 중 친숙한 형태의 것을 사용하면 된다고 나와있다. 아직 Vue2에 익숙하다면 Options API 를 사용하는 게 좋아보이고 Vue3에 이미 친숙하다면 Setup API를 사용하면 좋을 것 같다.
개인적으로 두 가지 모두 작성해보고 느낀 바로는 Setup API를 사용하는 것이 더 편했다. 그 이유는 다음과 같다.

  • getters, actions 등에서 state 접근시 state 키워드를 사용하지 않아도 된다.
    Options API의 경우 this.state 등으로 접근해야 하는데, Setup API의 경우 그냥 state 그 자체에 바로 접근이 가능하다.

  • 다른 Store 사용이 용이하다.
    A 라는 Store에서 B라는 Store의 action을 실행해야 하는 경우를 가정해보자. Options API의 경우 다음과 같이 코드를 작성해야 한다.

<script>
import { useAStore } from '~/store/a'
	actions: {
    	actionA () {
            const aStore = useAStore()
            store.actionA()
          },
          actionB () {
            const aStore = useAStore()
            store.actionB()
          },
    }
</script>        

useAStore()라는 코드를 두번 작성해야 해서 귀찮다...

<script>
import { useAStore } from '~/store/a'
	const aStore = useAStore()
    
    const actionA = () => {
    	aStore.actionA()
    }
    
    const actionB = () => {
    	aStore.actionB()
    }
</script>

Setup API를 사용하면 깔끔하게 한번만 작성하면 된다.


State

State 셋팅

State의 경우 Typescript를 적용해 State의 Type을 설정할 수 있다.

<script>
interface UserInfo {
  name: string
  age: number
}
// Options API
export const useUserStore = defineStore('user', {
  state: () => {
    return {
    	userList: [] as UserInfo[],
      	user: null as UserInfo | null,
    }
  },
})

// Setup API
export const useUserStore = defineStore('user', () => {
  const userList = ref<UserInfo[]>([])
  const user = ref<UserInfo || null >(null)
})
</script>

State Mutation

State의 값을 바꾸는 Mutation의 방식은 두가지로 나뉜다.

1. 직접적인 변경
2. $patch() 메소드를 이용한 변경
   $patch()를 사용하면 여러 State를 한번에 변경할 수 있고 array와 같은 컬렉션의 데이터를 변경할 때 용이
<script>
// 1. 직접적인 변경
store.count++

// 2. $patch 메소드를 사용한 mutation
// 2-1. 여러 state를 한번에 muation 하기
// $patch 메소드의 파라미터로 변경할 state 객체를 받아 mutation 할 수 있음
store.$patch({
  count: store.count + 1,
  age: 120,
  name: 'DIO',
})

// 2-2. array 형태의 State mutation 
// function을 파라미터로 넘겨서 mutation 할 수 있음
cartStore.$patch((state) => {
  state.items.push({ name: 'shoes', quantity: 1 })
  state.hasChanged = true
})
</script>

Getters

getters는 기존 Vuex와 마찬가지로 주로 state의 계산된 값을 반환하는데 사용된다.
이 외에도 다른 getters의 값을 가져와 재가공하는 경우도 있는데, 이 경우에는 this를 통해 store의 전체 instance의 getters 값을 가져올 수 있다. 만약 이렇게 다른 getters의 값을 가져오는 getters를 만드는 경우, 해당 getters의 return type을 명시해줘야 한다.

<script>

// Options API
export const useCounterStore = defineStore('counter', {
  state: () => ({
    count: 0,
  }),
  getters: {
  	// state의 계산된 값 반환
    doubleCount: (state) => state.count * 2,
    
    // getters를 가공해 반환하는 getters 정의
    doublePlusOne(): number {
      // getters의 반환 값 typing 필요
      return this.doubleCount + 1
    },
  },
})

// Setup API

export const useCounterStore = defineStore('counter', () => {
	// state
	const count = ref<number>(0);
    // getters 
    const doubleCount = computed(() => count.value * 2);
    const doublePlusOne = computed(() => doubleCount.value + 1);
})

</script>

Vuex와 마찬가지로 getters에서 인자를 받을 수도 있다. 이 경우 함수를 다시 return 하는 형태로 만들면 된다.

<script>
export const useStore = defineStore('user', {
  getters: {
  	// getters 호출시 userId를 파라미터로 넘기며 호출 할 수 있다.
    getUserById: (state) => {
      return (userId) => state.users.find((user) => user.id === userId)
    },
  },
})

</script>

Actions

actions에서는 state와 관련된 비지니스 로직을 구현하고 state를 변경한다.
기존 Vuex에서는 actions에서 비지니스 로직만을 담당하고 실제 state를 변경하는 것은 commit을 통해 mutations에 위임했다. 이와 달리 Pinia는 mutations를 굳이 거치지 않고 actions에서 바로 state를 변경할 수 있다

Vuex와 마찬가지로 actions는 await/async를 통해 비동기 처리도 가능하다.

<script>

// Options API
export const useUsers = defineStore('users', {
  state: () => ({
    userData: null,
  }),
  
  actions: {
    async loginUser(id, password) {
      try {
        this.userData = await api.post({ id, password })
        alert(`Welcome back ${this.userData.name}!`)
      } catch (error) {
        alert(error)
        return error
      }
    },
  },
})
</script>

위와 같이 로그인 API를 호출해 서버에서 받아온 데이터로 userData의 state를 변경하고 성공/실패 여부에 따라 alert을 띄우는 비지니스 로직을 설정할 수 있다.


Nuxt3과 함께 사용하기

Nuxt.js 프레임워크에서 Pinia를 사용하기 위해서는 @pinia/nuxt 모듈을 설치하면 된다.

모듈 설치 후 nuxt.config.ts 파일에 모듈을 등록하고 옵션을 설정하면 된다.

<script>
export default defineNuxtConfig({
  modules: [
    '@pinia/nuxt',
    {
        autoImports: [
          // `defineStore` 를 자동으로 import 해줌
          // import { defineStore } from 'pinia' 를 하지 않아도 알아서 import 해줌
          'defineStore',
        ],
    },
  ],
})
</<script>

만약 Nuxt3에서 Pinia 인스턴스에 접근하고 싶다면 다음과 같이 하면 된다.

1. usePinia()
	@pinia/nuxt 모듈을 사용하면 usePinia() 메소드가 자동으로 import 되어 pinia 인스턴스에 접근할 수 있다.
2. useNuxtApp()
	useNuxtApp()에서 $pinia를 통해 pinia 인스턴스에 접근할 수 있다.
<script>

export default defineComponent({
	setup () {
    	const pinia = usePinia()
        const { $pinia } = useNuxtApp()
    }
    return {
    	pinia,
        $pinia
    }
})

</script>

Module 대신 Store

기존에 Vuex에서는 하나의 Store를 Module 단위로 쪼개 관리하는 개념이 존재했다.

<script>
const moduleA = {
  state: () => ({ ... }),
  mutations: { ... },
  actions: { ... },
  getters: { ... }
}

const moduleB = {
  state: () => ({ ... }),
  mutations: { ... },
  actions: { ... }
}

const store = createStore({
  modules: {
    a: moduleA,
    b: moduleB
  }
})

</script>

위와 같이 A, B라는 모듈을 만들고 두개의 모듈을 하나의 Store로 묶어서 만들 수 있다. 또한 registerModule()을 통해 다이나믹하게 모듈을 만들 수 있었다.

Pinia에서는 Module이라는 개념이 사라지고 Store만 남게 된다. 즉, Module을 통해 Store를 만드는 것이 아닌 Store 그 자체를 만드는 것이다. Pinia에서는 id라는 유니크 값을 통해 Store를 생성하기 때문에 Vuex에서 namespaced 된 모듈을 생성했던 것 처럼 namespaced 된, 즉 독립된 Store를 생성할 수 있다고 설명한다.

따라서 Pinia 공식 문서에 따르면 Vuex와 비교했을 때 아래와 같은 디렉토리 구조를 갖게 된다.

# Vuex example (namespaced 모듈 사용)
src
└── store
    ├── index.js           # Vuex 초기화 및 modules import
    └── modules
        ├── module1.js     # 'module1' namespace
        └── nested
            ├── index.js   # 'nested' namespace, imports module2 & module3
            ├── module2.js # 'nested/module2' namespace
            └── module3.js # 'nested/module3' namespace

# Pinia equivalent, note ids match previous namespaces
src
└── stores
    ├── index.js          # (선택) Pinia 초기화. store가 자동 import되어 따로 import 할 필요 없음
    ├── module1.js        # 'module1' id
    ├── nested-module2.js # 'nestedModule2' id
    ├── nested-module3.js # 'nestedModule3' id
    └── nested.js         # 'nested' id

Vuex와 비교했을 때 Pinia는 간결한 구조를 가지지만 namespaced 되어 독립된 store 객체를 가진다는 컨셉은 유효하다.

이 부분의 경우 학습하면서 가장 헷갈렸다. 기존 레거시 프로젝트에서는 registerModule() 을 통해 동적으로 module을 생성하는 로직이 있어서 Pinia에서도 비슷한 방식으로 풀려고 했는데, 애초에 Module이라는 개념 자체가 부재했다. Pinia의 공식 문서를 보고 구글링해봐도 Store를 동적으로 생성하고 이를 다루는 예시가 딱히 없어서 어떻게 풀어내야 할지 난제다. 이 부분은 좀 더 조사가 필요할 듯 하다.


2022-10-24 Dynamic Store 생성 추가

동적 Store 생성 및 삭제

동적으로 Store를 생성하는 로직을 만들어봤다.
Nuxt3 구조에서 composables 디렉토리 하위에 동적으로 Store를 생성하는 로직을 아래와 같이 작성했다.

<script>
// composables/useDynamicStore.ts
export default function (id: string) {
  return defineStore(id, () => {
    return {
    	sates,
        getters,
        actions,
    }
  })
}

</scirpt>

useDynamicStore는 id를 파라미터로 받아서 동적으로 store를 생성 후 반환한다.

<script>
// middleware/store.ts
useDynamicStores(id)()
</script>

위 코드 처럼 Store를 동적으로 생성하고자 할 때 useDynamicStore를 호출하기만 하면 된다.

동적으로 만들어진 Store를 삭제하는 로직은 다음과 같다.

<script>
// composables/useDisposeStore.ts

import { Store, Pinia, getActivePinia } from 'pinia'

interface ExtendedPinia extends Pinia {
  _s: Map<string, Store>
}

export default function (id: string) {
  if (!id) return
  
  // Pinia에서 제공하는 getActivePinia() 메소드로 pinia 인스턴스 가져오기
  const activePinia = getActivePinia() as ExtendedPinia
  if (!activePinia) {
    throw new Error('There is no active Pinia instance')
  }
  
  // id 값이 일치하는 Store 가져오기
  const targetStore = activePinia._s?.get(id)
  if (targetStore) targetStore.$dispose()
}

</scirpt>

특정 id를 갖는 Store를 찾아 $dispose() 메소드를 통해 삭제하면 된다.
여기서 포인트는 getActivePinia() 메소드로 반환되는 Pinia 객체(proxy)의 _s 키 값에 Store들이 Map 객체 형태로 들어있다는 점이다. 이 객체에서 id 값이 일치하는 Store를 찾아 $dispose 하면 된다.

참고
https://lobotuerto.com/notes/til-how-to-reset-all-stores-in-pinia


결론

Vue3로 마이그레이션 하는 과정에서 느낀바로는 Pinia가 확실히 Vue3와 궁합이 좋은 듯 하다.
일단 형식도 setup 형태로 비슷하고 Vuex에 비해 코드도 훨씬 간결해지며 익숙해지면 가독성도 높다. 어쨌든 이제 Vuex의 버전업은 딱히 없을 것 같고 공식적으로도 Pinia를 권장하고 있으니 익숙해지는 과정을 거치면 될 것 같다.

번외로 Vue3, Nuxt3로 마이그레이션 하는 과정에서 참고할 수 있는 것들이 공식 문서 외에는 딱히 없어서 좀 외롭다... Vue 진영 자체가 React 보다 훨씬 작아서(특히 한국에서) 원래부터 외로웠지만 Vue3는 정말 더 외로운 것 같다...

0개의 댓글