Vuex와 함께하는 더 나은 전역 상태 관리

Wonkook Lee·2022년 3월 5일
11

Libraries

목록 보기
1/3

Better State Management with Vuex


배경 - 기술 도입의 당위성

현재 저희 회사는 프론트엔드 프레임워크로 Vue를 채택하여 사용하고 있습니다. 하지만 제가 담당하는 프로젝트에서 Vuex를 사용하고 있진 않았습니다. authentication 등 전역으로 관리되어야 하는 데이터를 Vue 커스텀 플러그인을 사용하고 있는 상황이었습니다.

경험이 많지 않은 입장에서 당연히 전역 상태 관리 라이브러리를 사용하는 것이 맞지 않나 하는 의구심이 들었습니다. 하지만 팀장님의 답변을 통해 반드시 최신 사용 기술을 도입하는 것이 늘 정답은 아님을 알게 되었습니다.

현 프로젝트에 전역 상태 관리 라이브러리가 사용되지 않았던 이유는 아래와 같았습니다.

  • 전역으로 관리해야 하는 데이터가 많지 않아 현재 사용중인 event bus나 plugin으로도 충분히 해결이 가능하다.
  • 전역 상태 관리 또한 명확한 가이드나 거버넌스가 확립되지 않으면 이 또한 남용될 가능성이 크다.
  • 서비스가 빠르게 구현, 확장되어야 하는 현 상황에서 마이그레이션에 투입되는 비용 부담이 크다.

업무에서 이상과 현실이 맞지 않을때 우리는 흔히 '실무적'이란 말을 사용합니다.
실무적으로는 충분한 기술적 검토와 적용 때문에 비즈니스 타이밍을 놓칠 수 없기 때문에 레거시와 서비스 구축 속도의 트레이드 오프가 이루어질 수 밖에 없습니다. 물론 Vuex가 그리 대단한 기술은 아닙니다. 하지만 '전역 상태 관리'는 기술이라기 보다 하나의 룰에 가깝습니다.

제가 도입을 주장했던 이유는 아래와 같습니다.

  • 표준으로 사용되는 전역 상태 관리 라이브러리를 도입함으로써 신규 인원이 투입되더라도 기술적 이해 비용을 절감할 수 있다.
  • 데이터 플로우를 정의하고 상태를 관리하는 모든 방법에 대해 Flux 모델로서 단일 원칙을 확립하자.
  • 서비스가 더 확장되기 전에 미리 도입하는 것이 낫다.

핵심은 좀 더 보편적인 방식으로 상태를 관리하고, 상태를 변경하는 등 관리상 수반되는 모든 행위는 단방향 데이터 플로우 원칙으로 통일해서 관리 비용을 줄여나가자는 취지였습니다.

이번 기회에 인기가 많고 흔히 사용되는 라이브러리라도 기술 도입의 당위성이 명확해야 함을 다시 한 번 생각하게 되었습니다.

감사하게도 도입을 긍정적으로 생각해주셔서 사내 기술 세미나에서 Vuex의 의의와 사용에 대해 발제, 진행을 맡게 되었습니다.

그 때 사내 위키에 간단히 적어 놓은 Vuex 가이드를 갈무리해서 블로그에 올리게 되었습니다.



Vuex란 무엇인가요?


VuexVue 전용 전역 상태 관리 라이브러리입니다.


상태 (State)

웹 프론트엔드 개발이 컴포넌트 기반으로 이루어지면서 하나의 문서가 작은 컴포넌트로 나뉘게 되었습니다. 컴포넌트에선 '동적으로 변할 수 있는 것'을 상태로 관리하고 이 상태가 변함에 따라 새로운 값이 화면에 보여지도록 합니다.

"Plain JavaScript object holds data influences the output of render."


지역 상태와 전역 상태 (Local State & Global State)

