Skip to content

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

lybell edited this page Aug 25, 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 없이 데이터를 받아오는 것이 끝나자마자 그 결과가 클라이언트 측 상태로 반영되게 하고 싶었어요.

해결 방법

난해한 로직 정리하기

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

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


const galleryStore = create<GalleryStore>((set) => ({
  getData: (url) => gallerySelector(url).data,
  setData: (data) => set({...data})
}));

function DataLoader()
{
  const getData = galleryStore((store) => store.getData);
  const setData = galleryStore((store) => store.setData);
  const data = getData(url);

  useEffect(() => {
    setData(data);
  }, [data]);
}

이전에 사용했던 로직은 고차 함수의 집합으로 구성되어 있어서 언뜻 보면 데이터의 흐름을 직관적으로 파악할 수 없었어요. 그래서, 이전의 로직을 좀 더 직관적이고 알기 쉽게 정리하는 과정을 거쳤어요. 이전의 로직을 함수 2개로 정리하면, 다음과 같아요.

const select = <T>({ key, get }) => {
  const selector = selectors.get(key);
  if (selector) return selector;
  const newSelector = get();
  selectors.set(key, newSelector);
  return newSelector;
}

const galleryStore = create<GalleryStore>((set) => ({
  getData: (url) => {
    return use<AxiosResponse<IGalleryDataResponse>>(
      select<AxiosResponse<IGalleryDataResponse>( {key: `gallery selector ${url}`, get: ()=>axios({method: "get", url})} )
    ).data;
  },
  setData: (data) => set({...data})
}));

중간 과정인 gellerySelector를 생략하니, key값을 기반으로 캐시된 비동기 객체를 반환하는 함수와, 이 객체를 suspense 기반 비동기 객체로 만드는 함수만 남았어요. 그리고 실제로 비동기를 요청하는 함수를 zustand 상태로 끌어올 수 있었어요. 즉, 비동기 함수 내부에서후 zustand 상태를 갱신하는 행위가 가능해져요.

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 등 없이도 비동기 로직이 완료되면 그 즉시 같은 상태의 클라이언트 상태를 참조하는 다른 컴포넌트에도 서버의 상태가 바로 반영되어요.

트러블슈팅

개발 서버에서 유령 상태 오류

프로젝트를 진행하던 중, zustand 상태를 변경하면 zustand 상태가 초기값으로 초기화되고, 비동기 요청을 보내도 영원히 갱신되지 않는 버그에 걸렸어요. 원인을 분석하다, 비동기 캐시가 원인이라는 것을 파악했어요.

  • 먼저, 컴포넌트가 렌더링되면 컴포넌트가 소비하는 zustand 상태의 getData 함수를 호출해요. 이 과정에서, 데이터가 비동기적으로 로드되고 zustand 상태를 변경시키는 Promise 객체가 별도의 캐시 객체에 저장되어요.
  • 이후, zustand 상태를 관장하는 코드가 변경되면서, zustand 상태를 소비하는 모든 모듈들이 재실행되지만, 캐시 객체가 존재하는 모듈은 재실행되지 않아요.
  • 컴포넌트에서 zustand 상태의 getData 함수를 호출하고, 동일한 key값으로 비동기 요청을 보내요.
  • 캐시 객체는 이전에 저장되어 있던 Promise를 반환하는데, 이 프로미스 객체는 이전에 존재했던 zustand의 상태에서 비롯된 객체에요. 이 프로미스 객체에서 참조하는 set 함수는 현재의 zustand 상태 객체의 set 변수가 아닌, 이전의 zustand 상태 객체의 것이에요.
  • 현재 zustand 상태 객체에 의존하던 컴포넌트들은 적절한 상태를 갱신받지 못해버려요.
function getQuery(key, promiseFn, dependencyArray = []) {
  // 캐시에 key값이 저장되어 있을 경우, 해당 promise를 반환합니다.
  if (queryMap.has(key)) {
    const { promise, depArr } = queryMap.get(key);
    if (isSame(depArr, dependencyArray)) return promise;
  }

  // 캐시에 없을 경우, 새 promise를 생성하고, 캐시에 의존성 배열과 함께 등록합니다.
  const promise = promiseFn();
  queryMap.set(key, { promise, depArr: dependencyArray });

  // 캐시의 지정된 기간이 지나면, 캐시와 캐시 그룹에서 해당 promise를 제거합니다.
  setTimeout(() => {
    queryMap.delete(key);
  }, CACHE_DURATION);
  return promise;
}

