Recoil 레시피: 비동기 액션

서버 통신, Promises 등

글 목차

  1. 소개
  2. 비동기 액션
  3. 스냅샷과 상태 모니터링
  4. 서버 사이드 렌더링

상태 관리 라이브러리에서 비동기 액션을 실행해야 할 상황은 자주 발생한다. 시간 지연을 두고 상태를 설정해야 한다거나 서버에서 데이터를 가져와서 사용해야 하는 경우 등이 있다. Redux는 써드 파티 라이브러리인 Redux Thunk이나 Redux Saga 등에 의존해 이런 처리를 해야했다. 또 다른 유명 라이브러리인 MobX는 조금 더 나은 비동기 처리 방식을 지원하지만 비동기 액션의 진행 상태를 추적하는 별도의 상태값을 필요로 했다(공식 문서 참고).

Recoil은 처음부터 비동기 처리를 염두에 두고 작성된 것으로 보이며 향후 React에 추가될 동시성 모드(Concurrent Mode)도 지원하는 것을 목표로 하고 있다. 아무래도 Facebook에서 만든 만큼 React의 신기능을 더 적극 지원하는 듯 하다.

동시 실행 모드(Concurrent Mode)

Recoil의 비동기 모드를 다루기에 앞서 현재 React에 실험적으로 도입된 동시 실행 모드(Concurrent Mode)를 먼저 짚고 가보자. 아주 간단하게 요약하자면 React가 알아서 렌더링 동작의 우선 순위를 정하고 적절한 때에 렌더링을 해준다는 개념인데, 이를 위해 지원하는 몇 가지 기능 중 대표적인 게 <Suspense>컴포넌트 이다. 렌더링이 준비될 때까지 렌더링을 멈춰두는 모드인 것이다.

다음은 ProfilePage컴포넌트를 비동기로 로드하는 예제이다(공식 사이트 발췌).

const ProfilePage = React.lazy(
  () => import('./ProfilePage')
); // Lazy-loaded

// 프로필을 읽어들이는 동안 로딩 스피너 표시
<Suspense fallback={<Spinner />}>
  <ProfilePage />
</Suspense>

위 예제에서 ProfilePage는 비동기로 읽어들이는 컴포넌트를 다 읽고 렌더링할 준비를 마친 후에야 비로소 렌더링이 시작된다. 이전까지는 렌더링을 하지 않고 미루어 두는(suspense) 상태가 되는데, 이 상태 도중에 대신 보여주고 싶은 콘텐츠를 fallback 속성으로 전달하면 된다.

요는, 렌더링을 연기할 컴포넌트를 Suspense 컴포넌트로 감싸야 한다는 것이다.

아톰의 비동기 기본값 설정

앞서 "소개"에서 아톰을 만드는 방법을 배웠다. 이 때 기본값으로 리터럴한 값을 사용했지만 사실 아톰의 기본값은 3가지 타입으로 설정할 수 있다. 첫 번째가 앞서 글에서 보았던 정적인 값이고, 두 번째는 Promise, 나머지 하나는 RecoilValue이다. 공식 문서에 나온 정의를 살펴보면 다음과 같다.

function atom<T>({
  key: string,
  default: T | Promise<T> | RecoilValue<T>,

  dangerouslyAllowMutability?: boolean,
}): RecoilState<T>

기본값 타입은 T | Promise<T> | RecoilValue<T>로 정의되므로 기본값은 특정 타입의 값, Promise 그리고 RecoilValue를 사용할 수 있다. Recoil 코어의 타입 정의를 보면 RecoilStateRecoilValue이기도 하다(참고). 따라서 아톰은 다른 아톰을 기본값으로 사용할 수도 있다.

export type RecoilValue<T> = RecoilValueReadOnly<T> | RecoilState<T>;

다만, 이 글에서는 비동기 액션을 위주로 다룰 것이므로 일단은 Promise 부분에만 집중해보자. 앞서 만든 간단한 카운터 애플리케이션을 생각해보자. 이제 숫자 0으로 설정했던 counter 아톰의 기본값을 10초 후 숫자 0을 반환하는 Promise로 바꾸어보겠다.

import {atom, useRecoilState} from 'recoil';

const counter = atom({
  key: 'counter',
  default: new Promise(resolve => {
    setTimeout(() => resolve(0), 10000);
  })
});

function Counter() {
  const [count, setCount] = useRecoilState(counter);
  const incrementByOne = () => setCount(count + 1);

  return (
    <div>
      Count: {count}
      <br />
      <button onClick={incrementByOne}>Increment</button>
    </div>
  );
}

