지난 번 플러터를 사용하여 프로젝트를 진행하였을 때, MVVM패턴을 통해 화면과 로직이 분리되어 수정이 편하고 비즈니스 로직 또는 서버의 변화에 대한 대처가 용이하다고 느꼈습니다. 이번에는 화면과 로직을 분리하는 방식을 리액트에서도 적용하고 싶었지만 몇 가지 문제가 있었고 이를 어떻게 해결할지에 대한 고민을 정리한 글입니다. 사실 커스텀 훅을 사용하는 것이 더 나은 방법이지만 로직을 리액트에서 완전히 분리하여 독립적으로 사용하는 방법을 생각해보는 내용입니다. 즉 리액트로 작성되어 있지 않은 로직을 리액트 컴포넌트에서 그대로 사용할 수 있는 방법입니다.

일단 코드만이라도 나누어보자

간단한 이해를 위해 실제 분리가 아닌 코드를 나눌 뿐이므로 바쁘시다면 생략하고 실제로 화면과 로직 분리하기로 바로 넘어가주세요.

로직만 수행하는 컴포넌트 만들기

화면에서 비즈니스 로직과 화면을 분리하기 위해 가장 먼저 상태들을 분류할 필요가 있습니다. 어떤 상태는 화면의 표시를 보조하기 위해 사용되고 어떤 상태는 서버에서 가져온 비즈니스 로직과 관련된 상태가 됩니다. 리액트의 상태들은 일반적으로 로컬 상태, 전역 상태, 서버 상태로 나눌 수 있습니다. 전역 상태는 이미 화면과 분리가 되어 있는 상태입니다. 크게 신경 쓸 필요가 없습니다. 서버 상태나 로컬 상태는 Tanstack Queary와 커스텀 훅(또는 useState를 사용한 단순 상태)으로 만들고 해당 훅을 사용하는 컴포넌트 자체를 ViewModel로 사용할 수 있을 것입니다. 이제 이 모든 상태는 화면을 표시하는 컴포넌트가 아닌 곳에서 사용합니다.

import { useState } from "react";
import { View } from "./View";

const MESSAGES = ["서버 데이터라고 합시다.", "데이터가 바뀌었어요.", "다음은 처음 데이터로 가요."];

export default function Model() {
  const [currentMessageIdx, setCurrentMessageIdx] = useState(0);

  function updateMessage() {
    setCurrentMessageIdx((pre) => (pre + 1) % MESSAGES.length);
  }
  return <View text={MESSAGES[currentMessageIdx]} onClickButton={updateMessage}></View>;
}

위의 코드는 간단한 Model을 표현하고 있습니다. currentMessageIdx를 이용해 MESSAGES에서 어떤 데이터를 사용할지 정합니다. 실제 코드에서는 MESSAGES가 서버가 가진 데이터들이고 currentMessageIdx를가 HTTP 요청의 파라미터라고 생각하시면 됩니다. 그리고 상태를 변경하는 로직인 updateMessage는 VIew에서 입력을 받으면 실행되는 비즈니스 로직입니다.

 

이를 더 세분화하여 서버에서 MESSAGES를 가져오는 repository, ViewModel이 데이터를 쉽게 사용할 수 있도록 가공하는 Model, ViewModel이 접근하는 통로인 Service로 나눌 수 있습니다. 하지만 지금은 Model을 하나로 두었습니다. 그리고 쉽게 확인하기 위해 VIew를 바로 불렀는데요. 이 부분은 나중에 수정하도록 하겠습니다.

화면만 그리는 컴포넌트 만들기

화면만 그리는 컴포넌트를 제작하는 것 역시 어렵지 않습니다. 모든 내용을 Props로 받아 표시하면 됩니다. VIew의 역할은 화면을 표시하는 것과 사용자의 입력을 받는 것 두 가지입니다. 스타일드 컴포넌트를 사용한다면 그 자체가 View의 예시라고 볼 수 있습니다. VIew에서 필요한 데이터를 Props로 받아 표시하기만 하면 VIew의 역할을 충실히 수행하는 것입니다.

type Props = {
  text: string;
  onClickButton: () => void;
};