const fcfsStore = create((set) => ({
  getData: () => {
    const promiseFn = async () => {
      const status = fetchServer("/api/fcfs/status");
      set( {...status} );
    }
    return use(getQuery("fcfs-data", promiseFn, [set]);
  }
}

그래서 비동기 요청을 캐시하는 함수가 의존성 배열을 참조하게 하여, 동일한 key값이지만 의존성 배열이 다르면 다시 비동기 요청을 보내도록 수정했어요. 그 외에도, 비동기 함수가 외부의 값을 참조할 때, 클로저로 인한 오류를 방지할 수 있게 되었어요.

동일한 요청 상태 미갱신 오류

프로젝트를 진행하던 중, 로그아웃 기능을 구현하는 과정에서 문제가 생겼어요. 그것은 바로 로그인/로그아웃을 2번 전환하는 과정에서, zustand 상태가 갱신되지 않는다는 점이었어요. 정확히 말하자면, a 변수에 의존하는 요청을 보내서 zustand 상태를 갱신하고, b 변수에 의존하는 요청을 보내서 zustand 상태를 갱신한 후, 다시 a 변수에 의존하는 요청을 보내면 상태가 갱신되지 않는다는 문제가 있었어요.

이유는 또 비동기 캐시에 있었어요.

// getQuery 함수는 위에 언급된 getQuery 함수를 참조

const fcfsStore = create((set) => ({
  status: "none"
  getData: (query) => {
    const promiseFn = async () => {
      const status = fetchServer(`/api/fcfs/status/${query}`);
      set( {status} );
    }
    return use(getQuery(`fcfs-data-${query}`, promiseFn, [set]);
  }
}

function MyLoader()
{
  const [query, setQuery] = useState("a");
  const getData = fcfsStore( store=>store.getData );
  const status = fcfsStore( store=>store.status );
  getData(query);

  return <div>
    <p>{status}</p>
    <button onClick={()=>setQuery("a")}>A의 데이터 불러오기</button>
    <button onClick={()=>setQuery("b")}>B의 데이터 불러오기</button>
  </div>
}

function MyLoaderWrapper()
{
  return <Suspense fallback="로딩중">
    <MyLoader />
  </Suspense>
}

위의 예시에서, B의 데이터 불러오기 버튼을 누른 뒤 다시 A의 데이터 불러오기 버튼을 누르는 상황을 가정해서 설명할게요.

  1. 최초 로딩 시 MyLoader가 평가되고, fcfsStore 상태의 getData 메소드를 평가해요. 이 메소드에는 "a"가 인자로 들어가요.
  2. /api/fcfs/status/a로 요청을 보내고, 그 값을 fcfsStore에 동기화하는 비동기 요청(이하 Promise a-1)을 정의해요.
  3. 비동기 캐시에 "fcfs-data-a"가 존재하는지 확인하고, 존재하지 않으므로, Promise a-1을 캐시에 저장하고 해당 프로미스를 실행해요.
  4. Promise a-1이 suspense 가능한 상태로 전환되고, "로딩중"이 렌더링되었다가, Promise a-1이 완료되면 status 상태를 갱신해요.
  5. 'B의 데이터 불러오기' 버튼을 누르면 query 상태가 "b"로 변환되어요. MyLoader가 리렌더링되면서, getData("b")가 평가되어요.
  6. 비동기 캐시에 "fcfs-data-b"가 존재하지 않으므로, /api/fcfs/status/b로 요청을 보내고, 이를 fcfsStore에 동기화해요.
  7. 다시 'A의 데이터 불러오기' 버튼을 누르면 query 상태가 "a"로 변환되어요. MyLoader가 리렌더링되면서, getData("a")가 평가되어요.
  8. 비동기 캐시에 "fcfs-data-a"가 존재하는지 확인해요. 이미 Promise a-1이 캐시에 존재하기 때문에, Promise a-1을 반환해요.
  9. 해당 Promise는 완료된 Promise이므로, fcfsStore에 동기화하는 작업을 수행하지 않아요.

쿼리 변수(위의 사례에서 query 상태)는 일종의 외부 의존성이므로 의존성 배열에 넣으면 문제를 해결할 수 있겠지만, 의존성 배열에 넣은 값이 바뀌면 이전의 비동기 요청의 값을 즉시 폐기한다는 문제가 있었어요. 즉, 동일한 쿼리를 갖는 비동기 요청을 2번 보내게 되어 효율성이 떨어지게 되어요.

const fcfsStore = create((set) => ({
  status: "none"
  getData: (query) => {
    const promiseFn = async () => {
      // 데이터를 서버에서 가져오는 것은 `fcfs-data-${query}` key로 캐시함
      const status = getQuery( `fcfs-data-${query}`, ()=>fetchServer(`/api/fcfs/status/${query}`) );
      set( {status} );
    }

    // 실제로 데이터를 갱신하는 부분은 `__zustand__fcfs-data` key로 캐시하고,
    // query가 바뀌면 데이터 갱신 부분을 폐기하도록 변경
    return use(getQuery(`__zustand__fcfs-data`, promiseFn, [set, query]);
  }
}

이 문제는 순수하게 서버로 값을 가져오는 비동기 로직을 별도로 캐시하고, 비동기 요청의 결과를 zustand 상태로 저장하는 로직은 set 변수와 쿼리 변수 중 아무거나 이전 상태에서 변경되면 이전의 비동기 로직을 폐기하는 방법을 이용하여 해결했어요. 이렇게 하면, 서버로 값을 가져오는 로직은 각각의 query 변수에 대해 캐시되므로 동일한 query가 2번 요청되어도 이전의 상태를 가져올 수 있어서 성능을 유지할 수 있고, zustand 상태를 갱신하는 로직은 query 변수가 바뀌면 바로 갱신되므로 다른 쿼리로 변경한 뒤 원래 쿼리로 변경해도 상태를 바로 반영할 수 있다는 이점이 있어요.

Clone this wiki locally