Skip to content

커스텀 zustand Suspender 개선으로 Suspense 더 활용하기

lybell edited this page Aug 8, 2024 · 4 revisions

배경상황

저희 프로젝트는 클라이언트 상태보다는 서버측 상태가 많아요. 일부 영역에만 사용되는 서버측 상태는 바로 불러와서 사용할 수 있지만, 여러 군데에서 같은 서버측 상태를 가져오고, 그 상태를 기반으로 클라이언트 측에서 별도로 상태를 업데이트하려면, 전역 상태관리 라이브러리와 서버측 상태 불러오기 로직을 통합해야 했어요.

서버측 상태를 클라이언트 측에서 별도로 업데이트하는 것이 왜 필요할까요? 그건 바로 서버와의 네트워크 요청을 줄이기 위해서에요. 서버측 상태는 최초 로딩 시에만 서버에서 불러오지만, 그 외의 경우에는 클라이언트의 액션으로 서버의 상태가 변경되는 경우가 많아요. 원칙대로라면, 서버의 상태를 변경시키고 변경된 상태를 받아오기 위해 다시 GET 요청을 보내야 하지만, 거의 대부분의 경우는 클라이언트에서 변경된 데이터와 서버에서 변경된 데이터는 일치하기 때문에 서버로 GET 요청을 보내서 상태를 갱신하는 것을 생략하고 클라이언트에서 변경한 것으로 갈음하는 거에요.

기존에 사용했던 방식

그것을 구현하기 위해, 이전에 제가 프로젝트를 했을 때 사용했던 방식을 활용하기로 했어요. (참고로 이 글은 제가 예전에 진행했던 프로젝트와 같은 팀원이었던 분이 쓰셨던 글이에요.)

const galleryStore = create<GalleryStore>((set) => ({
  data: {},
  userId: null,
  theme: THEME.DREAM,
  getData: (url) => gallerySelector(url).data,
  setData: (data, userId) => set({ data: { ...data, modifiedDate: Date.now() }, userId, theme: data.theme }),
  setTheme: (theme) => set({ theme }),
}));

const gallerySelector = (url?: string) =>
  select<AxiosResponse<IGalleryDataResponse>>({
    key: `gallery selector ${url}`,
    get: () => axios({ method: "get", url }),
  });

const selectors = new Map<string, Selector<any>>();
export const select = <T>({ key, get }: { key: string; get: () => Selector<T> }) => {
  const selector = selectors.get(key);
  if (selector) {
    return use<T>(selector);
  } else {
    const newSelector = get();
    selectors.set(key, newSelector);
    return use<T>(newSelector);
  }
};

간단하게 요약하자면, Suspense의 원리와 캐시를 사용한 방식이에요.

우선 zustand 상태로 selector 함수를 받는데, selector 함수는 변동 가능한 인자를 받아서, select 함수의 결과를 반환해요. select 함수는 key값과 promise를 반환하는 함수인 get 함수를 인자로 받아서, key값이 존재하지 않으면 promise를 새로 생성한 뒤, 이를 캐시에 저장하고, key값이 이미 있다면 key값에 해당하는 promise를 반환해요. 이 때, promise를 suspense 친화적으로 사용하기 위해, promise가 진행 중이면 promise를 throw해서 Suspense 컴포넌트가 트랩할 수 있게 하는 use 함수로 한 번 감싸요.

이를 이용하면 zustand 상태를 이용하면서, suspense 친화적으로 래핑된 비동기 상태를 선언적으로 사용할 수 있어요. suspense 친화적이기 때문에 로딩 중 상태나 오류 상태를 외부에 위임할 수 있다는 이점도 있어요.

왜 캐싱이 필요할까요?

// 대표적인 suspense 친화적 promise를 잘못 쓴 상태
// 무한 로딩이 일어나는 걸 볼 수 있습니다.
function MyAsyncComponent()
{
  const data = use(fetch("/api/v1/test"));
  return <div>{data}</div>
}

위의 방식에서, 그리고 저희 방식에서 캐싱을 사용하는 이유는 리액트에서 Suspense가 동작하는 것과 관련이 있어요. 간단히 말하자면, use 안에 있는 promise 객체가 처음 렌더링되었을 때와 비동기 처리가 완료되었을 때 서로 같도록 보장하기 위해서에요.

하위 컴포넌트에서 promise가 throw되면 Suspense는 promise가 해결될 때까지 fallback 엘리먼트를 렌더링해요. 그 뒤, promise가 해결되면 하위 컴포넌트를 다시 렌더링을 시도해요. 하지만, 위의 예제에서는 fetch("/api/v1/test") promise 객체가 다시 생성되면서, use로 감싸지게 되고, 또 다시 promise를 throw해요. 이것을 막기 위해, promise를 별도의 캐시에 저장해서, promise가 완료되었을 때 캐시된 동일한 promise 객체를 참조하게 하는 거에요.

