[우아한테크코스 4기] 끝나지 않은 프리코스 (내가 다시 설계한 MVC)

NinjaJuunzzi·2021년 12월 17일
5

배달의민족

목록 보기
5/6
post-thumbnail

기존 설계의 문제점

크게 두 가지로 정리된다.

  1. 모델을 서브 모델들로 분리하지 않아, 하나의 클래스에 모든 정보가 담기게 되어 유지보수가 너무 힘들었다.

  2. 컨트롤러가 하는 일에 대한 정의가 모호했다. 세 가지 정도로 컨트롤러의 하는 일을 정의하여, 이를 하나의 일만 하는 함수로 쪼갤 수 있었는데, 그러지 못했다. -> 모델을 업데이트, 뷰를 업데이트 하는 코드가 여러 함수로 분산되어, 나 조차도 어디를 수정해야할지 못찾는 경우가 생겼다.

이를 중점으로 설계를 개선해보았다. 부족하다고 느낀점을 계속해서 개선해나갈 계획이다.

모델

Master Model Class

앱에서 사용되는 모든 모델 객체들을 가지고 있다. 이 모델의 메소드를 이용하여, 특정 모델들을 참조할 수 있게된다.

// Master Model
class VendingMachineModel {
  constructor() {
    this.$global = new Global(store.getLocalStorage(DATA_MODEL_KEY.GLOBAL));
    this.$product = new Product(store.getLocalStorage(DATA_MODEL_KEY.PRODUCT));
    this.$user = new User(store.getLocalStorage(DATA_MODEL_KEY.USER));
    this.$vendingMachine = new VendingMachine(store.getLocalStorage(DATA_MODEL_KEY.VENDING_MACHINE));
    this.$inputs = new Inputs(store.getLocalStorage(DATA_MODEL_KEY.INPUTS));
  }

  getProductModel() {
    return this.$product;
  }

  getUserModel() {
    return this.$user;
  }

  getVendingMachineModel() {
    return this.$vendingMachine;
  }

  getInputsModel() {
    return this.$inputs;
  }

  getGlobalModel() {
    return this.$global;
  }
}

Sub Model Class

마스터 모델 클래스에서 객체로 가지고 있는 서브 모델 객체들의 클래스이다. 이 클래스는 Model 클래스를 상속받아, 확장된 클래스가 된다.

Sub Model Class - parent class

각 모델에서 사용되는 공통 메소드 및 오버라이드 해야하는 메소드들을 정의해두었다. $data 멤버에는 각 모델에서 사용되는 데이터가 객체형태로 저장된다.

// sub parent class
import store from '../../lib/store.js';

class Model {
  
  constructor({ previousData, modelName }) {
    
    this.$data = {};
    this.$modelName = modelName;
    this.initializeData(previousData);
  }

  initializeData(previousData) {
    this.$data = previousData ? previousData : this.generateDefaultValue();
    this.setDataInStore();
  }

  generateDefaultValue() {
    return {};
  }

  getDataByKey(key) {
    return this.$data[key];
  }
	
  setDataByKey(key, value) {
    this.$data[key] = value;
    this.setDataInStore();
  }

  // 전역 로컬 스토리지 스토어에 저장한다.
  setDataInStore() {
    store.setLocalStorage(this.$modelName, this.$data);
  }
}
export default Model;

sub model class - child class

각 모델 고유의 메소드 및 오버라이드 한 메소드들을 정의한다. 멤버는 따로 지정하지 않는다. (필요하지 않는 이상)

이 모듈(파일)에는 해당 모델에서 사용하는 키가 정의되어 있는데, 이 키를 통해서만 데이터를 얻고, 수정할 수 있게된다.

import { DATA_MODEL_KEY } from './lib/constants.js';
import Model from './Model.js';
export const PRODUCT_KEYS = {
  PRODUCT_LIST: 'productList',
};
class Product extends Model {
  constructor(previousData) {
    super({ previousData, modelName: DATA_MODEL_KEY.PRODUCT });
  }
  /* 각 모델이 갖는 데이터 형태에 따라 다르므로 오버라이드 한다. */
  generateDefaultValue() {
    return {
      [`${PRODUCT_KEYS.PRODUCT_LIST}`]: [],
    };
  }

  getProductList() {
    
    return this.getDataByKey(PRODUCT_KEYS.PRODUCT_LIST);
    
  }

  setNewProductAtLast(newProduct) {
    const productList = this.getProductList();
    
    this.setDataByKey(PRODUCT_KEYS.PRODUCT_LIST, [...productList, newProduct]);
    
  }

