Recoil

시작하며

React 프로젝트를 진행하다 보면 많은 프로젝트에서 상태관리 라이브러리를 사용하게 됩니다. 저 역시 이번에 새로운 프로젝트를 시작하면서 어떤 라이브러리를 사용하는 것이 좋을지 고민하게 되었고, 그 과정에서 다양한 상태관리 라이브러리에 대해 알아보게 되었습니다. 그러던 중 Facebook에서 2020년 발표한 Recoil이라는 React 전용 라이브러리에 대해 알게 되었습니다. 간단한 사용과 Facebook이라는 타이틀 때문인지 현재 많은 관심을 받는 라이브러리인 것 같아 좀 더 자세히 알아보게 되었습니다. 그 과정에서 알게 된 내용을 정리해보았습니다.

React 전역 상태 관리의 역사

Recoil을 소개하기에 앞서 간략하게 React의 전역 상태관리 어떤 식으로 변화했는지 간단히 알아보도록 하겠습니다.

props, state

  • 단방향 데이터 바인딩으로 인한 Props drilling이 문제가 되었습니다.
  • 전역적인 상태 관리의 필요성이 대두되었습니다.

2014.05 Flux Architecture

  • 대규모 어플리케이션에서 보다 일관된 데이터 관리를 위해 고안되었습니다.
  • 기존에 보편적으로 사용되던 MVC 패턴의 사용 시 데이터 흐름의 복잡도가 올라가는 문제 발생했습니다.
  • Flux 아키텍처는 단방향으로 데이터가 흐를 수 있도록 설계되었습니다.
  • 다만 직접적인 모듈이나 코드를 제시한 것이 아니라 컨셉을 제안한 상태였습니다.

2015.06 Redux

  • Flux 아키텍쳐를 참고하여 Redux가 만들어졌습니다.
  • 다른 프레임워크에서도 사용 가능한 라이브러리입니다.
  • dispatch 관리를 위해 redux-thunkredux-saga 등의 미들웨어 필요합니다. (비동기 처리 복잡)
  • 보일러플레이트 코드가 많습니다.(action, connect, mapStateToProps, mapDispatchToProps…)
  • react-toolkit의 등장으로 사용이 간편해졌습니다.
  • 현재까지 가장 인기 있는 React 상태관리 라이브러리입니다. (커뮤니티 활발, 디버깅 쉬움)

MobX

  • Redux와 마찬가지로 React에 종속적이지 않은 라이브러리입니다.
  • 비교적 적은 보일러 플레이트 코드로 좀 더 간단하게 코드를 작성할 수 있습니다.
  • Redux와 달리 다수의 store를 사용할 수 있습니다.
  • 객체지향적으로 설계되었습니다. Java Spring Framework와 유사한 아키텍쳐구조로 되어 있어 특히 서버 개발자들에게 친숙한 형태입니다.
  • 비교적 낮은 러닝 커브를 가지고 있습니다.
  • devTools, 커뮤니티가 다소 부족한 상태입니다. 특히 hooks와 함께 사용하는 mobx v6에 대한 대응이 늦어지면서 관련 레퍼런스는 더욱 찾아보기 어려워졌습니다.

2018.03 context API

  • v16.8 부터 제공되는 React 내장 API 입니다.
  • 외부 라이브러리 없이 상태 관리를 할 수 있습니다. (소규모 프로젝트에서 적절)
  • useReducer를 함께 사용하여 redux와 비슷한 형태로 관리할 수 있지만 이 역시 비동기 처리가 어렵다는 문제가 있습니다. (참고: useReducer로 비동기 로직 다루기)
  • 상태값을 변경하면 Provider로 감싼 모든 컴포넌트가 리렌더링 되는 문제 발생하여 성능 최적화가 별도로 필요합니다.
  • useMemo를 통해 Provider의 value props를 메모이제이션 하거나 독립적인 context를 만들면 리렌더링을 줄일 수 있습니다.

2020.05 Recoil

  • Facebook에서 출시된 React 전용 상태관리 라이브러리입니다. (React에 최적화)
  • 출시된 지 얼마 되지 않았지만 많은 관심을 받는 상태입니다.

