Suspense와 react query, jotai atomsWithQuery - boostcampwm-2022/web33-Mildo GitHub Wiki

Suspense를 적용하려는 이유

  1. jotai, react-query를 사용했음에도 불구하고 useEffect를 이용하여 로딩 상태를 따로 관리했다. 로딩 상태 관리 로직과 비동기 로직이 얽혀 복잡한 코드가 작성되었다.
  2. MainPage를 렌더링하고, useEffect 로 데이터를 가져온 후, 재렌더링하는 것은 비동기 요청이 병렬적으로 수행될 수 있다. 이 때문에 경쟁 상태(race conditions)에 취약할 수 있다.
    1. 현재 MainPage에서는 useEffectuseState 로 비동기 통신의 순서를 정해놓은 효과를 주고 있지만, 후에 비동기 통신이 추가 된다면 우리가 생각한 순서대로 데이터가 응답된다는 보장이 없어질 수도 있기 때문에 싱크가 맞지 않는 데이터를 제공할 수도 있다.
    2. Promise.all을 통해 해결할 수 있지만 React에서 Suspense라는 더 좋은 방식을 제공한다.

적용 전 상태

로딩 컴포넌트 렌더링

// MainPage.tsx
const [isLoading, setIsLoading] = useState(true);

useEffect(() => {
	// 비동기 통신 로직
	setIsLoading(false);
}, []);

