Vuex State Management Pattern 탄생 배경 정리

Feb 03, 2024
Vuex State Management Pattern 탄생 배경 정리
 
💡
The Weather Channel 프로젝트를 Vuex를 사용해 설계하게 되면서 왜 Vuex가 탄생하게 되었는지, 기존의 상태관리 라이브러리들과는 어떤 점에서 다른지, 어떤 아키텍처 패턴에 영감을 받아 만들어지게 되었는지 공부한 내용을 정리해보고자 합니다.
 
이 글을 쓰게 된 계기는 Vuex 공식문서의 다음 부분 때문이다.
This is the basic idea behind Vuex, inspired by FluxRedux and The Elm Architecture. Unlike the other patterns, Vuex is also a library implementation tailored specifically for Vue.js to take advantage of its granular reactivity system for efficient updates.
 
위의 글을 보면 Vuex가 FluxRedux, and The Elm Architecture 에 영감을 받았다고 나와있다. 그리고 다른 패턴들과의 차이점으로 Vuex가 내세운 강점은 효율적인 업데이트를 위해 Vue.js의 세분화된 반응성 시스템을 활용하도록 특별히 설계된 라이브러리 구현체라는 점이다.
Vuex가 Vue.js에 특화된 상태관리 라이브러리를 만들기 위해 Flux, Redux, 그리고 Elm Architecture에서 모티브를 얻었다는 사실에서 출발해 Elm Architecture, Flux, Redux 순으로 해당 패턴이 등장하게 된 배경을 정리해보며 Vuex패턴에 대한 이해도를 높이고자 한다.

Elm Architecture 등장배경

Elm은 JavaScript로 컴파일 되는 함수형 언어이다. Elm 공식문서에 따르면 'Elm 아키텍처'는 Elm 커뮤니티에서 게임을 만드는 도중 처음 발견하게 되었다. 모든 Elm 프로그램의 로직들은 아래와 같이 세개로 깨끗하게 분리될 수 있다.
  • Model - 어플리케이션의 상태를 담당한다. (the state of your application)
  • Update - 상태를 수정하는 부분이다. (a way to update your state)
  • View - HTML로 상태를 표현하는 부분이다. (a way to view your state as HTML)
 
이런 흐름을 내부적으로 Browser.sandbox라는 함수가 다뤄주고 있다.
1. Elm 프로그램 영역에서 Model로 만들어낸 View(Html) 값을 Elm 런타임에 전달한다.
2. 이 런타임은 내부적으로 Virtual DOM을 관리하며 웹브라우저를 위한 DOM을 그려내다.
  1. DOM과 관련한 이벤트가 발생하면 이 이벤트를 Msg 형태로 바꾸어서 다시 Elm 프로그램으로 전달한다.(Update)
 

Flux 등장 배경

Flux 아키텍처는 MVC 패턴의 문제점을 보완할 목적으로 고안되었으므로 먼저 MVC 패턴의 한계에 대해 짚고 넘어보자.
 

MVC 패턴

  • Model - 어플리케이션의 데이터를 담당하며, 내부 비지니스 로직을 처리한다.
  • View - 사용자에게 보여지는 화면을 담당하며, Controller에 연결되어 화면을 구성하는 단위요소이다.
  • Controller - 사용자로부터 입력(Input)을 받아서 ModelView를 중개하는 역할을 한다.
 
notion image
 
  1. 모든 입력(Input)들은 Controller로 전달된다.
  1. Controller는 입력에 해당하는 Model을 업데이트한다.
  1. Model 업데이트 결과에 따라 해당 업데이트와 관련된 ViewController 선택해 업데이트 요청을 보낸다. (이때 Controller는 여러 개의 View를 선택하고 관리할 수 있다.)
  1. ControllerView를 선택만 할 뿐 직접 업데이트하지는 않는다. 따라서 ViewModel의 데이터 변화에 따라 직접 업데이트한다.
 
 
