프론트엔드에서의 MVC/MVVM 패턴을 알아보자

 

MVC 패턴이란 무엇일까?

MDN에 따르면 MVC는 사용자 인터페이스, 데이터 및 논리 제어를 구현하는데 널리 사용되는 소프트웨어 디자인 패턴이다. 이 패턴은 소프트웨어의 비즈니스 로직과 화면을 구분하는데 중점을 두고 있다. 이러한 "관심사 분리"는 더나은 업무의 분리와 향상된 관리를 제공한다.

MVC 소프트웨어 디자인 패턴의 3가지 부분은 다음과 같은 역할을 가지고 있다.

1. 모델: 데이터와 비즈니스 로직을 관리한다.

2. 뷰: 레이아웃과 화면을 처리한다.

3. 컨트롤러: 이벤트를 처리하여 모델을 변경주는 명령, 변경된 모델을 다시 화면에 그려주는 명령을 수행한다.

 

MVC 패턴에 단점이 있다고..?

 

특히 Frontend에서의 MVC를 살펴보자면, 

View는 유저의 인터랙션을 받아들여 Model을 갱신하게 된다. 이는 View는 어떤 Model을 갱신시켜야 되는지에 대해 알고 있으며, 결론적으로 View는 Model에 의존성이 생기게 된다는 의미이다.

 

View가 Model과 의존성이 있는 것은 말만 들으면 문제가 없을 것 같지만, 클라이언트에서 Model과 View가 바뀌는 원인이 다른데 의존성이 존재한다는 것이 문제가 된다.

  • Model은 비즈니스가 변경되었을 때 바뀌게 되고, View는 유저 인터랙션으로 인해 바뀌게 된다.

즉 View에서 인터랙션이 일어나면 Model을 바꾸는 경우가 빈번하고, 이로 인해 밀접한 관계를 가지게 되면 의존성 관리에 어려움을 겪게 된다.

 

이러한 단점을 개선한 것이 제왕적 MVC 패턴이다

 

제왕적 MVC 패턴이란?

View와 Model의 의존 구조를 제거하고, Controller에서 사용자의 인터랙션에 따른 Model의 변화와, Model의 변화에 따른 View의 변화를 모두 처리해줘야 된다.

즉, Controller에서 View, Model의 변화를 모두 처리해줘야 하며, Controller의 유지보수가 어려진다는 단점이 존재한다.

 

간단한 TodoList를 MVC 패턴으로 구현해보도록 합시다.

 

이 코드에서 가장 중요한 부분은 컨트롤러이다.

컨트롤러의 코드를 보게 되면, View의 인터랙션을 인지하여, Model을 수정할 수 있는 이벤트 핸들러를 바운딩하는 코드와 Model이 변경될 때마다 View를 업데이트해주는 코드가 산재해 있다

만약 컨트롤러에서 관리해야 할 View가 점점 늘어날수록 컨트롤러의 유지보수도 어려워질 것이고, 의존성이 꼬이게 될 가능성이 매우 높아진다.

// Controller가 이벤트에 대한 행동을 수행한다.

export default class Controller {
  constructor(view, model) {
    this.view = view;
    this.model = model;
    this.render();
  }

  handleClickAddBtn() {
    // Model을 변경
    const todoText = this.view.todoInput.getInputValue();
    if (!todoText) {
      alert("내용을 입력해주세요.");
      return;
    }
    this.model.addTodoItem(todoText);

    // 변경된 Model을 기반으로 View를 변경
    this.renderTodoListView();

    this.view.todoInput.clearInput();
  }

  handleClickRemoveBtn(todoId) {
    try {
      this.model.removeTodoItem(todoId);
      this.renderTodoListView();
    } catch (error) {
      alert("삭제를 성공하지 못했습니다...");
    }
  }

  handleEnterPress(event) {
    console.log(event);
    if (event.code === "Enter") {
      this.handleClickAddBtn();
    }
  }

  handleToggleTodoStatus(todoId) {
    try {
      this.model.toggleTodoItem(todoId);
      this.renderTodoListView();
    } catch (error) {
      alert("토글을 성공하지 못했습니다...");
    }
  }