  setNewProductAtPosition(newProduct, index) {
    const productList = this.getProductList();
    
    productList[index] = newProduct;
    
    this.setDataByKey(PRODUCT_KEYS.PRODUCT_LIST, [...productList]);
  }

  findProduct(productId) {
    const productList = this.getProductList();

    return productList.find((product) => product.id === productId);
  }

  updateProduct(targetProduct) {
    const productList = this.getProductList();

    const index = productList.findIndex((product) => product.id === targetProduct.id);
    
    this.setNewProductAtPosition(targetProduct, index);
  }
}
export default Product;

컨트롤러

컨트롤러 또한 마스터 컨트롤러 클래스와 서브 컨트롤러 클래스 구조로 설계하였다.

Master Controller Class

마스터 컨트롤러의 $controller 에는 현재 렌더링 된 UI를 관리하는 sub controller가 담긴다.

마스터 컨트롤러는

  1. 모델과 뷰를 인자로 받는다.

  2. 서브 모델들을 초기화 하여 멤버로 가지고 있는다.

  3. 서브 컨트롤러들에게 필요한 모델과 뷰를 전달해준다.

  4. 서브 컨트롤러가 관리하는 UI를 렌더링 하도록 하고, 서브 컨트롤러를 멤버로 바인딩한다.


class VendingMachineController {
  constructor(view, model) {
    this.$view = view;
    this.$model = model;

    this.initializeModels();
    this.bindEventHandler();
    this.mountMainSection();
  }

  initializeModels() {
    this.$globalModel = this.$model.getGlobalModel();
    this.$userModel = this.$model.getUserModel();
    this.$inputsModel = this.$model.getInputsModel();
    this.$vendingMachineModel = this.$model.getVendingMachineModel();
    this.$productModel = this.$model.getProductModel();
  }

  bindEventHandler() {
    $(DOM.TAB_MENU_SECTION).addEventListener('click', this.onClickTab.bind(this));
  }

  onClickTab(e) {
    const {
      target: { textContent },
    } = e;
    this.mutateModelByNewTab(textContent);
    this.mountMainSection();
  }

  mutateModelByNewTab(tab) {
    this.$globalModel.setTab(tab);
  }

  mountMainSection() {
    const tab = this.$globalModel.getTab();
    if (tab === TAB.productAddMenu) {
      // 메인 섹션을 렌더링 한다.
      this.renderProductAddView();
      // 렌더링 된 메인섹션에 맞는 컨트롤러를 바인딩한다.
      this.bindProductAddController();
    }
    if (tab === TAB.vendingMachineManageMenu) {
      this.renderVendingMachineView();
      this.bindVendingMachineManageController();
    }
    if (tab === TAB.productPurchaseMenu) {
      this.renderProductPurchaseView();
      this.bindProductPurchaseController();
    }
  }

  /** mainSection의 rendering을 트리거한다. 이 함수에서 모델로부터 데이터를 빼와 뷰에 뿌려준다. */
  renderProductAddView() {
    const productInformationInputs = this.$inputsModel.getProductInformationInputs();

    const productList = this.$productModel.getProductList();
    this.$view.renderProductAddView(productInformationInputs, productList);
  }

  bindProductAddController() {
    this.$controller = new ProductAddController({
      productModel: this.$productModel,
      inputsModel: this.$inputsModel,
      view: this.$view.$mainSection,
    });
  }

  renderVendingMachineView() {
    const chargeInputs = this.$inputsModel.getVendingMachineChargeInputs();
    const coins = this.$vendingMachineModel.getCoins();

    this.$view.renderVendingMachineView({
      chargeInputs,
      coins: coins.getCoinsData(),
      chargeAmount: coins.getChargeAmount(),
    });
  }

  bindVendingMachineManageController() {
    this.$controller = new VendingMachineManageController({
      vendingMachineModel: this.$vendingMachineModel,
      inputsModel: this.$inputsModel,
      view: this.$view.$mainSection,
    });
  }

  renderProductPurchaseView() {
    const chargeInputs = this.$inputsModel.getUserChargeInputs();
    const chargeAmount = this.$userModel.getCharge();
    const productList = this.$productModel.getProductList();

    this.$view.renderProductPurchaseView({
      chargeInputs,
      chargeAmount,
      productList,
    });
  }

  bindProductPurchaseController() {
    this.$controller = new ProductPurchaseController({
      productModel: this.$productModel,
      inputsModel: this.$inputsModel,
      userModel: this.$userModel,
      view: this.$view.$mainSection,
    });
  }
}
export default VendingMachineController;

Sub Controller Class