위의 그림처럼 Controller를 통해 들어온 업데이트 요청은 우선 Model로 갔다가 업데이트가 완료되면 Controller가 해당 업데이트 결과를 Model로부터 받아 View로 전달한다. 이와 같은 Controller —(업데이트 요청)-→ Model —(업데이트 결과 전달) -→ Controller -- (업데이트 결과 전달) -→ View(업데이트 반영) 라는 흐름을 업데이트 요청과 반영에만 초점을 맞춰 단순화시킨다면 아래의 그림과 같이 View와 Model이 서로 의존성을 띄는 모습을 확인할 수 있다.
 
 
View와 Model이 서로 의존성을 띄게 된다는 특성이 바로 MVC모델의 단점이다. 페이스북과 같은 대규모 애플리케이션에서는 MVC가 너무 빠르게 복잡해진다. 페이스북 개발팀에 따르면 구조가 너무 복잡해진 탓에 새 기능을 추가할 때마다 크고 작은 문제가 생겼으며 코드의 예측이나 테스트가 어려워졌으며 새로운 개발자가 오면 적응하는데만 한참이 걸려서 빠르게 개발할 수가 없었다.
 
이 같은 문제의 대표적인 사례가 바로 페이스북의 안 읽은 글 갯수(unread count) 표시이다. 사용자가 읽지 않았던 글을 읽으면 읽지 않은 글 갯수에서 읽은 글 수만큼 빼면 되는 일견 단순해보이는 기능인데도, 페이스북 서비스에서 이를 MVC로 구현하기는 어려웠다고 한다.
어떤 글을 '읽음' 상태로 두면, 먼저 글을 다루는 thread 모델을 업데이트 해야하고 동시에 unread count 모델도 업데이트 해야한다. 대규모 MVC 애플리케이션에서 이 같은 의존성과 순차적 업데이트는 종종 데이터의 흐름을 꼬이게 하여 예기치 못한 결과를 불러일으킨다.
We originally set out to deal correctly with derived data: for example, we wanted to show an unread count for message threads while another view showed a list of threads, with the unread ones highlighted. This was difficult to handle with MVC — marking a single thread as read would update the thread model, and then also need to update the unread count model. These dependencies and cascading updates often occur in a large MVC application, leading to a tangled weave of data flow and unpredictable results.
 
결국 페이스북 개발팀은 MVC 패턴을 버리고 다른 아키텍처인 Flux를 적용하기로 한다.
 

Flux Architecture

Flux 애플리케이션은 크게 세 부분으로 구성된다.
  • Dispatcher - Flux 애플리케이션의 모든 데이터 흐름을 관리하는 허브 역할을 한다.
  • Store - 애플리케이션의 상태를 저장한다. MVC 패턴의 Model과 유사하지만 ORM(Ojbect-relational Mapping) 스타일로 데이터를 다루지 않는다. 그보다는 애플리케이션의 특정 도메인에 해당하는 상태를 다룬다고 보는 편이 좋다. MVC의 모델은 어떤 객체를 모델링하는데 주력하고 있다면, Flux의 스토어는 상태를 다룬다는 개념으로 접근해야 하므로 무엇이든 저장할 수 있다. 그리고 대체로 단순한 자바스크립트 Object로 구성된다.
  • View - 여기서 말하는 View는 MVC의 View와는 달리 스토어에서 데이터를 가져오는 한편 데이터를 자식 View로 전달하기도 하는 일종의 View-Controller로 보아야 한다. React를 기반으로 작성된 컴포넌트를 떠올리면 될 것이다.
 
 
Flux 아키텍처의 가장 큰 특징으로는 '단방향 데이터 흐름(unidirectional data flow)'을 들 수 있다. 데이터의 흐름은 언제나 Dispatcher에서 Store로, Store에서 View로, View에서 Action으로 다시 Action에서 Dispatcher로 흐른다.
 

동기의 경우

  1. Dispatcher로 전달할 액션 객체는 대체로 액션 생성자(Action creator, 아래 예제에서는 TodoActions.create와 TodoActions.destroy )라는 함수 또는 메소드를 통해 만들어진다. Dispatcher의 특정 메소드 dispatch를 호출할 때 Action이라는 데이터 묶음을 인수로 전달한다.
const TodoActions = { create(text) { AppDispatcher.dispatch({ actionType: Constants.TODO_CREATE, text }); }, ... destroy(id) { AppDispatcher.dispatch({ actionType: Constants.TODO_DESTROY, id }); }, ... };
출처: https://taegon.kim/archives/5288, 페이스북이 제공한 Todo 애플리케이션의 예제 코드 일부를 간소화한 버전
 
  1. Action을 호출한다.
// 리액트 컴포넌트에서 새 Todo 항목을 작성할 때 TodoActions.create(text);
 
  1. Store가 Dispatcher로부터 메시지를 수신한다.