  renderTodoListView() {
    this.view.todoList.displayTodoList(this.model.getTodoList());
    this.view.todoList.bindEventRemoveButton(
      this.handleClickRemoveBtn.bind(this)
    );
    this.view.todoList.bindToggleTodoItem(
      this.handleToggleTodoStatus.bind(this)
    );
    this.view.todoList.displayLastTodo(this.model.getTodoList());
  }

  renderTodoInputView() {
    this.view.todoInput.displayAddButton(this.handleClickAddBtn.bind(this));
    this.view.todoInput.bindPressEnterEvent(this.handleEnterPress.bind(this));
  }

  render() {
    this.renderTodoInputView();
    this.renderTodoListView();
  }
}

 

Model의 경우, 값이 변경될 때 LocalStorage와 같은 브라우저 스토리지를 활용하여 반영구적으로 저장하는 기능을 추가하였다. 

이 과정에서 `Proxy` 객체를 사용하고 싶었지만 배열의 불변성을 지키는 메서드를 활용했기 때문에 `Proxy`를 사용하여 속성 변경을 탐지할 수 없었다. 때문에 `Object.defineProperty()`라는 메서드를 활용하여 `TodoModel`의 인스턴스 자체의 todoList 속성을 수정하는 경우 브라우저 스토리지에 저장하도록 구현하였다.

export default class TodoModel {
  constructor(storageService) {
    let todoList = storageService.load() || [];
    Object.defineProperty(this, "todoList", {
      get() {
        return todoList;
      },
      set(value) {
        todoList = value;
        console.log("todoList has been reassigned");
        storageService.save(todoList);
      },
    });
  }

  getTodoList() {
    return this.todoList;
  }

  addTodoItem(todoText) {
    this.todoList = [
      ...this.todoList,
      { id: `${Date.now()}`, text: todoText, isDone: false },
    ];
  }

  removeTodoItem(todoId) {
    if (!todoId) {
      throw new Error("Not Found TodoItem ID");
    }
    this.todoList = this.todoList.filter((todo) => todo.id !== todoId);
  }

  toggleTodoItem(todoId) {
    if (!todoId) {
      throw new Error("Not Found TodoItem ID");
    }
    this.todoList = this.todoList.map((todo) =>
      todo.id === todoId ? { ...todo, isDone: !todo.isDone } : todo
    );
  }
}

 

 

View의 경우, Model을 기반으로 UI를 그리도록 구현하거나, 이벤트를 바인딩할 수 있는 메서드를 구현하여, Controller에서 이벤트 관련 함수를 주입할 수 있도록 구현하였다. 코드는 Sandbox에서 확인해볼 수 있다.

 

MVVM 패턴이란 무엇일까?

 

핵심은 Data Binder와 ViewModel이며, Data Binder를 통해 ViewModel에서의 View에 대한 의존성을 없앨 수 있다.

 

기존의 Controller에서 Model의 변경에 따라 View를 직접 변경해줬다면 MVVM은 바인딩을 통해 이를 자동으로 업데이트를 해주며, 바인딩은 Observer 패턴을 사용하여 구현한다.

 

 

ViewModel 코드를 먼저 보도록 하자.

export default class TodoViewModel {
  constructor(view, model) {
    this.view = view;
    this.model = model;
    this.model.subscribe(this.view.todoList.displayTodoList);
    this.model.subscribe(this.view.todoList.displayLastTodo);
    this.model.subscribe(this.bindTodoListEvents.bind(this));
    this.render();
  }

  handleClickAddBtn() {
    // Model을 변경
    const todoText = this.view.todoInput.getInputValue();
    if (!todoText) {
      alert("내용을 입력해주세요.");
      return;
    }
    this.model.addTodoItem(todoText);
    this.view.todoInput.clearInput();
  }

  handleEnterPress(event) {
    if (event.code === "Enter") {
      this.handleClickAddBtn();
    }
  }

  handleToggleTodoStatus(todoId) {
    try {
      this.model.toggleTodoItem(todoId);
    } catch (error) {
      alert("토글을 성공하지 못했습니다...");
    }
  }

