sharingStorage

React query와 Asyncronous Recoil (feat Suspense, Loadable) 본문

Front-End/React

React query와 Asyncronous Recoil (feat Suspense, Loadable)

Anstrengung 2023. 5. 8. 17:44

들어가며

현재 모던 프론트엔드에서 대부분의 리액트 코드는 서버와의 통신 상태에 따라 View를 나누어 보여주어야하기 때문에 아래와 같이 작성하는 경우가 빈번합니다.

/** React */

return (
  {isLoading && <LoadingPage />}
  {apiError && <ErrorPage />}
  {data && <MainPage />}
)

기본적으로 React 17에서의 Suspense는 Data Fetching을 위한 Pending Handler가 아니라, 기존의 워터폴 방식으로 이루어져있는 Render방식을 개선해주는 역할이기때문에, 위의 상황에서 활용하기에는 문제가 있으나, React-Query나 Recoil에서 React 18부터 정식 릴리즈되는 Suspense를 활용해 명령형이 아닌 선언형으로 서버와의 통신 상태에 따라 View를 나누어 보여줄 수 있게 되었습니다.

 React-query, Recoil등의 상태관리 라이브러리에서 Suspense를 활용하면 조금 더 나은 사용자 경험을 제공할 수 있을 것이라고 예상하기에 글을 작성해봅니다.

 

이 글은 Suspense, lazy loading, 명령형 프로그래밍, 선언형 프로그래밍에 대한 선수지식이 있으면 조금 더 이해가 쉬울 것 같아 그에 대한 개념을 먼저 정리해보려고 합니다.

 

Lazy Loading

웹에서의 Lazy Loading이란 필요한 자원을 미리 가져오지 않고 필요할 때 가져오는 전략을 말합니다. 웹에서 필요한 모든 자원들은 Lazy Loading의 대상이 될 수 있습니다.

스플리팅된 JS 번들이나 이미지는 Lazy Loading이 가능한 대표적인 자원입니다. 그리고 웹 개발에서 SPA가 지배적인 컨셉이 되는 바람에 잘 의식하지는 못하지만, axios나 fetch등의 클라이언트를 사용해 서버에 요청을 보내 가져오는 데이터(AJAX) 역시 Lazy Loading의 한 종류입니다.

 

Suspense

Suspense는 React 16버전가지는 주로 JS번들의 Lazy Loading을 위한 기능이였습니다. React.lazy를 사용해 컴포넌트를 동적으로 임포트하고, Suspense 안에 넣어주면 자동으로 번들이 분리되고(Code Splitting) 해당 컴포넌트가 렌더링될 필요가 있을 때 React는 비동기적으로 번들을 가져옵니다. 

React 18에서 Suspense는 무엇이든 기다릴 수 있는 기능으로 확장되었습니다. (글 아래에서 추가로 설명을 더하겠습니다.)

 

명령형 프로그래밍

"어떻게(how)"에 주목하는 프로그래밍 기법 => 비동기 상태값을 가지고 어떤 UI를 보여줄지에 대한 분기 로직을 JSX에 코딩 

  if(users.state==="loading"){
    return <div>Loading... </div>;
  }

   else if(users.state==="hasError"){
    return <div>Loading... </div>;
  }
  else {
    <Component></Component>
  }

 

선언형 프로그래밍

"무엇을(what)" 보여줄지에 대해 주목하는 프로그래밍 기법 => 비동기 상태 값에 따른 UI를 Prop으로 주입하기

      <div>
        <RecoilRoot>
          <ErrorBoundary fallback={<div>Error!</div>}>
            <Suspense fallback={<div>Loading...</div>}>
              <Users />
            </Suspense>
          </ErrorBoundary>
        </RecoilRoot>
      </div>

 

 

React18의 Suspense로 데이터 관리하기

Suspense란?

어떤 컴포넌트가 읽어야 하는 데이터가 아직 준비가 되지 않았다고 리액트에게 알려주는 React v18에서 정식 릴리즈된 새로운 매커니즘입니다.

공식문서: 자식 컴포넌트가 로딩이 끝날 때 까지 fallback을 나타낼 수 있게해주는 것

<Suspense fallback={<Loading />}>
  <SomeComponent />
</Suspense>

Suspense 라는 것은 간단히 설명하면 하위 컴포넌트의 비동기 작업이 완료되지 않았을 때 Suspense의 fallback에 있는 스트링 혹은 컴포넌트 등을 대신 렌더링하는 것 입니다. 비동기 작업이 완료되면 원래 렌더링돼야 되는 컴포넌트가 렌더링 됩니다.

 