기존 방식의 문제점

function GalleryLoader({ url }: { url: string }) {
  const getData = galleryStore((store) => store.getData);
  const setData = galleryStore((store) => store.setData);

  const data = getData(url);

  useEffect(() => {
    const { gallery, userId, page } = data;
    if (!isInitialized) {
      setData(data, userId);
    } else {
      setData(data, userId);
    }
  }, [data]);

하지만, 이 방식은 치명적인 문제가 있었어요. 서버의 상태를 Suspense 친화적으로 받아서 선언적으로 렌더링하는 것은 좋았지만, 그것을 클라이언트의 상태에 적용하기 위해 useEffect와 setData를 활용하고 있다는 것이 문제였어요.

이렇게 상태를 동기화하면 상태의 흐름이 알기 어려워진다는 것이 문제에요. 서버의 상태를 성공적으로 받아오면 바로 클라이언트의 상태에 동기화되어야 하는데, 클라이언트의 상태 동기화 부분이 컴포넌트에 useEffect로 존재하기 때문에 상태의 흐름 파악을 하기 어려워요.

또한, 실수로 개발자가 getData로 서버의 상태를 받아오고, setData로 서버의 상태를 동기화하는 로직을 추가하지 않는다면, 다른 컴포넌트에서 해당 상태를 가져올 때 서버의 상태를 받아올 수 없다는 문제도 있어요.

마지막으로, 부모가 다른 여러 컴포넌트에서 사용하기 어렵다는 문제점이 있었어요. useEffect로 서버의 상태를 동기화시킨다면, 어느 컴포넌트에서 클라이언트의 상태를 서버의 것으로 갱신하는 로직을 추가해야 하는지가 애매했어요.

그래서, 저희는 서버에서 데이터를 받아올 때, useEffect 없이 데이터를 받아오는 것이 끝나자마자 그 결과가 클라이언트 측 상태로 반영되게 하고 싶었어요.

해결 방법

난해한 로직 정리하기

(작성중...)

setter를 비동기 로직 안에 넣기

getData: () => {
    const promiseFn = async function () {
      // get server time and event info
      const [serverTime, eventInfo, participated] = await Promise.all([
        getServerPresiseTime(),
        getFcfsEventInfo(),
        getFcfsParticipated(),
      ]);
      const currentServerTime = serverTime;
      const currentEventTime = new Date(eventInfo.nowDateTime).getTime();

      // get countdown and syncronize state
      const countdown = Math.ceil(
        (currentEventTime - currentServerTime) / 1000,
      );
      set({
        currentServerTime,
        currentEventTime,
        countdown,
        eventStatus: eventInfo.eventStatus,
        isParticipated: participated.answerResult,
      });
    };
    return getQuerySuspense("fcfs-info-data", promiseFn);
  },

이렇게, 비동기 로직을 zustand state가 정의할 수 있도록 하면, zustand가 제공하는 set 함수를 쓸 수 있게 되어요. 함수가 정의된 스코프의 바깥 스코프의 변수를 참조할 수 있는 클로저를 이용한다면, 비동기 로직이 끝난 뒤, set 함수를 실행시켜서 zustand 상태를 변경시키는 것이 가능해져요. 정의된 비동기 로직은 getQuerySuspense의 2번째 인자로 보내져서, 별도의 저장소에 캐싱됨과 동시에 suspense 친화적인 객체로 변환되어요.

function CardGameInitializer() {
  const getData = useFcfsStore((store) => store.getData);
  getData();
  return <CardGame />;
}
function CardGameObserver () {
  const currentServerTime= useFcfsStore((store) => store.currentServerTime);
  return <div>{currentServerTime}</div>; // CardGameInitializer에서 로딩이 끝나자마자 CardGameObserver 컴포넌트에도 currentServerTime 상태가 반영됨
}
function CardGameSection() {
  return (
    <ErrorBoundary fallback={<div>에러남</div>}>
      <Suspense fallback={<div>로딩중</div>}>
        <CardGameInitializer />
      </Suspense>
    </ErrorBoundary>
    <CardGameObserver />
  );
}

이렇게 정의한 zustand 상태의 getData 함수는 위의 예시와 같이 Suspense, ErrorBoundary와 함께 사용해서 비동기 로직을 처리할 수 있어요. 추가로, 별다른 useEffect 등 없이도 비동기 로직이 완료되면 그 즉시 같은 상태의 클라이언트 상태를 참조하는 다른 컴포넌트에도 서버의 상태가 바로 반영되어요.

Clone this wiki locally