Vue의 데이터 흐름과 상태관리

carrot·2022년 9월 2일
0

vue

목록 보기
1/1
post-thumbnail

리뷰 소개

MVC 패턴에서 Model을 변경하는 View와의 상호작용이 많아지면서 Model간 상태를 변경하는 등 커지는 데이터 흐름을 통제하지 못하는 문제점이 발생되었습니다.

이러한 문제점의 근본은 데이터의 흐름을 관리하지 못하는 데에서 기인했다고 볼 수 있습니다. 데이터의 흐름이 추적되지 않거나 추적이 어려우면 그만큼 디버깅이 어려워지고 이는 에러 발생시 소모되는 시간과 비용이 증가한다는 의미와 같습니다.

MVC 패턴을 포함한 비슷한 패턴들이 가지는 문제점들을 보완하기 위한 디자인 패턴이 바로 Flux 패턴입니다.

  • 애플리케이션의 규모가 커지면 Model과 View의 상호작용이 늘어날 뿐만아니라 다양한 Model과 View의 복합적인 상호작용이 일어나 내부 구조가 복잡해 집니다.
  • Flux 아키텍처는 단방향 데이터 흐름을 기반으로 한 패턴입니다.
  • 데이터 추가, 수정, 삭제 등 데이터에 관련된 모든 요청은 Action으로 시작되며 Store가 업데이트 되면 이는 다시 View로 전달됩니다.

Vue에서의 상태 관리 유형

Vue 애플리케이션에서는 데이터 흐름에 따라 3가지의 상태관리 유형이 있습니다.

  • props - emit 기반의 상태관리
  • event bus 기반의 상태관리
  • vuex 라이브러리 기반의 상태관리

아래에서는 동일한 todolist에 대한 3가지 유형의 구조를 살펴보고 그 차이점과 상태관리의 필요성 등에 대해서 알아보도록 하겠습니다.

CASE 1. props - emit 기반 todolist

구조적 특징

  • 부모 컴포넌트가 모든 제어권을 가지고 있습니다.
    상태(📦 data), 상태에 접근하는 방법(🎮 methods), 데이터의 흐름(📑 props)이 모두 최상위 컴포넌트에서 관리됩니다.
  • 데이터 송신, 수신의 책임이 분리되어 있습니다.
    하위 컴포넌트는 📡 emit 이벤트를 발생시켜 데이터를 전송할 수 있지만 그 결과는 알 수 없습니다. 데이터에 접근할 수 있는 유일한 방법은 부모 컴포넌트로부터 전달받는 📑 props 입니다.
  • 컴포넌트간 통신이 복잡합니다.
    모든 통신은 상위 컴포넌트에서 🛰 emit 이벤트를 수신하고 📦 data를 업데이트 한 다음, 전달하고자 하는 다른 컴포넌트에 📑 props로 전달해 주어야 합니다.
  • 통신 범위가 한정적입니다.
    부모 컴포넌트의 뷰 인스턴스가 적용된 로컬 범위로 통신 범위가 제한됩니다.
<!-- App.vue -->
<template>
  <div id="app">
    <TodoHeader />
    <!-- 🛰 🕹 -->
    <TodoInput v-on:addTodo="addTodo" />
    <!-- 🛰 🕹🕹 📑 -->
    <TodoList
      v-bind:todos="todos"
      v-on:removeTodo="removeTodo"
      v-on:toggleCompleted="toggleCompleted"
    />
    <!-- 🛰 🕹 -->
    <TodoFooter v-on:allClear="allClear" />
  </div>
</template>

<script>
import TodoHeader from "./components/TodoHeader";
import TodoInput from "./components/TodoInput";
import TodoList from "./components/TodoList";
import TodoFooter from "./components/TodoFooter";