Suspense를 사용해야할 때

  • 컨텐츠가 로드되는 동안 대체 컴포넌트를 표시할 때
  • 컨텐츠를 한 번에 공개할 때
  • nested한 컨텐츠를 나타낼 때
  • fresh한 컨텐츠가 로딩되는 동안 stale한 컨텐츠를 표시할 때
  • 이미 나타난 컨텐츠가 숨겨지는 것을 막을 때
  • 페이지 전환이 일어나고 있음을 나타낼 때
  • 서버 오류와 서버데이터에 대한 fallback을 제공할 때

 

Props

children : 렌더링하려는 실제 UI. children이 렌더링하는 동안 중단되면 Suspense boundary가 fallback으로 전환됨

fallback : 로드가 완료되지 않은 경우 실제 UI대신 렌더링할 대체 UI. (어떤 React Node도 가능하지만 fallback은 spinner, skeleton같은 가벼운 placeholder view라는 것을 참고)

 

Exam1

컨텐츠가 로딩되는 동안 fallback나타내기

import { Suspense } from 'react';
import Albums from './Albums.js';

export default function ArtistPage({ artist }) {
  return (
    <>
      <h1>{artist.name}</h1>
      <Suspense fallback={<Loading />}> //Albums component가 로딩중일 때 <Loading /> 렌더링
        <Albums artistId={artist.id} />
      </Suspense>
    </>
  );
}

function Loading() {
  return <h2>🌀 Loading...</h2>;
}

Exam2

컨텐츠를 한번에 나타내기

import { Suspense } from 'react';
import Albums from './Albums.js';
import Biography from './Biography.js';
import Panel from './Panel.js';

export default function ArtistPage({ artist }) {
  return (
    <>
      <h1>{artist.name}</h1>
      <Suspense fallback={<Loading />}>
        <Biography artistId={artist.id} />
        <Panel>
          <Albums artistId={artist.id} />
        </Panel>
      </Suspense>
    </>
  );
}

function Loading() {
  return <h2>🌀 Loading...</h2>;
}


//Panel.js
export default function Panel({ children }) {
  return (
    <section className="panel">
      {children}
    </section>
  );
}

위 코드는 Suspense로 감싸줌으로써 Suspense 내부 트리를 단일 단위로 처리합니다. 즉 컴포넌트간에 로딩 속도의 차이가 있어도 모두 함께 한번에 로딩되고 그 전까진 loading 컴포넌트를 렌더합니다.
데이터를 로딩하는 컴포넌트는 Suspense boundary의 직접적인 자식일 필요는 없습니다. 위 코드에서 Biography와 Albums는 같은 closest parent Suspense boundary를 공유합니다.

 

 

React Query란?

React 애플리케이션에서 서버 상태 가져오기, 캐싱, 동기화 및 업데이트를 쉽게 만들어주는 라이브러리입니다.

 

React Query를 사용해야하는 이유

  1. 중복된 네트워크 호출 방지 (캐싱)
  2. 신선하지 않은 (stale) 데이터 처리
  3. 데이터 처리 상황 표시
  4. 무한 스크롤 구현
  5. 네트워크가 모종의 이유로 한 번 끊길 경우 재시도 횟수 설정 가능
  6. 네트워크가 유실되었다가 연결되면 자동으로 API호출

무한스크롤이란?

불러올 데이터의 양이 많아 한 번에 화면에 표시하기에 부담이 될 때, 최초에 부담이 되지 않는 양을 표시한 후 사용자가 스크롤 할 때 이후 일정량의 데이터를 불러오는 것을 의미합니다.

무한 스크롤을 라이브러리의 도움을 받지 않고 구현한다면, 데이터 저장을 위한 상태, 페이지 저장을 위한 상태, 예외 처리를 위한 상태 등 간단한 기능에 비해 꽤나 복잡하고 수고스러운 과정이 필요합니다.

React Query는 무한 스크롤의 손쉬운 구현을 위해 useInfiniteQuery Hook을 지원합니다.

  • 페이지 정보에 대한 관리
  • 로딩 상태 관리
  • 데이터 관리
  • 다음 페이지 호출 진행 여부에 대한 관리

위 번거로운 과정들을 각각의 상태로 자동으로 만들어주기 때문에 UI 구현에만 집중할 수 있습니다.

 

React-Query와 Suspense