상태는 지역 상태와 전역 상태로 나뉩니다.

  • 지역 상태는 단일 유닛의 컴포넌트에 영향을 줍니다. 유저의 입력 값이나 로컬 UI가 보이거나 보이지 않는 상태 등을 관리합니다.
  • 전역 상태는 많은 컴포넌트에 영향을 줍니다. 부모-자식 관계를 벗어나거나 자료 구조상 멀리 떨어진 컴포넌트 간에도 같은 데이터를 참조하여 동적으로 영향을 주고 받을 수 있습니다.
  • 전역 상태는 주로 인증, 인가와 관련된 상태나 장바구니 등 여러 컴포넌트에 영향을 주는 데이터를 다룰 때 사용됩니다.


전역 상태 (Global State)

문서의 어떤 곳에서라도 접근할 수 있는 영역을 '전역'이라고 합니다. 전역에 존재한다는 것은 컴포넌트 뿐만 아니라 컴포넌트 외부 즉, 어플리케이션의 코드 어디에서든지 접근과 참조가 가능함을 의미합니다.

어플리케이션을 구성하고 있는 코드 어디에서든지 접근과 참조가 가능하며, 변화에 따라 렌더링에 영향을 줘야 하는 값.



등장 배경 (MVC vs. FLUX)


MVC Pattern의 문제점

MVC 패턴이란? - Medium

  • 기존의 양방향 데이터 바인딩 방식은 모델의 변화에 따라 즉각적으로 뷰가 변합니다.
  • 양방향 데이터 방식으로 예측 불가능한 상황들이 생겨납니다.
  • 업데이트 되는 Model이 다수이고 서로 의존성이 얽혀있을 경우 이런 경우가 자주 발생합니다.

FLUX의 관점에서 MVC 버그의 원인은 양방향 데이터 흐름에 있다고 봅니다.

대표적으로 페이스북의 알림 버그, 로그인하면 메시지 알림 뱃지가 있지만, 메시지를 열어보면 아무 메시지가 없는 버그가 있습니다.

  • 어플리케이션이 복잡해질수록 양방향 데이터의 흐름은 기하급수적으로 증가합니다.
  • 기능 추가 및 변경에 따라 생기는 문제점을 예측할 수가 없습니다.
  • 예측 불가능한 코드가 양산되면서 지속되는 업데이트 루프가 발생됩니다.

FLUX Pattern: Unidirectional Flow

대표적으로 React와 Redux가 FLUX 패턴을 사용하며, VUEX 라이브러리도 FLUX 패턴에 영감을 받아 만들어졌다고 합니다. FLUX 패턴의 데이터 플로우는 아래의 특징을 가집니다.

  • 데이터 흐름이 Dispatcher에 의해 엄격한 규칙으로 강제됩니다.
  • 단방향 흐름 - 모든 상태 변화는 dispatcher에 의해 진행. store는 다른 store를 직접 변경할 수 없습니다. 모든 변화는 action을 거쳐 dispatcher에서 실행됩니다.
  • Store는 model이 없더라도 모든 상태를 저장할 수 있습니다.

  • Dispatcher : Flux의 모든 데이터 흐름을 관리하는 허브로서 전달된 Action을 보고 콜백을 실행하여 Store에 데이터를 전달합니다.

  • Store : 데이터(상태)를 저장하는 부분

  • View : Store의 변화를 감지하고 View를 업데이트해주는 부분. Controller View라고도 합니다.

  • Action : Dispatcher의 특정 메소드를 실행하면 Store에 변화를 일으킬 수 있는데, 이 메소드를 호출할 때 데이터 묶음을 인수로 전달합니다. 이를 액션(Action)이라고 합니다.



왜 Vuex를 써야 하나요?


로컬 상태 관리의 한계

⦿ Fat Components

과도하게 많은 데이터와 로직이 담긴 컴포넌트를 "팻 컴포넌트"라고 합니다. 단일 컴포넌트가 많은 역할을 하는 것은 바람직하지 않습니다. 컴포넌트의 역할을 한 눈에 파악하기 어려울 뿐더러 재사용성과 유지 보수 면에서도 적합하지 않은 방식입니다.

⦿ Unpredictable

