추상 클래스를 이용한 상태관리 컨벤션 확립 및 팀의 코드부채 최소화

pengooseDev·2024년 2월 14일
3
post-thumbnail
작업방의 일본 일러스트레이터 분이 그려주셨다

가장 첫 단추는 기존 팀원의 설계가 아닐까 생각해본다.
이 글은 FE 상태관리 패턴 대한 이야기이다.


ReactJS의 아쉬움

View가 state를 들고있다. 이에 대한 비즈니스 로직도 들고있다.
물론, 이에 대한 의존성을 최소화 하기 위해 커스텀 훅과 상태관리 도구(contextAPI, 전역 상태관리 도구 등)를 사용한다.

문제점은 왜?이다.
이에 대한 본질적인 이유를 신입 개발자가 알기 어려우며, 아래의 팀원들은 이에 대해 깊은 고민을 하지 않고 구현에만 집중한다고 가정한다.


Redux

가장 추상화가 강제되어, 팀 프로젝트에 안정적이라고 평가받는 redux를 생각해보자.
slice 내부에 state를 변경시키는 actions(setter)가 추상화되어있다.

해당 라이브러리를 도입하면 slice 내부에서 setter를 강제적으로 추상화해야 하기 때문에, 컴포넌트에서 setter를 추상화 할 확률이 현저히 줄어든다. atom 기반 상태관리 도구의 잠재적 위험 요소를 배재한 것이다.

즉, redux를 도입하면 최소한 팀원이 setter를 컴포넌트(View) 내부에서 추상화하여 View와 state의 비즈니스 로직의 의존도가 올라갈 상황을 배제한다.
이로써 팀원들이 작성하는 코드가 회사의 자본이 될 확률은 올라간다는 것이다.


Redux의 한계

그럼에도 불구하고, 발생하는 코드 부채는 다음과 같다.
1. selector에 대한 컨벤션 부재. (심지어 컴포넌트에서 호출하는 경우가 잦아 View와 Redux의 새로운 의존성이 발생)
2. setter를 사용하기 위해 사용처에서 dispatch 함수를 래핑이 강제됨.
3. 하나의 객체에서 모든 도메인의 state가 관리된다.

KakaoStyle나 Toss에서 찾아본 레퍼런스로, 1번에 대한 문제를 폴더로 나누어 추상화 된 selector를 export하여 해결한다. 다만, 문서 수준에서 관리되며, 구현 방식의 강제성이 없다는 점에서 약간 아쉬움이 남는다.


원하는 것

필자 원하는 최소한의 컨벤션은 아래와 같았다.

하나의 state에 대한 gettersetter하나의 객체 내에서 추상화 될 것.

필자는 여러 상태관리 도구를 찾아보다 자유롭게 추상화할 수 있는 recoil과 jotai를 고려하게 되었고, jotai를 선택하기로 했다.


구현

import { Atom, WritableAtom, atom } from 'jotai';

export abstract class AtomManager<T> {
  protected initialState: T;
  protected atom: WritableAtom<T, any, void>;

  constructor(initialState: T) {
    this.initialState = initialState;
    this.atom = atom(this.initialState);
  }

  abstract selectors: {
    [K in keyof Partial<T>]: Atom<T[K]>;
  };

  abstract actions: {
    [key: string]: WritableAtom<T | null, any, void>;
  };
}

/**
 * initialState까지 캡슐화 하고 싶은 경우 도입.
 * 다만, 타입이 굉장히 엄격해져, 보편적인 상황에선 권장되지 않음.
 */
export interface AtomManagerStatic<T> {
  new (initialState: T): AtomManager<T>;

  INITIAL_STATE: T;
}

> @pengoose/jotai


사용 Flow

문서에 작성했듯이. getter, setter에 대한 추상화가 강제된 상태에서 더욱 효율적으로 쓸 수 있는 flow는 아래와 같다.

추상 클래스 상속 및 구현 => 커스텀 훅 => 컴포넌트(View)

1. 추상 클래스 상속 및 구현

import { atom } from 'jotai';
import { AtomManager } from '@pengoose/jotai';
import { PlaylistStatus, Music } from '@/types';

// AtomManager 상속
export class Playlist extends AtomManager<PlaylistStatus> {
  // selectors(getters), actions(setters) 추상화
  public selectors = {
    playlist: atom((get) => get(this.atom).playlist),
    currentMusic: atom((get) => {
      const { playlist, index } = get(this.atom);

      return playlist[index];
    }),
  };

