추상 클래스를 이용한 상태관리 컨벤션 정립

pengooseDev·2024년 1월 2일
1
post-thumbnail

목표 : 상태 관리 라이브러리를 추상 클래스로 구현 방식을 강제하여 getter, setter, weakMap, initialState를 하나의 객체에서 관리한다.
View 컴포넌트에서 비즈니스 로직을 최소화하여 일정 이상의 코드 품질이 확보되는 환경을 구축한다.

문서화를 진행하더라도 생각보다 문서를 꼼꼼히 잘 읽지 않는다는 경우가 가끔 있어, TS의 장점을 최대한 활용해 문제를 해결하고자 한다.

추상화 Flow

Class 상속을 기반한 구현 => 커스텀 훅 => View의 Flow로 상태 관리를 진행코자 한다.


Redux를 사용하지 않은 이유

Redux의 경우, 이미 추상화가 잘 되어있지만 selector 부분이 아쉬워 배제하게 되었다. getter(selector)의 자유도가 높아 View 컴포넌트 내부에서 추상화 하는 경우가 종종 보여서 아쉽다는 느낌을 지울 수 없었다. getter와 setter를 함께 중앙화하기엔 이미 reducer(setter)에 대한 추상화가 진행되어있어, 추상화의 중복을 피할 수 없었다는 점에서 의문이 들었기 때문이다.

레퍼런스를 체크해본 결과 폴더 구조로 getter와 setter를 분리하여 관리하는 경우를 확인할 수 있었다. 다만, 필자의 목표는 하나의 객체에서 적절한 캡슐화와 높은 응집도를 기반으로 상태를 관리하는 것이다.

하나의 파일 내에서 관리되는 redux 기반 코드는 아래와 같은 느낌일 것이다.

import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { Music } from '@/types';
import { RootState } from '../store';
import { SliceManager, PlaylistSlice } from '@/types';

/* Constants & utils */
export class Playlist extends SliceManager<PlaylistSlice>{
  // InitialSlice Field에 대한 구현 방식을 TS로 구축한다.
  static NAME = 'playlist';
  static INITIAL_SLICE: PlaylistSlice = {
    playlist: [],
    index: 0,
  };

  static isEmpty(playlist: Music[]) {
    return playlist.length === 0;
  }

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

/* Selector(Getter) */
export const PlaylistSelector = {
  playlist({ playlistSlice }: RootState) {
    return playlistSlice.playlist;
  },

  currentMusic({ playlistSlice }: RootState) {
    const { playlist, index } = playlistSlice;

    return playlist[index];
  },
};

/* Slice(Setter) */
const playlistSlice = createSlice({
  name: Playlist.NAME,
  initialState: Playlist.INITIAL_SLICE,
  reducers: {
    addMusic: (state, action: PayloadAction<Music>) => {
      if (state.playlist.some(({ id }) => id === action.payload.id)) return;

      state.playlist.push(action.payload);
    },

    remove: (state, action: PayloadAction<Music>) => {
      state.playlist = state.playlist.filter(
        (item) => item.id !== action.payload.id
      );
    },

    play: (state, action: PayloadAction<number>) => {
      state.index = action.payload;
    },

    next: (state) => {
      const isEmpty = Playlist.isEmpty(state.playlist);

      if (isEmpty) return;

      const isLastMusic = state.index === state.playlist.length - 1;

      state.index = isLastMusic ? 0 : state.index + 1;
    },

    prev: (state) => {
      const isEmpty = Playlist.isEmpty(state.playlist);

      if (isEmpty) return;

      const isFirstMusic = Playlist.isFirstMusic(state.index);

      state.index = isFirstMusic ? state.playlist.length - 1 : state.index - 1;
    },
  },
});

export const { addMusic, nextMusic, prevMusic, playMusic, removeMusic } =
  playlistSlice.actions;
export default playlistSlice.reducer;

자유도 높은 Recoil과 Jotai

두 라이브러리 모두, 자유로운 getter와 setter의 추상화가 가능하다. 하지만 이러한 자유로움은 때로 독이 된다. redux와 달리 추상화 방식이 강제되지 않기에 View 컴포넌트 내부에서 setter의 추상화를 진행하는 실수를 종종 목격해왔고, 컴포넌트의 높은 결합도가 발생해 기술부채로 이어지는 경우가 잦다고 판단했다.

필자는 Jotai를 이용해 목표를 달성하고자 했다. recoil보다는 jotai가 꾸준한 업데이트를 진행하고 있었으며, 공식 문서와 제공하는 기능들을 보았을 때 DX와 성능에 더욱 신경을 쓴다는 느낌이 강하게 들었기 때문이다. (Jotai를 추천해주신 강준님께 감사의 말씀을 남긴다)


AtomManager 추상 클래스

우선 selector(getter)와 action(setter)을 하나의 객체에서 관리하고싶다.
selector는 읽기 전용 atom으로, action은 쓰기가 가능한 atom으로 구현한다.

import { Atom, WritableAtom } from 'jotai';

export abstract class AtomManager<T> {
  constructor(protected atom: WritableAtom<T, any, void>) {}