서브 컨트롤러는 사용되는 모델과, 렌더링 된 view를 전달받는다. ProductAddController 이므로 view로는 ProductAddView 객체가, model 로는 productModel, inputsModel이 전달된다.

import { DOM, PLAIN_TEXT } from '../../lib/constants.js';
import { $, generateRandomProductId } from '../../lib/utils.js';

class ProductAddController {
  constructor({ productModel, inputsModel, view }) {
    this.$productModel = productModel;
    this.$inputsModel = inputsModel;
    this.$view = view;
    this.bindEventHandler();
  }

  bindEventHandler() {
    $(DOM.PRODUCT_ADD_FORM).addEventListener('input', this.onInputProductAddForm.bind(this));
    $(DOM.PRODUCT_ADD_FORM).addEventListener('submit', this.onSubmitProductAddForm.bind(this));
  }

  // model 과 뷰를 업데이트 한다.
  onInputProductAddForm(e) {
    const {
      target: { value, id },
    } = e;

    this.mutateModelByNewProductInput(id, value);
    this.renderViewByNewProductInput();
  }

  mutateModelByNewProductInput(id, value) {
    this.$inputsModel.setInputByIdAttribute(id, value);
  }

  renderViewByNewProductInput() {
    this.$view.renderWithNewInput(this.$inputsModel.getProductInformationInputs());
  }

  /** 상품을 만들어 모델을 업데이트하고 뷰를 업데이트한다. */
  onSubmitProductAddForm(e) {
    e.preventDefault();
    try {
      const newProduct = this.makeNewProduct();

      this.mutateModelByNewProduct(newProduct);
      this.renderViewByNewProductList();

      this.mutateModelByPlainInputValue();
      this.renderViewByNewProductInput();
    } catch (error) {
      alert(error);
    }
  }

  makeNewProduct() {
    const { name, price, quantity } = this.$inputsModel.getProductInformationInputs();

    return {
      id: generateRandomProductId(),
      name,
      price: Number(price),
      quantity: Number(quantity),
    };
  }

  mutateModelByNewProduct(newProduct) {
    this.$productModel.setNewProductAtLast(newProduct);
  }

  renderViewByNewProductList() {
    const productList = this.$productModel.getProductList();
    this.$view.renderWithNewProductList(productList);
  }

  mutateModelByPlainInputValue() {
    this.$inputsModel.setInputByIdAttribute(DOM.PRODUCT_NAME_INPUT, PLAIN_TEXT);
    this.$inputsModel.setInputByIdAttribute(DOM.PRODUCT_PRICE_INPUT, PLAIN_TEXT);
    this.$inputsModel.setInputByIdAttribute(DOM.PRODUCT_QUANTITY_INPUT, PLAIN_TEXT);
  }
}
export default ProductAddController;

컨트롤러 정리

컨트롤러는 핸들러 함수를 메소드로 갖고, 이를 렌더링 된 DOM Element에 바인딩한다. 핸들러 함수 내부에서는 다음과 같은 메소드가 실행된다.

1. 모델과 뷰를 업데이트 할 데이터를 만든다. 

2. 모델을 업데이트 하는 메소드를 실행한다.

3. 뷰를 업데이트 하는 메소드를 실행한다.

무조건 위 절차를 지향하며, 구현된다. (좀 더 룰처럼 만들 수 있을 것 같다)

뷰 또한 마스터 뷰, 서브 뷰로 설계해보았다. 뷰가 하는 일은 데이터를 전달받아 렌더링 하는 것 뿐. 이를 좀 더 선언적으로 구현하고 싶어 옵저버 패턴을 떠올렸지만, 뭐든 단계가 있는 법이라 생각한다. 지금은 명령적이어도 이 아키텍처를 좀 더 연습해보고 싶다.

$mainSection 멤버에는 렌더링 되는 SubView 객체가 담기게 된다. 위 SubController는 이 $mainSection을 참조하여, 렌더 메소드들을 트리거 한다.

Master View Class

import { DOM } from '../../lib/constants.js';
import { $ } from '../../lib/utils.js';
import { TAB } from '../model/lib/constants.js';
import ProductAddView from './ProductAdd.js';
import ProductPurchaseView from './ProductPurchase.js';
import VendingMachineManage from './VendingMachineManage.js';

class VendingMachineView {
  constructor() {
    this.$app = $(DOM.APP);

    this.mount();
  }

  mount() {
    this.$app.innerHTML = this.generateAppTemplate();
  }

