6 분 소요

Recoil

상태 관리 라이브러리인 Recoil을 알아보고 그 사용 방법과 적용해본 후기를 남겨본다.

Recoil이란?

Recoil이란 React 전용 상태 관리 라이브러리 중 하나로 atoms(공유 상태)와 selector(순수 함수)를 통해 상태값을 공유하고, 변환하는 방식을 가지고 있다.

왜 사용하는지?

간단하게 사용할 수 있고, redux에 비해 가볍기 때문이다.

모두 다 알 듯, 보통 react와 redux 조합을 많이 사용한다.
그러나 요즘에는 redux의 단점들이 더 부각되어 다른 방법들을 많이 사용하려고 하는 듯 하다.

redux의 여러 단점을 꼽아보자면

  1. 패턴의 러닝 커브

    Flux 패턴을 사용하는 redux는 도입하기 위해서 새로운 패턴을 배워야 하고,
    또 그것이 마냥 쉽지만은 않다.
    러닝 커브가 길면 도입하기 쉽지 않다. (물론 지금은 보편적이긴 하지만)

  2. 복잡해지는 코드

    패턴 때문에 코드량이 많아지고 (actions, reducer, constants, store….), 파일 복잡도도 커진다.

  3. 비동기 처리를 위한 추가적인 라이브러리 필요

    패턴에 의해 reducer는 순수 함수로 이루어져있어야 하는데, 이는 비동기 처리를 위한 다른 라이브러리가 필요하다는 것이다.
    redux에서는 middleware 라고 하고, 대표적인 라이브러리로는 thunk, saga가 있다.
    추가적인 단점이 또 나왔다. thunk는 그나마 간단하지만 saga를 제대로 배우려면 러닝 커브가 늘어난다. (generator, yield…)

  4. 상태 관리? 비동기 처리?

    개인적으로는 별로라고 생각했던 단점이다.
    redux를 사용하고 있는데, 만약 특정 도메인에서는 상태 관리는 굳이 따로 필요없고, 비동기 처리만 한다고 하면?
    middleware를 사용하게 될텐데, 상태 관리를 사용하지 않음에도 그 큰 redux를 사용한다..
    또한 코드와 파일 복잡도도 길어질 것이다.

    그래서 요즘에는 react-query, swr 등 data-fetching 라이브러리를 사용하기도 하는 듯 하다.

이 외에 context-api의 단점도 꼽을 수 있지만, 다 정리하지 않겠다.
(공통된 상위 요소에 위치해야하므로 렌더링 문제, 단일 값만 저장 가능, 코드 더러워짐 등등)

장점

recoil의 장점은 위 라이브러리들의 단점을 커버하는 경향이 있다.

  1. react 전용이며 사용이 간단하다.

    react 전용이므로 동시성 모드 등 react에서 제공하는 기능들과 호환이 좋다.
    또한 redux에 비해 코드도 적고, 사용이 간단하다.

  2. 비동기 처리

    추가적인 라이브러리 없이 비동기 처리가 가능하다.

  3. 캐싱 제공으로 어떤 부분에서는 장점을 갖는다.

  4. meta에서 개발 및 유지보수하고 있다.

등등이 있다.

단점

  1. experimental state management 즉, 아직 major 버전이 아니다.

    그래서 어느 부분에서는 unstable한 부분이 있다. 도대체 언제 메이져로 올라오는 것 일까…

  2. 사용이 간단?

    진입 장벽은 낮고 처음에는 매우 쉬우나,
    비동기 처리 등 고도화 할 수록 어려워진다.. (내가 바보라 그런가)

  3. 생태계

    redux에 비해 문서가 많이 없다.
    개인적으로는 recoil 뿐만 아니라 애초에 코딩이 초보인데,
    패턴을 보고 배울 아티클이 아주 많지는 않다. (redux에 비해..)

설명 (도큐먼트)

recoil은 atomselector를 이용해서 상태 데이터 플로우를 만들어 준다.

atom

기본적인 상태(상태의 기본 단위)이며 여러 컴포넌트들이 공유 및 구독할 수 있다.

atom 상태 값을 변경할 수 있고,
변경될 때마다 구독하고 있는 컴포넌트가 리렌더된다.

selector

순수 함수이며 atom이나 다른 selector를 변형하여 사용하는 상태(derived data)이다.

따라서 upstream으로 사용하고 있는 atom이나 selector가 변함에 따라 재평가 되며 리렌더가 일어나게 된다.
(문서에는 이렇게 설명하고 있지만 어쩄든 selector가 다른 atom이나 selector를 구독하여 알맞게 변형하는 느낌. 혹자는 react의 useMemo로 많이 비교하기도 한다.)

사용 방법

recoilRoot

다른 여타 상태 관리 라이브러리와 동일하게 root로 감싸주어야 한다.
(recoil을 사용하고자 하는 컴포넌트의 부모/상위 컴포넌트에 root가 있어야 한다는 뜻.)

import React from "react";
import { RecoilRoot } from "recoil";

function App() {
  return (
    <RecoilRoot>
      <AnimalsApp />
    </RecoilRoot>
  );
}

