MobX에서 MobX-State-Tree로 전역 상태 관리 마이그레이션

Ji-Heon Park·2024년 8월 31일
1

TmaxRG

목록 보기
8/10

1. 서론

취업 전에는 전역 상태 관리를 단순히 props drilling을 피하기 위한 도구로만 여겼다. Redux와 같은 보일러플레이트가 장황하고, 엄격한 상태 관리는 불필요하다고 느끼고, Recoil과 같이 편하게 사용할 수 있는 상태 관리 라이브러리를 선호했다. 신입 때 전역 상태 관리 통합 리팩토링을 주도할때도 Recoil을 강하게 주장했다.

그러나 많은 인원이 투입되는 프로젝트를 경험하면서 안정적이고 예측 가능한 상태 관리 시스템을 선호하게 되었다. 엄격한 상태 관리는 서비스의 안정성과 코드 품질을 향상시키며, 상태 변경을 쉽게 추적할 수 있다는 점이 큰 장점이었다.

2. MobX의 개인적인 생각

현재 팀에서는 MobX를 주로 사용하고 있다. MobX는 클래스 기반으로 Observable한 데이터를 관리하며, 컴포넌트를 고차 함수로 감싸서 Observable하게 만들어 상태 변화를 감지한다. 하지만 MobX는 버전에 따라 다양한 코드 작성 방식이 존재하고, 자유도가 높다. 실제로 MobX의 공식 문서에서도 'unopinionated'하다고 소개한다.

MobX의 상태 전달 방식은 독립적인 스토어를 관찰하여 상태 변화를 감지한다. 스토어가 독립적으로 관리되기 때문에, 각 컴포넌트나 모듈에 맞춤화된 상태 관리와 특정 기능이나 도메인에 쉽게 집중할 수 있게 한다.

그러나 여러 개의 스토어가 존재하게 되면서, 상태 간의 흐름을 이해하기 어려워지고 디버깅이 복잡해졌다. 독립적으로 구현된 스토어들 간에 의존성이 생기면서 결합도가 높아졌고, 스토어에 대한 명확한 기준이 없어 응집도가 낮아지게 되어 재사용이 매우 어려운 문제가 발생했다.

3. MobX-State-Tree 도입

이러한 문제를 해결하면서 일관된 코드스타일로 MobX를 더 잘 활용할 방법을 고민하던 중, MobX-State-Tree(이하 MST)를 알게 되었다. MST는 MobX를 엔진으로 사용하면서, opinionated한 MST를 템플릿으로 활용할 수 있게 해준다. 이 개념을 접하고 내가 찾던 기술이라는 확신이 들어, 현재 리드하고 있는 프로젝트에 도입해 보았다.

MobX-State-Tree 공식문서에서 소개하는 React에서의 Best Practice를 참고했다.
각각의 노드로 트리를 구성하고 루트를 통해 중앙집중적인 구조로 만들 수 있다. 이를 컨텍스트에 등록하여 훅 기반으로 사용하는 것이 매력적이었다.

3.1. 소스 코드

기존 MobX의 코드 일부는 다음과 같았다.

class AppListStore {
    private _currentCard: CardItem | null = null;
    ...

    constructor() {
      makeAutoObservable(this, {});
    }

	// getter & setter
    public get currentCard() {
      return this._currentCard;
    }
    public set currentCard(value: CardItem | null) {
      this._currentCard = value;
    }

	// action
	init() {
    	this._currentCard = null;
    }

	// computed
	parseAppId () {
    	return this._currentCard.cardId.split("_")[0];
    }

	...
}

먼저 스토어의 리프 노드를 types.model로 정의 한다. 여기서 MST에서 제공하는 메서드로 타입을 정의할 수 있다.

// models/Card.ts
export const Card = types.model({
  id: types.string,
  name: types.string,
  complted: types.boolean,
  appLink: types.string,
  pageCount: types.number,
});

Card의 부모 노드를 동일하게 types.model로 정의한다. 필요한 파생상태 (computed)는 view, 액션 함수는 actions를 사용해 정의 할 수 있다.

// models/Card.ts
import { SnapshotIn, types } from 'mobx-state-tree';
import { parseCardId } from '@/utils/cardIdHelper';

export const CardStore = types
  .model({ currentCard: types.maybeNull(Card) })
  .views((self) => ({
    get parsedMeta() {
      return parseCardId(self.currentCard?.cardId);
    },

    get isContentsType() {
      const { appId } = parseCardId(self.currentCard?.cardId);

      return Number(appId) >= CRITERIA_OF_CONTENTS_TYPE;
    },
  }))
  .actions((self) => ({
    init() {
      self.currentCard = null;
    },

    setCurrentCard(card: SnapshotIn<typeof Card> | null) {
      self.currentCard = card;
    },
  }));