  generateAppTemplate() {
    return `<h1>🥤자판기🥤</h1><section id="${DOM.TAB_MENU_SECTION}">
      <button id="${DOM.PRODUCT_ADD_MENU}">${TAB.productAddMenu}</button>
      <button id="${DOM.VENDING_MACHINE_MANAGE_MENU}">${TAB.vendingMachineManageMenu}</button>
      <button id="${DOM.PRODUCT_PURCHASE_MENU}">${TAB.productPurchaseMenu}</button>
      </section>
      <main id="${DOM.MAIN_SECTION}"></main>`;
  }

  /** 인자는 객체로 바꾼다? */
  renderProductAddView(productInformationInputs, productList) {
    this.$mainSection = new ProductAddView(
      $(DOM.MAIN_SECTION),
      productInformationInputs,
      productList
    );
  }

  renderVendingMachineView({ chargeInputs, coins, chargeAmount }) {
    this.$mainSection = new VendingMachineManage({
      mainSection: $(DOM.MAIN_SECTION),
      chargeInputs,
      coins,
      chargeAmount,
    });
  }

  renderProductPurchaseView({ chargeInputs, chargeAmount, productList }) {
    this.$mainSection = new ProductPurchaseView({
      mainSection: $(DOM.MAIN_SECTION),
      chargeInputs,
      chargeAmount,
      productList,
    });
  }
}

export default VendingMachineView;

Sub View Class

Sub View는 두 가지 렌더링을 담당한다.

  1. 처음 마운트 될 때의 렌더링

  2. 데이터가 업데이트 되어, 기존 돔을 새로운 데이터에 맞게 조작할 때 (이 부분을 선언적으로 개선하고 싶긴하다 )

import { DOM } from '../../lib/constants.js';
import { $ } from '../../lib/utils.js';

class ProductAddView {
  constructor(mainSection, productInformationInputs, productList) {
    this.$app = mainSection;
    this.mount(productInformationInputs, productList);
  }

  mount(productInformationInputs, productList) {
    this.$app.innerHTML = this.generateTemplate(productInformationInputs, productList);
  }

  generateTemplate(productInformationInputs, productList) {
    return `
      ${this.createProductAddFormSectionTemplate(productInformationInputs)}
      ${this.createProductListSectionTemplate(productList)}
      `;
  }

  createProductAddFormSectionTemplate(productInformationInputs) {
    return `<h3>상품 추가하기</h3><form id="${DOM.PRODUCT_ADD_FORM}">
    <input id="${DOM.PRODUCT_NAME_INPUT}" placeholder="상품명" value="${productInformationInputs.name}"></input>
    <input id="${DOM.PRODUCT_PRICE_INPUT}" type="number" placeholder="가격" value="${productInformationInputs.price}"></input>
    <input id="${DOM.PRODUCT_QUANTITY_INPUT}" type="number" placeholder="수량" value="${productInformationInputs.quantity}"></input>
    <button id="${DOM.PRODUCT_ADD_BUTTON}">추가하기</button>
    </form>
    `;
  }

  createProductListSectionTemplate(productList) {
    return `
      <h3>상품 현황</h3>
      <table id="${DOM.PRODUCT_LIST_TABLE}">
        ${this.createTableRowsTemplate(productList)}
      </table>
      `;
  }

  createTableRowsTemplate(productList) {
    return `
    <tr id="${DOM.PRODUCT_LIST_TABLE_HEADER}"><td>상품명</td><td>가격</td><td>수량</td></tr>
    ${productList
      .map(
        (product) => `
      <tr class="${DOM.PRODUCT_MANAGE_ITEM_CLASSNAME}">
        <td class="${DOM.PRODUCT_MANAGE_NAME_CLASSNAME}">${product.name}</td><td class="${DOM.PRODUCT_MANAGE_PRICE_CLASSNAME}">${product.price}</td><td class="${DOM.PRODUCT_MANAGE_QUANTITY_CLASSNAME}">${product.quantity}</td>
      </tr>
    `
      )
      .join('')}`;
  }

  renderWithNewInput({ name, price, quantity }) {
    $(DOM.PRODUCT_NAME_INPUT).value = name;
    $(DOM.PRODUCT_PRICE_INPUT).value = price;
    $(DOM.PRODUCT_QUANTITY_INPUT).value = quantity;
  }

  renderWithNewProductList(productList) {
    $(DOM.PRODUCT_LIST_TABLE).innerHTML = this.createTableRowsTemplate(productList);
  }
}
export default ProductAddView;

결론

내가 다시 설계해본 M-V-C는 위와 같다. 댓글로 지적해주시면 너무 감사하구, 개선점이 생길때마다 이 포스트의 내용도 바뀔 것 같다.

profile
Frontend Ninja

0개의 댓글