Ray Book
상태 관리의 발전

Vue의 길, Vuex에서 Pinia로

Vue의 반응성 위에 올라간 상태 관리, mutation과 action의 분리가 왜 등장했고, 왜 다시 사라졌는지를 추적합니다.

state-managementvuevuexpinia

Vue의 출발점, 반응성이 내장되어 있다

Vue는 처음부터 반응성 시스템을 프레임워크에 내장하고 있었습니다. 데이터를 선언하면 Vue가 자동으로 getter/setter를 설정하고, 데이터가 바뀌면 관련된 컴포넌트만 업데이트됩니다.

// Vue 2의 반응성
const vm = new Vue({
  data: { count: 0 }
});
vm.count++; // 자동으로 DOM 업데이트

이것은 React와 근본적으로 다른 접근입니다. React에서는 setState를 호출해야 리렌더링이 일어나지만, Vue에서는 데이터를 직접 변경하는 것만으로 충분합니다. 반응성이 프레임워크 수준에서 해결되어 있기 때문입니다.

하지만 컴포넌트 간 상태 공유 는 별도 문제였습니다. 부모-자식 관계가 아닌 컴포넌트가 같은 데이터를 사용해야 할 때, props로 전달하는 것은 비효율적입니다. 이 문제를 해결하기 위해 Vuex 가 등장했습니다.

Vuex, Redux를 Vue 방식으로

Vuex (2015) 는 Redux에서 영감을 받되, Vue의 반응성 시스템 위에 구축되었습니다. Redux처럼 단일 스토어에 전역 상태를 보관하고, 상태 변경을 추적할 수 있게 설계되었습니다.

하지만 Vuex만의 독특한 구분이 있었습니다, mutationaction 의 분리입니다.

const store = createStore({
  state: {
    count: 0,
    todos: []
  },

  // mutation, 동기적으로만 상태를 변경
  mutations: {
    increment(state) {
      state.count++
    },
    addTodo(state, todo) {
      state.todos.push(todo)
    }
  },

  // action, 비동기 로직 가능, mutation을 commit
  actions: {
    async fetchAndAddTodo({ commit }) {
      const todo = await api.fetchTodo()
      commit('addTodo', todo)
    }
  },

  // getter, 파생 상태
  getters: {
    doneTodos: state => state.todos.filter(t => t.done)
  }
})

왜 mutation과 action을 분리했는가

이 분리는 DevTools를 위한 설계 결정 이었습니다.

Vue DevTools는 mutation이 발생할 때마다 상태의 스냅샷을 기록합니다. mutation이 동기적이라는 보장이 있기 때문에, mutation 전후의 상태를 정확히 비교할 수 있습니다. 만약 mutation 안에서 비동기 작업이 일어나면, DevTools가 스냅샷을 찍는 시점에 상태가 아직 변경되지 않았을 수 있습니다.

// 이런 코드를 방지하기 위한 규칙
mutations: {
  // ❌ mutation 안에서 비동기, DevTools가 추적 불가
  badMutation(state) {
    api.fetchData().then(data => {
      state.data = data  // 이 시점에 DevTools 스냅샷은 이미 지나감
    })
  }
}

그래서 비동기 로직은 action에, 실제 상태 변경은 mutation에 넣도록 강제했습니다. action이 비동기 작업을 수행한 후 commit으로 mutation을 호출하면, DevTools는 mutation 시점의 상태 변경을 정확히 기록할 수 있습니다.

실무에서 느낀 한계

이론적으로는 합리적이었지만, 실무에서는 불편함이 쌓였습니다.

첫째, 보일러플레이트가 많습니다. 간단한 상태 변경 하나에도 mutation 정의, action 정의 (비동기라면), 컴포넌트에서 mapMutations 또는 mapActions 호출, 여러 단계를 거쳐야 합니다.

둘째, 이름 짓기가 고통스럽습니다. mutation과 action에 비슷한 이름을 두 번 짓거나, 구분을 위해 mutation은 대문자 (SET_COUNT), action은 소문자 (setCount) 같은 컨벤션을 만들어야 합니다.

셋째, "이걸 mutation에 넣어야 하나 action에 넣어야 하나" 고민이 생깁니다. 단순한 동기 로직도 혹시 나중에 비동기가 되면 어떡하지? 하는 고민에 action으로 감싸는 경우가 많았습니다. 그러면 mutation은 그저 action의 래퍼가 됩니다.