export default {
  name: "App",
  // 📦
  data() {
    return {
      todos: [],
      id: 0,
    };
  },
  // 🎮 🕹🕹🕹🕹
  methods: {
    addTodo(newTodoItem) {
      const { item, completed } = newTodoItem;
      this.todos = [...this.todos, { item, completed }];
      localStorage.setItem(
        `todo-${newTodoItem.item}`,
        JSON.stringify(newTodoItem)
      );
    },
    removeTodo(targetTodo) {
      this.todos = this.todos.filter((todo) => todo.item !== targetTodo);
      localStorage.removeItem(`todo-${targetTodo}`);
    },
    toggleCompleted(targetTodo) {
      this.todos = this.todos.map((todo) =>
        todo.item === targetTodo
          ? { ...todo, completed: !todo.completed }
          : todo
      );
      let todo = JSON.parse(localStorage.getItem(`todo-${targetTodo}`));
      localStorage.setItem(
        `todo-${targetTodo}`,
        JSON.stringify({
          ...todo,
          completed: !todo.completed,
        })
      );
    },
    allClear() {
      this.todos = [];
      localStorage.clear();
    },
  },
  // 🗄 📁📁📁📁
  components: {
    TodoHeader,
    TodoInput,
    TodoList,
    TodoFooter,
  },
  created: function () {
    if (localStorage.length > 0) {
      for (let i = 0; i < localStorage.length; i++) {
        this.todos.push(JSON.parse(localStorage.getItem(localStorage.key(i))));
      }
    }
  },
};
</script>
<!-- Style 태그는 생략했습니다 -->
  • App.vue 컴포넌트는 4개의 📡 emit 이벤트를 🛰 on(수신)하고, 이에 대응하는 4개의 🎮 method를 가지고 있습니다.
  • 모든 하위컴포넌트들이 필요한 📦 data인 todos를 관리하고 있습니다.
<!-- TodoInput.vue -->
<script>
export default {
  // 📦
  data: function () {
    return {
      newTodoItem: {
        completed: false,
        item: "",
      },
      showModal: false,
    };
  },
  methods: {
    addTodo: function () {
      if (this.newTodoItem.item === "") {
        this.showModal = true;
        return;
      } else {
        this.$emit("addTodo", this.newTodoItem);
        this.clearInput();
      }
    },
    clearInput: function () {
      this.newTodoItem.item = "";
    },
  },
  components: {
    Modal: ModalComponent,
  },
};
</script>
  • 📦 컴포넌트 내부에서만 유효한 로컬 데이터를 가지고 있습니다.
  • 📡 새로운 투두를 추가하기 위해 emit 이벤트를 발생시키고 있지만, 그 결과는 알 수 없습니다.
<!-- TodoList.vue -->
<script>
export default {
  // 📑
  props: ["todos"],
  methods: {
    removeTodo: function(todo) {
      // 📡
      this.$emit("removeTodo", todo);
    },
    toggleCompleted: function(todo) {
      // 📡
      this.$emit("toggleCompleted", todo);
    }
  }
};
</script>
  • 📑 할 일 목록을 출력하기 위해 부모 컴포넌트로 부터 todos 데이터를 props로 전달받고 있습니다.
  • 📡 생성된 할 일에 완료 버튼과 삭제 버튼에서 사용할 emit 이벤트가 있습니다. 마찬가지로 이벤트에 대한 결과는 알 수 없습니다.
<!-- TodoFooter.vue -->
<script>
export default {
  methods: {
    allClear: function() {
      // 📡
      this.$emit("allClear");
    }
  }
};
</script>
  • 📡 모든 투두리스트를 삭제하기 위한 emit 이벤트가 있습니다. 마찬가지로 결과는 알 수 없습니다.

CASE 2. Event Bus 기반 todolist

