본문 바로가기
React

Recoil under the hood🤿 리코일을 직접 만들어보기

by ttum 2023. 11. 24.

최근 1년 넘는 시간동안 회사에서 리코일을 사용했다. 다양한 환경에서 사용하다보니 자연스럽게 리코일에 대해 공부하게 되었고 이를 바탕으로 글을 작성해보려고 한다.

 

📌 오늘 해보고자 하는 것

✅ Context로 구현하기 vs 리코일 사용하기

✅ 리코일은 어떻게 필요한 부분만 리렌더하는가?

✅ 리코일을 직접 만들어보자!

 

📌 대상 독자

이 글은 기본적으로 Recoil을 사용해본 사람이 읽으면 좋을 것같다. 리코일이 처음이라면 이 글을 먼저 읽고 한번 사용해보기를 추천한다. 리코일은 리액트 hook을 사용할 수 있고 Context API에 대한 이해도가 어느정도 있다면 정말 쉽게 사용해볼 수 있다. 

 

✅ Context로 구현하기 vs 리코일 사용하기

상태관리를 하다보면 해당 상태가 너무 많은 컴포넌트에서 사용하고 싶어, 최적의 공통 부모를 찾아 올라가다가 props drilling(특정 상태를 사용하지 않는 컴포넌트들이 단지 상태 전달만을 위해서 props를 내려주고 있는 상태)으로 고생했었다. 결국 글로벌단에서 편리한 상태관리에 대한 수요가 자연스럽게 생겨났다.

리코일에서는 props drilling을 해결할 수있는 Context API라는 것을 제공한다. 사용하고싶은 최상단 컴포넌트를 Provider로 감싸주면 자손중 어느 곳에서도 useContext를 이용해 간편하게 가져와 사용할 수 있다. props drilling 문제를 해결해 준것이다. 그러나 Context API를 사용하며 몇가지 불편한 점들이 눈에 띄었는데,

  1. 프로젝트마다 매번 Provider와 reducer를 만드는 것이 번거로웠다.
  2. Context가 단일 값(단순 문자열, number 등)을 가지지 않는 경우(객체 상태관리 등)에 연관된 자손들은 모두 리렌더링이 되어서 최적화가 어려웠다.

 

Step 1. Context로 구현해보기

아래 코드에서 counter값이 변경될때마다 CounterView, CounterButtons 모두 반복해서 리렌더링된다.

값을 set해주는 부분이고 counter값에 영향을 받지 않으므로 굳이 리렌더링될 필요가 없음에도 계속해서 리렌더링이 된다. 이를 해결하기 위해서는 Context를 분리해주면 되겠지만 이렇게 관심사 분리를 위해 Context를 계속 생성하다보면 너무 번거롭기 짝이 없다.

 

Step 2. 리코일로 구현해보기

여기에 Recoil을 적용해보면 어떨까? 놀랍게도 CounterButtons는 리렌더링되지 않고 값 변경에 영향을 받는 CounterView 컴포넌트만 리렌더링이 된다. 이처럼 리코일에서 atom 상태를 만들고 구독하면 실제로 해당 상태 값을 사용하는 부분에만 리렌더링을 해줄 수 있다.

 

✅ 리코일은 어떻게 필요한 부분만 리렌더링할까?

아래는 리코일에서 스토어를 생성하는 부분의 실제 코드이다.

// https://github.dev/facebookexperimental/Recoil
// packages > recoil > core > Recoil_RecoilRoot.js
const AppContext = React.createContext<StoreRef>({current: defaultStore});
const useStoreRef = (): StoreRef => useContext(AppContext);

 

위 코드를 보고 알 수 있는 내용들은 다음과 같다.

1. 리코일도 역시 Context를 만들어 사용하고 있다.

2. Context안에서 관리되는 상태는 Ref값이다. (리액트에서 Ref는 값이 변경되더라도 렌더함수를 트리거하지 않는다.)

 

2번의 이유때문에 값이 변경되어도 연관된 모든 컴포넌트에 리렌더링이 일어나지 않았던 것이다. 그렇다면 자연스럽게 다음 질문이 따라온다. ref는 리렌더링을 트리거하지 않는데 어떻게 리코일은 상태를 구독한 컴포넌트를 리렌더링 시켜줄 수 있는가?

 

이를 알아보기 위해 recoil의 useRecoilValueLoadable_TRANSITION_SUPPORT함수를 살펴보자.

아래 함수는 useRecoilValue, 즉 상태를 구독하는 함수의 코어단 중 일부이다.

function useRecoilValueLoadable_TRANSITION_SUPPORT<T>(
  recoilValue: RecoilValue<T>,
): Loadable<T> {
  // 🐸
  const storeRef = useStoreRef();
  const componentName = useComponentName();

...

  // 🐹
  useEffect(() => {
    const subscription = subscribeToRecoilValue(
      storeRef.current,
      recoilValue,
      _state => {
        setState(updateState);
      },
      componentName,
    );

    setState(updateState);

    return subscription.release;
  }, [componentName, recoilValue, storeRef, updateState]);

  // 🐻
  const [state, setState] = useState(getState);

  return state.key !== recoilValue.key ? getState().loadable : state.loadable;
  }

 