const TodoStore = Object.assign({}, EventEmitter.prototype, { ... }); // TODO 항목 작성 function create(text) { ... } // TODO 항목 삭제 function destroy(id) { ... } // 스토어를 업데이트하는 콜백 함수 등록 AppDispatcher.register(function(action){ switch(action.actionType) { case Constants.TODO_CREATE: create(action.text); TodoStore.emitChange(); break; case Constants.TODO_DESTROY: destroy(action.id); TodoStore.emitChange(); break; ... } });
 
  1. 뷰는 관련 스토어의 변경 사항을 감지할 수 있는 이벤트 리스너를 스토어에 등록하고, 스토어에 변경 사항이 발생하면 이를 뷰에 반영한다. 이 때 뷰에서는 자신의 setState()나 forceUpdate() 메소드를 실행하고, 이로 인해 자동적으로 render() 메소드도 호출된다. 다음은 페이스북의 Todo 예제에서 가져온 React 컴포넌트의 일부 코드이다. 이를 통해 React로 작성된 뷰와 스토어가 어떻게 동작하는지 짐작해 볼 수 있다.
function getTodoState() { return { allTodos: TodoStore.getAll(), areAllComplete: TodoStore.areAllComplete() }; } const TodoApp = React.createClass({ getInitialState() { return getTodoState(); }, componentDidMount() { TodoStore.addChangeListener(this._onChange); }, componentWillUnmount() { TodoStore.removeChangeListener(this._onChange); }, render(){ ... }, _onChange() { this.setState(getTodoState()); } });
 

비동기의 경우

MVC의 모델과 달리 스토어는 단지 상태만을 다루므로 서버에서 데이터를 가져오는 것과 같은 비동기 동작은 액션에서 처리해야 한다. 이때, 서버 동기화와 같은 비동기 동작은 동작이 실패할 가능성도 있으므로 액션 타입을 여러 단계로 나눈다. 예를 들어 앞서 살펴본 TODO_CREATE 액션을 실행할 때 서버에 저장하는 비동기 동작도 같이 이루어진다고 가정해본다면 서버에 문제없이 저장했을 때 발생할 TODO_CREATE_SUCCESS액션과 저장하지 못했을 때 발생할 TODO_CREATE_FAIL액션을 추가하여 비동기 액션을 작성한다. 이 동작을 코드로 작성해본다면 다음과 같다.
const TodoActions = { create(text) { var id = getUniqueId(); AppDispatcher.dispatch({ actionType: Constants.TODO_CREATE, id text }); ajaxPost(saveURL) .then(data => { AppDispatcher.dispatch({ actionType: Constants.TODO_CREATE_SUCCESS, id }); }) .catch(reason => { AppDispatcher.dispatch({ actionType: Constants.TODO_CREATE_FAIL, reason }); }); }, ... };
 
 

Redux 등장 배경

Redux 패턴은 Flux 아키텍처의 구현체 중 하나로서 가볍고 단순한 구조가 특징이다. Redux 공식문서에도 나와있듯이 Redux는 Flux, Elm, 그리고 Immutable 라이브러리 등등의 기존 기술이 가진 장점들을 가져와 사용하고 있다. 먼저 Redux 패턴을 이해하기 위해서는 React와 같은 라이브러리가 어떻게 구성되어 있는지 이해할 필요가 있다.
 
React의 상태관리 구성요소는 다음 3가지이다.
  •  State - 웹앱 전체의 상태를 관리하는 부분이다.
  •  View - 현재 상태(state)에 기초한 UI를 선언하는 화면단이다.
  • Actions - 사용자 입력에 따라 이벤트가 발생할 때 State를 업데이트 시키기위한 동작을 미리 정의해놓은 것이다.
 
이는 Vue 또한 동일하다. 이를 Vue의 상태 관리 구성요소로 설명하면 다음과 같다.
  • state : 컴포넌트 간에 공유할 data
  • view : 데이터가 표현될 template
  • actions : 사용자의 입력에 따라 반응할 methods
 
notion image
 
  1. Store에 선언된 State에 따라 View가 rendering된다.(UI가 화면에 그려진다.)
  1. 사용자가 버튼을 클릭하는 등 이벤트가 발생하면 해당 Action이 실행되며 State가 업데이트된다.
  1. 새로 업데이트된 State에 따라 View가 새로 그려진다.
 