구조적 특징

  • 부모 컴포넌트는 컴포넌트 출력에 대한 권한만 소유합니다.
  • 데이터 통신에 대한 기능을 🚌 Event Bus라는 Vue 인스턴스 객체에 이전합니다.
  • 🚌 Event Bus 인스턴스 객체에 접근할 수 있는 모든 컴포넌트간 자유로운 데이터 통신이 가능합니다.
    컴포넌트간 📡 emit 이벤트 발생과 🛰 on 이벤트 수신으로 데이터를 주고받을 수 있습니다. 이로 인해 데이터 통신의 추적, 관리의 복잡성이 증가한다는 문제점이 발생합니다.
  • 📁 컴포넌트의 복잡성이 증가합니다.
    📡 emit 이벤트를 수신하는 컴포넌트의 구조가 복잡해집니다. 이벤트에 대응하는 🎮 methods를 관리해야 하고 필요에 따라 📦 data를 📑 props로 전달하기도 해야 합니다.
<!-- App.vue -->
<template>
  <div id="app">
    <TodoHeader />
    &<TodoInput />
    <TodoList />
    <TodoFooter />
  </div>
</template>
<script>
import TodoHeader from "./components/TodoHeader";
import TodoInput from "./components/TodoInput";
import TodoList from "./components/TodoList";
import TodoFooter from "./components/TodoFooter";

export default {
  name: "App",
  components: {
    TodoHeader,
    TodoInput,
    TodoList,
    TodoFooter
  }
};
</script>
  • App.vue 부모 컴포넌트는 이제 자유입니다. 잘 키운 자식 컴포넌트들은 이제 알아서 척척척 스스로 어린이가 되었습니다.
  • 하위 컴포넌트에 대한 범위를 지정하고 출력하는 역할만 담당하고 있습니다.
<!-- TodoInput.vue -->
<script>
// 🚌
import EventBus from "./common/EventBus";

export default {
  data: function() {
    return {
      newTodoItem: {
        completed: false,
        item: ""
      },
      showModal: false
    };
  },
  methods: {
    addTodo: function() {
      if (this.newTodoItem.item === "") {
        this.showModal = true;
        return;
      } else {
        // 🚌 📡
        EventBus.$emit("addTodo", this.newTodoItem);
        this.clearInput();
      }
    },
    clearInput: function() {
      this.newTodoItem.item = "";
    }
  },
  components: {
    Modal: ModalComponent
  }
};
</script>
  • 🚌 EventBus 인스턴스에 📡 emit 이벤트를 발송하고 있습니다.
  • 전송하는 데이터의 목적지는 알 수 있지만, 그 결과는 알 수 없습니다.
    산타할아버지 집주소로 택배를 보냈지만 받았는지는 알 수 없는 것과 같습니다.
<!-- TodoList.vue -->
<script>
// 🚌
import EventBus from "./common/EventBus";

export default {
  // 📦
  data: function() {
    return {
      todos: []
    };
  },
  // 🎮
  methods: {
    // 🕹
    removeTodo: function(targetTodo) {
      this.todos = this.todos.filter(todo => todo.item !== targetTodo);
      localStorage.removeItem(`todo-${targetTodo}`);
    },
    // 🕹
    toggleCompleted: function(targetTodo) {
      this.todos = this.todos.map(todo =>
        todo.item === targetTodo
          ? { ...todo, completed: !todo.completed }
          : todo
      );
      let todo = JSON.parse(localStorage.getItem(`todo-${targetTodo}`));
      localStorage.setItem(
        `todo-${targetTodo}`,
        JSON.stringify({
          ...todo,
          completed: !todo.completed
        })
      );
    },
    // 🕹
    addTodo: function(newTodoItem) {
      const { item, completed } = newTodoItem;
      this.todos = [...this.todos, { item, completed }];
      localStorage.setItem(
        `todo-${newTodoItem.item}`,
        JSON.stringify(newTodoItem)
      );
    },
    // 🕹
    allClear: function() {
      this.todos = [];
      localStorage.clear();
    }
  },
  created: function() {
    if (localStorage.length > 0) {
      for (let i = 0; i < localStorage.length; i++) {
        this.todos.push(JSON.parse(localStorage.getItem(localStorage.key(i))));
      }
    }
    // 🛰🛰🛰🛰
    EventBus.$on("addTodo", this.addTodo);
    EventBus.$on("removeTodo", this.removeTodo);
    EventBus.$on("toggleCompleted", this.toggleCompleted);
    EventBus.$on("allClear", this.allClear);
  }
};
</script>
  • TodoList 컴포넌트가 데이터 관리의 역할을 맡게 되면서 이벤트 수신의 역할까지 맡게 되었습니다.
  • 이를 통해 컴포넌트의 복잡성은 📦 data를 관리하는 곳에 집중된다고 볼 수 있습니다.
  • 📦 data 관리를 위해 🎮 method가 생성되고 📡 emit 이벤트를 🛰 on 하는 등의 역할이 추가되면서 권한이 한 곳으로 집중됩니다.
