Recoil 레시피: 스냅샷과 상태 모니터링

전역 상태를 씹고 뜯고 맛보고 즐기고

글 목차

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

Recoil에서는 다른 상태 관리 라이브러리와 달리 전역 데이터 스토어가 잘 드러나지 않는다. 데이터 정의는 아톰이 담당하고, 파생 데이터는 셀렉터, 데이터의 조작은 훅을 통해 이루어진다. 스토어 역할을 하는 <RecoilRoot> 컴포넌트는 있지만 스토어 그 자체에는 접근할 수 없다. 그렇다고 전역 데이터를 다룰 수 있는 방법이 아예 없는 건 아니다. 아직 불완전하고 불안정하긴 하지만 Recoil에서 제공해주는 스냅샷 기능을 통해 전역 데이터에 접근할 수 있다.

스냅샷

변하지 않는 정적인 데이터를 보관하려고 상태 관리 라이브러리를 사용하지는 않을 것이다. 상태는 끊임없이 변한다. 스냅샷은 계속 변하는 상태의 "한 순간"이다. 상태가 동영상이라면 스냅샷은 동영상의 한 프레임인 것이다.

스냅샷은 Snapshot 클래스로 표현되는데 공식 문서에서는 이 클래스를 다음과 같이 정의한다.

class Snapshot {
  getLoadable: <T>(RecoilValue<T>) => Loadable<T>;
  getPromise: <T>(RecoilValue<T>) => Promise<T>;

  map: (MutableSnapshot => void) => Snapshot;
  asyncMap: (MutableSnapshot => Promise<void>) => Promise<Snapshot>;
}

기본적으로 Snapshot은 값을 변경할 수 없는 불변 객체인데 map 또는 asyncMap을 사용해서 수정할 수 있다. 이 부분은 글 뒤에서 다룰 것이다.

스냅샷 얻기

이제 스냅샷을 가져와보자. 스냅샷은 세 가지 방식을 통해 가져올 수 있다. 모두 역할이 조금씩 다르기는 하지만 useRecoilSnapshot(), useRecoilTransactionObserver(), useRecoilCallback()이라는 훅을 사용해 스냅샷을 구할 수 있다.

useRecoilSnapshot()

먼저 상대적으로 간단한 useRecoilSnapshot()부터 살펴보자.

import {useRecoilSnapshot} from 'recoil';

function SnapshotCount() {
  const snapshotList = useRef([]);
  const snapshot = useRecoilSnapshot();

  useEffect(() => {
    snapshotList.current = [...snapshotList.current, snapshot];
  }, [snapshot]);

  return (
    <p>Snapshot count: {snapshotList.current.length}</p>
  );
}

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

코드 샌드박스의 예제에서는 카운터 컴포넌트와 함께 위 컴포넌트를 사용했다. 카운터의 값이 증가하면 상태가 변하고, 따라서 보관된 스냅샷의 갯수도 증가할 것이다. 혹시 코드의 의미가 분명하지 않게 느껴진다면, useRefuseEffect는 스냅샷을 보관하기 위해 사용했을 뿐이므로 여기서는 useRecoilSnapshot의 사용법에만 집중하면 된다.

스냅샷은 상태가 변할 때마다 생성된다. 따라서 SnapshotCount 컴포넌트는 상태가 변할 때마다 렌더링된다. 성능에 주의하며 사용해야 할 것이다.

useRecoilTransactionObserver()

현재 버전인 0.0.10에서는 아직 UNSTABLE 상태라서 훅 뒤에 _UNSTALBE을 붙인 useRecoilTransactionObserver_UNSTABLE을 사용해야 한다. 이 훅에 전달된 콜백 함수는 아톰 상태가 변경될 때마다 호출되지만 여러 업데이트가 동시에 일어나는 경우에는 묶어서 한 번만 호출될 수도 있다.

첫 번째 인수로 호출할 콜백 함수를 전달한다. 콜백 함수에 전달되는 첫 번째 인수는 snapshotpreviousSnapshot이라는 프로퍼티를 가진 객체인데 각각 현재 스냅샷과 이전 스냅샷을 의미한다. 공식 문서에 나타난 정의를 살펴보면 다음과 같다.

function useRecoilTransactionObserver_UNSTABLE(({
  snapshot: Snapshot,
  previousSnapshot: Snapshot,
}) => void)

이 훅을 사용해서 앞서 살펴본 스냅샷 보관 예제를 다시 작성해보면 다음과 같다.

import {useRecoilTransactionObserver_UNSTABLE} from 'recoil';