  abstract selectors: {
    // 제네릭 타입을 사용하여 각 field에 대한 getter를 강제하고, 사용처에서 type 추론이 가능토록한다.
    [K in keyof T]: Atom<T[K]>; 
  };

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

사실 위 기능만 상속받아 구현해도 충분하다. 하지만 조금 욕심을 얹어 InitialState도 해당 객체 내에서 static field로 관리하고자 한다.


issue) static in TS

InitialState와 같은 경우, static field로 구현하고자 했다. 안타깝게도 TS의 추상 클래스에선 static field에 대한 지원을 하지 않는다.

사실 static field를 포기하면 구현은 아주 간단했지만, 욕심 때문에 버릴 수가 없어서 1달 정도 여러 issue들을 돌아다니며 시간을 투자해서 해결하게 되었다.

추상 class의 static field는 아래와 같은 방식으로 설정이 가능하다.

export interface AtomStatic<T> {
  new (initialState: T): AtomManager<T>;

  INITIAL_STATE: T;
}
import { PlaylistStatus } from '@/types/playlist';
import { AtomManager, AtomStatic } from '@/types/store';

const Playlist: AtomStatic<PlaylistStatus> 
  = class Playlist extends AtomManager<PlaylistStatus> {};
  • AtomStatic<PlaylistStatus>: static field에 대한 구현 컨벤션 확립.
  • AtomManager<PlaylistStatus>: 추상 클래스를 상속받아 내부 메서드 및 멤버 변수 구현 컨벤션 확립.

사실 고민이 많았다.
static field를 고집하게 되면, class의 상속과 interface 할당이 복잡해지고 가독성이 떨어졌기 때문이다. 또한, 다른 라이브러리들을 보더라도 보편적으로 initialState는 외부에서 주입하는 형식이다.

위 코드는 개인 프로젝트여서 INITIAL_STATE를 static field로 관리하겠지만, 팀 프로젝트로 변경된다면 static field를 제거하고 추상 class만 상속받아 쓰는 것이 가독성과 협업 측면에서 나아보인다.


Flow 살펴보기

1. 추상 클래스와 interface

import { Atom, WritableAtom } from 'jotai';

export abstract class AtomManager<T> {
  constructor(protected atom: WritableAtom<T, any, void>) {}

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

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

export interface AtomStatic<T> {
  new (initialState: T): AtomManager<T>;

  INITIAL_STATE: T;
}

2. class 추상화

import { Music } from '@/types';
import { PlaylistStatus } from '@/types/playlist';
import { AtomManager, AtomStatic } from '@/types/store';
import { atom } from 'jotai';

const Playlist: AtomStatic<PlaylistStatus> = class Playlist extends AtomManager<PlaylistStatus> {
  static INITIAL_STATE: PlaylistStatus = {
    playlist: [],
    index: 0,
    currentMusic: null,
  };

  constructor(initialState: PlaylistStatus = Playlist.INITIAL_STATE) {
    const initialAtom = atom(initialState);

    super(initialAtom);
  }

  public selectors = {
    playlist: atom((get) => {
      const { playlist } = get(this.atom);

      return playlist;
    }),

    index: atom((get) => {
      const { index } = get(this.atom);

      return index;
    }),

    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],
      }));
    }),

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

      set(this.atom, (prev: PlaylistStatus) => ({
        ...prev,
        playlist: playlist.filter((item) => item.id !== music.id),
      }));
    }),

    play: atom(null, (_, set, index: number) => {
      set(this.atom, (prev: PlaylistStatus) => ({ ...prev, index }));
    }),

    next: atom(null, (get, set) => {
      const { playlist, index } = get(this.atom);

      const isEmpty = this.isEmpty(playlist);

      if (isEmpty) return;

      const isLastMusic = index === playlist.length - 1;

      set(this.atom, (prev: PlaylistStatus) => ({
        ...prev,
        index: isLastMusic ? 0 : prev.index + 1,
      }));
    }),

    prev: atom(null, (get, set) => {
      const { playlist, index } = get(this.atom);

      const isEmpty = this.isEmpty(playlist);

      if (isEmpty) return;

      const isFirstMusic = this.isFirstMusic(index);

      set(this.atom, (prev: PlaylistStatus) => ({
        ...prev,
        index: isFirstMusic ? playlist.length - 1 : prev.index - 1,
      }));
    }),
  };

  private isEmpty(playlist: Music[]) {
    return playlist.length === 0;
  }

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

export const playlistManager = new Playlist(Playlist.INITIAL_STATE);

3. hook을 이용한 도메인 분리

import { playlistManager } from '@/model';
import { useAtomValue, useSetAtom } from 'jotai';

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

  return {
    playlist: useAtomValue(playlist),
    currentMusic: useAtomValue(currentMusic),
    play: useSetAtom(play),
    next: useSetAtom(next),
    prev: useSetAtom(prev),
    add: useSetAtom(add),
    remove: useSetAtom(remove),
  };
};

4. 사용

import styled from 'styled-components';
import Image from 'next/image';
import { usePlaylist } from '@/hooks';

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

  return (
    <Wrapper>
      <h1>Playlist</h1>
      <h2>현재 재생중인 음악: {currentMusic?.title}</h2>
      <hr></hr>
      <MusicList>
        {playlist?.map((music, index) => (
          <MusicCard
            key={music.id}
            onClick={() => play(index)}
            isCurrentMusic={currentMusic?.id === music.id}
          >
            <Image
              src={music.thumbnail}
              width={200}
              height={120}
              alt='thumbnail'
            />
            <Title>{music.title}</Title>
            <Remove onClick={() => remove(music)}>X</Remove>
          </MusicCard>
        ))}
      </MusicList>
    </Wrapper>
  );
};

0개의 댓글