글 목차
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>
);
}
코드 샌드박스의 예제에서는 카운터 컴포넌트와 함께 위 컴포넌트를 사용했다. 카운터의 값이 증가하면 상태가 변하고, 따라서 보관된 스냅샷의 갯수도 증가할 것이다. 혹시 코드의 의미가 분명하지 않게 느껴진다면, useRef
나 useEffect
는 스냅샷을 보관하기 위해 사용했을 뿐이므로 여기서는 useRecoilSnapshot
의 사용법에만 집중하면 된다.
스냅샷은 상태가 변할 때마다 생성된다. 따라서 SnapshotCount
컴포넌트는 상태가 변할 때마다 렌더링된다. 성능에 주의하며 사용해야 할 것이다.
useRecoilTransactionObserver()
현재 버전인 0.0.10
에서는 아직 UNSTABLE 상태라서 훅 뒤에 _UNSTALBE
을 붙인 useRecoilTransactionObserver_UNSTABLE
을 사용해야 한다. 이 훅에 전달된 콜백 함수는 아톰 상태가 변경될 때마다 호출되지만 여러 업데이트가 동시에 일어나는 경우에는 묶어서 한 번만 호출될 수도 있다.
첫 번째 인수로 호출할 콜백 함수를 전달한다. 콜백 함수에 전달되는 첫 번째 인수는 snapshot
과 previousSnapshot
이라는 프로퍼티를 가진 객체인데 각각 현재 스냅샷과 이전 스냅샷을 의미한다. 공식 문서에 나타난 정의를 살펴보면 다음과 같다.
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
Recoil GitHub 저장소에서useTransactionObservation()
hook doesn't. It contains bothsnapshot
andpreviousSnapshot
, 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()
훅이 지원하지 않는 셀렉터도 지원한다. 또한snapshot
과previousSnapshot
을 둘 다 포함한다. 하지만 스냅샷에는 아직 아톰, 셀렉터 혹은 변경된 아톰을 훑을 메커니즘이 없다. 따라서 일단은 기존 훅을 유지하기로 하고 해당 기능을 추가하면서 사용자들이 옮겨가도록 하자.
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>
);
}
스냅샷에서 상태값 가져오기
스냅샷을 구했으면 이제 사용해 볼 차례이다. 앞서 스냅샷 섹션에서 살펴보았듯이 스냅샷 객체에는 getPromise
와 getLoadable
이라는 메소드가 있는데 이를 통해 상태값을 가져올 수 있다.
바로 앞에서 살펴본 예제를 살짝 변경해서 버튼을 클릭할 때마다 스냅샷에 저장된 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
객체의 state
와 contents
프로퍼티를 통해 로딩 상태를 확인하고 값을 가져올 수 있다.
스냅샷의 상태값 변경하기
기본적으로 스냅샷은 조작을 할 수 없는 불변(immutable) 객체이다. 하지만 map
과 asyncMap
을 통해 값이 수정된 새로운 스냅샷을 만들어 낼 수는 있다. Array.map
을 연상하기 좋은 이름이지만 이 메소드는 스냅샷 내부의 상태값을 훑지 않는다. 단지 상태값을 변형해서 새로운 스냅샷을 만들 수 있는 인터페이스만 제공할 뿐이다.
map
과 asyncMap
의 첫 번째 인수는 값이 변형된 새로운 스냅샷을 만드는 콜백 함수이다. 이 콜백 함수에는 첫 번째 인수로 변형할 수 있는 스냅샷을 의미하는 MutableSnapshot
객체가 전달되는데 이 객체에 속한 set
과 reset
을 통해 값이 스냅샷 내부의 상태값을 수정할 수 있다. 다음은 공식 문서에 나타난 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?
개인적인 의견이지만 map
과 asyncMap
은 혼란을 줄 수 있는 이름이라고 생각한다. 이 메소드 이름에서 연상되는 Array.map
과는 달리 map
과 asyncMap
은 스냅샷에 포함된 상태값을 훑지 않는다. 다만 기존 스냅샷의 값을 변형한 새로운 스냅샷을 만들어 낼 뿐이다. 어쩌면 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.type
은 url
밖에 없으므로 값을 추적하려면 이 값을 사용할 수 밖에 없다.
거듭 말하지만 이 훅은 이미 DEPRECATED로 표시되어 있고, 언제라도 사라질 가능성이 있다. 향후 아톰의 변경 사항을 추적할 수 있는 더 나은 방법이 나타날 때까지 임시로 사용하는 거라고 생각하자.
마치며
이 글에서는 전역 상태를 저장하는 스냅샷을 다루는 방법을 살펴보았다. 스냅샷은 전체 상태 추이를추적할 때 유용하므로 특히 디버깅이나 테스트에서 많이 활용될 것으로 예상한다. 다만 아직 API가 변경될 여지가 많아 보이므로 사용에 있어 주의를 기울일 필요가 있다.
목표했던 내용의 마지막인 다음 글에서는 서버 사이드 렌더링에 대해서 다루어 볼 것이다. 여기까지 배우고 나면 기존 상태 관리 라이브러리를 Recoil로 변경하는 것도 고려해볼 만하다.
좋은 글 감사합니다! snapshot 기능을 일종의 history 처럼 undo redo 용으로 사용하고자 하는데, 특정상태에 대해서만 snapshot을 남기게 하는건 뭔가 snapshot 목적에 조금 위배되는 일일까요?
recoil에 대해 상세하게 알려주셔서 정말 많은 도움이 되었습니다. 감사합니다ㅠㅠ
저는 useRecoilCallback에 get이 없어서 get용으로 요긴하게 사용하고 있습니다. ㅎㅎ
useRecoilValue으로 값을 가져오면 매번 가져오다보니 수천개의 배열값을 읽으면서 가져오면 퍼포먼스가 떨어지는 거 같아서요 ㅎㅎ