코드 샌드박스에서 실행하기

이 코드를 이대로 실행하면 React에서 에러가 발생한다. Counter 컴포넌트는 이제 아톰의 기본값이 설정되기 전에는 렌더링이 미루어지는 상태가 된다. 따라서 위에서 살펴보았듯이 Suspense 컴포넌트로 감싸야 한다. App 컴포넌트를 두고 아래와 같이 Counter 컴포넌트를 감싸서 실행하면 에러가 사라지고 로딩 후 10초가 지난 후 카운터 컴포넌트가 렌더링된다.

import React, { Suspense } from "react";
import Counter from "./Counter";

export default function App() {
  return (
    <div>
      <h1>Counter</h1>
      <Suspense fallback={<div>Loading...</div>}>
        <Counter />
      </Suspense>
    </div>
  );
}

코드 샌드박스에서 실행하기

아톰의 기본값은 언제나 정적인 값이다. Promise를 기본값으로 설정하면 Promise 객체가 아닌 Promise 객체에서 반환하는 정적인 값이 기본값으로 설정된다. 값이 준비되기 전까지 해당 아톰을 구독한 컴포넌트의 렌더링이 지연될 뿐이다.

Loadable

Loadable 클래스는 아톰 또는 셀렉터의 현재 준비 상태와 값을 알려준다. 비동기 값을 반환하는 아톰이나 셀렉터를 useRecoilValueLoadable 또는 useRecoilStateLoadable 훅에 전달하면 Lodable 인스턴스를 반환한다.

import {atom, useRecoilValueLoadable} from 'recoil';

...
  const countLoadable = useRecoilValueLoadable(counter);
...

Loadbale에는 몇 가지 메소드와 프로퍼티가 있지만 아마도 현재 준비 상태를 알려주는 statecontents 프로퍼티가 가장 유용할 것이다. statehasValue, hasError, loading 중 하나의 값이 되고, contentshasValue 상태일 때는 실제값, hasError 상태일 때는 에러 객체가 되고, loading 상태일 때는 Promise객체가 된다.

비동기 아톰이나 셀렉터라 하더라도 Loadable을 사용해서 값을 가져오고, loading 상태의 contents를 사용하지 않으면 <Suspense>를 사용하지 않아도 된다. 다음 예제는 Loadable을 사용해 <Suspense> 없이 로딩 중 상태를 보여주는 카운터 컴포넌트이다.

import {atom, useRecoilValueLoadable} from 'recoil';

const counter = atom({
  key: 'counter',
  default: new Promise(resolve => {
    setTimeout(() => resolve(0), 10000);
  })
});

function Counter() {
  const countLoadable = useRecoilValueLoadable(counter);
  let count = '';

  switch (countLoadable.state) {
    case 'hasValue':
      count = countLoadable.contents; // contents는 Number
      break;
    case 'hasError':
      count = countLoadable.contents.message; // contents는 Error
      break;
    case 'loading':
    default:
      count = '... loading ...'; // 로딩 중일 때
  }

  return (
    <div>
      Count: {count}
    </div>
  );
}

코드 샌드박스에서 실행

서버에서 데이터 가져오기

"소개"에서 셀렉터를 다룰 때 셀렉터에는 "기능의 실행"이라는 목적도 있다고 했다. 사실 공식 문서에는 "a function(함수 또는 기능)"이라는 말이 "derived state(파생된 상태)"라는 말보다 먼저 나온다. 활용도 측면에서 볼 때 파생된 상태를 구하는 것보다 순수 함수로서의 기능이 더 메인이라고 볼 수도 있다.

다음은 GitHub의 API를 통해 Recoil 프로젝트의 Star 수를 가져오는 셀렉터와 이를 표현하는 RecoilStar 컴포넌트이다. 비동기 값을 다루고 있으므로 당연히 <Suspense> 컴포넌트로 감싸주었다.

import {selector, useRecoilValue} from 'recoil';

const recoilStar = selector({
  key: 'recoil/star',
  get: async () => {
    const response = await fetch(
      'https://api.github.com/repos/facebookexperimental/Recoil'
    );
    const recoilProjectInfo = await response.json();
    return recoilProjectInfo.stargazers_count;
  }
});

function RecoilStar() {
  const starCount = useRecoilValue(recoilStar);
  return (
    <div>Stars of Recoil: {starCount}</div>
  );
}