이러한 어플리케이션 상태관리를 더 효율적으로 하기위해 Redux 패턴이 등장했다. Redux 패턴으로 사용자가 로그인하는 동작을 설명하면 다음과 같다.(참고: https://www.zerocho.com/category/React/post/57b60e7fcfbef617003bf456)
 
  1. 사용자가 View에 있는 로그인 Form에 필요한 정보(아이디, 비밀번호 등)를 채우고 로그인 버튼을 누른다. Form에는 미리 로그인 Action을 연결해두었는데(handleSubmit 함수 부분), Action은 로그인이라는 동작(아래에서는 login)을 정의해둔 것이다.
import React, { useCallback } from 'react'; import useInput from '@hooks/useInput'; import { useDispatch, useSelector } from 'react-redux' const LoginPage = () => { const dispatch = useDispatch(); const [id, handleId] = useInput(''); const [password, handlePassword] = useInput(''); const accessToken = useAppSelector((state) => state.user.accessToken); const handleSubmit = useCallback( () => { dispatch(login(id, password)); }, [userID, password], ); render() { return ( accessToken ? <div>로그인 성공</div> : <form onSubmit={handleSubmit}> <label> <span>아이디</span> <input value={id} onChange={handleId}/> </label> <label> <span>비밀번호</span> <input type="password" value={password} onChange={handlePassword} /> </label> </form> ); } }; export default LoginPage;
/page/Login.jsx
  1. Dispatcher을 통해서 Action에서 Store로 데이터가 넘어간다.
dispatch(login(id, password));
 
  1. Dispatcher가 아래와 같이 미리 정의해 둔 로그인 Action을 실행한다.
export const LOGIN = 'LOGIN'; export const LOGIN_REQUEST = 'LOGIN_REQUEST'; export const LOGIN_SUCCESS = 'LOGIN_SUCCESS'; export const LOGIN_FAILURE = 'LOGIN_FAILURE'; export const login = (id, password) => { return { type: LOGIN, promise: { method: 'post', url: '/login', data: { id, password } } }; };
/redux/action/user.js
 
  1. Action에서 Store로 데이터가 넘어간다. Store에는 Middleware과 Reducer이 있다. 아래의 configureStore 파일은 Reducer을 Store과 연결할 때 필요하다.
import { createStore, applyMiddleware, compose } from 'redux'; import reducer from '../reducer/user.js'; import promiseMiddleware from '../middleware/promiseMiddleware.js'; export default function(initialState) { const enhancer = compose(applyMiddleware(promiseMiddleware)); return createStore(reducer, initialState, enhancer); }
/redux/store/configureStore.js
 
  1. Middleware는 Reducer에 가기 전 Action을 조작하다는 곳이다. 위의 Action에 만들어 둔 promise 옵션을 처리할 미들웨어는 다음과 같다.
import axios from 'axios'; export default () => { return next => action => { const { promise, type, ...rest } = action; next({ ...rest, type: `${type}_REQUEST` }); return axios({ method: promise.method, url: promise.url, data: promise.data }) .then(result => { next({ ...rest, result, type: `${type}_SUCCESS` }); }) .catch(error => { next({ ...rest, error, type: `${type}_FAILURE` }); }); }; };
/redux/middleware/promiseMiddleware.js
 
  1. Reducer은 데이터를 처리하는 부분이다. 이곳에서 Action별로 어떻게 state를 바꿀 지 결정한다. 한 가지 주의할 점은 반드시 새로운 객체를 반환 해야한다는 점이다.
    1. import { combineReducers } from 'redux'; import { login, LOGIN_REQUEST, LOGIN_SUCCESS, LOGIN_FAILURE } from '../action/user.js'; const defaultState = { isLoggedIn: false, fetchingUpdate: false, user: {} }; const userReducer = (state = defaultState, action) => { switch (action.type) { case LOGIN_REQUEST: return { ...state, fetchingUpdate: true }; case LOGIN_SUCCESS: return { ...state, fetchingUpdate: false, isLoggedIn: true, user: action.result, }; case LOGIN_FAILURE: return { ...state, fetchingUpdate: false }; } }; export default combineReducers({ user: userReducer });
      /redux/reducers/user.js
7. Reducer에서 처리된 데이터를 다시 View로 가져와 화면을 re-rendering한다.
const accessToken = useAppSelector((state) => state.user.accessToken); render() { return ( accessToken ? <div>로그인 성공</div> : <form onSubmit={handleSubmit}> 중간 생략... </form> ); } };
/page/Login.jsx
 
 