코드 주석 🐸

여기에서 기존에 Context로 만들어두었던 Ref로 만들어진 스토어를 불러온다.

 

코드 주석 🐻

햄스터 주석을 보기전에 곰 주석을 먼저 보자. useRecoilValueLoadable 훅을 사용하는 컴포넌트는 state라는 로컬 상태값을 가지게된다. 그리고 우리가 이미 잘 알고있다시피 로컬 상태가 변하게 되면 해당 컴포넌트는 리렌더링된다.

어느정도 실마리가 보이는 것 같다. 각 훅을 사용하는 컴포넌트들이 로컬 상태값을 가지고있고 Ref값의 변경으로는 리렌더링이 발생하지 않기 때문에 state값을 업데이트 시켜주어 리렌더링을 트리거할 수 있을 것 같다.

 

코드 주석 🐹

useRecoilValueLodable 훅을 사용하는 컴포넌트에서는 이 useEffect가 트리거된다. 여기에서는 리코일 Store에게 "이 컴포넌트에서 그 상태값을 구독할거에요"를 알려주는 부분이다. 보다시피 함수 인자로 _state => setState(updateState)를 넘겨준다. ref의 값이 변경되는 시점에 이 함수가 실행된다면 🐻 코드에 있던 state값이 업데이트될 것이므로 이 컴포넌트는 리렌더링이 될 것이다.

리코일 코드를 열어보니 생각보다 단순하고 반짝이는 아이디어를 통해 상태를 효율적으로 관리하고 있었다.

 

 

✅ 리코일을 직접 만들어보자!

리코일의 핵심 동작을 알게되었으니 직접 만들어 볼 수 있을 것 같은 느낌이 든다. 실제 리코일 코드는 훨씬 복잡하고 효율적으로 돌아가지만 대략적인 코어부만 고려하여 코드를 만들었다.

 

만들어 볼 내용은 다음과 같다.

1. Context와 Ref로 스토어 만들기

2. atom으로 구독할 수 있는 상태를 만들기

3. useRecoilValue, useSetRecoilValue를 만들기

4. 필요한 부분에서만 리렌더링이 발생하도록 하기

 

1. Context와 Ref로 스토어 만들기

// 🐶
class Store {
  stateMap = {};
  callbackMap = {};
  update(key) {
    const callbacks = this.callbackMap[key];
    const value = this.stateMap[key];
    callbacks.forEach((callback) => {
      callback(value);
    });
  }
}

// 🦊
const defaultStore = new Store();
const RecoilContext = createContext();
const useStoreRef = () => useContext(RecoilContext);

// 🐰
const RecoilRoot = ({ children }) => {
  const storeRef = useRef(defaultStore);

  return (
    <RecoilContext.Provider value={storeRef}>{children}</RecoilContext.Provider>
  );
};

 

코드 주석 🐶

이 부분은 스토어 클래스를 구성하는 부분이다. 크게 stateMap, callbackMap, update가 있다.

1) stateMap: 각 atom의 값들을 key-value형태로 저장하는 부분. 실제 상태값들이 저장되는 곳.

2) callbackMap: 각 값들이 업데이트되었을때 호출되어야하는 콜백함수들이 들어있는 곳이다. 특정 key에는 호출되어야하는 callback배열이 value값으로 들어가있다.

3) update: key를 받아서 이에 해당하는 callback들을 호출해주는 함수. 이 함수가 호출되면 로컬 상태값이 업데이트되며 컴포넌트의 리렌더링이 일어나게 된다. callbackMap에서 키로 콜백 배열을 뽑아와 호출해준다.

 

코드 주석 🦊

이 부분에서는 Store 객체를 하나 생성해주고, Context를 생성해준다.

useStoreRef는 RecoilContext를 바로 사용할수있도록 만들어둔 훅이다.

 

코드 주석 🐰

Context Provider 부분이다. 이 Context에서는 미리 만들어둔 storeRef객체를 value값으로 내려준다.

 

2. atom으로 구독할 수 있는 상태를 만들기

// 🐸
class RecoilValue {
  constructor(key) {
    this.key = key;
  }
}

// 🤡
const atom = (key, defaultValue) => {
  const storeRef = defaultStore;
  storeRef.stateMap[key] = defaultValue;
  const recoilValue = new RecoilValue(key, defaultValue);
  return recoilValue;
};

// 🦁
const CountState = atom("CountState", 0);

 

코드 주석 🐸

RecoilValue라는 클래스는 스토어에 관리될 key를 생성해주는 클래스다. 단순 키값만 들고있다.

 

코드 주석 🤡

이 코드는 atom 함수를 만들어준다. 실제 코드에서는 atom에서 store를 사용할 수 있도록 주입해주는 부분이 있지만, 간편하게 구현하기 위해 위에 만든 Store객체를 직접 참조하는 방식으로 사용했다.

RecoilValue를 이용해 객체를 만들고 해당 키와 디폴트 값을 스토어에 저장해준다.

 

코드 주석 🦁