코드 샌드박스에서 실행하기

보다시피 Recoil에서는 비동기 데이터를 가져오는 과정이 정적인 데이터를 가져올 때와 크게 다르지 않다. 써드파티 플러그인도 필요하지 않고, 로딩 상태를 표현하는 상태를 정의할 필요도 없다. 그러면서도 React의 기본 기능을 활용하여 성능 상의 이점도 누릴 수 있다.

파라미터에 따라 비동기 데이터 요청하기

위 예제에서 정적인 URL에서 데이터를 가져오는 셀렉터를 만들어 보았다. 하지만 실무에서는 파라미터에 따라 다른 URL에서 데이터를 가져와야 할 경우가 많을 것이다. 게시판에서 글 목록을 가져오는 postList 셀렉터가 있다고 생각해보자. 사용자가 다른 페이지 번호를 클릭하면 해당 페이지의 글 목록을 다시 읽어와야 할 것이다. 이 때 사용할 수 있는 방법은 두 가지가 있다.

첫째, 페이지 번호 아톰을 작성해서 셀렉터에서 해당 번호를 가져와서 URL을 변경하도록 한다.

import {atom, selector} from 'recoil';

const pageNumber = atom({
  key: 'pageNumber',
  default: 1,
});

const postList = selector({
  key: 'postList',
  get: async ({get}) => {
    const page  = get(pageNumber);
    const posts = await getPostList(page);
    return posts;
  }
}

두 번째 방법은 selectorFamily를 사용하는 것이다. selectorFamily는 동일한 기능을 수행하되 파라미터에 따라 결과를 달리할 때 유용하다. selectorFamily는 게터와 세터 부분에 파라미터를 전달받는 함수 생성기를 작성한다.

import {selectorFamily} from 'recoil';

const postList = selectorFamily({
  key: 'postList',
  get: (page) => async () => {
    const posts = await getPostList(page);
    return posts;
  }
}

그 후 다음과 같이 사용한다. 달라진 것은 셀렉터 객체를 그대로 전달하는 대신 셀렉터 패밀리에 파라미터를 전달하여 실행하여 셀렉터를 생성하도록 했다는 것 뿐이다.

function PostList({page}) {
  const posts = useRecoilValue(postList(page));

  return (
    <div>
      {posts.map(post => <Post key={post.id} post={post} />)}
    </div>
  );
}

이제 앞 섹션에서 작성했던 recoilStar를 여러 프로젝트에 사용할 수 있는 projectStar로 바꿔보자. 또한 변수나 컴포넌트의 이름 외에 무엇이 달라졌는지 주의하면서 살펴보자.

import {selectorFamily, useRecoilValue} from 'recoil';

const projectStar = selectorFamily({
  key: 'project/star',
  get: (projectPath) => async () => {
    if (!projectPath) return '...';

    const response = await fetch(
      `https://api.github.com/repos/${projectPath}`
    );
    const projectInfo = await response.json();
    return projectInfo.stargazers_count;
  }
});

function ProjectStar() {
  const [project, setProject] = useState('');
  const starCount = useRecoilValue(projectStar(project));
  const changeProject = ({target}) => setProject(target.value);

  return (
    <div>
      Project:
      <select onChange={changeProject}>
        <option value="">Select Project</option>
        <option value="facebook/react">React</option>
        <option value="facebookexperimental/Recoil">Recoil</option>
      </select>
      <br />
      Stars: {starCount}
    </div>
  );
}

코드 샌드박스에서 실행

위에서 언급한 첫 번째 방법(아톰 의존성을 추가하는 방법)과 두 번째 방법(selectFamily를 사용하는 방법)의 큰 차이점은 값이 갱신되는 조건이다.

게시판에서 1페이지를 보다가 2페이지를 열고 다시 1페이지로 돌아오는 경우를 생각해보자.

첫 번째 방법에서는 아톰의 값이 변할 때마다 서버에 데이터를 요청하기 때문에 총 3번 서버에 요청하게 된다.

반면, 두 번째 방법에서 파라미터를 전달하여 생성된 각 셀렉터는 게터 함수 내에 의존성이 없다. 따라서 이 셀렉터에서 반환하는 값은 갱신되지 않는다. 다시 말해 1페이지를 다시 로드하려고 할 때는 기존에 읽었던 1페이지의 데이터를 그대로 반환한다는 뜻이다.

아마 서버에서 업데이트된 데이터를 반영해서 표현해주어야 하는 게시판에서는 첫 번째 방법이 더 적합하겠지만 변경될 가능성이 적은 데이터라면 두 번째 방법도 유용할 것이다.

Info

파라미터를 전달하여 아톰을 작성하는 atomFamily도 있다(참조 링크).

비동기 셀렉터 안에서 Loadable 사용하기

비동기 셀렉터 안에서도 다른 셀렉터를 실행하고 반환된 값을 사용할 수 있다. 앞서 작성한 projectStar 셀렉터에서 랜덤한 값을 반환하는 rand 셀렉터를 실행하고 반환된 값을 별 갯수 뒤에 추가해보자.

import { selector, selectorFamily, useRecoilValue } from "recoil";

const rand = selector({
  key: "rand",
  get: () => Math.random()
});

const projectStar = selectorFamily({
  key: "project/star",
  get: projectPath => async ({ get }) => {
    if (!projectPath) return "...";

    const randomNum = get(rand);
    const response = await fetch(`https://api.github.com/repos/${projectPath}`);
    const projectInfo = await response.json();
    return projectInfo.stargazers_count + ' ' + randomNum;
  }
});

코드 샌드박스에서 실행하기

이제 rand 셀렉터를 5초 후에 랜덤한 숫자를 반환하는 비동기 셀렉터로 바꿔보자. 이제 get(rand)Promise 객체를 반환할 것이므로 await 키워드를 사용해 값을 가져와야 한다.

import { selector, selectorFamily, useRecoilValue } from "recoil";

const rand = selector({
  key: "rand",
  get: () => new Promise(resolve =>
    setTimeout(() => resolve(Math.random()), 5000)
  )
});

const projectStar = selectorFamily({
  key: "project/star",
  get: projectPath => async ({ get }) => {
    if (!projectPath) return "...";

    const randomNum = await get(rand);
    const response = await fetch(`https://api.github.com/repos/${projectPath}`);
    const projectInfo = await response.json();
    return projectInfo.stargazers_count + ' ' + randomNum;
  }
});

코드 샌드박스에서 실행하기

여전히 잘 동작하지만 문제가 한 가지 있다. rand 셀렉터의 동작이 지연되면 projectStar 셀렉터의 동작 또한 느려진다.

Recoil에는 이런 경우에 사용할 수 있는 noWait이라는 유틸리티 함수가 마련되어 있다. noWait에 셀렉터를 인수로 전달하여 실행하면 Loadable 객체를 반환하는 셀렉터가 만들어진다. 이렇게 작성된 셀렉터는 다른 여느 셀렉터와 마찬가지로 훅에 전달해서 사용할 수 있고, 다른 셀렉터의 게터 안에서 get 함수를 사용해 값을 가져올 수도 있다. 만약 전달된 셀렉터가 에러가 아닌 정적인 값을 반환한다면 반환된 Loadable 객체의 state는 항상 hasValue가 된다.

noWait을 사용하면 위의 예제를 다음과 같이 바꿀 수 있다.

import {
  selector,
  selectorFamily,
  useRecoilValue,
  noWait
} from "recoil";

const rand = selector({
  key: "rand",
  get: () =>
    new Promise(resolve => setTimeout(() => resolve(Math.random()), 5000))
});

const projectStar = selectorFamily({
  key: "project/star",
  get: projectPath => async ({ get }) => {
    if (!projectPath) return "...";

    const randomNumLoadable = get(noWait(rand));
    const response = await fetch(`https://api.github.com/repos/${projectPath}`);
    const projectInfo = await response.json();

    if (randomNumLoadable.state === "hasValue") {
      return projectInfo.stargazers_count + " " + randomNumLoadable.contents;
    }

    return projectInfo.stargazers_count + " ...";
  }
});

코드 샌드박스에서 실행하기

요약

이 글에서는 Recoil에서 비동기 데이터 다루는 방법을 알아보았다. 보다시피 Recoil의 비동기 액션은 정적인 데이터를 다룰 때와 크게 다르지 않고 React의 기능을 십분 활용하여 다른 라이브러리에 비해 충분한 장점이 있다.

앞 글과 이 글에서는 개별 데이터 단위인 아톰과 셀렉터를 위주로 살펴보았다. 다음 글에서는 전체 데이터를 살펴볼 수 있는 스냅샷(Snapshot)이라는 기능을 알아보겠다.

댓글을 남겨주세요

This site uses Akismet to reduce spam. Learn how your comment data is processed.