  handleClickRemoveBtn(todoId) {
    try {
      this.model.removeTodoItem(todoId);
    } catch (error) {
      alert("삭제를 성공하지 못했습니다...");
    }
  }

  bindTodoListEvents() {
    this.view.todoList.bindEventRemoveButton(
      this.handleClickRemoveBtn.bind(this)
    );
    this.view.todoList.bindToggleTodoItem(
      this.handleToggleTodoStatus.bind(this)
    );
  }

  bindTodoInputEvents() {
    // 이벤트 바인딩
    this.view.todoInput.bindAddTodoEvent(this.handleClickAddBtn.bind(this));
    this.view.todoInput.bindPressEnterEvent(this.handleEnterPress.bind(this));
  }

  render() {
    this.bindTodoListEvents();
    this.bindTodoInputEvents();
  }
}

 

Controller의 코드와 비교해봤을 때, View에 이벤트 핸들러를 넣는 코드가 있다는 점은 동일하다. 

그러나, Model의 변경으로 인해 View를 다시 업데이트 해주는 함수가 따로 없고, 이러한 함수들은 model의 subscribe 메서드를 통해 관리하는 것처럼 보인다.

 

이번에는 Model 코드를 살펴보도록 하자.

import Observable from "./Observable.js";

export default class TodoModel extends Observable {
  constructor(storageService) {
    super();
    this.storageService = storageService;
    let _todoList = storageService.load() || [];

    Object.defineProperty(this, "todoList", {
      get() {
        return _todoList;
      },
      set(value) {
        _todoList = value;
        this.notify(value);
        this.storageService.save(value);
      },
    });
  }

  getTodoList() {
    return this.todoList;
  }

  addTodoItem(todoText) {
    this.todoList = [
      ...this.todoList,
      { id: `${Date.now()}`, text: todoText, isDone: false },
    ];
  }

  removeTodoItem(todoId) {
    if (!todoId) {
      throw new Error("Not Found TodoItem ID");
    }
    this.todoList = this.todoList.filter((todo) => todo.id !== todoId);
  }

  toggleTodoItem(todoId) {
    if (!todoId) {
      throw new Error("Not Found TodoItem ID");
    }
    this.todoList = this.todoList.map((todo) =>
      todo.id === todoId ? { ...todo, isDone: !todo.isDone } : todo
    );
  }
}

 

MVC 패턴의 Model과 거의 다를 바가 없어보인다. 그러나 `Observable`이라는 클래스를 상속받고 있고, `Object.defineProperty()` 메서드를 볼 때, 속성의 값이 변경될 때 `notify` 메서드를 호출하는 것을 볼 수 있다.

`notify`는 `TodoModel` 내부에 정의되지 않았으므로 `Observable`에서 정의되어 있음을 유추해볼 수 있다.

 

export default class Observable {
  constructor() {
    this.callbacks = [];
  }

  notify(value) {
    this.callbacks.forEach((callback) => callback(value));
  }

  subscribe(callback) {
    this.callbacks.push(callback);
  }

  unsubscribe(callback) {
    this.callbacks = this.callbacks.filter((cb) => cb !== callback);
  }
}

`Observable`을 보면 `subscribe` 메서드를 통해 `callbacks` 배열에 `Observable`을 상속하는 `Model`에 의존하는 View에 대한 업데이트 함수를 추가하는 것을 확인할 수 있다. 

`notify` 메서드를 통해서 `callbacks`에 저장된 View 업데이트 함수를 호출하고, `unsubscribe` 메서드를 통해서 업데이트 함수를 제거할 수 있다.

 

즉, Model의 수정으로 인한 View의 업데이트에 대한 책임을 Controller에서 `Observer`로 옮기게 된 것이라고 볼 수 있다. 

물론, `Observable`를 상속한 모델이 View 업데이트 함수를 관리하는 것이니 MVC의 첫번째 패턴과 동일하지 않냐고 반문할 수 있지만, MVC는 View 업데이트 함수를 Model 업데이트 함수와 직접적으로 연결이 되지만, 여기서는 그렇지 않으며 model이 변경될 때에 자동으로 호출하게 하여 제어의 역전을 이루게 된다.

 

 

출처

 

https://developer.mozilla.org/ko/docs/Glossary/MVC