return (
	{isLoading ? (
    <MapLoading />
  ) : (
    <Map
      latitude={coordinates!.latitude}
      longitude={coordinates!.longitude}
    />
	...
);
  • isLoading 이라는 state가 true이면 MapLoading 컴포넌트를 보여주고, false일 경우 Map 컴포넌트를 보여준다.
// App.tsx
function App() {
  return (
		...
      <MainPage />
    ...
  );
}
  • 적용 전 App 컴포넌트 상태

구현 과정과 학습 내용

1. Suspense 적용

// App.tsx
function App() {
  return (
    ...
      <Suspense fallback={<MapLoading />}>
        <MainPage />
      </Suspense>
    ...
  );
}
  • 비동기 통신을 하는 컴포넌트(여기서는 <MainPage />)를 Suspense로 감싸주고 fallback 으로 로딩 시 보여줄 컴포넌트(여기서는 <MapLoading />)를 넘겨주면 된다기에 그대로 적용해봤다.
  • 비동기 통신이 끝나면 <MapLoading /> 대신 <MainPage />를 보여준다고 하기에 대체 Suspense가 얼마나 똑똑하면 비동기 통신을 끝내는 걸 알아서 감지하고 MainPage를 보여주는 걸까? 하는 의문이 들었다.

비동기 통신 에러

Untitled

  • 그런 생각을 하던 도중 만난 에러, latitude 를 읽을 수 없다는 에러이다.
  • latitude 는 MainPage에서 Map 컴포넌트를 렌더링하기 위해 전달하는 props이다.
  • latitudeundefined 인 것으로 보아, 비동기 통신으로 데이터를 받아오기도 전에 Map 컴포넌트에 props를 전달하려고 한 것 같다.
💡 Suspense가 똑똑해서 비동기 통신을 자동으로 감지하여 MainPage를 보여주는 것이 아니다. Suspense는 특정 조건에 의해 로딩을 해제하고 MainPage를 보여줄 것이다.

그럼 Suspense는 대체 뭘 보고 로딩을 해제하는가?

2. throw Promise

  • 위의 궁금증을 가진 채로 [해당 블로그](https://www.daleseo.com/react-suspense/)를 참고하여 그대로 따라해봤다.
  • MainPage에 너무 많은 로직이 있었기 때문에 일단 코드를 다 지우고 MainPage에서 실험해보기로 했다.
  • App의 Suspense를 지우고 MainPage로 옮겼다. MainPage에 비동기 처리를 하는 Test 컴포넌트를 넣고 Suspense로 감싸줬다.
// MainPage.tsx
const fetch = () => {
  let allAreas: { ok: boolean } | null = null;
  const suspender = apis.getAllArea().then(data => {
    allAreas = data;
  });

  return {
    read() {
      if (allAreas === null) {
        throw suspender;
      } else {
        return allAreas;
      }
    }
  };
};

const MainPage = () => {
	return (
		...
		<Suspense fallback={<MapLoading />}>
			<Test />
		</Suspense>
	);
}
  • suspender 는 api 서버에 요청한 데이터이다. 임시로 MainPage 코드와 전혀 상관없는 데이터를 받아왔다.
  • fetch 함수에서 굳이 클로저를 이용하여 read() 함수가 있는 객체를 반환하는가?(후에 이유를 알게 된다.)
// Test.tsx
interface TestProps {
  resource: {
    read(): {
      ok: boolean;
    };
  };
}

const MainPage: React.FC<TestProps> = ({ resource }) => {
  const allAreas = resource.read();

  return (
    <>
      <div>{allAreas.ok}</div>
    </>
  );
};
  • 이렇게 실행했더니 로딩 화면이 보인 후, Test 컴포넌트가 렌더되었다.
  • 성공적으로 Suspense가 적용된 것이다.

왜 되는거지?

  1. throw suspender는 무엇인가?
  2. 왜 클로저를 이용한 것인가?

Suspense 동작 방식

위의 궁금증은 이 블로그를 보고 해결되었다.

  1. Suspense의 children이 Promise를 throw하면 Suspense는 fallback 프로퍼티를 렌더링한다.
  2. throw된 Promise가 완료(fulfilled)되면 Suspense는 children을 다시 렌더링한다.
  • 여기서 Suspense의 children은 Test 컴포넌트이고 fallback 프로퍼티는 MapLoading이다.
  • 즉, Test 컴포넌트가 Promise를 throw하면 MapLoading 컴포넌트를 렌더링한다. Test 컴포넌트에서 해당 Promise의 작업이 완료되면 Test 컴포넌트를 다시 렌더링한다.
  1. throw suspender는 무엇인가?

    • MapLoading 컴포넌트를 보여주기 위해 Promise 객체를 throw하는 것이다.
  2. 왜 클로저를 이용한 것인가?

    const fetch = () => {
      let allAreas: { ok: boolean } | null = null;
      const suspender = apis.getAllArea().then(data => {
        allAreas = data;
    	});
    
      if (allAreas === null) {
        throw suspender;
      } else {
        return allAreas;
      }
    };
    • 만약 이렇게 if문으로 반환했다면 allAreas 의 값을 그대로 반환한다.
      • 만약 데이터가 빨리 왔다면 객체를 return 했을 것이고, 느리게 왔다면 Promise를 throw했을 것이다.
      • 이 결과는 fetch 함수를 한 번 호출한 이후 절대 바뀌지 않는다.
      • 따라서 운 좋으면 바로 Test 컴포넌트를 보여주고, 운이 나쁘면 무한 로딩인 것이다.
    • fetch()를 통해 비동기 요청을 한다면 throw suspender 또는 return allAreas이다. 만약 throw suspender로 인해 pending 상태가 유지된다면 이후 다시 fetch()가 실행되어 이전에 실행되고 있던 비동기 요청을 이어받지 못하고 새로운 비동기 요청을 하게 되어 무한 pending이 될 수 있다.
    const fetch = () => {
      let allAreas: { ok: boolean } | null = null;
      const suspender = apis.getAllArea().then(data => {
        allAreas = data;
      });
    
      return {
        read() {
          if (allAreas === null) {
            throw suspender;
          } else {
            return allAreas;
          }
        }
      };
    };
    • 클로저로 fetch().read()를 하게 되어 비동기 요청이 끝나지 않아 throw suspender를 하게 되면 pending 상태가 된다. 이후 fetch().read()를 통해 확인할 때 다시 비동기 요청을 하지 않고 클로저 내부에 비동기 요청을 하고 있는 suspender에 의해 다시 pending되거나 allAreas를 반환하는 동작을 해줄 수 있다.

3. useQuery 적용

  • 위와 같은 fetch 함수를 일일이 만들어 사용하는 것보다 이미 사용하고 있는 useQuery 에 suspense를 적용해보기로 했다.
// App.tsx
function App() {
  return (
		<>
      <QueryClientProvider client={queryClient}>
        <GlobalStyle />
        **<Suspense fallback={<MapLoading />}>**
          <MainPage />
        **</Suspense>**
      </QueryClientProvider>
    </>
  );
}
// 실제 우리 프로젝트에서 적용해본 것
// MainPage.tsx
const { data: coordinates } = useQuery<CoordinatesTypes>(
  ['userCoodinates'],
  () => { ... },
  {
    **suspense: true**
  }
);

return (
	// 삼항 연산자가 사라졌다.
  <Map
    latitude={coordinates!.latitude}
    longitude={coordinates!.longitude}
  />
);
  • fetch 함수에서 suspense를 동작하게 하기 위해선 throw suspender 또는 return fulfilledData 형태를 만들어줘야 했다.
  • useQuery에는 이러한 형태를 만들지 않더라도 기본적으로 suspense를 지원하고 있었다.
  • 이전 코드에서는 useEffect의 의존성을 이용하여 순차적으로 동작할 수 있도록 했는데, 이 과정에서 나온 코드가 상당히 보기 힘들었다.
  • useEffect를 통해 비동기 통신하는 로직을 useQuery로 변환하여 비동기 과정을 상태 관리와 분리하여 선언적으로 관리할 수 있었다.

4. atomsWithQuery 적용

  • 전역 상태와 비동기 로직에 의존되어 있는 전역 상태allAreasInfouserInfo 이다.
  • 두 전역 상태는 아직 useEffect를 통해 로딩이 되고 있어 suspense를 적용하기로 했다.
  • 우리 프로젝트에서는 전역 상태 관리 라이브러리로 jotai를 사용하기 때문에 jotai-tanstack-query라는 패키지에서 제공하는 atomsWithQuery 를 이용해보기로 했다.
  • 사용 방법은 useQuery 와 매우 유사하다.

allAreasInfo 적용

// 우리 프로젝트에 atomsWithQuery를 적용한 코드
export const [allAreasInfoAtom] = atomsWithQuery<SortAllAreasTypes[]>(_ => ({
  queryKey: ['areas'],
  queryFn: async () => {
    const { data: allAreas }: GetAllAreaResponseTypes = await apis.getAllArea();

    return sortAllAreas;
  },
}));
  • atomsWithQuery 를 적용하기 이전에는 useEffect 를 이용하여 비동기 처리를 하는 로직과 atom 값을 설정해주는 로직이 복잡하게 얽혀있었다.
  • 사용하고 난 후에는 코드가 간결해지고 전역 상태 또한 역할이 분명해졌다.
// 비동기 데이터 쿼리를 사용하기 이전
const [value, setValue] = useAtom(allAreasInfoAtom);

useEffect(() => {
	const fetchValue = async () => {
		const res = await fetch(); // 비동기 요청
		setValue(res.data); // 상태 업데이트
	};
	
	fetchValue();
}, []);

// 비동기 데이터 쿼리를 사용한 후
// 이 한줄에 비동기 요청과 상태 업데이트 모두 진행됨
const [value] = useAtom(allAreasInfoAtom);

userInfo 적용

⚠️ atomsWithQuery setter에 대한 오해

atomsWithQuery에는 setter가 없다?

  • atomsWithQuery 로 받아오는 데이터 중 유저 정보가 있었는데, 그 중 북마크 리스트를 업데이트 해야 했다.
  • atomsWithQuery 에도 atom 처럼 setter가 당연히 있는 줄 알고 사용하려 했다가 setter가 없다는 에러가 발생했다.

해결 과정

  • atomsWithMutation 에러로 고민하던 도중, 공식문서에서 사용한 atomsWithQuery 와 우리가 사용한 atomsWithQuery 의 차이점을 발견했다.
// 예시
import { atom, useAtom } from 'jotai'
import { atomsWithQuery } from 'jotai-tanstack-query'

const idAtom = atom(1);
const [userAtom] = atomsWithQuery((get) => ({
  queryKey: ['users', get(idAtom)],
  queryFn: async ({ queryKey: [, id] }) => {
    const res = await fetch(`https://jsonplaceholder.typicode.com/users/${id}`);
    return res.json();
  },
}));
  • 공식 문서에서는 get 이라는 파라미터를 받고 있었다. 이 get은 다른 atom을 인자로 받는 함수였다.
  • get 이 무엇인지 공식 문서에서 제대로 설명해주지 않았지만(못 찾은 것일 수도 있다.), 실험해보니 인자로 준 atom을 setter로 변경시키면 atomsWithQueryqueryFn 이 실행되는 듯 했다. 즉, 다시 서버에 요청을 보내는 것이다.
  • 그렇기 때문에 atomsWithQuery 는 setter를 통해 전역상태를 바꾸는 것이 아닌 queryKey를 바꾸어 비동기 요청을 하게 되고, 이 결과로 전역 상태도 바뀌게 된다.
💡 `atomWithQuery`는 setter를 통해 직접 상태를 업데이트하는 것이 아닌 `queryKey`를 통해 비동기 재요청하게 되어 상태를 업데이트해준다.
// 예시 코드 (정확히 동작하지 않을 수 있음, 이해를 돕기 위한 코드)
const UserData = () => {
	const [id, setId] = useAtom(idAtom);
  const [data] = useAtom(userAtom);

	setId(2);

  return <div>{JSON.stringify(data)}</div>
}
  • id를 setId로 바꾸어 주면 userAtomqueryKey 가 바뀌었기 때문에 비동기 요청을 하게 된다. 이렇게 queryKey를 업데이트하여 비동기 재요청을 할 수 있게 된다.
    • 하지만 다시 setId(1)을 하게 되면 캐싱된 data를(id = 1일 경우는 이미 요청을 했다는 가정) 가져오게 된다. 옵션으로 언제까지 캐싱을 해줄지 정해줄 수 있으며 많은 옵션을 제공해주고 있기 때문에 참고하면 좋을 것 같다.

이제 사용자 정보를 가져오는 jotai 전역 상태를 업데이트 하는 법을 알아냈으니 적용하는 일만 남았다.

export const userBookmarkAtom = atom<string[]>([]);

export const [userInfoAtom] = atomsWithQuery(get => ({
  queryKey: ['users', get(userBookmarkAtom)],
  queryFn: async (): Promise<GetUserResponseTypes> => {
    const data = await apis.getWhetherUserLoggedIn();

    return data;
  }
}));
  • userInfoAtom의 key값으로 userBookmarkAtom을 하여 북마크를 추가하거나 삭제할 때 사용자 정보를 업데이트할 수 있게 하였다.
const setBookmarkAtom = useSetAtom(userBookmarkAtom);
const [userInfo] = useAtom(userInfoAtom);

// 북마크 삭제
await apis.deleteBookmark(areaName, userId);
setBookmarkAtom(
  bookmarks.filter((bookmark: string) => bookmark !== areaName)
);

// 북마크 추가
await apis.addBookmark(areaName, userId);
setBookmarkAtom(bookmarks.concat(areaName));
  • 사용자의 북마크를 추가하거나 삭제하면 setBookmarkAtom을 통해 userBookmarkAtom을 변경해주게 되고 userInfoAtomget(userBookmarkAtom)을 해주어 비동기 요청을 하게 된다. 이렇게 북마크가 업데이트된 사용자 정보를 다시 가져올 수 있게 된다.

Suspense 적용

  • 수정 전
// SecondLevelComponent.tsx

{isGraphLoading ? (
  <MapLoading ... /> // props 생략
) : (
  <Chart
    type='bar'
    options={options}
    series={series}
    width='100%'
    height={2500}
  />
)}
  • 수정 후
// SecondLevelComponent.tsx

<Chart
  type='bar'
  options={options}
  series={series}
  width='100%'
  height={2500}
/>
// InfoDetailModal.tsx

...

<Suspense fallback={<MapLoading .../>}>
  <SecondLevelComponent
    firstLevelInfo={firstLevelInfo}
    isSecondLevel={isSecondLevel}
  />
</Suspense>

...
  • 이외에도 사용자 정보를 가져오는 비동기 로직과 그 데이터를 전역 상태로 저장하는 로직이 합쳐지면서 관련된 코드를 전부 삭제할 수 있었고, 좀 더 깔끔한 코드가 작성되었다.

결론

  • 리액트 Suspense의 동작 원리 학습
  • useQuery와 atomsWithQuery로 비동기 로직과 상태 관리 코드를 간결하게 작성
  • useQuery와 atomsWithQuery로 Suspense 기능 사용

참고 사이트

⚠️ **GitHub.com Fallback** ⚠️