전역 상태 관리에 대한 고민 - 와플카드 리스트(feat. context)

younoah·2022년 3월 15일
2

와플카드 서비스가 궁금하다면!
와플카드 서비스 둘러보기 : https://waffle-card.com/
와플카드 깃허브 둘러보기 : https://github.com/waffle-card


😱 와플카드 리스트의 비효율성

와플카드 홈페이지에는 총 3개의 탭이 존재한다.

  • 오늘의 카드 : 모든 와플카드 리스트를 렌더링한다.

  • 나의 카드 : 나의 와플카드를 렌더링 한다.

  • 관심 카드 : 좋아요한 와플카드 리스트를 렌더링한다.

처음 홈페이지를 구현할 때, 탭을 누를때마다 각 탭별로 각각 고유의 네트워크 요청을 하고 탭에 해당 하는 와플카드 리스트 데이터를 받아오는 방식으로 구현을 했다. 그리고 와플카드 리스트 상태를 업데이트한 후 자식에게 Prop으로 전달하는 방식으로 구현을 했다.

영상을 자세히 보면 탭을 누를때마다 네트워크 요청을 보내고 로딩 스피너가 렌더링 되는것이 보일것이다.

위와 같은 구조인데 해당 구조의 문제점

  1. 복잡한 prop 드릴링으로 컴포넌트간에 구조가 복잡해진다.
  2. 관심사 분리가 되어있지 않다. 즉, 컴포넌트에서 상태 관리와 네트워크 요청을 모두 관리하며 코드가 지전분하다.
  3. 탭이 바뀔 때마다 혹은 페이지를 벗어나서 돌아올 때마다 매번 비효율적으로 네트워크 요청을 한다.
  4. 무엇보다 매번 로딩이 되기 때문에 사용성이 매우 떨어진다.

😎 와플카드 리스트 구조와 성능 개선하기

위와 같은 문제를 해결하고자 다음과 같은 구조로 설계를 변경하였다.

  1. 와플카드 리스트 를 전역상태로 분리한다.
  2. 홈페이지 에서 와플카드 리스트 스토어에게 데이터를 요청한다.
  3. 와플카드 리스트 스토어에서 캐싱된 데이터가 있다면 캐싱된 데이터를 반환한다.
  4. 캐싱된 데이터가 없다면 와플카드 리스트 스토어에서 네트워크 요청을 하여 응답으로 받은 데이터를 스토어에 저장하고 캐싱 한 이후에 데이터를 반환한다.

이렇게 구조를 변경한 결과 탭을 누를때 마다 혹은 페이지를 벗어나서 돌아올 때 캐싱된 데이터를 우선적으로 활용하니 무분별한 네트워크 요청을 하지 않게 되었다.

또한 상태를 전역으로 다룸으로써 컴포넌트의 관심사 분리로 리팩토링의 이점을 얻고 Prop 드릴링을 피할 수 있었다.

최종적으로 더 빠른 렌더링을 하게 되어 유저의 사용성은 증가하게 되었다.

탭을 누를때마다 로딩도 보이지 않고 페이지를 벗어나서 다시 돌아왔을 때도 캐싱된 데이터를 잘 활용하고 있다.



와플카드 전역상태 관리 코드 둘러보기

이전 글에서 언급했던 recoil의 캐싱 문제 때문에 context로 커스텀하게 작성하여 구현을 하였다.

우선 전체 코드를 띄우고 시작을 하겠다. 아래 설명에서는 부가적인 코드를 제거하고 핵심 코드만 짚어가면서 진행하려고 한다.

import { createContext, useCallback, useContext, useState } from 'react';
import { waffleCardApi } from '@/apis';
import { userState } from '@/recoils';
import { useRecoilValue } from 'recoil';
import { WaffleCardType } from '@/types';

const cachedWaffleCards: { [type: string]: WaffleCardType[] | null } = {
  total: null,
  my: null,
  like: null,
};

const WaffleCardsStateContext = createContext<WaffleCardType[] | null>([]);
const WaffleCardsDispatchContext = createContext<{
  setWaffleCardsByType: (type: string, waffleCards: WaffleCardType[]) => void;
  refreshWaffleCards: (type?: string) => void;
}>({
  setWaffleCardsByType: () => {
    return;
  },
  refreshWaffleCards: () => {
    return;
  },
});