atom

만약 동물원의 동물 종류를 나타내어야 하는 페이지가 있다고 해보자.

const animalsState = atom({
  key: "animalsState", // 고유한 key이어야 함.
  default: [],
});

동물의 종류와 상태 및 수를 나타낼 수 있도록 저장하는 state를 animalsState라고 한다.

atom은 원자 단위의 상태로

key는 유일한 값이어야 한다.
그래서 회사에서는 선배의 의견에 따라 사용할 때 변수 이름과 도메인 이름을 조합해서 사용했었다.
꽤 좋은 방법이라고 생각한다!

default는 추후 비동기 처리로 가져올 데이터가 오기 전 정해놓는 값이다.
일단은 빈 배열로 둔다.
(ts를 사용한다면 default의 타입과 state의 타입을 모두 명시해야한다. 아니면 타입 오류)

추후 데이터가 이런 식으로 나타나면 좋겠다.

[
  {
    "id": 1326,
    "species": "elephant",
    "existence": true,
    "number": 6,
    "display": "DAY"
  },
  {
    "id": 1327,
    "species": "wolf",
    "existence": false,
    "number": 0,
    "display": "DAY"
  },
  {
    "id": 1328,
    "species": "owl",
    "existence": true,
    "number": 12,
    "display": "NIGHT"
  }
]

useRecoilState / useRecoilValue / useSetRecoilState

그렇다면 컴포넌트에서 구독은 어떻게?

매우 초간단이라 recoil이 쉽다고 하는 것 같다.

store든 뭐든 atom/selector를 정의해둔 파일에서 import 해온다.
recoil에서 hook을 꺼낸다.
사용에 따라 맞는 hook을 사용하면 된다.

  • useRecoilState: useState와 매우 유사! recoil state를 인자로 받으면 배열을 반환해준다. 배열의 첫번째는 상태를 사용할 수 있는 변수, 두번째는 상태를 업데이트할 수 있는 set함수를 반환한다.
  • useRecoilValue: 상태를 읽어오기만 한다면 이를 사용한다.
  • useSetRecoilState: 상태를 업데이트하기만 한다면 이를 사용한다.
import { useRecoilState, useRecoilValue, useSetRecoilState } from "recoil";
import { animalsState } from "../store";

function AnimalsComponent() {
  const [animals, setAnimals] = useRecoilState(animalsState);

  const updateAnimals = () => {
    setAnimals([]); // 업데이트 시 사용
  };

  // animals 배열 가지고 지지고 볶고...
  return <div>animals info here</div>;
}
  • useResetRecoilState도 있음. 추가 기능들은 공식 문서에서 확인!

selector

동물의 정보를 가져오려고 한다면, 서버에 있는 데이터를 불러와야 한다.
이때 비동기 처리가 필요한데, selector에서 비동기 처리가 가능하다.

이해하기 쉽게 각 동물의 상태(eachAnimalState)라고 했다.
atom으로 저장 및 정해둔 animalIdState를 이용해서 url로 데이터를 get한다.
비동기이므로 “get” 키에 async를 붙여주어야 한다.

const eachAnimalState = selector({
  key: "eachAnimalState",
  get: async ({ get }) => {
    // axios 가져오는 부분은 부정확할 수 있음*
    const { data } = await axios.get({
      url: `https://get/animalsdata/url/${get(animalIdState)}`,
    }); // animalIdState는 특정 동물의 id를 저장해두는 atom임.

    const animalData = data.info.animal
      ? new AnimalViewModel(data.info.animal)
      : undefined;

    return {
      id: animalData?.id || "",
      species: animalData?.name || "",
      existence: animalData?.existence || false,
      number: animalData?.number || 0,
      display: animalData?.display || "DAY",
    };
  },
});

그래서 컴포넌트에서 구독할 때는 어떻게?

selector도 마찬가지로 useRecoilValue로 구독할 수 있다.

그러나 문제는 selector는 읽기 전용이다. 값을 업데이트할 수 없기 때문에 set을 사용할 수 없다!
(무슨 일이 있어도 selector는 절대 불변이다. 만약 selector를 업데이트하고 싶은 상황이면 그 방법은 잘못된 것일 것..)

import { useRecoilValue } from "recoil";
import { eachAnimalState } from "../store";

function SelectedAnimalComponent() {
  const animal = useRecoilValue(eachAnimalState);

  return <div>{animal.species}</div>;
}

근데 eachAnimalState비동기 처리를 하는 selector였다.

그럼 비동기 처리를 하는 동안에는 어떤 값을 보여주어야 하는 걸까?

이럴 때 쓰는 것이 바로

Suspense 및 useRecoilValueLoadable

Suspense

먼저 React.Suspense 및 ErrorBoundary를 이용해서 데이터가 준비되지 않은 경우 처리를 해줄 수 있다.

recoil state가 비동기 처리로 인해 데이터가 준비가 안되었음에도 그냥 useRecoilValue를 사용했을 경우,
Suspense/ErrorBoundary처리가 없다면 에러가 뜬다.