create를 통해 루트 스토어를 생성한다. 여기서 자식 스토어의 상태값을 초기화해준다.

// models/Root.ts
const RootModel = types.model({
  cardStore: CardStore,
  otherStore: OtherStore,
});

이제 루트스토어를 만든다. 루트스토어는 방금 만든 CardStore의 부모이다. 이외 다른 스토어도 동일하게 작성 후 루트노드에 등록할 수 있다.

// models/Root.ts
export const rootStore = RootModel.create({
  cardStore: {
    currentCard: null,
  },
  otherStore: {
    others: {},
  },
});

훅 기반으로 사용할 수 있도록 Context에 등록한다. 기존 ContextAPI랑 동일하게 사용할 수 있다.

observer가 작동하기 위해서 Context Provider는 사실 필수적이지 않다. 컨텍스트는 컴포넌트에 스토어의 포인터를 제공하는 한가지 방법일 뿐이다. 즉 컨텍스트는 종속성 주입을 위해 사용하고 observer + observable은 변경 추적을 위해 사용한다.

이 내용은 mobx-state-tree 공식 깃허브의 이슈에서 활발하게 토론된 적이 있는데 유익한 내용이 많다.

export type RootInstanceType = Instance<typeof RootModel>;
const RootStoreContext = createContext<RootInstanceType | null>(null);

export const Provider = RootStoreContext.Provider;

export function useStore() {
  const store = useContext(RootStoreContext);
  if (store === null) {
    throw new Error('Store cannot be null, please add a context provider');
  }

  return store;
}


// usage
export default observer(function App() {
  return (
    <Provider value={rootStore}>
        <Lnb />
        <Form />
    </Provider>
  );
});

export default observer(function Form() {
  const { cardStore } = useStore();

  ...
  return (
    <Container>
        <H1>카드 이름: {cardStore.currentCard.name}</H1>
    	<button onClick={() => {
			cardStore.currentCard.init();
		}}>
    		초기화
    	</button>
    </Container>
  )
}

이런식으로 기존의 상태 관리를 점진적으로 수정하여 MST의 마이그레이션을 완료하였다. 기존의 클래스 기반 관리에서 Hook 기반의 상태 관리를 할 수 있게 되어 좀 더 리액트스러운 코드 작성이 가능해졌다. 또한 중앙 집중적인 관리 방식으로 디버깅과 관리에 용이하다고 생각한다.

번외: 리서치 중 알게 된건데 상태관리 라이브러리가 되기 위해서는 최소한 아래의 두가지 조건을 만족해야 한다고 한다.
1. 상태를 기반으로 다른 상태를 만들어 낼 수 있어야 한다. (파생상태)
2. 필요에 따라 이러한 상태 변화를 최적화 할 수 있어야 한다.
리액트의 ContextAPI가 상태관리를 위한 API가 아닌 상태를 주입해주는 API라고 부르는 이유가 위 두가지 조건을 만족하지 못해서라고 한다. (리액트 딥다이브 책에서 봄)

3.2. 한계점: 대용량 데이터에서 성능 이슈

If you have a performance critical application that handles a huge amount of mutable data, you will probably be better off by using 'raw' MobX, which has a predictable and well-known performance and much less overhead. - mobx-state-tree 공식문서 일부

전역 상태를 마이그레이션 하며 화면이 멈추는 듯한 프레임 드랍 현상을 발견했다. 이는 몇만개의 리스트 데이터를 MobX-State-Tree로 전환하면서 발생했다.

공식문서에서 이유를 찾을 수 있었는데 MST는 스냅샷 등 다양한 편리한 기능을 제공하지만 이로인해 최적화를 하여도 항상 일정한 오버헤드가 발생한다는 거였다. 따라서 방대한 양의 가변데이터는 MST의 사용을 권장하지 않는다고 한다.

해당 데이터만 MobX로 남길 수 있었지만 '꼭 이 데이터가 전역상태여야하나?' 라는 의문이 들어 해당 데이터가 전역상태로 관리된 이유를 파악하였다. 코드를 따라가보니 리스트의 첫번째 아이템에 따라 다른 컴포넌트의 렌더링 조건이 달라지는 로직이 필요해서였다

이는 다른 방향으로 구현할 수 있겠다 싶었고 현재 선택된 아이템의 computed 파생 상태를 만들어 동일한 조건을 지킬 수 있었다. 거대한 양의 리스트 데이터는 지역 상태로 전환하여 문제를 해결했다.

profile
Frontend Developer | 기록되지 않은 것은 기억되지 않는다

0개의 댓글

관련 채용 정보