Recoil의 특징

  • React 문법 친화적이다: 전역 상태 값도 React의 state처럼 간단한 get/set 인터페이스로 사용할 수 있는 boilerplate-free API를 제공합니다.
  • React와 개발 방향성이 같다: 동시성 모드(Concurrent Mode)를 비롯한 다른 새로운 React의 기능들과의 호환 가능성도 가집니다.
  • 비동기 처리를 간단하게 할 수 있다: 추가 라이브러리 없이 recoil만으로 가능합니다.
  • 내부적으로 캐싱이 된다: 동일한 atom 값에 대해 내부적으로 메모이즈된 값을 반환하여 속도가 빠릅니다. (하지만…)

시작하기

설치

  • npm
    npm install recoil
  • yarn
    yarn add recoil
  • CDN
    <script src="https://cdn.jsdelivr.net/npm/recoil@0.0.11/umd/recoil.production.js"></script>

Bundler

WebpackRollup같은 모듈 번들러와도 문제없이 호환됩니다.

ES5

공식문서에서는 아래와 같이 설명하고 있습니다.

Recoil 빌드는 ES5로 트랜스파일 되지 않으므로, Recoil을 ES5와 사용하는 것은 지원하지 않는다. ES6 기능을 제공하지 않는 브라우저를 지원해야 하는 경우 Babel을 이용하여 코드를 컴파일하고 preset @babel/preset-env를 이용하여 수행할 수는 있지만 문제가 발생할 수 있다.

특히, Recoil은 ES6의 Map과 Set 타입에 의존하는데, 이러한 ES6의 요소에 polyfills를 사용하는 것은 성능상에 문제가 발생할 수 있다.

ES6로 사용하는 것을 권장하고 있기 때문에 브라우저 지원범위에 따라 사용 여부를 고민해 볼 수 있을 것 같습니다. 대부분의 모던 브라우저에서 ES6를 제공하고 있지만, IE11에서는 ES6를 완벽하게 지원하고 있지 않기 때문에 잠재적인 문제가 발생할 수 있습니다. 모바일 환경에서는 사용이 가능한 상황이며, IE11의 지원이 공식적으로 중단되었기 때문에 앞으로 PC에서도 좀 더 많이 사용할 수 있을 것으로 기대됩니다.

참고: Can I Use.. ES6

RecoilRoot

  • 컴포넌트에서 Recoil state를 사용하기 위해서는 recoil 상태를 사용하고자 하는 컴포넌트의 부모에 RecoilRoot를 선언해주어야 합니다.
  • RecoilRoot는 여러 개를 선언할 수도 있습니다.
  • 예제는 간단한 동작을 위해 root 컴포넌트에 선언해주었습니다.
// App.js
import React from 'react';
import { RecoilRoot } from 'recoil';

const App = () => {
  return (
    <RecoilRoot>
      <CharacterCounter />
    </RecoilRoot>
  );
}

Atoms

  • Atoms는 state의 단위이며 업데이트와 구독이 가능합니다. atom 값을 읽는 컴포넌트들은 암묵적으로 atom을 구독합니다. 따라서 atom에 어떤 변화가 있다면 그 atom을 구독하는 모든 컴포넌트가 리렌더링 됩니다.
  • Atoms를 설정할 때는 atom()을 사용하면 됩니다. key와 default값을 필수로 선언해주어야 합니다.
    • key: 내부적으로 atom을 식별하는 데 사용되는 고유한 문자열. 이 문자열은 어플리케이션 전체에서 다른 atom과 selector에 대해 고유해야 합니다. (전역적으로 고유한 값을 가져야 하므로 네이밍 시 $와 같은 구분자를 붙여 사용하기도 합니다.)
    • default: atom의 초깃값. 다양한 타입을 사용할 수 있으며, 동일한 타입의 값을 나타내는 다른 atom이나 selector도 가능합니다.

참고: 현재 atom을 설정할 때 Promise을 지정할 수 없다는 점에 유의해야 한다. 비동기 함수를 사용하기 위해서는 selectors를 사용한다.

// atoms.js
import { atom } from "recoil";

export const countState = atom({
  key: "countState", // 전역적으로 고유한 값
  default: 0 // 초깃값
});
  • 컴포넌트에서 atom을 읽고 쓸 수 있게 하기 위해서는 useRecoilState()를 사용하면 됩니다.
  • 기본값 대신 Recoil state를 인자로 받는 다는 것을 제외하면 React의 useState()와 상당히 유사한 형태를 가지고 있습니다.
  • useRecoilState()는 상태값과, setter함수를 리턴합니다. 이 hook은 암묵적으로 state를 구독합니다. 따라서 atom 값이 변경되면 컴포넌트가 자동적으로 리렌더링 됩니다.