function AnimalComponent() {
  return (
    <Wrapper>
      <ErrorBoundary>
        <React.Suspense fallback={<div>Loading...</div>}>
          <AnimalContainer>
            <AnimalInfo anmialId={1} />
            <AnimalInfo anmialId={2} />
            <AnimalInfo anmialId={3} />
          </AnimalContainer>
        </React.Suspense>
      </ErrorBoundary>
    </Wrapper>
  );
}

useRecoilValueLoadable

만약 suspense를 사용하기 애매한 컴포넌트라면
recoil에서 제공하는 useRecoilValueLoadable을 사용할 수 있다.

loadable은 객체로서 atom/selector의 현재 상태를 나타내준다.
상태는 값을 사용할 수 있는 상태, 에러 상태, 펜딩 상태가 있다.

즉, useRecoilValueLoadable을 사용하면 loadable 객체를 반환받는데,
이때 state에 따라 원하는 동작을 하면 된다.

  • state: hasValue / hasError / loading 으로 나뉨.
  • contents: 실제 값이 들어있음. hasValue 상태일 때만 실제 값이 들어감. hasError일 경우 error 객체가 들어가고, loading 상태일 경우 promise 객체가 있음.
import { useRecoilValueLoadable } from "recoil";

function AnimalComponent() {
  const animalStateLoadable = useRecoilValueLoadable(eachAnimalState);

  // 이게 좋은 방법일지는 잘 모르겠다..
  const animalInfo = useMemo(
    () =>
      animalStateLoadable.state === "hasValue"
        ? animalStateLoadable.contents
        : {},
    [animalStateLoadable]
  );

  // 혹은 이렇게 할 수도 있다.
  return (
    <Wrapper>
      {animalStateLoadable.state === "hasValue" ? (
        <AnimalContainer>
          <AnimalInfo anmialId={1} />
          <AnimalInfo anmialId={2} />
          <AnimalInfo anmialId={3} />
        </AnimalContainer>
      ) : (
        <div>loading...</div>
      )}
    </Wrapper>
  );
}

여러 가지

atomFamily & selectorFamily

family는 atom/selector에 인자를 받는다. 그리고, 그 인자에 따라 값이 저장된다.

map을 생성한다고 한다.

  • document
    The atomFamily() essentially provides a map from the parameter to an atom.
    

처음에는 도대체 family가 뭐지 했는데, 전달하는 인자에 따른 atom들인 것을 알게 되니 그렇구나 싶었다.
직접 사용해보면 느낌이 확 올 것이다.

useRecoilCallback

react의 useCallback과 유사하다.

recoilValue들은 컴포넌트에서 구독하고 있다면 컴포넌트가 렌더될때 비동기 처리(혹은 캐시된 데이터를 보여주거나)가 된다.

그런데 만약 사용자 이벤트를 받아서 비동기 처리를 하고 싶거나, 성능/기능 상 lazy하게 불러오고 싶다면!?

그 때 callback을 사용해서 함수를 반환받아 사용하면 되겠다.

*더 이상의 이상한 예를 들기가 힘들어 document에 있는 예를 가져왔다..

import { atom, useRecoilCallback } from "recoil";

const itemsInCart = atom({
  key: "itemsInCart",
  default: 0,
});

function CartInfoDebug() {
  // useRecoilCallback은 콜백함수와 deps array를 인자로 받는다.
  const logCartItems = useRecoilCallback(
    ({ snapshot }) =>
      async () => {
        // snapshot에서 여러 기능들을 제공하기 때문에 atom/selector 값을 여기저기서 불러올 수 있다.
        const numItemsInCart = await snapshot.getPromise(itemsInCart);
        console.log("Items in cart: ", numItemsInCart);
      },
    []
  );

  // click 이벤트가 발생할 때만 비동기 처리하도록 한다.
  return (
    <div>
      <button onClick={logCartItems}>Log Cart Items</button>
    </div>
  );
}

구현하고 싶은 기능이 있으면 문서를 잘 뒤져보면 많은 기능들을 제공하고 있기 때문에
문서를 많이 보면 좋은 것 같다.

+ recoil github issue도 봐야함..

적용했던 후기

recoil 기본적인 부분은 간단하다!

근데 프로젝트에 적용했을 때는 생각보다 어려웠다..

family부터, 비동기 처리, 컨벤션도 그렇고, 캐시 정책, refetch 등등

지금와서 생각해보면 아주 어렵다고는 말 못할 것 같은데,
생경해서 조금 헤맬 수도 있다고 생각한다.
(특히나 초보(나)의 경우)

redux보다 가볍고, 꽤 이뿌게 코드를 정리할 수 있고, 캐시도 지원되어 성능도 좋고(물론 아닐 때도 있다.), 요새 뜨는 상태 관리 라이브러리이니 경험해서 좋았다.

그러나 정보(아티클)가 많이 없어 힘들었다.
나는 best practice를 따라가는 사람이기에 recoil을 적용한 좋은 사례가 있었음 좋았겠다 싶었다.

참고 아티클

Recoil 공식문서


댓글남기기