현재 저희 회사는 프론트엔드 프레임워크로 Vue를 채택하여 사용하고 있습니다. 하지만 제가 담당하는 프로젝트에서 Vuex를 사용하고 있진 않았습니다. authentication 등 전역으로 관리되어야 하는 데이터를 Vue 커스텀 플러그인을 사용하고 있는 상황이었습니다.
경험이 많지 않은 입장에서 당연히 전역 상태 관리 라이브러리를 사용하는 것이 맞지 않나 하는 의구심이 들었습니다. 하지만 팀장님의 답변을 통해 반드시 최신 사용 기술을 도입하는 것이 늘 정답은 아님을 알게 되었습니다.
현 프로젝트에 전역 상태 관리 라이브러리가 사용되지 않았던 이유는 아래와 같았습니다.
업무에서 이상과 현실이 맞지 않을때 우리는 흔히 '실무적'이란 말을 사용합니다.
실무적으로는 충분한 기술적 검토와 적용 때문에 비즈니스 타이밍을 놓칠 수 없기 때문에 레거시와 서비스 구축 속도의 트레이드 오프가 이루어질 수 밖에 없습니다. 물론 Vuex가 그리 대단한 기술은 아닙니다. 하지만 '전역 상태 관리'는 기술이라기 보다 하나의 룰에 가깝습니다.
제가 도입을 주장했던 이유는 아래와 같습니다.
핵심은 좀 더 보편적인 방식으로 상태를 관리하고, 상태를 변경하는 등 관리상 수반되는 모든 행위는 단방향 데이터 플로우 원칙으로 통일해서 관리 비용을 줄여나가자는 취지였습니다.
이번 기회에 인기가 많고 흔히 사용되는 라이브러리라도 기술 도입의 당위성이 명확해야 함을 다시 한 번 생각하게 되었습니다.
감사하게도 도입을 긍정적으로 생각해주셔서 사내 기술 세미나에서 Vuex의 의의와 사용에 대해 발제, 진행을 맡게 되었습니다.
그 때 사내 위키에 간단히 적어 놓은 Vuex 가이드를 갈무리해서 블로그에 올리게 되었습니다.
Vuex란 Vue 전용 전역 상태 관리 라이브러리입니다.
웹 프론트엔드 개발이 컴포넌트 기반으로 이루어지면서 하나의 문서가 작은 컴포넌트로 나뉘게 되었습니다. 컴포넌트에선 '동적으로 변할 수 있는 것'을 상태로 관리하고 이 상태가 변함에 따라 새로운 값이 화면에 보여지도록 합니다.
"Plain JavaScript object holds data influences the output of render."
상태는 지역 상태와 전역 상태로 나뉩니다.
문서의 어떤 곳에서라도 접근할 수 있는 영역을 '전역'이라고 합니다. 전역에 존재한다는 것은 컴포넌트 뿐만 아니라 컴포넌트 외부 즉, 어플리케이션의 코드 어디에서든지 접근과 참조가 가능함을 의미합니다.
어플리케이션을 구성하고 있는 코드 어디에서든지 접근과 참조가 가능하며, 변화에 따라 렌더링에 영향을 줘야 하는 값.
FLUX의 관점에서 MVC 버그의 원인은 양방향 데이터 흐름에 있다고 봅니다.
대표적으로 페이스북의 알림 버그, 로그인하면 메시지 알림 뱃지가 있지만, 메시지를 열어보면 아무 메시지가 없는 버그가 있습니다.
대표적으로 React와 Redux가 FLUX 패턴을 사용하며, VUEX 라이브러리도 FLUX 패턴에 영감을 받아 만들어졌다고 합니다. FLUX 패턴의 데이터 플로우는 아래의 특징을 가집니다.
Dispatcher : Flux의 모든 데이터 흐름을 관리하는 허브로서 전달된 Action을 보고 콜백을 실행하여 Store에 데이터를 전달합니다.
Store : 데이터(상태)를 저장하는 부분
View : Store의 변화를 감지하고 View를 업데이트해주는 부분. Controller View라고도 합니다.
Action : Dispatcher의 특정 메소드를 실행하면 Store에 변화를 일으킬 수 있는데, 이 메소드를 호출할 때 데이터 묶음을 인수로 전달합니다. 이를 액션(Action)이라고 합니다.
과도하게 많은 데이터와 로직이 담긴 컴포넌트를 "팻 컴포넌트"라고 합니다. 단일 컴포넌트가 많은 역할을 하는 것은 바람직하지 않습니다. 컴포넌트의 역할을 한 눈에 파악하기 어려울 뿐더러 재사용성과 유지 보수 면에서도 적합하지 않은 방식입니다.
큰 프로젝트에서 컴포넌트 단위에서만 상태가 관리되다보면 필요한 상태가 어디에 저장되었는지, 무엇 때문에 데이터 변조(Mutation)가 발생되는지 쉽게 예측할 수 없습니다. 예측이 어려운 로직이 중첩되어가면 결국 어플리케이션은 신뢰할 수 없는 상태가 됩니다.
앞서 설명된 "예측 불가능한 문제"와 같은 맥락으로, 데이터의 출처와 변조를 짐작하기 어려운 상태가 되면 버그와 에러가 발생하기 쉽습니다.
전역에서 관리되어야 하는 상태는 컴포넌트에서 Store Module로 분리해서 관리합니다. 상태와 관련된 로직도 함께 분리되기 때문에 단일 책임 원칙에 적합하며 컴포넌트를 좀 더 Lean하게 관리할 수 있게 되고 전역 상태의 출처가 명확해집니다.
전역 상태 관리와 데이터 플로우를 쉽게 예측할 수 있습니다. 양방향으로 데이터를 주고 받았던 MVC 모델과 다르게 데이터를 참조하는 방식과, 데이터에 변화를 주는 방식에 명확한 절차를 주어 예측 불가능한 데이터 흐름을 방지합니다.
상태의 데이터 플로우가 명확해지고 상태를 다루는 방법이 일원화되면 예측하지 못한 에러가 발생할 확률을 줄일 수 있습니다.
전역 상태를 저장하고, 참조하는 곳에 맞추어 포맷을 바꾸거나 값의 동기/비동기적 변화를 주는 로직을 한데 모아 놓은 곳을 Store라고 합니다.
전역 상태를 뜻합니다. Vuex의 State는 전역 상태가 담긴 객체를 반환하는 형태로 구현됩니다.
state를 '동기적'으로 변경시킵니다. Vuex의 Mutation은 이벤트와 비슷합니다. Mutation을 동작시키는 것을 Committing이라고 합니다.
(원문: The only way to change the state in a Vuex store is by committing a mutation.)
Mutation을 '비동기적'으로 실행시키는 역할을 합니다. HTTP 통신과 같은 비동기적 작업을 수행한 후 상태를 변화시킬 때 메소드를 정의하여 사용합니다.
(원문: Instead of mutating the state, actions commit mutations. Actions can contain abritrary asynchronous operations.)
전역 상태 값을 원하는 포맷이나 타입으로 가공하여 참조할 수 있도록 정의하는 곳입니다. Vue의 컴포넌트단에서 사용하는 computed와 유사합니다.
NPM
$ npm install vuex@next --save
Yarn
yarn add vuex@next --save
하나의 App은 오직 하나의 Root Store를 가집니다.
a. createStore
로 store를 생성해줍니다.
b. state를 반환하는 state 메소드를 호출하고 그 안에 전역 상태와 초기값을 할당합니다.
c. app.use(store)
로 store를 등록합니다. (Vue3)
// main.js
import { createStore } from 'vuex'
const store = createStore({
state() { // 전역 상태를 반환하는 인스턴스
return { // 여기 전역 상태가 들어갑니다.
counter: 0 // 전역 상태의 초기값을 설정합니다.
}
}
})
컴포넌트 단에서 $store.state
로 전역 상태의 참조가 가능합니다.
// $store.state.stateName
this.$store.state.counter
값은 바뀌지만 올바른 데이터 플로우가 아닙니다.
methods: {
addOne() { // 값을 변화시키는 함수는 mutations에 정의해서 사용하세요.
this.$store.state.counter = this.$store.state.counter + 1
}
}
전역 상태 값을 직접 변경하는 것은 좋은 방법이 아닙니다.
전역 상태를 변경하는 메소드를 하나의 싱글 소스로 운용하기 위해, 데이터의 의도치 않은 변조를 막기 위해, 디버깅을 용이하게 하기 위해 꼭 Dispatcher를 통해 State를 변경합시다.
store 안에 mutations 객체를 만들고 state를 변경할 메소드를 선언합시다.
// main.js
const store = createStore({
state() {
return {
counter: 0
};
},
mutations: { // Vue 컴포넌트에서 methods와 같습니다.
increment(state) { // 기본으로 해당 store의 state 객체를 인자로 받습니다.
state.counter = state.counter + 2;
}
},
});
컴포넌트 단에서 아레와 같이 mutation 메소드를 호출합니다.
// component in use
export default {
methods: {
addOne() {
this.$store.commit('increment');
// store의 commit 메소드의 인자로 mutating method 명을 전달
}
}
}
Payload
객체를 사용해서 Mutation 메소드에 인자를 전달할 수 있습니다.
// Store 객체
mutations: {
increase(state, payload) {
state.counter = state.counter + payload.value;
},
},
// 사용하는 곳
this.$store.commit('increase', { value: 5 });
this.$store.commit('methodName', { property: value });
// 인자 두개를 전달하는 것을 좀 더 간단하게 만들면
this.$store.commit({
type: 'methodName',
property: value
});
// type에서 메소드명을 전달할 수 있고 나머지 필드는 메소드가 됩니다
상태값을 사용하는 컴포넌트마다 상태값이 다르게 계산될 필요가 생길 수 있습니다.
이 때 각 컴포넌트에서 computed를 사용하는 대신 전역 Getter를 사용하면 중복이 없으며 한 곳에서 관리됩니다.
getters: {
finalCounter(currentState, getters) {
return state.counter * 2;
}
}
export default {
computed: {
counter() {
return this.$store.getters.finalCounter;
},
},
};
normalizedCounter는 finalCounter의 값이 100 이상일 경우 최대치 100만 반환하는 또 다른 getter입니다. getter는 두번째 인자 자리에 또 다른 getter를 참조할 수 있는 getters 객체를 받습니다.
getters: {
finalCounter(state) {
return state.counter * 3;
},
normalizedCounter(_, getters) {
const finalCounter = getters.finalCounter;
if (finalCounter < 0) {
return 0;
}
}
}
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 값을 가져와 사용하고 싶을때 편한 Helper 함수입니다.
import { mapGetters } from 'vuex';
computed: {
...mapGetters([’finalCounter’, ‘normalizedCounter’]),
}
mapActions()
한 번에 여러 Actions 메소드를 가져와 사용할 수 있는 Helper 함수입니다.
import { mapActions } from 'vuex';
// ...
<button @click="increment"></button>
<button @click="increase({ value: 10 })"></button>
// ...
methods: {
...mapActions(['increment', 'increase']),
},
Action을 다른 이름으로 맵핑할 수도 있습니다.
methods: {
...mapActions({
inc: 'increment',
increase: 'increase',
}),
},
유저 인증과 같은 특정 목적의 Store는 Global Store로부터 모듈화하여 별도의 namespace로 관리할 수 있습니다.
const counterModule = {
state() {},
mutations: {},
actions: {},
getters: {}
};
위처럼 똑같은 Store 객체를 생성하고 변수에 할당합니다.
그리고 Global Store의 modules 프로퍼티에 모듈 store를 할당합니다.
const store = createStore({
modules: {
numbers: counterModule,
},
// ...
});
모듈로 나누어도 global store로 통합되어 그대로 사용할 수 있습니다.
하지만 module 스코프가 적용되어 상위 또는 다른 모듈 store의 프로퍼티를 참조할 순 없습니다.
getter는 4개의 인자를 받습니다.
getters: {
getterName(context, getters, rootState, rootGetters) { ... },
},
모듈을 사용해 스토어를 관리하게 될 때 각각의 모듈이 가진 액션이 동일한 이름일 경우 문제가 발생합니다. 예) books 모듈과 cart 모듈에 동일한 이름의 액션이 있을 경우 문제가 발생
네임스페이싱을 통해 각 스토어마다 네임스페이스를 분리하여 충돌이나 간섭을 예방할 수 있습니다.
export default {
namespaced: true,
}
this.$store.getters['namespace/getterName']
this.$store.$store.dispatch({ type: 'numbers/increase', value: 10 })
computed: {
counter() {
return this.$store.getters['numbers/normalizedCounter'];
}
},
computed: {
...mapGetters('numbers', ['finalCounter', 'normalizedCounted']),
},
methods: {
...mapActions('numbers', {
inc: 'increment',
increase: 'increase',
});
}
Global Store는 index.js에 나머지 구조는 하위 모듈 폴더에 action, mutation 분리해서 파일 분리합니다. 또는 각각의 module 폴더를 modules 하위 폴더로 넣는 방법도 있습니다.
권장되는 Best Practice는 아래의 방식이며 store가 크지 않아도 되는 경우 index 파일로 module을 일원화 합니다.
src
├── store
│ ├─── index.js
│ ├─── actions.js
│ ├─── getters.js
│ ├─── mutations.js
│ │
│ └─── moduleName
│ ├── index.js
│ ├── actions.js
│ ├── getters.js
│ └── mutations.js
index.js
import { createStore } from 'vuex';
import counterModule from './modules/counterModule';
// global store
export default createStore({
modules: {
numbers: counterModule,
},
counterModule.js
export default {
namespaced: true,
state() {
return {
counter: 0,
};
},
Thank you
Vuex Docs - https://vuex.vuejs.org/
Maximilian Schwarzmüller - Vue: The Complete Guide