Vuex State Management Pattern

  1. Application-level state is centralized in the store.
  1. The only way to mutate the state is by committing mutations, which are synchronous transactions.
  1. Asynchronous logic should be encapsulated in, and can be composed with actions.
 
notion image
 
The Weather Channel 프로젝트에서 현재 날씨를 가져올 때 Vuex패턴을 적용한 예시는 다음과 같습니다.
  1. 사용자가 Dashboard 페이지에 접속한 후 해당 component가 mounted 될 때 dispatch 함수를 통해 미리 등록한 weather/loadCurrentWeatherData이라는 Action을 호출한다.
<script> export default { name: 'DashBoard', 중간 생략... mounted() { this.$store.dispatch('weather/loadCurrentWeatherData', { cityName: 'Seoul' }) }, } </script>
/src/views/Weather/DashBoard.vue
 
  1. 아래 코드는 store/index.js 파일은 Vuex Store에 미리 weather module를 정의해놓은 것이다.
import Vue from 'vue' import Vuex from 'vuex' import user from './modules/user' import weather from './modules/weather' Vue.use(Vuex) const debug = process.env.NODE_ENV !== 'production' export default new Vuex.Store({ modules: { user, weather, }, strict: debug, plugins: debug ? [Vuex.createLogger()] : [], })
/src/store/index.js
 
  1. weather module에는 다음과 같이 state, getters, actions, mutations이 미리 등록되어 있다.
import { getCurrentWeatherData } from '@/api' const state = () => ({ currentWeatherData: [], oneCallApiData: [], }) const getters = { doneCurrentWeatherMain(state) { return state.currentWeatherData?.weather[0].main }, } const actions = { async loadCurrentWeatherData({ commit }, payload) { commit('setCurrentWeatherData', await getCurrentWeatherData(payload.cityName)) }, } const mutations = { setCurrentWeatherData(state, payload) { state.currentWeatherData = payload }, } export default { namespaced: true, state, getters, actions, mutations, }
/src/store/modules/weather.js
 
  1. Dashboard 페이지에서 호출된 loadCurrentWeatherData Action에서는 getCurrentWeatherData(payload.cityName) 라는 비동기 요청을 다루고 있다. OpenWeatherMap API를 통해 axios로 보낸 오늘 날씨에 대한 get 요청의 응답이 오길 기다린 후, commit 함수로 Mutations 에 등록된 setCurrentWeatherData 함수를 호출하고 있다.
async loadCurrentWeatherData({ commit }, payload) { commit('setCurrentWeatherData', await getCurrentWeatherData(payload.cityName)) },
import axios from 'axios' axios.defaults.baseURL = 'https://api.openweathermap.org/data/2.5' const API_KEY = process.env.VUE_APP_OPEN_WEATHER_API_KEY const getCurrentWeatherData = async (cityName) => { // https://openweathermap.org/current try { const response = await axios.get('/weather', { params: { q: cityName, appid: API_KEY, }, }) return response.data } catch (error) { console.error(error) return false } } export { getCurrentWeatherData, getOneCallApiData }
/src/api/index.js
 
  1. Mutations의 setCurrentWeatherData 함수에서 State에 정의된 currentWeatherData 의 값을 변경하고 있다.
const mutations = { setCurrentWeatherData(state, payload) { state.currentWeatherData = payload }, }
 
  1. Getters에 등록된 doneCurrentWeatherMain함수에서 View로 보여질 데이터를 가공한다.
const getters = { doneCurrentWeatherMain(state) { return state.currentWeatherData?.weather[0].main }, }
 
  1. DashBoard component에서 Getters에 등록된 doneCurrentWeatherMain를 가져와 보여주고 있다. 업데이트된 데이터가 사용자에게 보여진다.
<template> <div> <v-alert v-show="!doneCurrentWeatherMain" type="error" > 오늘 날씨 정보를 가져오는 도중 문제가 발생했습니다. </v-alert> <div> <h1>Dashboard</h1> <span>{{ doneCurrentWeatherMain }}</span> </div> </div> </template> <script> import { mapGetters } from 'vuex' export default { name: 'DashBoard', computed: { ...mapGetters('weather/', [ 'doneCurrentWeatherMain', ]), }, mounted() { this.$store.dispatch('weather/loadCurrentWeatherData', { cityName: 'Seoul' }) }, } </script>
/src/views/Weather/DashBoard.vue
 
Share article

veganee