interface WaffleCardsProviderProps {
  children: React.ReactElement | React.ReactElement[];
}

export const WaffleCardsProvider = ({ children }: WaffleCardsProviderProps) => {
  const [waffleCards, setWaffleCards] = useState<WaffleCardType[] | null>([]);
  const user = useRecoilValue(userState);

  const setWaffleCardsByType = useCallback(
    async (type, options = {}) => {
      if (!user && type !== 'total') {
        setWaffleCards(() => []);
        return;
      }

      if (options?.cached && cachedWaffleCards[type]) {
        setWaffleCards(() => cachedWaffleCards[type]);
        return;
      }

      const waffleCardsCommand: {
        [command: string]: () => Promise<WaffleCardType[]>;
      } = {
        total: async () => {
          const { data: waffleCards } = await waffleCardApi.getWaffleCards();
          return waffleCards;
        },
        my: async () => {
          if (cachedWaffleCards.total && user) {
            return cachedWaffleCards.total.filter(
              waffleCard => waffleCard.user.id === user.id,
            );
          }

          const { data: waffleCards } = await waffleCardApi.getMyWaffleCard();
          return waffleCards;
        },
        like: async () => {
          if (cachedWaffleCards.total && user) {
            return cachedWaffleCards.total.filter(waffleCard =>
              waffleCard.likeUserIds.includes(user.id),
            );
          }

          const { data: waffleCards } =
            await waffleCardApi.getMyLikedWaffleCards();
          return waffleCards;
        },
      };

      try {
        const waffleCards = await waffleCardsCommand[type]();

        cachedWaffleCards[type] = [...waffleCards];

        setWaffleCards(() => [...waffleCards]);
      } catch (error: any) {
        console.error(`in WaffleCards Recoil: ${error.message}`);
        return [];
      }
    },
    [user],
  );

  const refreshWaffleCards = async (type = 'total') => {
    Object.keys(cachedWaffleCards).forEach(async type => {
      cachedWaffleCards[type] = null;
    });

    await setWaffleCardsByType(type);
  };

  return (
    <WaffleCardsStateContext.Provider value={waffleCards}>
      <WaffleCardsDispatchContext.Provider
        value={{
          setWaffleCardsByType,
          refreshWaffleCards,
        }}
      >
        {children}
      </WaffleCardsDispatchContext.Provider>
    </WaffleCardsStateContext.Provider>
  );
};

export const useWaffleCardsState = () => useContext(WaffleCardsStateContext);
export const useWaffleCardsDispatch = () =>
  useContext(WaffleCardsDispatchContext);


가장 먼저 데이터를 캐싱할 스토어 역할의 객체를 생성한다.

const cachedWaffleCards: { [type: string]: WaffleCardType[] | null } = {
  total: null,
  my: null,
  like: null,
};

와플카드 목록은 탭의 구분대로 3가지로 나누어 캐싱을 한다.



그 다음 WaffleCardsProvider의 골격을 잡아준다.

export const WaffleCardsProvider = ({ children }: WaffleCardsProviderProps) => {
  const [waffleCards, setWaffleCards] = useState<WaffleCardType[] | null>([]);

  const setWaffleCardsByType = async (type, options = {}) => {
  };

  const refreshWaffleCards = async (type = 'total') => {
  };

  return (
    <WaffleCardsStateContext.Provider value={waffleCards}>
      <WaffleCardsDispatchContext.Provider
        value={{
          setWaffleCardsByType,
          refreshWaffleCards,
        }}
      >
        {children}
      </WaffleCardsDispatchContext.Provider>
    </WaffleCardsStateContext.Provider>
  );
};

WaffleCardsProvider 내부에 2가지 핵심 메서드가 있다.

setWaffleCardsByType

  • waffleCards 상태를 업데이트하는 함수이다.

  • 타입(tabValue)과 옵션({cached: boolean})을 인자로 받는다.

  • 타입(tabValue)에 따라 해당하는 네트워크 요청을 한뒤 cachedWaffleCards 객체에 저장을 하고 waffleCards 상태를 업데이트 한다.

  • 만약 옵션으로 {cached: true} 를 받는다면 캐싱된 데이터를 우선적으로 확인하고 캐싱된 데이터가 있다면 네트워크 요청을 진행하지 않고 기존의 cachedWaffleCards 객체에 저장된 데이터를 활용하여 waffleCards 상태를 업데이트 한다. 만약 캐싱된 데이터가 없다면 네트워크 요청을 진행한다.