React v18부터 Suspense가 API call에 따른 loading 상태를 표현할 수 있게 되면서 react-query, swr같은 data fetching library가 Suspense를 지원하고 있습니다. ErrorBoundary 조합과 함께라면 컴포넌트는 Success 케이스만 표현하면 됩니다.

 

React-Query로 데이터 관리하기 (feat Suspense)

function App() {
  return (
    <Suspense fallback={<div>...loading</div>}>
     <ErrorBoundary fallback={<div>Error page</div>}> 
      <TodoList />
     </ErrorBoundary>
    </Suspense>
  );
}

function TodoList() {
  const { data: todoList } = useQuery("todos", () => client.get("/todos"), {
    suspense: true,
  });

  return (
    <div>
      <section>
        <h2>Todo List</h2>
        {todoList?.data.map(({ id, title }) => (
          <div key={id}>{title}</div>
        ))}
      </section>
    </div>
  );
}

상위에서 Suspense로 컴포넌트를 감싸주고, useQuery 옵션에서 suspense 를 켜주기만 하면, Loading 상태를 나타냅니다. TodoList 에서 API fetch가 발생하는 동안 Loading fallback을 보여주는 것입니다.

fallback이란?

어떤 기능이 약해지거나 제대로 동작하지 않을 때, 이에 대처하는 기능 또는 동작을 말합니다. 따라서 여기에선 컴포넌트 로딩과정에서 예외 또는 에러 발생시 나타내주는 UI를 말합니다. 

 

※ v4버전까지도 useQueries에 suspense를 지원하지 않습니다. 또한 useQuery는 waterfall을 만드는 한계를 가지고 있기 때문에 사용에 주의하셔야합니다. 그 배경에는 useQueries는 단순히 useQuery의 복수가 아니라 다른 메커니즘으로 동작하고 있기 때문이라고 하네요. 

 

 

Recoil이란?

Recoil은 React 프로젝트를 위한 많은 전역 상태관리 라이브러리들 중 하나로, 2020년 5월 Facebook에서 출시하였습니다.

그렇기에, 다른 라이브러리(Redux, Mobx)와는 달리 React 전용이며 React에 최적화되어 있다고 할 수 있습니다.

 

Recoil의 등장배경

 1. 현재 사용하고 있는 전역 상태 라이브러리(Redux)에 대한 불만

 - Redux의 복잡한 Boiler Plate와 높은 러닝커브

 - Redux에서 비동기 데이터 처리에 필요한 서드 파트 라이브러리 (redux-saga, redux-thunk ..) 요구

 - React 전용 라이브러리가 아니다!  - React 관점에선 외부요인으로 store가 취급되며 동시성 모드를 구현하기에 호환성이 부족.

 

 2. 새로운 사상 도입

 기존 리액트의 철학인 탑 다운에서 작은 상태들을 쌓아 바텀 업 스타일로 상태관리 철학을 변경

비동기 상태도 조합이 가능함

 

Recoil 기본

Atom - 하나의 상태 [어플리케이션 상태에 대한 데이터자원] 

Selector - 여러 생태(Atom)를 조합한 새로운 상태. 두 메서드 사용 가능

 - get : 각 상태 조합 로직

 - set : 사용하는 상태 별로 각각 수정로직을 적용

 

 

전역상태 관련 Hooks

전역상태(Atoms, Selector)를 get/set 하기 위해 Recoil에서 제공하는 Hooks들을 사용합니다. 기본적으로 아래 4가지가 크게 사용됨.

  • useRecoilState() : useState() 와 유사. [state, setState] 튜플에 할당하며, 인자에 Atoms(혹은 Selector)를 넣어준다.
  • useRecoilValue() : 전역상태의 state 상태값만을 참조하기 위해 사용된다. 선언된 변수에 할당하여 사용하면 된다.
  • useSetRecoilState() : 전역상태의 setter 함수만을 활용하기 위해 사용된다. 선언된 함수변수에 할당하여 사용하면 된다.
  • useResetRecoilState() : 전역상태를 default(초기값)으로 Reset 하기 위해 사용된다. 선언된 함수변수에 할당하여 사용하면 된다.

 

Recoil의 단점

  • 개발자 도구가 완벽하지 않습니다 - 디버깅, 스냅샷 테스트를 하는데 신뢰성 부족
  • 모든 API들이 높은 신뢰성을 보장하지 않습니다. useGetRecoilValue, useRecoilRefresher 등은 공식문서도 UNSTABLE로 분류. 

 

Asyncronous Recoil 사용하기 (feat Suspense)