function SnapshotCount() {
  const snapshotList = useRef([]);

  useRecoilTransactionObserver_UNSTABLE(({snapshot}) => {
    snapshotList.current = [...snapshotList.current, snapshot];
    console.log("Snapshot updated", snapshotList);
  });

  return (
    <p>Snapshot count: {snapshotList.current.length}</p>
  );
}

코드 샌드박스에서 실행

코드를 실행해보면 알겠지만 useRecoilSnapshot()과 아주 큰 차이가 있다. useRecoilTransactionObserver컴포넌트를 다시 렌더링하지 않는다. 따라서 출력 결과물인 "Snapshot count" 부분의 숫자가 변경되지 않는다. 콘솔을 살펴보면 분명 콜백 함수는 상태가 변경될 때마다 실행되고 있지만 컴포넌트 렌더링에는 영향을 주지 않는 것이다. 스냅샷은 구할 수 있으면서도 컴포넌트를 다시 렌더링하지 않으므로 성능 면에서 분명한 이득이 있다.

공식 문서에 따르면 이 훅은 전역 상태를 모니터링하거나 디버깅해야 할 때 유용하다. 하지만 현재 버전 기준으로 전역 상태 모니터링에 이 훅을 사용하기에는 아쉬운 점이 있다. 현재는 스냅샷에서 값을 가져오려면 아톰 또는 셀렉터 객체를 알고 있어야 한다. 아톰 객체나 셀렉터없이 스냅샷에 저장된 값을 루프 등을 통해 훑을 방법도 없고, 변경된 상태가 무엇인지 확인할 방법도 없다.

다만 useRecoilTransactionObserver를 구현한 PR의 설명란을 보면 조만간 Snapshot에 해당 기능이 추가될 가능성이 있다고 생각한다.

This adds support for selectors that the old useTransactionObservation() hook doesn't. It contains both snapshot and previousSnapshot, but the snapshots don't yet have a mechanism for iterating the set of atoms/selectors or dirty atoms. So, keep the old hook for now and migrate users over as we add that functionality.

이 훅은 기존 useTransactionObservation() 훅이 지원하지 않는 셀렉터도 지원한다. 또한 snapshotpreviousSnapshot을 둘 다 포함한다. 하지만 스냅샷에는 아직 아톰, 셀렉터 혹은 변경된 아톰을 훑을 메커니즘이 없다. 따라서 일단은 기존 훅을 유지하기로 하고 해당 기능을 추가하면서 사용자들이 옮겨가도록 하자.

Recoil GitHub 저장소에서

useRecoilCallback()

useCallback과 같이 의존성에 따라 갱신되는 메모이즈된 함수를 생성한다. 다만, 생성된 함수에 스냅샷과 상태를 다루는 객체 및 함수가 함께 전달된다는 점이 다르다. 이 훅을 사용하기에 앞서 비슷하게 동작하는(하지만 조금 더 단순한) useCallback을 익히는 편이 좋다. 일단 공식 문서에 나타난 useRecoilCallback의 타입 정의부터 살펴보자.

type CallbackInterface = {
  snapshot: Snapshot,
  gotoSnapshot: Snapshot => void,
  set: <T>(RecoilState<T>, (T => T) | T) => void,
  reset: <T>(RecoilState<T>) => void,
};

function useRecoilCallback<Args, ReturnValue>(
  callback: CallbackInterface => (...Args) => ReturnValue,
  deps?: $ReadOnlyArray<mixed>,
): (...Args) => ReturnValue

useCallback처럼 첫 번째 인수에는 메모이즈할 함수가 전달되고 두 번째 인수에는 의존성이 전달된다. 주의할 점은 useRecoilCallback에서 첫 번째 인수는 스냅샷 관련 기능을 전달하기 위해 한 번 더 감싸진 "실행할 함수를 만드는 함수"라는 것이다. 두 종류 훅의 차이를 보여주기 위해 아주 간단한 예제를 작성해보았다.

const log1 = useCallback(() => {
  console.log('called with ', 'nothing');
});
const log2 = useRecoilCallback(({snapshot}) => () => {
  console.log('called with ', snapshot);
});

useRecoilCallback에서 생성된 함수의 첫 번째 인수로 전달되는 객체에는 snapshot 객체 외에도 gotoSnapshot 함수, set 함수, reset 함수가 포함되어 있다. 일단은 스냅샷을 가져오는 부분에만 집중해보자.

import {useRecoilCallback} from 'recoil';