refreshWaffleCards

  • waffleCards 상태를 새롭게 업데이트 하기 위한 함수이다.
  • 캐싱된 데이터를 삭제하고 네트워크 요청을 하여 waffleCards 상태를 최신으로 갱신하기 위한 함수이다.

2개의 메서드를 구현하면 아래와 같다.

export const WaffleCardsProvider = ({ children }: WaffleCardsProviderProps) => {
  const [waffleCards, setWaffleCards] = useState<WaffleCardType[] | null>([]);

  const setWaffleCardsByType = async (type, options = {}) => {
    // 만약 {cached: boolean}이면 캐싱된 데이터로 waffleCards 상태 업데이트
    if (options?.cached && cachedWaffleCards[type]) {
       setWaffleCards(() => cachedWaffleCards[type]);
       return;
    }
    
    // type별로 네트워 요청을 분기처리 하기 위한 객체
    const waffleCardsCommand = {
      total: async () => {
        const { data: waffleCards } = await waffleCardApi.getWaffleCards();
        return waffleCards;
      },
      my: async () => {
        const { data: waffleCards } = await waffleCardApi.getMyWaffleCard();
        return waffleCards;
      },
      like: async () => {
        const { data: waffleCards } =
              await waffleCardApi.getMyLikedWaffleCards();
        return waffleCards;
      },
    };

    // type별로 네트워크 요청후 waffleCards 상태 업데이트
    try {
      const waffleCards = await waffleCardsCommand[type]();
      cachedWaffleCards[type] = [...waffleCards];
      setWaffleCards(() => [...waffleCards]);
    } catch (error: any) {
      console.error(`in WaffleCards Recoil: ${error.message}`);
      return [];
    }
  };

  // 캐싱된 데이터를 삭제하고 네트워크 요청을 하여  waffleCards 상태를 최신으로 갱신
  const refreshWaffleCards = async (type = 'total') => {
    Object.keys(cachedWaffleCards).forEach(async type => {
      cachedWaffleCards[type] = null;
    });

    await setWaffleCardsByType(type);
  };

  return (
    <WaffleCardsStateContext.Provider value={waffleCards}>
      <WaffleCardsDispatchContext.Provider
        value={{
          setWaffleCardsByType,
          refreshWaffleCards,
        }}
      >
        {children}
      </WaffleCardsDispatchContext.Provider>
    </WaffleCardsStateContext.Provider>
  );
};

비록 recoil처럼 깔끔한 구현은 아니지만 원래 목표하던 스토어에서 데이터와 관련된 네트워크 요청을 관리하고 캐싱 구현을 완료했다.


회고

context의 가장 치명적인 단점은 정해진 패턴이 없다는 것이라고 생각한다. Redux는 flux 패턴이라는 정해진 패턴이 있어서 누가 코드를 작성하든 코드량이 많더라도 이해하는것은 어렵지 않다고 생각한다. (물론... 난 아직일수도...)

하지만 context는 정해진 패턴이 없기 때문에 작성자의 주관이 매우 강하게 녹아들어 자칫하면 다른 사람이 해당 코드를 이해하기 힘들어 할수도 있다. 누군가는 useRedcer를 사용하여 리듀서 패턴을 가지고 갈 수도 있고 누군가는 단순하게 useState만으로 간결하게 구현할 수도 있고 또 누군가는 외부의 모듈을 조합해서 구현할 수도 있다.

내 코드를 되돌아 보니 정해진 패턴이 없다는 생각이 들었다. 아직 패턴에 대해 경험이 없고 지식이 부족하다 보니 내가 생각하는 로직으로만 구현한 느낌이 든다.

다음에는 리액트 쿼리를 활용해보고 싶다.

profile
console.log(noah(🍕 , 🍺)); // true

1개의 댓글

comment-user-thumbnail
2023년 2월 22일

리액트 쿼리를 활용한 글도 얼른 보고싶네요 👍🏻

답글 달기