큰 프로젝트에서 컴포넌트 단위에서만 상태가 관리되다보면 필요한 상태가 어디에 저장되었는지, 무엇 때문에 데이터 변조(Mutation)가 발생되는지 쉽게 예측할 수 없습니다. 예측이 어려운 로직이 중첩되어가면 결국 어플리케이션은 신뢰할 수 없는 상태가 됩니다.

⦿ Error-Prone

앞서 설명된 "예측 불가능한 문제"와 같은 맥락으로, 데이터의 출처와 변조를 짐작하기 어려운 상태가 되면 버그와 에러가 발생하기 쉽습니다.


적합한 전역 상태 관리의 장점

⦿ Outsourced state management

전역에서 관리되어야 하는 상태는 컴포넌트에서 Store Module로 분리해서 관리합니다. 상태와 관련된 로직도 함께 분리되기 때문에 단일 책임 원칙에 적합하며 컴포넌트를 좀 더 Lean하게 관리할 수 있게 되고 전역 상태의 출처가 명확해집니다.

⦿ Predicatable state management / flow

전역 상태 관리와 데이터 플로우를 쉽게 예측할 수 있습니다. 양방향으로 데이터를 주고 받았던 MVC 모델과 다르게 데이터를 참조하는 방식과, 데이터에 변화를 주는 방식에 명확한 절차를 주어 예측 불가능한 데이터 흐름을 방지합니다.

⦿ Clearly defined data flow: Less Error

상태의 데이터 플로우가 명확해지고 상태를 다루는 방법이 일원화되면 예측하지 못한 에러가 발생할 확률을 줄일 수 있습니다.



Vuex의 핵심 요소


Store

전역 상태를 저장하고, 참조하는 곳에 맞추어 포맷을 바꾸거나 값의 동기/비동기적 변화를 주는 로직을 한데 모아 놓은 곳을 Store라고 합니다.

State

전역 상태를 뜻합니다. Vuex의 State는 전역 상태가 담긴 객체를 반환하는 형태로 구현됩니다.

Mutations

state를 '동기적'으로 변경시킵니다. Vuex의 Mutation은 이벤트와 비슷합니다. Mutation을 동작시키는 것을 Committing이라고 합니다.
(원문: The only way to change the state in a Vuex store is by committing a mutation.)

Actions

Mutation을 '비동기적'으로 실행시키는 역할을 합니다. HTTP 통신과 같은 비동기적 작업을 수행한 후 상태를 변화시킬 때 메소드를 정의하여 사용합니다.
(원문: Instead of mutating the state, actions commit mutations. Actions can contain abritrary asynchronous operations.)

Getters

전역 상태 값을 원하는 포맷이나 타입으로 가공하여 참조할 수 있도록 정의하는 곳입니다. Vue의 컴포넌트단에서 사용하는 computed와 유사합니다.



Vuex 시작하기


설치


NPM

$ npm install vuex@next --save

Yarn

yarn add vuex@next --save

기본 설정 (Configuration)


1. Store 생성과 등록

하나의 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			// 전역 상태의 초기값을 설정합니다.
        }
    }
})

2. State 참조하기

컴포넌트 단에서 $store.state로 전역 상태의 참조가 가능합니다.

// $store.state.stateName

this.$store.state.counter

3. State 값, 직접 변경하면 안됩니다.

값은 바뀌지만 올바른 데이터 플로우가 아닙니다.

methods: {
	addOne() {       // 값을 변화시키는 함수는 mutations에 정의해서 사용하세요.
    	this.$store.state.counter = this.$store.state.counter + 1
    }
}



Mutations


전역 상태 값을 직접 변경하는 것은 좋은 방법이 아닙니다.

전역 상태를 변경하는 메소드를 하나의 싱글 소스로 운용하기 위해, 데이터의 의도치 않은 변조를 막기 위해, 디버깅을 용이하게 하기 위해 꼭 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 명을 전달
		}
	}
}

Passing Data to Mutations with Payloads

Payload 객체를 사용해서 Mutation 메소드에 인자를 전달할 수 있습니다.