function SnapshotCount() {
  const snapshotList = useRef([]);
  const updateSnapshot = useRecoilCallback(({ snapshot }) => () => {
    snapshotList.current = [...snapshotList.current, snapshot];
    console.log("updated:", snapshotList.current);
  });

  return (
    <div>
      <p>Snapshot count: {snapshotList.current.length}</p>
      <button onClick={updateSnapshot}>현재 스냅샷 보관</button>
    </div>
  );
}

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

당연히 컴포넌트를 다시 렌더링하지 않으며, 상태가 변경되어도 해당 콜백이 호출되지 않는다. 생성된 updateSnapshot을 명시적으로 실행할 때만 스냅샷 정보를 가져와서 처리한다. 위 예제에서 useRef 대신 useState를 사용해서 snapshotList를 정의하면 버튼을 클릭할 때마다 컴포넌트가 다시 렌더링 될 것이다.

import {useRecoilCallback} from 'recoil';

function SnapshotCount() {
  const [snapshotList, setSnapshotList] = useState([]);
  const updateSnapshot = useRecoilCallback(({ snapshot }) => () => {
    setSnapshotList(prevList => [...prevList, snapshot]);
  });

  return (
    <div>
      <p>Snapshot count: {snapshotList.length}</p>
      <button onClick={updateSnapshot}>현재 스냅샷 보관</button>
    </div>
  );
}

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

스냅샷에서 상태값 가져오기

스냅샷을 구했으면 이제 사용해 볼 차례이다. 앞서 스냅샷 섹션에서 살펴보았듯이 스냅샷 객체에는 getPromisegetLoadable이라는 메소드가 있는데 이를 통해 상태값을 가져올 수 있다.

바로 앞에서 살펴본 예제를 살짝 변경해서 버튼을 클릭할 때마다 스냅샷에 저장된 count 아톰의 값을 확인해보도록 하자. 두 메소드 중 무엇을 사용해도 상관없겠지만 일단 다음 예제에서는 getPromise부터 사용해보았다.

function SnapshotCount() {
  const [snapshotList, setSnapshotList] = useState([]);
  const updateSnapshot = useRecoilCallback(({ snapshot }) => async () => {
    const count = await snapshot.getPromise(counter);
    console.log("Count: ", count);

    setSnapshotList(prevList => [...prevList, snapshot]);
  });

  return (
    <div>
      <p>Snapshot count: {snapshotList.length}</p>
      <button onClick={updateSnapshot}>현재 스냅샷 보관</button>
    </div>
  );
}

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

여기서 counter는 카운터 상태값을 정의하는 아톰이다. 샌드박스의 예제에서는 기존 예제와 달리 아톰을 별도의 파일로 분리하고 있다. 다른 상태 관리 라이브러리와 마찬가지로, 다루어야 할 상태가 많아지면 아톰과 셀렉터를 별도의 파일 또는 폴더로 분리하여 관리하는 편이 더 좋을 것이다.

이름에서 보듯이 getLoadable 메소드는 Loadable 객체를 반환한다. 이 객체는 앞서 "비동기 액션" 편에서 다룬 바 있으므로 자세히 알고 싶다면 참고하기 바란다. 간단히 말해, Loadable 객체의 statecontents 프로퍼티를 통해 로딩 상태를 확인하고 값을 가져올 수 있다.

스냅샷의 상태값 변경하기

기본적으로 스냅샷은 조작을 할 수 없는 불변(immutable) 객체이다. 하지만 mapasyncMap을 통해 값이 수정된 새로운 스냅샷을 만들어 낼 수는 있다. Array.map을 연상하기 좋은 이름이지만 이 메소드는 스냅샷 내부의 상태값을 훑지 않는다. 단지 상태값을 변형해서 새로운 스냅샷을 만들 수 있는 인터페이스만 제공할 뿐이다.

mapasyncMap의 첫 번째 인수는 값이 변형된 새로운 스냅샷을 만드는 콜백 함수이다. 이 콜백 함수에는 첫 번째 인수로 변형할 수 있는 스냅샷을 의미하는 MutableSnapshot 객체가 전달되는데 이 객체에 속한 setreset을 통해 값이 스냅샷 내부의 상태값을 수정할 수 있다. 다음은 공식 문서에 나타난 MutableSnapshot의 정의이다.

class MutableSnapshot {
  set: <T>(RecoilState<T>, T | DefaultValue | (T => T | DefaultValue)) => void;
  reset: <T>(RecoilState<T>) => void;
}

바로 앞의 예제를 다시 조금 변경해보자. 스냅샷에서 카운터 값을 가지고 오기 전에 카운터 값을 조작하려고 한다.

