Vuex로 전역 상태와 스토어 모듈 관리하기

현재 Vue 공식 권장 상태 관리 라이브러리는 Pinia이며, Vuex는 유지보수 모드로 전환되었습니다. 아래 내용은 이 글을 작성하던 당시를 기준으로 합니다.
배경 - 기술 도입의 당위성
당시 회사는 프론트엔드 프레임워크로 Vue를 사용했지만 제가 맡은 프로젝트에는 Vuex가 없었습니다. 인증 상태처럼 여러 곳에서 쓰는 데이터는 Vue 커스텀 플러그인으로 관리했습니다.
처음에는 전역 상태 관리 라이브러리를 당연히 써야 한다고 생각했습니다. 팀장님과 이야기하면서 널리 쓰이는 기술을 도입하는 일이 언제나 정답은 아니라는 점을 배웠습니다.
그 프로젝트에서 전역 상태 관리 라이브러리를 쓰지 않았던 이유는 아래와 같았습니다.
- 전역으로 관리할 데이터가 많지 않아 기존 event bus와 plugin으로도 충분했습니다.
- 전역 상태 관리 역시 명확한 규칙 없이 도입하면 남용될 수 있었습니다.
- 서비스를 빠르게 구현하고 확장해야 하는 시점이라 마이그레이션 비용이 부담스러웠습니다.
기술을 충분히 검토해 적용하더라도 제품 출시나 사업 일정을 놓칠 수는 없습니다. 기존 구조를 유지할 비용과 새 규칙을 도입할 비용을 함께 비교해야 합니다. Vuex 자체보다 중요한 것은 전역 상태를 어떤 경로로 읽고 바꿀지 팀의 규칙을 세우는 일이었습니다.
제가 도입을 주장했던 이유는 아래와 같습니다.
- 널리 쓰이는 라이브러리를 도입하면 새로 합류한 사람이 프로젝트의 상태 관리 방식을 익히기 쉽습니다.
- 상태를 읽고 바꾸는 경로를 Flux의 단방향 데이터 흐름으로 통일할 수 있습니다.
- 서비스가 더 커지기 전에 옮기면 변경 범위를 줄일 수 있습니다.
저는 익숙한 도구를 들인다는 점보다 상태 변경 경로를 단방향으로 통일해 관리 비용을 줄인다는 점을 앞세워 제안했습니다.
제안이 받아들여져 사내 기술 세미나에서 Vuex의 도입 배경과 사용법을 발표했습니다. 이 글은 그때 사내 위키에 적은 가이드를 정리한 것입니다.
Vuex란 무엇인가요?
Vuex란 Vue 전용 전역 상태 관리 라이브러리입니다.
상태 (State)
컴포넌트 기반 프론트엔드에서는 화면을 작은 단위로 나눕니다. 컴포넌트는 동적으로 변하는 값을 상태로 관리하고, 상태가 바뀌면 새 값을 화면에 반영합니다.
"Plain JavaScript object holds data influences the output of render."
지역 상태와 전역 상태 (Local State & Global State)
상태는 지역 상태와 전역 상태로 나뉩니다.
- 지역 상태는 한 컴포넌트나 가까운 하위 트리에 영향을 줍니다. 사용자 입력값이나 로컬 UI의 표시 여부가 여기에 해당합니다.
- 전역 상태는 부모·자식 관계에서 멀리 떨어진 여러 컴포넌트가 같은 값을 읽거나 바꿔야 할 때 사용합니다.
- 인증·인가 상태나 장바구니처럼 화면 여러 곳이 함께 참조하는 데이터가 대표적입니다.

전역 상태를 넓게 공유할수록 그 값을 구독하는 컴포넌트의 범위도 커질 수 있습니다. 모든 값을 전역으로 옮기기보다 여러 화면이 실제로 함께 쓰는 상태만 Store에서 관리하는 편이 좋습니다.
등장 배경 (MVC vs. FLUX)
MVC Pattern의 문제점
MVC 자체가 양방향 데이터 바인딩을 요구하는 것은 아닙니다. 아래 그림은 여러 모델과 뷰의 갱신이 얽힌 구현에서 생길 수 있는 문제를 단순화한 것입니다.