// 흔한 패턴, action이 mutation을 그냥 중계만 함
actions: {
  setCount({ commit }, value) {
    commit('SET_COUNT', value)  // 이 중간 단계가 정말 필요한가?
  }
},
mutations: {
  SET_COUNT(state, value) {
    state.count = value
  }
}

넷째, TypeScript 지원이 약합니다. mutation과 action의 문자열 기반 호출 (commit('SET_COUNT'), dispatch('fetchData')) 은 타입 추론이 어렵습니다. 오타를 런타임까지 발견하지 못하는 경우가 빈번했습니다.

Pinia, mutation 없는 세계

Pinia는 Vue 팀의 Eduardo San Martin Morote가 2019년에 처음 만들고, 2021년에 안정 버전 (v2.0.0) 을 출시한 뒤, 2022년 2월에 Vue 3의 공식 상태 관리 라이브러리로 채택되었습니다. Vuex를 대체합니다.

가장 큰 변화는 mutation이 사라졌다 는 것입니다.

// Pinia
export const useCounterStore = defineStore('counter', {
  state: () => ({
    count: 0,
    todos: []
  }),

  actions: {
    increment() {
      this.count++  // 직접 변경, mutation 불필요
    },
    async fetchAndAddTodo() {
      const todo = await api.fetchTodo()
      this.todos.push(todo)  // 비동기 안에서도 직접 변경
    }
  },

  getters: {
    doneTodos: (state) => state.todos.filter(t => t.done)
  }
})

mutation을 왜 제거할 수 있었는가

Pinia 공식 문서는 mutation 제거의 이유를 명시합니다, "mutations were extremely verbose" (mutation은 극도로 장황했다).

DevTools가 발전하면서 action 단위로도 충분히 상태를 추적할 수 있게 되었습니다. Pinia의 DevTools 통합은 action 호출을 기록하고, action 전후의 상태 diff를 보여줍니다. mutation이라는 별도 레이어 없이도 동일한 수준의 디버깅이 가능해진 것입니다.

Pinia가 개선한 것들

TypeScript 퍼스트. this.count로 상태에 접근하므로 완전한 타입 추론이 가능합니다. 문자열 기반 commit이나 dispatch가 없습니다.

// Pinia, 완전한 타입 추론
const counter = useCounterStore()
counter.count      // number로 추론
counter.increment() // 자동완성 지원

Composition API 지원. Setup Store 문법으로 Composition API와 자연스럽게 통합됩니다.

// Setup Store, Composition API 스타일
export const useCounterStore = defineStore('counter', () => {
  const count = ref(0)
  const doubled = computed(() => count.value * 2)

  function increment() {
    count.value++
  }

  return { count, doubled, increment }
})

이 문법은 일반 Composition API 코드와 거의 동일합니다. ref, computed, 일반 함수, 별도의 상태 관리 문법을 배울 필요가 없습니다.

여러 스토어. Vuex는 모듈 시스템으로 네임스페이스를 나눴지만 복잡했습니다. Pinia는 스토어를 여러 개 만들 수 있고, 스토어 간 의존성도 자연스럽게 표현됩니다.

// 스토어 간 의존성
export const useCartStore = defineStore('cart', () => {
  const authStore = useAuthStore() // 다른 스토어 사용
  // ...
})

Vuex에서 Pinia로의 교훈

Vuex에서 Pinia로의 전환은 중요한 교훈을 남깁니다. DevTools를 위해 도입한 추상화가, DevTools가 발전하면서 불필요해졌다 는 것입니다. mutation은 "디버깅을 위한 제약"이었는데, 더 나은 디버깅 도구가 등장하면서 그 제약의 근거가 사라졌습니다.

또 하나의 교훈은 프레임워크의 기본 메커니즘을 최대한 활용하는 것 의 가치입니다. Pinia의 Setup Store는 Vue의 Composition API를 그대로 사용합니다. 별도의 문법을 만들지 않고, 이미 있는 refcomputed를 활용합니다. 상태 관리 라이브러리가 프레임워크와 별개의 세계를 만들지 않고, 프레임워크 안에 자연스럽게 녹아드는 방향입니다.

다음 단계

Vue가 반응성 시스템 위에서 상태 관리를 단순화하는 동안, Angular는 전혀 다른 출발점에서 비슷한 여정을 걸었습니다. Zone.js라는 독특한 변경 감지 메커니즘에서 시작해, NgRx의 보일러플레이트를 거쳐, Signals라는 새로운 반응성 프리미티브에 도달했습니다.