Suspense를 사용할 때 데이터 페칭이 완료되지 않은 컴포넌트의 경우에는 렌더링이 정지됩니다. React는 이 컴포넌트를 넘겨버리고 다른 컴포넌트의 렌더링을 시도합니다. 렌더링을 시도할 컴포넌트가 남아있지 않으면, 컴포넌트 트리 상에서 존재하는 것 중 가장 가까운 Suspense, 혹은 ErrorBoundary의 Fallback UI를 찾습니다.

 

이렇게 응답이 계속 흘러들어오도록 하면 컨텐츠를 더 일찍 표시할 수 있다는 장점이 있습니다. 응답을 기대하면서 명령적인 예외처리나 후처리를 해줄 필요가 없기 때문입니다. 응답이 왔을 때 명령적으로 컴포넌트의 State나 Redux Store 등에 비동기 요청의 결과값을 넣어 렌더링할 필요도 없습니다.

 

src파일구조

src 
├── apis
│    ├── fetchUsers.js
├── components
│    └── userAtom.jsx
│    └── Users.jsx
├── ...
App.jsx

 

먼저 비동기 api를 처리하기 위한 fetchUsers.js파일을 작성해줍니다.

//fetchUsers.js 

import axios from "axios";
const fetchUsers = async () => {
  const response = await axios.get(
    "https://jsonplaceholder.typicode.com/users"
  );
  return response.data;
};

export { fetchUsers };

 

다음으로 atoms.jsx파일을 만들어 Recoil atoms를 정의합니다.

//userAtom.js

import { atom, selector } from "recoil";

import { fetchUsers } from "../apis/fetchUsers";

export const userAtom = atom({
  key: "userAtom",
  default: selector({
    key: "userAtom/default",
    get: async () => {
      const users = await fetchUsers();
      return users;
    },
  }),
});

여기서는 usersState atom을 만들어 Recoil 상태로 사용자 목록을 관리합니다.

default 값으로 selector를 사용하여 fetchUsers 함수를 호출하여 사용자 데이터를 가져옵니다.

 

userAtom을 사용하여 Recoil 상태를 가져와 사용자 목록을 렌더링하는 Users.jsx파일을 작성합니다.

//Users.jsx 
import React from "react";
import { useRecoilValue } from "recoil";
import { usersAtom } from "./atoms";

const Users = () => {
  const users = useRecoilValue(usersAtom);

  return (
    <div>
      <h2>Users</h2>
      <ul>
        {users.map((user) => (
          <li key={user.id}>
            {user.name} ({user.email})
          </li>
        ))}
      </ul>
    </div>
  );
};

export default Users;

 

마지막으로 App.jsx에 Users컴포넌트를 호출 후 RecoilRoot와 loading처리를 위한 Suspense로 감싸줍니다.

import React, { Suspense } from "react";
import { RecoilRoot } from "recoil";
import Users from "./components/Users";
import style from "./App.css";

function App() {
  return (
    <>
      <div>
        <RecoilRoot>
          <Suspense fallback={<div>Loading...</div>}>
            <Users />
          </Suspense>
        </RecoilRoot>
      </div>
    </>
  );
}

export default App;

 

ErrorBoundary로 에러처리하기!

ErrorBoundary란?

자식 컴포넌트를 망가뜨리는  JS에러를 대신 처리하는 React 컴포넌트. 

자식 컴포넌트 렌더링, React 생명주기, constructure에서 발생하는 에러를 처리할 수 있지만 이벤트 핸들러, 비동기 코드 는 처리하지 못하므로 따로 에러처리를 신경써줘야한다.

 

react-error-boundary 설치

yarn add react-error-boundary

ErrorBoundary 적용

//App.jsx ErrorBoundary 감싸주기
import { ErrorBoundary } from "react-error-boundary";
import "./App.css";
function App() {
  return (
    <>
      <div>
        <RecoilRoot>
          <ErrorBoundary fallback={<div>Error!</div>}>
            <Suspense fallback={<div>Loading...</div>}>
              <Users />
            </Suspense>
          </ErrorBoundary>
        </RecoilRoot>
      </div>
    </>
  );
}

 

Asyncronous Recoil 사용하기 (feat Loadable)

Recoil에서 비동기 데이터를 처리하는 방식은 Suspense 외에 다른 방법으로 Loadable Hooks(useRecoilValueLodable, useRecoilStateLodable) 을 사용하는 방법이 있습니다.