- 모델과 뷰가 여러 경로로 서로를 갱신하면 상태가 바뀐 원인을 추적하기 어렵습니다.
- 업데이트되는 모델이 많고 의존 관계까지 얽히면 같은 값이 여러 차례 바뀌는 루프가 생길 수 있습니다.
FLUX는 MVC 버그의 원인을 양방향 데이터 흐름에서 찾습니다.
Flux 소개에서 자주 언급되는 사례는 알림 뱃지에는 숫자가 남아 있지만 실제 메시지 목록은 비어 있던 페이스북의 버그입니다.
- 애플리케이션이 복잡해질수록 값을 갱신하는 경로가 늘어납니다.
- 어느 변경이 다른 모델과 뷰에 영향을 주는지 예측하기 어려워집니다.
- 서로를 다시 갱신하는 업데이트 루프가 생길 수도 있습니다.
FLUX Pattern: Unidirectional Flow
React 자체가 Flux를 요구하지는 않습니다. Redux와 Vuex는 Flux의 단방향 데이터 흐름에서 영향을 받은 상태 관리 라이브러리이며, 다음과 같은 흐름을 따릅니다.
- Dispatcher가 상태 변경 요청을 한 방향으로 전달합니다.
- Store는 다른 Store를 직접 바꾸지 않고, 모든 변경은 Action과 Dispatcher를 거칩니다.
- Store는 애플리케이션 상태와 그 상태를 바꾸는 로직을 보관합니다.

-
Dispatcher: 전달된 Action에 맞는 콜백을 실행해 Store로 보냅니다.
-
Store: 데이터와 상태 변경 로직을 보관합니다.
-
View: Store의 변화를 구독하고 화면을 갱신합니다.
-
Action: 어떤 변화가 필요한지 나타내는 데이터 묶음입니다.
왜 Vuex를 써야 하나요?

로컬 상태 관리의 한계
⦿ 비대한 컴포넌트
과도하게 많은 데이터와 로직이 담긴 컴포넌트를 "팻 컴포넌트"라고 합니다. 한 컴포넌트가 여러 역할을 맡으면 변경 이유가 섞이고, 화면 코드와 공유 상태 로직을 따로 시험하기도 어려워집니다.
⦿ 예측하기 어려운 상태
규모가 큰 프로젝트에서 공유 상태까지 각 컴포넌트가 제각기 관리하면 값의 위치와 변경 원인을 찾기 어렵습니다. 이런 경로가 겹치면 현재 상태를 신뢰하기도 어려워집니다.
⦿ 넓어지는 오류 범위
데이터의 출처와 변경 경로를 찾기 어렵다면 버그가 생겼을 때 확인해야 할 범위도 넓어집니다.
적합한 전역 상태 관리의 장점
⦿ 공유 상태 로직 분리
여러 화면이 공유하는 상태와 변경 로직을 Store Module로 옮기면 컴포넌트는 화면 표현에 집중하고 전역 상태의 출처도 분명해집니다.
⦿ 추적할 수 있는 상태 흐름
상태를 읽는 경로와 바꾸는 경로가 나뉘므로 데이터 흐름을 추적하기 쉽습니다.
⦿ 명확한 변경 경로
상태 변경 방식을 하나로 모으면 디버깅할 경로도 좁아집니다.
Vuex의 핵심 요소

