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

코드 샌드박스에서 실행

Update 2021-01-29: 글의 오류가 발견되어 이 지점부터 Info 박스 앞 부분까지 새로 작성했습니다.

selectorselectorFamily 모두 캐시를 사용한다. 즉, 입력값이 동일한 경우에는 get 메서드를 다시 실행하지 않고 캐시에 있는 값을 반환한다. 캐시키는 selector/selectorFamily를 작성할 때 전달한 key 프로퍼티와 get 메서드 내부에 있는 의존성(위 postList 셀렉터 예제의 경우 pageNumber 아톰)의 종류와 값, 그리고 실행시 전달된 파라미터로 결정된다. 다만 selector는 파라미터를 전달하지 않으므로 key와 의존성 값으로만 캐시키의 동일성을 확인할 것이다.

재밌는 점은 selectorFamily에 전달되는 파라미터의 동일성은 레퍼런스가 아닌 값을 확인한다는 점이다. 위 예제 projectStar 셀렉터의 get 함수의 첫 번째 인수가 문자열 아니라 객체라고 생각해보자. get 함수는 다음과 같이 선언되고 사용될 것이다.

// 선언부
get: (info) => async() => {
  const { projectPath } = info;
  ...
}

// 사용
const starCount = useRecoilValue(
  projectStar( { projectPath: '...' } )
);

자바스크립트에서 { a: '' } === { a: '' } 문장은 언제나 거짓이다. 두 값이 동일(equal)하지 않기 때문이다. 하지만 보다시피 두 값은 동등(equivalent)하다. Recoil의 selectorFamily는 파라미터 값이 동등한 지 살펴본다. 따라서 변경된 예제에서도 projectPath의 값이 동일하다면 캐시된 값이 그대로 반환된다.

내부 구현을 살펴보면 전달된 파라미터를 문자열화(stringify)해서 키로 사용하는 것을 볼 수 있다(링크). 보통 이런 기능을 구현할 때는 JSON.stringify로 퉁치는(?) 경우도 많이 보았는데 꽤 공들여 구현해놓은 점이 나름 재밌게 느껴졌다.

Recoil의 stringify 구현

지금까지 살펴보았듯이 selectorselectorFamily 두 방식의 차이는 파라미터의 유무에 있다. 따라서 굳이 상태에 저장할 필요가 없는 값이라면 selectorFamily를 사용해도 좋을 것이다.

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)이라는 기능을 알아보겠다.

  1. nowait + promise Rand를 사용한 가장 마지막 예제에서, 첫번 째로 react프로젝트를 불러올때는 5초뒤에 randNum이 생기는데, 두번 째로 recoil프로젝트를 불러올때는 아무런 딜레이 없이 바로 randNum이 표시되는 이유는 무엇일까요? 두번째 recoil프로젝트를 선택할때도 아직 인풋이 recoil일때의 memoized selector instance가 없기때문에 똑같이 5초간의 딜레이가 발생될 줄 알았어요.
    혹시 이유를 설명해주실 수 있을까요?

    1. 이건 제가 예제를 조금 잘못 구성한 감이 있습니다.
      처음 로딩할 때는 기존 상태값이 없었기 때문에 get()을 통해 rand 셀렉터의 값을 구하기 때문에 5초 후에 딜레이가 발생합니다. 셀렉터의 값은 의존성이 걸린 아톰값이 변경될 때만 변경이 되고 그 외에는 기존에 캐시된 값을 사용하는데, 이 때문에 두 번째 로딩할 때는 처음 설정한 뒤 캐시된 값을 그대로 사용합니다. rand 셀렉터의 getter는 의존성 걸린 아톰이 없기 때문에 한 번 생성된 랜덤값을 계속 사용하게 되는 거죠. 별 갯수 옆에 표시되는 랜덤값이 첫 번째 로딩할 때나 두 번째 로딩할 때 모두 같은 것을 볼 수 있습니다. 값의 변경이 없다는 뜻이죠. 확인해보시면 알겠지만 두 번째로 로딩할 때는 셀렉터의 get() 부분이 실행되지 않습니다.

    1. [파라미터에 따라 비동기 데이터 요청하기] 파트에서 궁금한점이 생겨서 여쭤봅니다~!
      selector와 selectorFamily의 차이점으로 캐싱된 값을 쓰는지 아닌지의 차이라고 이해했습니다.

      근데 selectorFamily를 사용해 만든 코드를 selector로 바꿔보았는데
      selector를 쓸때도 캐싱된 값을 사용하는 것으로 보여집니다.

      project를 (1)React -> (2)Recoil -> (3)React로 바꿔보았는데요.
      네트워크 탭을 열어보면 request를 2번 요청하고 (3) 요청은 캐싱된 값을 쓰는 것으로 보입니다.
      https://codesandbox.io/s/brave-rubin-berf7?file=/src/ProjectStar.js

      제가 어떤 부분을 잘못 이해했을까요?

      1. 지적하신 부분에 오류가 있었네요. selector와 selectorFamily 모두 값을 캐싱하는 게 맞고, selector는 key 프로퍼티만 캐시키로 사용하기 때문에 의존성 걸린 값이 변경되면 다시 값을 불러온다는 의미로 작성했던 글이었습니다. 반면 selectorFamily는 key 프로퍼티 + 함수에 전달한 인수에 의존성이 걸린다는 의미였고요.

        그런데 지금 확인해보니 key + 내부 아톰 의존성의 값까지 모두 캐시키로 사용하는 것으로 보입니다. 따라서 사용 방법에 있어 selectorFamily와의 차이는 값을 직접 함수에 전달하느냐, 그렇지 않으면 상태를 통해 전달하느냐 정도 밖에 없습니다.

        글을 올리면서 코드도 여러 번 확인해보고 올리는데 제가 당시에 실수한 건지 아니면 그 사이에 변경이 있었는지 잘 모르겠네요. 말씀하신 오류를 반영해서 본문 글을 수정해두겠습니다.

      2. 이왕 글을 수정하는 김에 빠뜨렸던 내용도 추가해서 글을 업데이트했습니다. 덕분에 오류를 수정할 수 있었습니다. 감사합니다.

        1. 덕분에 리코일 도입이 쉬워져서 제가 감사드립니다~!
          내부 캐싱로직을 볼까 하다가 부담스러워서 댓글 남겼는데, 관련내용까지 보충해주셔서 잘 이해할수 있었습니다!! 감사합니다~

  2. 많은 업데이트가 있었을텐데 기본적인건 이 블로그에서 다 배우고 갑니다.
    정말 감사합니다!

댓글을 남겨주세요

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