<!-- TodoClear.vue -->
<script>
// 🚌
import EventBus from "./common/EventBus";

export default {
  methods: {
    allClear: function() {
      // 🚌 📡
      EventBus.$emit("allClear");
    }
  }
};
</script>
  • 🚌 EventBus만 추가되었을 뿐이지 TodoClear 컴포넌트는 오늘도 평화롭습니다.

CASE 3. Vuex 기반 todolist

구조적 특징

  • 데이터에 대한 흐름이 단방향으로 정리되었습니다.
    데이터에 대한 접근은 무조건 Action을 통해 이루어집니다.
  • 데이터에 접근하는 방법을 스토어가 생성, 관리합니다.
    📦 state에 접근하는 유일한 방법은 스토어가 생성한 🎮 mutations 메서드를 이용하는 방법입니다.
  • 데이터의 흐름에 대한 추적이 용이합니다.
    vuex는 vue 개발자도구에서 지원되며 상태에 대한 정보, action 이벤트에 대한 추적 정보 등을 제공합니다.
<!-- store.js -->
import Vue from "vue";
import Vuex from "vuex";
import { storage } from './mutations';
Vue.use(Vuex);

export const store = new Vuex.Store({
  // 📦
  state: {
    todos: storage.getTodos(),
  },
  // 🛰 🎮
  mutations: {
    // 🕹
    addTodo: function (state, newTodoItem) {
      const { item, completed } = newTodoItem;
      state.todos = [...state.todos, { item, completed }];
      storage.createTodo({ item, completed });
    },
    // 🕹
    removeTodo: function (state, targetTodo) {
      state.todos = state.todos.filter((todo) => todo.item !== targetTodo);
      storage.removeTodo(targetTodo);
    },
    // 🕹
    toggleCompleted: function (state, targetTodo) {
      state.todos = state.todos.map((todo) =>
        todo.item === targetTodo
          ? { ...todo, completed: !todo.completed }
          : todo
      );
      storage.toggleComplted(targetTodo);
    },
    // 🕹
    clearAll: function (state) {
      state.todos = [];
      storage.clearAll();
    },
  },
});
  • 생성된 스토어가 📦 state(data), 🎮 mutations(method)를 가지고 있습니다.
  • 컴포넌트에서 todos 데이터 값을 얻기 위해서는 this.$store.state.todos와 같은 깐깐징어처럼 정의된 방법을 따라야 합니다.
  • 데이터를 변경하기 위한 방법도 🎮mutations에서 정의한 🕹method를 사용하는 것입니다.
<!-- App.vue -->
<template>
  <div id="app">
    <TodoHeader />
    <TodoInput />
    <TodoList />
    <TodoFooter />
  </div>
</template>

<script>
import TodoHeader from "./components/TodoHeader";
import TodoInput from "./components/TodoInput";
import TodoList from "./components/TodoList";
import TodoFooter from "./components/TodoFooter";

export default {
  name: "App",
  components: {
    TodoHeader,
    TodoInput,
    TodoList,
    TodoFooter,
  },
};
</script>
  • 이벤트 버스 시절부터 편안한 노후를 보내고 있습니다.
    데이터 흐름에 대한 책임에서 자유로워졌습니다.