// Counter.js
import React from "react";
import { useRecoilState, useResetRecoilState } from "recoil";
import { countState } from "../states/atoms";

export const Counter = () => {
  const [count, setCount] = useRecoilState(countState);
  const resetCount = useResetRecoilState(countState);

  const increase = () => {
    setCount(count + 1);
  };

  const reset = () => {
    resetCount();
  };

  return (
    <div>
      <h2>{count}</h2>
      <button onClick={() => increase()}>+</button>
      <button onClick={() => reset()}>reset</button>
    </div>
  );
};

Selectors

  • Selector는 전역 상태 값을 기반으로 어떤 계산을 통해 파생된 상태(derived state)를 반환하는 순수함수입니다.
  • get함수만 제공되면 Selector는 읽기만 가능한 RecoilValueReadOnly 객체를 반환합니다. set 함수 또한 제공되며 (optional) Selector는 쓰기 가능한 RecoilState 객체를 반환합니다.
  • get 매개변수를 이용하여 atom이나 다른 selector를 참조할 수 있습니다
  • 주의⚠️: atom의 값이 같으면 내부적으로 반환 값을 메모이즈 하고 있어 캐싱된 값을 반환하므로, 요청을 줄이고 빠르게 값을 반환할 수 있지만 이로 인해 다른 문제가 발생할 수 있습니다. 발생할 수 있는 문제를 해결하는 방법을 업데이트 했지만 공식문서에서 UNSTABLE API로 분류하고 있으므로 사용 시 주의가 필요합니다.
// selectors.js
import { selector } from "recoil";
import { countState } from "./atoms";

export const countNextState = selector({
  key: "counterNextState",
  get: ({ get }) => {
    return get(countState) + 1;
  }
});

  • useRecoilValue() hook을 사용해서 charCountState 값을 읽을 수 있습니다.
  • 사용방법은 앞서 설명한 useRecoilState()와 거의 동일하지만 setter함수는 반환하지 않습니다. 그러므로 setter 함수 없이 state를 읽기만 하는 컴포넌트에서 유용합니다.
// CounterInfo.js
import React from "react";
import { useRecoilValue } from "recoil";
import { countNextState } from "../states/selectors";

export const CounterInfo = () => {
  const nextCount = useRecoilValue(countNextState);
  return <p>the next number is {nextCount}</p>;
};

Recoil Counter Example에서 위에서 설명한 간단한 카운터 예제를 확인하실 수 있습니다. count 값을 1씩 증가시키고, 초기화할 수 있으며, + 버튼을 눌렀을 때 다음 count 값을 미리 계산해서 보여주는 예제입니다.

atom 및 selector사용과 관련된 주요 hooks

Atoms 및 Selectors와 상호작용하기 위해 자주 사용되는 hooks입니다.

  • useRecoilState(): atom을 읽고쓰기 위해 사용. 컴포넌트는 atom을 구독함.
  • useRecoilValue(): atom을 읽기만 할 때 사용. 컴포넌트는 atom을 구독함.
  • useSetRecoilState(): atom을 쓰려고만 할 때 사용.
  • useResetRecoilState(): atom을 default 값으로 초기화 할 때 사용.

useSetRecoilState()useResetRecoilState() hook은 state를 구독하지 않습니다. 따라서 atom 값이 변경되더라도 해당 컴포넌트는 리렌더링이 발생하지 않습니다. state를 변경하기만 하고 읽지 않는 경우 리렌더링을 방지하기 위해 아래와 같이 사용하는 것이 좋습니다.

// bad
const [ value, setValue ] = useRecoilState(valueState);
setValue({...value, foo: 'bar'})

// good
const setValue = useSetRecoilState(valueState)
setMenu((prevValue) => ({ ...prevValue, foo: 'bar' }))

비동기 처리

앞서 Recoil의 특징으로 비동기 처리가 간단하다고 했습니다. 그렇다면 Recoil에서 비동기 처리를 어떻게 하는지 알아보도록 하겠습니다.

1) React Suspense

  • selector에 async/await를 사용하여 외부 서버에서 api 를 불러오는 비동기 코드를 작성했습니다.
  • 아래 예제는 랜덤하게 강아지의 사진을 불러오는 예제입니다.