  public actions = {
    add: atom(null, (get, set, music: Music) => {
      const { playlist } = get(this.atom);

      if (playlist.some(({ id }) => id === music.id)) return;

      set(this.atom, (prev: PlaylistStatus) => ({
        ...prev,
        playlist: [...prev.playlist, music],
      }));
    }),
  };

  // 해당 도메인에 대한 유틸함수 추상화 및 캡슐화
  private isEmpty(playlist: Music[]) {
    return playlist.length === 0;
  }

  private isFirstMusic(index: number) {
    return index === 0;
  }
}

// 생성
const initialState: PlaylistStatus = {
  playlist: [],
  index: 0,
};

export const playlistManager = new Playlist(initialState);

2. 커스텀 훅

// usePlaylist.ts
import { useAtomValue, useSetAtom } from 'jotai';
import { playlistManager } from '@/viewModel';

export const usePlaylist = () => {
  const {
    selectors: { playlist, currentMusic },
    actions: { play, next, prev, add, remove },
  } = playlistManager;

  return {
    // #FIXME: 반복되는 useAtomValue, useSetAtom은 atomManager에 래핑한 유틸함수로 개선 예정.
    // Getters(Selectors)
    playlist: useAtomValue(playlist),
    currentMusic: useAtomValue(currentMusic),

    // Setters(Actions)
    play: useSetAtom(play),
    next: useSetAtom(next),
    prev: useSetAtom(prev),
    add: useSetAtom(add),
    remove: useSetAtom(remove),
  };
};

3. 컴포넌트(View)

// Playlist.tsx
import { usePlaylist } from '@/hooks';
import { Music } from '@/types';

export const Playlist = () => {
  const { playlist, currentMusic, play, next, prev, add, remove } =
    usePlaylist();

  return (
    <div>
      <h1>Playlist</h1>
      <ul>
        {playlist?.map((music) => {
          const { id, title, thumbnail } = music;

          return (
            <li key={id}>
              <img src={thumbnail} alt={title} />
              <p>{title}</p>
              <button onClick={() => remove(music)}>Remove</button>
            </li>
          );
        })}
      </ul>
      <button onClick={() => add(currentMusic)}>Add to Playlist</button>
      <button onClick={() => play(currentMusic)}>Play</button>
      <button onClick={prev}>Prev</button>
      <button onClick={next}>Next</button>
    </div>
  );
};

장점과 개선할 점

장점

  • 새로 합류한 팀원에게 아래의 규칙만 각인시키면 코드 레벨에서 상태 관리에 대한 컨벤션을 통제할 수 있다.

    1. 반드시 AtomManager를 상속 받아 구현할 것.
    2. atom은 반드시 커스텀 훅 내부에서만 호출할 것.
  • 하나의 도메인에 대한 추상화(getter, setter)가 하나의 객체 내부로 강제되어 View 내부에 비즈니스 로직이 사라진다.

  • 반복되지 않는 특정 도메인에 대한 util 함수를 캡슐화 할 수 있다.

  • DI를 이용해, State를 포함한 도메인 간의 복잡한 비즈니스 로직 구현을 간단하게 구현할 수 있다.

  • 극단적인 예시로 상태관리 도구가 바뀌더라도 컴포넌트에 손을 댈 필요가 없다. 즉, 이는 View와 state의 깔끔한 분리를 의미한다.


개선할 점

  • 커스텀 훅 호출부에 반복되는 로직을 래핑함수로 제거해야한다. (차주 배포 예정)
// usePlaylist.ts
import { useWrapAtomManager } from '@pengoose/jotai';
import { useAtomValue, useSetAtom } from 'jotai';
import { playlistManager } from '@/viewModel';

export const usePlaylist = () => {
  const {
    selectors: { playlist, currentMusic },
    actions: { play, next, prev, add, remove },
  } = useWrapAtomManager(playlistManager)();

  return {
    // Getters(Selectors)
    playlist,
    currentMusic,

    // Setters(Actions)
    play,
    next,
    prev,
    add,
    remove,
  };
};

고민하는 부분

  • 도메인이 커져 원시 아톰의 규모가 커질 경우(예를 들어 유저의 회원가입 폼을 받을 때, 하나의 원시 아톰에서 7개의 폼을 관리할 경우), jotai의 메모이징에 대한 장점을 훼손할 수 있다. 물론, 하나의 AtomManager가 주입받은 type을 기반으로 여러개의 원시 아톰 생성을 강제하는 방법도 고려중이다. (optional하게 상속받아 구현)

0개의 댓글