<!-- TodoInput.vue -->
<script>
export default {
  // 📦
  data: function() {
    return {
      newTodoItem: {
        completed: false,
        item: ""
      },
      showModal: false
    };
  },
  methods: {
    addTodo: function() {
      if (this.newTodoItem.item === "") {
        this.showModal = true;
        return;
      } else {
        // 📡 🕹
        this.$store.commit("addTodo", this.newTodoItem);
        this.clearInput();
      }
    },
    clearInput: function() {
      this.newTodoItem.item = "";
    }
  },
  components: {
    Modal: ModalComponent
  }
};
</script>
  • 📦 Input 엘리먼트에서 입력받는 투두 값을 보관하기 위한 로컬 data가 있습니다.
  • 📡 데이터를 추가하기 위해 깐깐징어가 정한 🕹method를 사용하고 있습니다.
<!-- TodoList.vue -->
<template>
  <div class="todolist-container">
    <ul>
      <!-- 📡 📑 -->
      <li
        v-for="todo in this.$store.state.todos"
        v-bind:key="todo.item"
        name="todo.item"
      >
        <span v-on:click="toggleCompleted(todo.item)">
          <img v-show="!todo.completed" src="../assets/circle-icon.png" />
          <img v-show="todo.completed" src="../assets/check-circle-icon.png" />
        </span>
        {{ todo.item }}
        <span v-on:click="removeTodo(todo.item)">
          <img src="../assets/trash-icon.png" />
        </span>
      </li>
    </ul>
  </div>
</template>

<script>
export default {
  methods: {
    removeTodo: function (todo) {
      // 📡 🕹
      this.$store.commit("removeTodo", todo);
    },
    toggleCompleted: function (todo) {
      // 📡 🕹
      this.$store.commit("toggleCompleted", todo);
    },
  },
};
</script>
  • 📦 state의 todos 데이터를 구독하고 있습니다.
    이는 📑 props로 데이터를 전달받는 것과 같으며, todos가 업데이트 되면 그 정보는 즉각 반영되어 v-for 디렉티브가 할 일 목록을 다시 반복 렌더링 합니다.
  • 📦 state의 todos 데이터를 변경하기 위한 🕹 method를 통해 데이터를 전송하고 있습니다.
<!-- TodoFooter.vue -->
<template>
  <div class="footer-container">
    <span class="all-clear-button" v-on:click="allClear">All Clear</span>
  </div>
</template>

<script>
export default {
  methods: {
    allClear: function () {
      // 📡 🕹
      this.$store.commit("clearAll");
    },
  },
};
</script>
  • 꿀보직입니다. 다음생에 웹 애플리케이션으로 태어난다면 Footer로 보내달라고 해야겠습니다.

비교 & 정리

  • 데이터 흐름을 관리하는 방법은 애플리케이션의 규모와 관리해야 하는 상태(데이터)의 수에 따라 결정할 수 있습니다.
  • 동적인 데이터를 출력하기만 하는 구조에서는 props-emit 방식이 더 유리한 방법일 수 있습니다.
    EventBus와 Vuex를 도입하는데 필요한 제반사항을 갖추는데 비용이 추가되기 때문입니다.
  • 애플리케이션이 규모에 비해 데이터 흐름이 적다면 글로벌 이벤트 버스를 통한 해결 방안도 있으므로 vuex 도입이 만능이 아니며 규모에 알맞는 방법에 대한 사전 진단이 필요하다고 할 수 있습니다.
  • 프로젝트의 규모에 따라서 통신 흐름을 결정하는 기준은 데이터 입니다. 데이터가 전역적으로 사용되는지, 변경이 되지 않는 static한 데이터 인지에 따라서 복합적인 상태관리 구조가 같이 사용됩니다.
  • 기업소개를 하는 페이지는 정해진 데이터가 출력되는 구조이기 때문에 단순한 props 전달 구조로, 방문자에게 문의를 받는 페이지는 입력 메시지나 고객 정보가 유지되어야 하므로 vuex의 스토어를 사용하는 등 애플리케이션에 유연한 적용을 할 수 있습니다.
profile
당근같은사람

0개의 댓글