Store
Store는 전역 상태와 그 상태를 읽고 바꾸는 로직을 모아 둔 곳입니다.
State
전역 상태를 뜻합니다. Vuex의 State는 전역 상태가 담긴 객체를 반환하는 형태로 구현됩니다.
Mutations
Mutation은 state를 동기적으로 바꿉니다. Vuex Store의 상태는 mutation을 commit하는 방식으로 변경합니다.
Actions
Action은 동기·비동기 작업을 수행하고 mutation을 commit합니다. HTTP 응답을 기다릴 수 있지만, 그 뒤 상태를 바꾸는 mutation 자체는 동기적으로 실행됩니다.
Getters
Getter는 상태를 필요한 형식으로 계산해 제공합니다. Vue 컴포넌트의 computed와 비슷합니다.
Vuex 시작하기
설치
NPM
$ npm install vuex@next --save
Yarn
yarn add vuex@next --save
기본 설정 (Configuration)
Store 생성과 등록
앱 하나에는 Root Store가 하나 있습니다.
a. createStore로 Store를 만듭니다.
b. state 함수가 전역 상태의 초깃값을 반환하게 합니다.
c. Vue 3에서는 app.use(store)로 Store를 등록합니다.
// main.js
import { createApp } from "vue";
import { createStore } from "vuex";
import App from "./App.vue";
const store = createStore({
state() {
// 전역 상태의 초깃값을 반환합니다.
return {
counter: 0,
};
},
});
createApp(App).use(store).mount("#app");
State 참조하기
컴포넌트에서는 $store.state로 전역 상태를 참조합니다.
// $store.state.stateName
this.$store.state.counter;
State를 직접 바꾸지 않습니다
값은 바뀌지만 Vuex가 정한 변경 경로를 벗어납니다.
export default {
methods: {
addOne() {
// 값은 바뀌지만 mutation을 거치지 않습니다.
this.$store.state.counter = this.$store.state.counter + 1;
},
},
};
Mutations

전역 상태 값을 직접 변경하는 것은 좋은 방법이 아닙니다.
상태 변경은 mutation을 commit하는 경로로 모읍니다. 그래야 변경 원인을 추적하고 디버깅하기 쉽습니다. Vuex에서 dispatch는 action을 실행하고 commit은 mutation을 실행합니다.
Store의 mutations 객체에 state를 바꿀 메서드를 선언합니다.
// main.js
const store = createStore({
state() {
return {
counter: 0,
};
},
mutations: {
// Vue 컴포넌트에서 methods와 같습니다.
increment(state) {
// 기본으로 해당 store의 state 객체를 인자로 받습니다.
state.counter = state.counter + 1;
},
},
});
컴포넌트에서는 다음과 같이 mutation을 호출합니다.
// component in use
export default {
methods: {
addOne() {
this.$store.commit("increment");
// commit에 실행할 mutation 이름을 전달
},
},
};
Payload로 Mutation에 값 전달하기
Payload 객체로 mutation에 값을 전달합니다.
const mutations = {
increase(state, payload) {
state.counter = state.counter + payload.value;
},
};
// 사용하는 곳
this.$store.commit("increase", { value: 5 });
Type을 포함한 객체로 Commit하기
this.$store.commit("methodName", { property: value });
// 인자 두개를 전달하는 것을 좀 더 간단하게 만들면
this.$store.commit({
type: "methodName",
property: value,
});
// type에는 mutation 이름을, 나머지 필드에는 payload를 담습니다
Getters

여러 컴포넌트가 같은 방식으로 상태를 계산한다면 Getter에 그 로직을 모을 수 있습니다.
const getters = {
finalCounter(state) {
return state.counter * 2;
},
};
export default {
computed: {
counter() {
return this.$store.getters.finalCounter;
},
},
};
getter에서 다른 getter 값 참조하기
normalizedCounter는 finalCounter가 100을 넘을 때 100을 반환합니다. Getter는 두 번째 인자로 다른 Getter를 참조할 수 있는 객체를 받습니다.
const getters = {
finalCounter(state) {
return state.counter * 3;
},
normalizedCounter(_, getters) {
const finalCounter = getters.finalCounter;
if (finalCounter > 100) {
return 100;
}
return finalCounter;
},
};
Actions

- action에서는 mutation과 달리 비동기 작업을 실행할 수 있습니다.
- HTTP 요청과 응답 처리, 오류 처리 등을 맡길 수 있습니다.
- action이 상태를 직접 바꾸지 않고 mutation을 commit합니다.
- action은
context를 인자로 받습니다.