// selectors.js
import { selector } from "recoil";

export const randomDog = selector({
  key: "randomDog",
  get: async () => {
    const response = await fetch("https://dog.ceo/api/breeds/image/random");
    const data = await response.json();
    return data.message;
  }
});
  • useRecoilValue()를 통해 컴포넌트에서 가져온 이미지 URL을 표시했습니다.
// DogImage.js
import React from "react";
import { useRecoilValue } from "recoil";
import { randomDog } from "../states/selectors";

export const DogImage = () => {
  const imageUrl = useRecoilValue(randomDog);

  return (
    <div>
      <img src={imageUrl} alt="" width="100%" height="auto" />
    </div>
  );
};

  • 위의 내용까지만 작업하면 API를 불러오는 도중에 대한 예외 처리가 되어있지 않아 에러가 발생합니다.
  • RadomDog 컴포넌트를 Suspense로 감싸 fallback에 로딩 중 컴포넌트를 추가했습니다.
  • React.Suspense는 React 18.0 에서 정식 기능으로 제공하고 있습니다.
// Loading.js
import React from "react";

export const Loading = () => {
  return <div>Loading...</div>;
};
// App.js
import React, { Suspense } from "react";
import { RecoilRoot } from "recoil";
import { Loading } from "./components/Loading";
import { DogImage } from "./components/DogImage";

export default function App() {
  return (
    <RecoilRoot>
      <h1>Recoil Random Dog</h1>
      <Suspense fallback={<Loading />}>
        <DogImage />
      </Suspense>
    </RecoilRoot>
  );
}

Recoil Async RadomDog Example은 앞서 설명한 Suspense를 이용한 비동기 처리 예제입니다. 새로고침할때마다 새로운 강아지 이미지를 불러오는 것을 확인하실 수 있습니다.

2) useRecoilValueLoadable()을 이용한 비동기 처리

Suspense는 React 18 부터 정식 채택 되었기 때문에 이보다 낮은 버전을 사용하는 경우 다른 방법이 필요할 수 있습니다. 이러한 경우 어떻게 비동기를 처리할 수 있을지 알아보도록 하겠습니다.

  • useRecoilValueLoadable() hook을 사용하여 렌더링 중 상태(status)를 확인할 수 있습니다.
  • 앞서 사용했던 useRecoilValue()는 Error를 던지거나 Promise를 반환하지 않습니다.
  • useRecoilValueLoadable()는 Loadable 객체를 반환합니다. 이를 이용해 비동기 처리를 할 수 있습니다.
  • Lodable 객체
    • state: slector의 상태를 반환. hasValuehasErrorloading 값 중 하나를 String으로 반환합니다.
    • contents: Loadable이 나타내는 값.
      • state가 hasValue이면 실제 값.
      • state가 hasError이면 throw된 Error객체.
      • state가 loading이면 Promise.
  • 앞서 작성한 코드에서 App.js의 Suspense관련 코드 제거하도록 하겠습니다.
// App.js
import React, { Suspense } from "react";
import { RecoilRoot } from "recoil";
import { Loading } from "./components/Loading";
import { DogImage } from "./components/DogImage";

export default function App() {
  return (
    <RecoilRoot>
      <h1>Recoil Random Dog</h1>
      <DogImage />
    </RecoilRoot>
  );
}

  • useRecoilValueLoadable()을 이용해 로딩중 처리를 추가했습니다.
  • switch문을 이용하여 imageUrlLoadable객체의 state값에 따라 로딩중, 에러발생, 정상응답에 대한 케이스를 분리하는 코드를 작성했습니다.
// DogImage.js
import React from "react";
import { useRecoilValueLoadable } from "recoil";
import { randomDog } from "../states/selectors";
import { Loading } from "../components/Loading";

export const DogImage = () => {
  const imageUrlLoadable = useRecoilValueLoadable(randomDog);

  const render = () => {
    switch (imageUrlLoadable.state) {
      case "loading":
        return <Loading />;
      case "hasError":
        throw imageUrlLoadable.contents;
      default:
        return (
          <div>
            <img
              src={imageUrlLoadable.contents}
              alt=""
              width="100%"
              height="auto"
            />
          </div>
        );
    }
  };

  return render();
};