export const View: React.FC<Props> = ({ text, onClickButton }) => {
  return (
    <>
      <p>{text}</p>
      <button onClick={onClickButton}>next message</button>
    </>
  );
};

이제 View가 완성되었습니다. 사용자의 입력을 받는 버튼은 ViewModel에서 받은 onClickButton 함수를 실행시킬 겁니다. 그러면 표시되는 텍스트가 바뀌게 됩니다. CSS로 조금만 꾸미면 화면은 다음과 같이 그려집니다.

결과

실제로 화면과 로직 분리하기

지금까지 완성한 코드는 MVVM 패턴이라고 볼 수는 없습니다. 화면을 표시하는 컴포넌트와 비즈니스로직을 처리하는 컴포넌트가 분리되었지만 둘 사이의 의존성이 존재합니다. 코드로는 분리되었지만 논리적으로 분리되어있지 않은 상황입니다. Model과 View를 겉보기에만 분리되지 않고 실제로 분리한다는 것은 둘 사이의 의존성을 없앤다는 말과도 비슷합니다. 서로의 존재와 상관없이 독자적으로 작동할 수 있어야 한다는 뜻입니다. 지난 번에 Flutter에서는 redux와 같이 전역 상태라이브러리이지만 해당 상태를 사용하는 위젯(컴포넌트 )가 없을 때 메모리에서 완전히 삭제하는 기능이 있는 라이브러리인 riverPod을 이용하여 편하게 MVVM 패턴을 구했었습니다. 하지만 제가 알기로는 리액트에서는 불가능합니다.

 

단방향 데이터 흐름을 특성으로 하는 React에서는 MVVM 패턴을 그대로 사용하기 보다는 조금 다르게 만들 필요가 있습니다. 개념적으로 데이터와 로직을 분리하기 위해서는 다음과 같은 목표를 이루어야 합니다.

  1. View는 오직 화면 렌더링에만 집중하기
  2. Model은 비즈니스 로직 처리에만 집중하기
  3. View와 Model의 의존성 없애기
  4. ViewModel을 통해 View와 Model이 통신하며 ViewModel은 View의 구조에 영향받지 않고 필요한 것을 제공할 수 있어야함
  5. Model의 모든 데이터와 로직은 ViewModel을 통해 가공되어 View가 자유롭게 사용 가능해야 함

현재까지 작성한 코드는 1번과 2번 조건까지만 만족합니다. 둘 사이에는 분명한 의존성이 존재하고 ViewModel이 존재하지 않습니다. 중요한 점은 서버 및 비즈니스 로직의 변화가 화면의 표시에 영향을 주지 않아야 하고 화면은 비즈니스 로직과 무관하게 변경 가능해야 합니다. 이를 위해 View와 Model 사이에 ViewModel을 둔 것이 핵심이라고 생각했습니다. 결국 중요한 것은 서버 상태나 비즈니스 로직의 변화가 화면 렌더링에 영향을 주지 않도록 의존성을 지워주는 것입니다.

View와 Model 사이의 통신은 모두 ViewModel을 통해 이루어집니다.

위의 그림은 context-api를 이용해 View와 Model의 의존성을 분리하는 방법입니다. Context API로 ViewModel을 주입하여 Prop 드링릴도 피하고 Model과 View 사이에 ViewModel을 두어 서로의 변경 사항이 서로의 코드에 영향을 주지 않게 만들 수 있습니다. 쉽게 설명하면 ViewModel에서 데이터와 로직을 View와 Model 서로 사용이 가능한 형태로 변경하기 때문에 각각의 변경이 서로 영향을 주지 않습니다. 그래서 View와 Model 사이에 이를 중재하는 ViewModel을 두고 context API를 이용해 View와 Model을 자식으로 두었습니다. ViewModel이 View와 Model이 서로 통신하는 통로가 되는 것입니다. 실제 MVVM 패턴과는 멀어졌지만 데이터와 비즈니스 로직을 UI로부터 분리하는 목표는 동일합니다.

 

그럼 계획에 따라 화면과 비즈니스 로직을 분리해보겠습니다.

Model

const MESSAGE = ["서버 데이터라고 합시다.", "데이터가 바뀌었어요.", "다음은 처음 데이터로 가요."];