const actions = {
increment(context) {
setTimeout(() => {
context.commit("increment");
}, 2000);
},
};
this.$store.dispatch("increment");
// 또는
this.$store.dispatch({
type: "increment",
value: 5,
});
매핑 헬퍼
매핑 헬퍼를 사용하면 여러 상태와 메서드를 컴포넌트에 한꺼번에 연결할 수 있습니다.
mapState: state를 연결합니다.mapGetters: getters를 연결합니다.mapMutations: mutations를 연결합니다.mapActions: actions를 연결합니다.
매핑 헬퍼 사용하기
mapGetters()
여러 Getter를 computed에 연결합니다.
import { mapGetters } from "vuex";
export default {
computed: {
...mapGetters(["finalCounter", "normalizedCounter"]),
},
};
mapActions()
여러 Action을 methods에 연결합니다.
<button @click="increment">1 증가</button>
<button @click="increase({ value: 10 })">10 증가</button>
import { mapActions } from "vuex";
export default {
methods: {
...mapActions(["increment", "increase"]),
},
};
Action을 다른 이름으로 매핑할 수도 있습니다.
export default {
methods: {
...mapActions({
inc: "increment",
increase: "increase",
}),
},
};
Store를 모듈로 나누기
Store의 모듈화
사용자 인증처럼 목적이 뚜렷한 상태는 별도 모듈과 namespace로 관리할 수 있습니다.
const counterModule = {
state() {
return {};
},
mutations: {},
actions: {},
getters: {},
};
Store와 같은 형태의 객체를 만든 뒤 Root Store의 modules에 등록합니다.
const store = createStore({
modules: {
numbers: counterModule,
},
// ...
});
모듈로 나눠도 하나의 Root Store에 합쳐집니다. 다만 모듈의 state는 모듈별로 따로 관리되며, getter나 action에서 rootState, rootGetters를 통해 상위 스토어의 상태에 접근할 수 있습니다. 또한 namespaced: true를 설정하지 않으면 getter/action/mutation은 전역 네임스페이스에 그대로 등록됩니다.
모듈의 지역 State 이해하기
getter는 4개의 인자를 받습니다.
const getters = {
getterName(state, getters, rootState, rootGetters) {
// ...
},
};
state: 현재 모듈의 stategetters: 현재 모듈의 gettersrootState: Root Store의 staterootGetters: Root Store의 getters

모듈에 Namespace 적용하기
모듈마다 같은 이름의 Action이 있으면 이름이 충돌할 수 있습니다. 예를 들어 books와 cart 모듈이 모두 add를 정의할 수 있습니다.
namespaced: true를 설정하면 모듈 이름으로 범위를 나눌 수 있습니다.
export default {
namespaced: true,
};
참조 방법
this.$store로 직접 접근하는 경우
this.$store.getters["namespace/getterName"];
this.$store.dispatch({ type: "numbers/increase", value: 10 });
helper로 접근하는 경우
- Namespaced Getters로 접근
export default {
computed: {
counter() {
return this.$store.getters["numbers/normalizedCounter"];
},
},
};
같은 접근을 mapGetters로 줄이면 다음과 같습니다.
export default {
computed: {
...mapGetters("numbers", ["finalCounter", "normalizedCounter"]),
},
};
- Namespaced Actions로 접근
export default {
methods: {
...mapActions("numbers", {
inc: "increment",
increase: "increase",
}),
},
};
Vuex 파일 구조 나누기
Root Store는 index.js에 두고 규모가 커진 모듈은 하위 폴더에서 action과 mutation을 나눌 수 있습니다. 작은 Store라면 한 파일에 두는 편이 찾기 쉽습니다.
src
└── store
├── index.js
├── actions.js
├── getters.js
├── mutations.js
└── modules
├── counter
│ ├── index.js
│ ├── actions.js
│ ├── getters.js
│ └── mutations.js
├── products
└── users
index.js
import { createStore } from "vuex";
import counterModule from "./modules/counter";
// global store
export default createStore({
modules: {
numbers: counterModule,
},
});
modules/counter/index.js
import actions from "./actions";
import getters from "./getters";
import mutations from "./mutations";
export default {
namespaced: true,
state() {
return {
counter: 0,
};
},
actions,
getters,
mutations,
};
References
Vuex Docs - https://vuex.vuejs.org/ Maximilian Schwarzmüller - Vue: The Complete Guide