이 부분에서 실제로 atom을 이용해서 Count 숫자값을 담는 상태를 하나 만들어두었다.

 

3. useRecoilValue, useSetRecoilValue를 만들기

코드 주석 😈

useRecoilValue와 useSetRecoilValue는 컴포넌트에서 상태를 구독하고 상태를 업데이트하는 훅이다. 이 두 함수 모두 atom으로 만든 상태를 인자로 받아 사용한다.

 

코드 주석 🦄

useRecoilValue 훅을 사용하는 컴포넌트는 스토어 값이 변경되었을때 컴포넌트를 리렌더링해주어야하기 때문에 로컬 상태를 만들어 관리한다. 위의 리코일 코드중에서 useRecoilValueLodable 함수의 🐻 주석 부분과 동일한 역할을 한다.

 

코드 주석 🐷

useRecoilValue 훅이 실행되면서 storeRef에다가 상태가 업데이트되면 state를 업데이트시켜줄 수 있도록 callback함수를 넘기는 부분이다. 마찬가지로 위 리코일 코드 useRecoilValueLoadable의 🐹 부분의 구독함수와 유사한 역할을 한다.

 

코드 주석 🐻‍❄️

실제로 리코일에서는 비동기 상태업데이트를 위해서 여러 로직들이 추가되었지만, 내가 만든 리코일에서는 값이 업데이트되는 순간 바로 구독하는 모든 컴포넌트에 notify(update함수 실행)을 해준다. 그러면 값이 변경되는 시점에 해당 상태를 구독된 모든 컴포넌트는 값이 리렌더링되면서 최신 값을 받아볼 수 있다.

// 😈
const useRecoilValue = (recoilValue) => {
  const storeRef = useStoreRef();
  const key = recoilValue.key;
  // 🦄
  const [state, setState] = useState(storeRef.current.stateMap[key]);

  // 🐷
  useEffect(() => {
    storeRef.current.callbackMap[key]?.push((_state) => setState(_state));

    return () => {
      storeRef.current.callbackMap[key]?.pop();
    };
  }, [storeRef.current]);

  return state;
};

// 😈 
const useSetRecoilValue = (recoilValue) => {
  const storeRef = useStoreRef();
  const key = recoilValue.key;

  // 🐻‍❄️
  return (updater) => {
    storeRef.current.stateMap[key] = updater(storeRef.current.stateMap[key]);
    storeRef.current.update(key);
  };
};

 

4. 필요한 부분에서만 리렌더링이 발생하도록 하기

// 👽
const CounterView = () => {
  const counterValue = useRecoilValue(CountState);
  const setLog = React.useContext(LogContext);

  React.useEffect(() => {
    setLog((logs) => [...logs, `CounterView rendered`]);
  });

  return <h1>{counterValue}</h1>;
};

// 🤖
const CounterButtons = () => {
  const setCounterValue = useSetRecoilValue(CountState);
  const setLog = React.useContext(LogContext);

  React.useEffect(() => {
    setLog((logs) => [...logs, `CounterButtons rendered`]);
  });

  return (
    <div>
      <button onClick={() => setCounterValue((value) => value + 1)}>+</button>
      <button onClick={() => setCounterValue((value) => value - 1)}>-</button>
    </div>
  );
};

// 🤠
const CounterView2 = () => {
  const counterValue = useRecoilValue(CountState);
  const setLog = React.useContext(LogContext);

  React.useEffect(() => {
    setLog((logs) => [...logs, `CounterView 2 rendered`]);
  });

  return <h3>{counterValue}</h3>;
};

const CounterView3 = () => {
  const counterValue = useRecoilValue(CountState);
  const setLog = React.useContext(LogContext);

  React.useEffect(() => {
    setLog((logs) => [...logs, `CounterView 3 rendered`]);
  });

  return <h3>{counterValue}</h3>;
};

// 🐨
export default function App() {
  return (
      <RecoilRoot>
        <div className="section">
          <CounterView />
          <CounterButtons />
        </div>
        <CounterView2 />
        <CounterView3 />
      </RecoilRoot>
  );
}

 

코드 주석 👽

이 부분은 값을 구독해서 사용하는 컴포넌트이다. 값이 변경되면 counterValue값이 업데이트되면서 컴포넌트가 리렌더링된다.

 

코드 주석 🤖

이 부분은 값을 조작하는 부분이다. setCounterValue를 통해 값을 변경하면 이 상태를 구독한 모든 컴포넌트에 리렌더링이 발생한다.

 

 코드 주석🤠

이 부분도 마찬가지로 값을 구독하기 때문에 CounterButtons에서 값을 변경시키면 함께 리렌더링된다.

 

코드 주석🐨

이 부분은 컴포넌트를 리턴하는 부분이다. 최상단에 직접만든 컨텍스트 프로바이더인 RecoilRoot로 감싸주고 있다. 그리고 밑에 여러 컴포넌트가 들어가있더라도 실제로 상태값을 수독하는 CounterView, CounterView2, CounterView3에서만 리렌더링이 일어난다.

 

최종결과

결과는 보다시피 구독한 컴포넌트에서만 리렌더링이 일어나는 것을 확인할 수 있다.