export default class Model {
  constructor(private index = 0) {}

  getMessageByIndex = (): string => {
    this.index = (this.index + 1) % MESSAGE.length;
    return MESSAGE[this.index];
  };
}

Model입니다. 특이하게도 React의 컴포넌트가 아닙니다. Model은 비즈니스 로직을 담당하고 React는 UI를 표시하는 라이브러리이기 때문에 전혀 이상한 것이 아닙니다. getMessageByIndex가 실제로는 서버의 데이터를 가져오는 것이라고 생각하시면 됩니다. class로 만든 이유는 전역 네임스페이스의 오염을 막을 수 있고 ViewModel에 의존성을 주입하는 것이 용이하기 때문입니다.(ViewModel은 Model을 가공하기 때문에라도 Model을 알아야 합니다!) 또 Model은 비즈니스와 관련된 데이터인 this.index를 가지고 있습니다.

 

View-ViewModel-Model이 모여 하나의 화면을 그리는데 서로 간의 데이터 통신은 기존의 방식보다 조금 더 거치는 단계가 많아진 만큼 성능에 불리한 면이 있을 수 있습니다. 그래서 View-ViewModel-Model의 구조체가 너무 커진다면 이것을 분리하는 것이 좋습니다.

View

import { useViewModel } from "./ViewModel";

export const View: React.FC = () => {
  const { message, clickCount, fetchMessage } = useViewModel();

  return (
    <>
      <p> 클릭 횟수: {clickCount}</p>
      <button onClick={fetchMessage}>next message</button>
      <p>{message}</p>
    </>
  );
};

View는 크게 달라지는 것이 없습니다. props로 받아오던 데이터와 로직을 useViewModel이라는 context를 사용하는 커스텀 훅으로 대체하였습니다. 받아온 데이터를 표시하고 버튼과 같은 사용자와의 상호작용을 ViewModel로 전달할 수 있어야 합니다.

ViewModel

import { createContext, useContext, useState } from "react";
import Model from "./Model";

const defaultValue = {
  message: "No Message",
  clickCount: 0,
  fetchMessage: () => {},
};

const ViewModelContext = createContext(defaultValue);

export const useViewModel = () => useContext(ViewModelContext);

export const withViewModelProvider = (View: React.FC, model = new Model()) => {
  return (props: object) => {
    const [message, setMessage] = useState<string>("No Message");
    const [clickCount, setClickCount] = useState(0);

    const fetchMessage = () => {
      try {
        setClickCount((prev) => ++prev);
        const result = model.getMessageByIndex();
        setMessage(result);
      } catch (error) {
        console.error("Failed to get message:", error);
      }
    };

    return (
      <ViewModelContext.Provider value={{ message, fetchMessage, clickCount }}>
        <View {...props} />
      </ViewModelContext.Provider>
    );
  };
};

ViewModel에서 사용할 context를 먼저 만들고 데이터의 변화에 따라 화면을 다시 렌더링할 수 있도록 가공된 데이터는 useState에 담았습니다. 그리고 cotntext를 제공할 Provider를 래핑하여 사용합니다. ViewModel이 필요한 데이터를 fetching하기 위해 인덱스가 필요한데, 화면의 리렌더링과 관련없이 버튼을 누른 횟수만을 기억해야 되므로 useRef를 이용하였습니다. 모든 데이터는 ViewModel이 가지고 있고 View가 해당 데이터를 받아 사용합니다. 그리고 앞서 언급했듯 Model은 컴포넌트가 아닙니다. 대신 Model을 외부에서 주입받아 Model을 ViewModel이 사용하는 형태입니다. 여기서 clickCount는 ViewModel이 관리하는 UI 관련 데이터입니다. Model에서 언급했던 비즈니스 관련 데이터(this.index)와 달리 ViewModel이 관리하는 데이터입니다.

 

View-ViewModel-Model의 구조체를 렌더링 하기 위해서는 다음과 같은 방법을 사용하면 됩니다.

import { View } from "./View";
import { ViewModelProvider } from "./ViewModel";

function App() {
  return (
    <ViewModelProvider>
      <Vie></Vie>
    </ViewModelProvider>
  );
}

export default App;

HOC로 조금 더 쉽게 사용하기