function SnapshotCount() {
  const [snapshotList, setSnapshotList] = useState([]);
  const updateSnapshot = useRecoilCallback(({ snapshot }) => async () => {
    const newSnapshot = snapshot.map(({ set }) => 
      set(counter, Date.now())
    );
    const count = await newSnapshot.getPromise(counter);
    console.log("Count: ", count);

    setSnapshotList(prevList => [...prevList, newSnapshot]);
  });

  return (
    <div>
      <p>Snapshot count: {snapshotList.length}</p>
      <button onClick={updateSnapshot}>현재 스냅샷 보관</button>
    </div>
  );
}

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

콘솔에 나타난 로그를 보면 스냅샷 내부에 저장된 counter 상태의 값이 현재 시각을 의미하는 숫자로 변경되었음을 알 수 있다.

map?

개인적인 의견이지만 mapasyncMap은 혼란을 줄 수 있는 이름이라고 생각한다. 이 메소드 이름에서 연상되는 Array.map과는 달리 mapasyncMap은 스냅샷에 포함된 상태값을 훑지 않는다. 다만 기존 스냅샷의 값을 변형한 새로운 스냅샷을 만들어 낼 뿐이다. 어쩌면 transform이라는 이름이 더 명확하지 않았을까.

특정 스냅샷으로 상태 되돌리기

드디어 스냅샷 기능의 마지막이다. 특정 시점의 스냅샷을 얻었고, 스냅샷 내부의 상태값도 알게 되었으며 스냅샷 자체를 변형하는 것도 가능해졌다. 그렇다면 이제는 특정 스냅샷으로 상태를 되돌려 볼 차례이다.

Redux 개발 도구를 사용해 보았다면 아래와 같은 시간 여행(Time travel) 기능을 본 적이 있을 것이다. 계속 기록되는 액션 중에서 특정 시점으로 이동(Jump)하면 모든 상태가 해당 시점의 값으로 설정된다.

스냅샷에서도 비슷한 동작을 구현할 수 있다. 특정 시점의 스냅샷을 보관해두었다가 모든 상태값을 특정 스냅샷에 저장된 것을 기준으로 설정하면 된다. 이 때 useGotoRecoilSnapshot 훅이 사용된다. 이 훅을 사용하면 함수를 반환하는데, 이 함수에 스냅샷 객체를 전달하면 해당 시점으로 상태가 복원된다. 앞서 살펴보았던 예제를 조금 변경해서 스냅샷 목록을 표시하고 버튼을 클릭하면 해당 스냅샷으로 상태를 복원하는 코드를 작성해보자.

function SnapshotCount() {
  const [snapshotList, setSnapshotList] = useState([]);
  const updateSnapshot = useRecoilCallback(({ snapshot }) => async () => {
    setSnapshotList(prevList => [...prevList, snapshot]);
  });
  const gotoSnapshot = useGotoRecoilSnapshot();

  return (
    <div>
      <p>Snapshot count: {snapshotList.length}</p>
      <button onClick={updateSnapshot}>현재 스냅샷 보관</button>
      <ul>
        {snapshotList.map((snapshot, index) => (
          <li key={index}>
            <button onClick={() => gotoSnapshot(snapshot)}>
              Snapshot #{index + 1}
            </button>
          </li>
        ))}
      </ul>
    </div>
  );
}

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

카운터 값을 변형해가면서 임의의 시점에서 스냅샷을 추가한 후 해당 시점 스냅샷의 버튼을 클릭하면 카운터 값이 바뀌는 것을 볼 수 있다. 다음 동영상은 이 간단한 애플리케이션이 어떻게 동작하는지 보여준다.

변경된 상태 모니터링하기

앞서 살펴본 방법으로는 스냅샷은 구할 수 있었지만 어떤 상태가 변경되었는지 알 방법이 없다. 또한 스냅샷 객체는 내부에 어떤 아톰이 저장되어 있는지 알려주지 않으며 변경된 아톰을 알 방법도 없다. 이 때 useTransactionObservation 훅을 사용하면 변경된 아톰을 추적할 수 있다. useRecoilTransactionObservation과 이름이 유사하므로 사용할 때 주의가 필요하다.

useTransationObservation 훅 역시 UNSTABLE 상태이기 때문에 사용할 때는 useTrasactionObservation_UNSTABLE로 호출해야 한다. 앞서 useRecoilTransactionObserver 섹션 말미에 잠깐 언급한 대로 useTransactionObserver는 임시로 허용하고 있을 뿐이고 내부 코드에서는 이미 DEPRECATED 딱지가 붙었다. 바로 다음 릴리스에서라도 스냅샷의 기능이 충분해지면 사라질 수 있다.