Lodable 객체는 state(비동기 처리상태), contents(상태값) 2가지 프로퍼티를 반환. (Promise 패턴과 유사)

 

 state : atom또는 selector의 상태 

  • hasValue , hasError , loading 3가지 상태를 반환한다.

contents : atom이나 contents의 상태값을 의미한다.

  • hasValue 상태일 땐 value를, hasError 일 땐 Error 객체를, 그리고 loading 일 땐 Promise를 가지고 있다.

 

Loadable적용하기

//Users.jsx

import { useRecoilValue } from "recoil";
import { useRecoilValueLoadable } from "recoil";
import { usersAtom } from "./atoms";

const Users = () => {
  const users = useRecoilValueLoadable(usersAtom);  //usersAtom을 구독
  switch (users.state) { //구독한 users의 상태에 따라 렌더링할 컴포넌트 분리
    case "loading":
      return <div>Loading... </div>;
    case "hasError":
      return <div>Error!</div>;
    default:
      console.log(users);
      return (
        <div>
          <h2>Users</h2>
          <ul>
            {users.contents.map((user) => ( //users.contents로 접근해야 데이터 가져올 수 있음 
              <li key={user.id}>
                {user.name} ({user.email})
              </li>
            ))}
          </ul>
        </div>
      );
  }
};

export default Users;

useRecoilValueLoadable이라는 함수를 제공합니다. 해당 함수는 비동기 데이터를 처리할 때 처리 상태를 함께 리턴해주어서 비동기 데이터 상태에 따라 여러가지 로직을 제어할 수 있도록 해줍니다.

  const users = useRecoilValueLoadable(usersAtom);

users 객체구조

contents와 state를 함께 가지고 있음

App.jsx에 Suspense나 ErrorBoundary를 추가할 필요가 없어졌습니다.

//App.jsx
import { RecoilRoot } from "recoil";
import Users from "./components/Users";
import "./App.css";
function App() {
  return (
    <>
      <div>
        <RecoilRoot>
            <Users />
        </RecoilRoot>
      </div>
    </>
  );
}

 

Suspense VS Loadable

Recoil은 내부에서 비동기 요청이 발생할 경우 Suspense를 사용하도록 개발되었습니다. 필요할 경우 Loadable 을 사용하여 Suspense 없이 컴포넌트 내에서 데이터의 로딩 상태와 값에 따른 UI를 구성할 수도 있지만 Loadable 을 사용한다 하더라도, 로딩되는 동안 렌더링에 필요한 데이터를 수급할 수 없기 때문에 Suspense 를 사용하는 것과 크게 다르지않게 Fallback UI를 구성해야합니다.

또한 하나의 컴포넌트가 여러 Recoil 상태를 구독하는 경우 모든 상태를 Loadable 을 사용하지 않는다면 어차피 예상치 못한 경우에 Suspense 에 의한 Fallback 렌더링이 발생하기 쉽습니다. 구독하는 Recoil 상태가 여러 다른 Recoil 상태값에 의존성을 가지는 파생된 상태라면 상황은 더욱 복잡해진다.

때문에 Wrapper 구조로 간단하게 UI가 분리되지않는 피할 수 없는 경우가 아니라면 Loadable 보다는 Suspense 를 활용하는 것이 관리에 용이합니다.

 

또한 Suspense를 사용해 선언형으로 비동기 처리를 해줄 수 있기 때문에 가독성 측면에서도 장점을 가질 수 있습니다.

 

마무리하며 

Recoil과 Suspense를 공부해보면서 React의 렌더링 시스템과 비동기 처리가 찰떡같이 결합하고 있다는 생각이 들었습니다. 기존에 Redux를 사용에서 서드파티 라이브러리인 thunk나 saga를 사용할 때 React의 흐름을 벗어나던 것 과 달리 Recoil은 React 일반 state의 흐름을 벗어나지 않고 거의 동일하게 흘러간다는 것과 Recoil과 Suspense로 비동기 데이터에 대한 로직 처리를 간편하게 해준다는 것은 큰 매력으로 다가왔습니다. 

이렇게 깊게 하나의 라이브러리를 공부해보니 많은 사람들이 사용한다는 이유로 상태관리 라이브러리를 선택하는 것이 아닌 각 라이브러리의 장단점과 용도를 정확히 파악하고 무엇보다 "나의 프로젝트에 적합한가?"를 가장 먼저 따져보고 선택하는 습관을 기르는 것이 좋을 것 같다는 생각이 듭니다.

 

Reference

Comments