사실 이번에 함수형 프로그래밍을 공부하면서 함수합성 나아가 리액트의 HOC와 컴포지션과 같은 개념을 알게되었습니다. 블로그에 곧 정리하려고 공부중입니다. 맛보기로 함수합성을 이용해 MVVM 패턴을 조금 더 간단하게 사용할 수 있도록 바꾸어 보겠습니다.

// ViewModel.tsx
export const withViewModelProvider = (WrappedComponent: React.FC) => {
  return (props: object) => {
    //... 원래 로직

    return (
      <ViewModelContext.Provider value={{ message, fetchMessage, clickCount: clickCount.current }}>
        <WrappedComponent {...props} />
      </ViewModelContext.Provider>
    );
  };
};

// View.tsx
export const MyComponent = withViewModelProvider(View);

// 사용하려는 곳
function App() {
  return <MyComponent />;
}

context가 없어도 됩니다.

예시와 같은 경우, 짧은 거리에 있는 자식에게 데이터를 전달할 경우 porps를 사용하지 않아도 됩니다. 그럴 경우 ViewModel이 context가 없는 단순히 View의 부모 컴포넌트로 만들어 불필요하게 context를 만들지 않는 것이 나을 수도 있습니다. 컴포넌트가 얼마나 복잡한지에 따라 데이터(상태)를 context를 사용할지, props로 전달할지 결정하여야 합니다.

 

사실은 context를 사용하여 여러 개의 컴포넌트를 유지하기 보다 로직만 수행하는 컴포넌트는 커스텀 훅으로 만드는 것이 훨씬 일반적입니다.

 

상태 관리 라이브러리가 아닌 context인 이유

redux와 zustand 같은 상태 관리 라이브러리는 전역 상태 관리를 위해 만들어집니다. 그래서 컴포넌트가 언마운트되더라도 상태는 메모리에서 내려가지 않고 유지됩니다. 하지만 많은 경우 컴포넌트가 언마운트 되면 사용하던 상태 또한 필요없어지고 다시 마운트 되더라도 초기 상태를 가져야 할 필요가 있습니다. 컴포넌트가 마운트 될 때마다 구독해야할 redux 상태를 초기 상태로 업데이트하는 것은 낭비가 될 수 있고 컴포넌트가 사용된적이 없더라도 그 컴포넌트가 구독하는 상태가 메모리에 상주하는 것은 명백한 낭비입니다. 그래서 실제로 전역상태가 필요한 것이 아니라면 context를 통해 mvvm 패턴을 구현하는 것이 옳다고 생각하였습니다.

 

필요에 따라 상태 관리 라이브러리를 context 대신 쓰거나 앞에서 언급한 것처럼 단순히 props로 상태를 전달하여도 됩니다.

요약

오늘은 리액트에서 MVVM 패턴을 적용하여 화면과 비즈니스 로직을 분리하는 방법을 다루어 보았습니다. View는 오직 화면 렌더링만을 책임지고, Model은 비즈니스 로직을 처리하며, ViewModel을 통해 View와 Model 간의 의존성을 제거하며 UI 변환로직을 담당합니다. 리액트에서 이를 이루기 위해 ViewModel의 context를 만들어 View와 ViewModel을 연결합니다. Model은 비즈니스 로직을 처리하는 역할만을 수행해야 하므로 UI 라이브러리인 리액트에서 벗어난 형태를 가지게 되었습니다. 이를 통해 화면과 로직 간의 의존성을 줄이고, 데이터와 로직의 변경과 UI의 변경이 상호 간의 영향을 미치지 않도록 구성하여 유지 보수가 용이하도록 할 수 있습니다.

 

마지막으로, MVVM 패턴은 장점만 있는 것이 아닙니다. MVVM 패턴을 적용하다 보면 구조가 복잡해질 수 있습니다. ViewModel과 Model이 많아지거나, ViewModel이 너무 많은 책임을 지게 되면, 코드가 복잡해지고 유지보수가 어려울 수 있습니다. 이럴 때는 View-ViewModel-Model 구조체를 역할에 따라 적절히 분리하거나, 중첩된 구조를 단순화하는 것이 좋습니다.

+ Recent posts