하지만 현재 버전 0.0.10에서는 공식 문서에서 언급조차 되지 않는 숨은 API인 useTransactionObservation만이 변경된 임의의 아톰을 추적할 수 있는 유일한 방법이다. 이 훅의 타입을 정의해보면 다음과 같다.

function useTransactionObservation(
  callback: {
    atomValues: Map<string, any>,
    previousAtomValues: Map<string, any>,
    atomInfo: Map<string, externallyVisibleAtomInfo>,
    modifiedAtoms: Set<string>,
    transactionMetadata: {[string]: object, ...},
  }) => void
)

디버그 로거를 만든다고 생각하면 가장 중요한 두 가지 정보는 변경된 아톰의 키가 담겨있는 modifiedAtoms와 전체 아톰의 키와 값이 담겨있는 atomValues이다. 앞서 다룬 간단한 카운터 예제에 디버그 로그를 작성하는 컴포넌트를 하나 추가해보자.

import {useTransactionObservation_UNSTABLE} from 'recoil';

function Log() {
  useTransactionObservation_UNSTABLE(({atomValues, modifiedAtoms}) => {
    console.group('State changed');
    for(const key of modifiedAtoms) {
      console.log('Key:', key, 'Value:', atomValues.get(key));
    }
    console.groupEnd();
  });
  return null;
}

코드 샌드박스에서 실행

이제 카운터가 증가할 때마다 콘솔에 무엇이 변경되었는지 로그가 나타날 것이다. 하지만 자세히 살펴보면 아직도 문제가 존재한다. 변경된 아톰의 이름은 표기가 되는데 변경된 값이 무엇인지는 나타나지 않는 것이다. 로그에서 Value: 부분은 항상 undefined가 된다. 무엇이 문제일까?

역시 공식 문서에는 나타나있지 않지만 사실 아톰을 만들 때는 persistence라는 프로퍼티도 함께 설정할 수 있다. 다만 0.0.10 버전 현재 UNSTABLE 상태라 persistence_UNSTABLE을 통해 설정해야 한다. 이 속성은 다음과 같이 정의할 수 있다.

type PersistenceInfo = {
  type: 'none' | 'url',
  backButton?: boolean,
};

이제 카운터의 아톰에 persistence 속성을 추가한 후, 다시 카운터를 증가시켜 보자.

const counter = atom({
  key: "counter",
  default: 0,
  persistence_UNSTABLE: {
    type: "url"
  }
});

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

카운터를 증가시키면서 표시되는 로그에 변경된 값도 나타나는 것을 확인할 수 있을 것이다.

카운터 상태를 URL에 사용할 것은 아니라 persistence.type의 값이 url로 되어있는 부분이 의미상 맞지 않는 듯 보이지만 당장은 다른 방법이 없다. 아톰의 persistence 속성이 설정되어 있지 않거나 persistence.type의 값이 none이면 atomValues에서 변경된 값을 가져올 수 없기 때문이다. 현재 none이 아닌 persistence.typeurl 밖에 없으므로 값을 추적하려면 이 값을 사용할 수 밖에 없다.

거듭 말하지만 이 훅은 이미 DEPRECATED로 표시되어 있고, 언제라도 사라질 가능성이 있다. 향후 아톰의 변경 사항을 추적할 수 있는 더 나은 방법이 나타날 때까지 임시로 사용하는 거라고 생각하자.

마치며

이 글에서는 전역 상태를 저장하는 스냅샷을 다루는 방법을 살펴보았다. 스냅샷은 전체 상태 추이를추적할 때 유용하므로 특히 디버깅이나 테스트에서 많이 활용될 것으로 예상한다. 다만 아직 API가 변경될 여지가 많아 보이므로 사용에 있어 주의를 기울일 필요가 있다.

목표했던 내용의 마지막인 다음 글에서는 서버 사이드 렌더링에 대해서 다루어 볼 것이다. 여기까지 배우고 나면 기존 상태 관리 라이브러리를 Recoil로 변경하는 것도 고려해볼 만하다.

  1. 좋은 글 감사합니다! snapshot 기능을 일종의 history 처럼 undo redo 용으로 사용하고자 하는데, 특정상태에 대해서만 snapshot을 남기게 하는건 뭔가 snapshot 목적에 조금 위배되는 일일까요?

  2. 저는 useRecoilCallback에 get이 없어서 get용으로 요긴하게 사용하고 있습니다. ㅎㅎ
    useRecoilValue으로 값을 가져오면 매번 가져오다보니 수천개의 배열값을 읽으면서 가져오면 퍼포먼스가 떨어지는 거 같아서요 ㅎㅎ

댓글을 남겨주세요

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