Recoil useRecoilValueLoadable Example는 앞의 예제와 동일한 내용을 useRecoilValueLoadable()을 이용하여 구현한 예제입니다.

3) 매개변수가 있는 쿼리(selectorFamily())

  • props를 이용하여 매개변수를 전달해 비동기 호출이 필요한 경우가 있습니다. 이러한 경우에는 selectorFamily()를 이용해 매개변수를 전달 할 수 있습니다.
  • selectorFamily()는 selector()와 유사하지만 getset에 매개변수를 전달할 수 있습니다.
  • 아래에서는 selectorFamily()에 대해서만 설명하겠지만, 이와 유사하게 atom에 매개변수를 전달할 때 사용하는 atomFamily()도 있습니다.
  • recoil내 상태값이 아닌 외부 인자를 활용해 상태값을 관리할때 유용하게 사용할 수 있습니다.
// selectors.s
import { selectorFamily } from "recoil";

export const dogsByBreed = selectorFamily({
  key: "dogsByBreed",
  get: (breed) => async () => {
    const response = await fetch(
      `https://dog.ceo/api/breed/${breed}/images/random`
    );
    const data = response.json();
    return data.message;
  }
});
  • DogImage 컴포넌트에 props를 전달할 수 있도록 변경했습니다.
// DogImage.js
import React from "react";
import { useRecoilValue } from "recoil";
import { dogsByBreed } from "../states/selectors";

export const DogImage = ({ breed }) => {
  const imageUrl = useRecoilValue(dogsByBreed(breed));

  return (
    <div>
      <img src={imageUrl} alt="" width="100%" height="auto" />
    </div>
  );
};

  • App.js에서 DogImage컴포넌트에 적절한 breed 값을 전달했습니다.
// App.js
import React, { Suspense } from "react";
import { RecoilRoot } from "recoil";
import { DogImage } from "./components/DogImage";
import { Loading } from "./components/Loading";

export default function App() {
  return (
    <RecoilRoot>
      <h1>Recoil Dog by Breed</h1>
      <Suspense fallback={<Loading />}>
        <DogImage breed="beagle" />
        <DogImage breed="pug" />
        <DogImage breed="shiba" />
      </Suspense>
    </RecoilRoot>
  );
}

Recoil selectorFamily Example는 품종에 따라 강아지 이미지를 보여주는 예제입니다. selector에 props를 전달하여 비동기 쿼리를 실행하고 있습니다.

마무리

  • 앞서 설명한 내용 외에 Recoil에서는 useRecoilCallback(), useRecoilSnapshot(), waitForAll() 동시성 helper 같은 다른 api도 많이 제공하고 있습니다. 더 자세한 내용이 궁금하시다면 (Recoil 공식 문서)를 참고 부탁드립니다.
  • 제가 내용을 정리하면서 느낀 장단점을 간단히 정리해보았습니다.
    • 장점
      • 간단하다
        • 세팅이 간단
        • React 친화적인 문법
        • 간단한 비동기 요청
      • react와 업데이트 방향이 같다
      • 내부적으로 자동적으로 캐싱되어 빠르다(side-effect 존재)
      • 상태를 분산적으로 두어 코드 스플리팅이 가능하다
    • 단점
      • 안정성
        • UNSTABLE한 API
        • 2022.07 현재 최신 버전이 0.7.4
        • 저장소명이 facebookexperimental/Recoil
      • 개발자 도구의 부재
        • useRecoilSnapshot()가 존재하지만 UNSTABLE
        • 비공식 devTools가 있긴 하지만.. 🤔
      • 부족한 레퍼런스
  • 알아보고나니 ‘간단하다’라는 것 만으로도 엄청난 장점을 가지고 있는 라이브러라라고 느꼈습니다. 다만 아직 실험적인 단계로 다소 안정성이 떨어지는 느낌이라 실무에서 프로젝트에 적용하기에는 약간의 부담이 느껴졌습니다. 다른 라이브러리와 함께 사용하거나 소규모 프로젝트에서 사용해보면 좋지 않을까 하는 생각이 들었습니다. 현재 적용중인 프로젝트가 있다면 관련된 팁이나 문제점등을 자유롭게 공유해주시면 많은 도움이 될 것 같습니다 😀. 긴 글 읽어주셔서 감사합니다!

참고


0개의 댓글

답글 남기기

Avatar placeholder

이메일 주소는 공개되지 않습니다.