// Store 객체
mutations: {
	increase(state, payload) {
		state.counter = state.counter + payload.value;
	},
},

// 사용하는 곳
this.$store.commit('increase', { value: 5 });

Payload 객체 하나만 전달하기: Type

this.$store.commit('methodName', { property: value });
// 인자 두개를 전달하는 것을 좀 더 간단하게 만들면

this.$store.commit({
	type: 'methodName',
	property: value
});
// type에서 메소드명을 전달할 수 있고 나머지 필드는 메소드가 됩니다



Getters


상태값을 사용하는 컴포넌트마다 상태값이 다르게 계산될 필요가 생길 수 있습니다.
이 때 각 컴포넌트에서 computed를 사용하는 대신 전역 Getter를 사용하면 중복이 없으며 한 곳에서 관리됩니다.

getters: {
	finalCounter(currentState, getters) {
		return state.counter * 2;
	}
}

export default {
	computed: {
		counter() {
			return this.$store.getters.finalCounter;
		},
	},
};

getter에서 다른 getter 값 참조하기

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


  • action은 mutataion 메소드와 달리 비동기적인 함수를 사용할 수 있습니다.
  • HTTP 통신 요청이나 리졸브 로직, 에러 핸들링을 위해 사용합니다.
  • action 메소드에서 직접 상태를 변경하면 안된다. 항상 mutation을 사용해서 상태를 변경해야 합니다.
  • context를 인자로 받습니다.

actions: {
	increment(context) {
		setTImeout(() => {
			context.commit('increment');
		}, 2000);
	},
},

this.$store.dispatch('increment');

// 또는

this.$store.dispatch({
	type: 'increment',
	value: 5,
});



Helper Functions


헬퍼 함수를 사용하면 쉽게 여러개의 전역 상태나 메소드를 참조하거나 호출할 수 있습니다.

  • mapState - state를 연결해주는 함수
  • mapGetters - getters를 연결해주는 함수
  • mapMutations - mutations를 연결해주는 함수
  • mapActions - actions를 연결해주는 함수

Using Mapping Helpers

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',
    }),
  },



Organizing your Store with Modules


Store의 모듈화

유저 인증과 같은 특정 목적의 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의 프로퍼티를 참조할 순 없습니다.



Understanding Local Module State


getter는 4개의 인자를 받습니다.

getters: {
	getterName(context, getters, rootState, rootGetters) { ... },
},
  • context : 현재 모듈 스코프에 해당하는 state(context) 정보
  • getters : 현재 모듈 스코프에 해당하는 getters
  • rootState : 전역 스토어의 스코프에 해당하는 State 정보
  • rootGetters : 전역 스토어의 Getters



Namespacing Modules


모듈을 사용해 스토어를 관리하게 될 때 각각의 모듈이 가진 액션이 동일한 이름일 경우 문제가 발생합니다. 예) books 모듈과 cart 모듈에 동일한 이름의 액션이 있을 경우 문제가 발생

네임스페이싱을 통해 각 스토어마다 네임스페이스를 분리하여 충돌이나 간섭을 예방할 수 있습니다.

export default {
	namespaced: true,
}

참조 방법

this.$store로 직접 접근하는 경우

this.$store.getters['namespace/getterName']
this.$store.$store.dispatch({ type: 'numbers/increase', value: 10 })

helper로 접근하는 경우

  • Namespaced Getters로 접근
computed: {
	counter() {
		return this.$store.getters['numbers/normalizedCounter'];
	}
},

computed: {
	...mapGetters('numbers', ['finalCounter', 'normalizedCounted']),
},
  • Namespaced Actions로 접근
methods: {
	...mapActions('numbers', {
		inc: 'increment',
		increase: 'increase',
	});
}



Structuring Vuex Code & Files 파일 분리 & 폴더 구조


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



References

Vuex Docs - https://vuex.vuejs.org/
Maximilian Schwarzmüller - Vue: The Complete Guide

profile
© 가치 지향 프론트엔드 개발자

0개의 댓글