원문: Sveta Slepner https://medium.com/swlh/recoil-another-react-state-management-library-97fc979a8d2b
많은 React 상태 관리 라이브러리들이 있고, 가끔 새로운 라이브러리가 등장한다. 그러나 페이스북에서 직접 상태 관리 솔루션을 소개하는 것은 흔하지 않다. 이 라이브러리가 어떤 장점이 있고 새로운 점이 있는지, 그리고 앞으로 시간을 투자할 가치가 있는지 알아보자.
(Recoil.js — 페이스북에서 만든 상태 관리 라이브러리)
페이스북 소프트웨어 엔지니어 Dave McCabe가 유튜브에서 열린 'React Europe 2020' 온라인 이벤트에서 새로운 상태 관리 라이브러리를 소개하였다. 2020년 5월 현재 Recoil은 아직 실험 단계이지만(페이스북의 일부 프로덕션에 사용되었을 것이라고 추측한다), McCabe와 그의 동료들이 어떤 이유로 이 오픈소스 라이브러리를 만들었는지는 흥미롭다.
그들은 복잡한 UI를 대상으로 전역 상태 관리를 위한 최적화 방법을 찾으려고 하였으나 성능 및 효율성이라는 장벽에 부딪혔다. 그리고 이 문제를 해결할 가장 좋은 방법은 직접 라이브러리를 만드는 것이라고 결정하였다.
기존의 상태 관리 라이브러리들은 어떠한 문제도 없다. 하지만 중요한 점은 상태 관리 라이브러리들이 React 라이브러리가 아니라는 점이다. store는 "외부요인으로" 취급되는 것이기 때문에 React의 내부 스케줄러에 접근할 수 없다. 지금까지는 이것이 중요하지 않을 수도 있었다. 그러나 동시성 모드가 등장하며 이야기가 달라졌다. 아마도 페이스북 소프트웨어 개발자들은 동시성 모드를 사용하고 있을 것이고, 그들이 React와 동시성 모드를 손쉽게 사용할 수 있는 해결 방안이 필요하였을 것이다 (Recoil은 내부적으로 React의 상태를 사용하고 있으며, 동시성 모드에 대한 지원도 곧 추가될 것이다).
또한 일부 라이브러리(Redux..)는 강력한 기능을 제공하지만, 기본적인 store 구성을 위해 많은 보일러 플레이트와 장황한 코드를 작성해야 한다. 또한 비동기 데이터 처리 또는 계산된 값 캐시와 같은 중요한 기능은 라이브러리의 기능이 아니며, 이를 해결하기 위해 또 다른 라이브러리를 사용해야 한다. 그리고 만약 selector가 동적인 prop을 받는 경우 이값을 정확하게 memoization하는 것은 어려운 일이다.
React가 가진 상태 공유 솔루션인 Context API도 한계가 있다.
반복적이고 복잡한 업데이트에 사용할 경우 비효율적이다. 또 다른 페이스북 엔지니어인 Sebastian Markbage의 말을 인용하면:
개인적으로 새로운 Context는 locale/theme와 같은 낮은 빈도의 업데이트에 사용 가능하다고 생각한다. 또한 이전에 Context를 사용했던 방법으로 사용해도 좋다.(즉 정적인 값, 구독을 통해 업데이트를 전파하는 것) 하지만 Context는 Flux와 같은 상태 관리 시스템을 대체할 수 없다.
React-Redux 팀도 이전 버전과 비교하였을 때 상당한 성능 문제가 있어 6버전에서 Context API로 재작성한 라이브러리의 일부를 롤백해야 했다 (현재 React-Redux는 store 참조를 전달할 때만 컨텍스트를 사용한다).
이미지의 목록을 보여주는 list
컴포넌트와 특정 이미지의 메타데이터를 보여주는 info
컴포넌트를 렌더링한다고 생각해보자. 이미지를 클릭하면 해당 메타데이터가 info
컴포넌트에 표시되어야 한다. 또한 이미지의 이름도 변경할 수 있어야한다.
이름을 변경하면 선택한 이미지 컴포넌트와 메타데이터 컴포넌트만 다시 렌더링하는 것이 가장 좋은 결과일 것이다.
하지만 Context API로 이것을 구현하기는 어려울 것이다. 왜냐하면 Context API는 데이터 서브셋을 대상으로 변경을 감지하고 업데이트할 수 없기 때문이다.
Provider 하위의 모든 consumer들은 Provider 속성이 변경될 때마다 다시 렌더링된다. (https://reactjs.org/docs/context.html#before-you-use-context)
만약 Provider의 값이 배열이나 객체인 경우 구조가 조금이라도 변경된다면, 그 Context를 구독하고 있는 하위의 모든 것(컴포넌트가 그 값의 일부분만 사용하더라도)이 다시 렌더링될 것이다. Javier Calzado의 예시를 보면 이해가 갈 것이다. 이를 통해 모든 이미지를 하나의 Context에 저장할 수 없다는 것을 알 수 있다. 왜냐하면 이미지 하나의 이름을 변경하면 모든 것이 다시 렌더링될 것이기 때문이다 (memoization을 통해 일부 문제를 해결할 수 있지만, 모든 것을 해결할 수 있는 방법은 아니며 한계가 있다).
각각의 이미지가 각각의 Context를 가지고 있다고 가정해보자. 정확한 이미지 수를 알고 있다면 아무 문제가 없다. 하지만 이미지를 동적으로 변경할 수 있고, 추가할 수 있다면? 새로운 이미지에 Context Provider를 추가하여 컴포넌트 트리를 다시 구성하고 전체 서브 트리를 다시 마운트해야한다. 더 좋지 않은 방법이다. 아래 GIF를 통해 이 문제를 보자.
(동적으로 provider를 추가하면 전체 하위 트리가 다시 마운트됨)
3번째 슬라이드에서 Context Provider를 추가한 후 하위의 모든 것이 다시 마운트된다.
성능에도 좋지 않을 뿐 아니라 Provider와 컴포넌트 트리 노드 사이에 강한 커플링이 생긴다.
우선 첫번째, Recoil은 배우기 쉽다. API가 단순하고 이미 hook을 사용하고 있는 사람들에게 익숙할 것이다. Recoil을 시작하기 위해서는 어플리케이션을 RecoilRoot
로 감싸고, 데이터를 atom
이라는 단위로 선언하여 useState
를 Recoil의 useRecoilState
로 대체해야 한다.
두번째, 컴포넌트가 사용하는 데이터 조각만 사용할 수 있고, 계산된 selector를 선언할 수 있으며, 비동기 데이터 흐름을 위한 내장 솔루션까지 제공한다.
동적 키로 atom을 만들고, selector에 인자를 보내는 등 모두 간단하게 할 수 있다.
그리고 앞에서 말한 바와 같이 곧 React 동시성 모드에 대한 지원도 될 것이다.
Atom — atom은 하나의 상태의라고 볼 수 있다. 컴포넌트가 구독할 수 있는 React state라고 생각하면 된다. atom의 값을 변경하면 그것을 구독하고 있는 컴포넌트들이 모두 다시 렌더링된다. atom을 생성하기 위해 어플리케이션에서 고유한 키 값과 디폴트 값을 설정해야한다. 디폴트 값은 정적인 값, 함수 또는 심지어 비동기 함수(나중에 지원 예정)가 될 수 있다.
export const nameState = atom({
key: 'nameState',
default: 'Jane Doe'
});
useRecoilState — atom의 값을 구독하여 업데이트할 수 있는 hook. useState
와 동일한 방식으로 사용할 수 있다.
useRecoilValue — setter 함수 없이 atom의 값을 반환만 한다.
useSetRecoilState — setter 함수만 반환한다.
import {nameState} from './someplace'
// useRecoilState
const NameInput = () => {
const [name, setName] = useRecoilState(nameState);
const onChange = (event) => {
setName(event.target.value);
};
return <>
<input type="text" value={name} onChange={onChange} />
<div>Name: {name}</div>
</>;
}
// useRecoilValue
const SomeOtherComponentWithName = () => {
const name = useRecoilValue(nameState);
return <div>{name}</div>;
}
// useSetRecoilState
const SomeOtherComponentThatSetsName = () => {
const setName = useSetRecoilState(nameState);
return <button onClick={() => setName('Jon Doe')}>Set Name</button>;
}
selector — seletor는 상태에서 파생된 데이터로, 다른 atom에 의존하는 동적인 데이터를 만들 수 있게 해준다. Recoil의 selector는 기존에 우리가 알던 selector의 개념과는 조금 다르다. Redux의 reselect
와 MobX의 @computed
처럼 동작하는 "get" 함수를 가지고 있다. 하지만 하나 이상의 atom을 업데이트 할 수 있는 "set" 함수를 옵션으로 받을 수 있다. 이 부분은 나중에 다룰 테니, 일단 "selector" 부분만 살펴보자.
// 동물 목록 상태
const animalsState = atom({
key: 'animalsState',
default: [{
name: 'Rexy',
type: 'Dog'
}, {
name: 'Oscar',
type: 'Cat'
}],
});
// 필터링 동물 상태
const animalFilterState = atom({
key: 'animalFilterState',
default: 'dog',
});
// 파생된 동물 필터링 목록
const filteredAnimalsState = selector({
key: 'animalListState',
get: ({get}) => {
const filter = get(animalFilterState);
const animals = get(animalsState);
return animals.filter(animal => animal.type === filter);
}
});
// 필터링된 동물 목록을 사용하는 컴포넌트
const Animals = () => {
const animals = useRecoilValue(filteredAnimalsState);
return animals.map(animal => (<div>{ animal.name }, { animal.type }</div>));
}
데모 링크를 보면 매우 간단하다는 것을 알 수 있다.
위에서 이야기했던 이미지 어플리케이션을 Recoil로 만들어보자.
어플리케이션의 요구 사항은 다음과 같다.
처음 두 가지 요구 사항을 위해 각각의 이미지를 자체 atom에 저장하려고 한다. 이를 위해 atomFamily
를 사용할 수 있다. atomFamily는 atom과 동일하지만, 다른 인스턴스와 구분이 가능한 매개변수를 받을 수 있다. 아래의 두 코드는 같은 의미이다.
// atom
const itemWithId = memoize(id => atom({
key: `item-${id}`,
default: ...
}))
// atomFamily
const itemWithId = atomFamily({
key: 'item',
default: ...
});
atom과 유일한 차이점은 atomFamily
는 내부적으로 memoization을 할 것이기 때문에, 각 인스턴스마다 고유한 키를 만들 필요가 없다는 것이다.
atom
과 atomFamily
는 default 값을 만들기 위해 다른 함수를 호출할 수도 있다. 여기서도 마찬가지로, atomFamily
에서는 내부적으로 생성한 고유한 id를 넘겨줄 수 있다는 차이가 있다.
export const imageState = atomFamily({
key: "imageState",
default: id => getImage(id)
});
컴포넌트가 imageState
를 최초로 호출할 때, default 값을 만드는 함수(getImage(id)
)가 호출될 것이다.
이 함수는 비동기 함수일 수 있으며, Recoil이 React Suspense의 도움을 받아 처리할 것이다.
store의 코드:
const getImage = async id => {
return new Promise(resolve => {
const url = `http://someplace.com/${id}.png`;
let image = new Image();
image.onload = () =>
resolve({
id,
name: `Image ${id}`,
url,
metadata: {
width: `${image.width}px`,
height: `${image.height}px`
}
});
image.src = url;
});
};
export const imageState = atomFamily({
key: "imageState",
default: async id => getImage(id)
});
컴포넌트의 코드:
// 이미지 목록
const Images = () => {
const imageList = useRecoilValue(imageListState);
return (
<div className="images">
{imageList.map(id => (
<Suspense key={id} fallback="Loading...">
<Image id={id} />
</Suspense>
))}
</div>
);
};
// 단일 이미지
const Image = ({ id }) => {
const { name, url } = useRecoilValue(imageState(id));
return (
<div className="image">
<div className="name">{name}</div>
<img src={url} alt={name} />
</div>
);
};
전체 데모 코드: https://tmc9k.codesandbox.io/
위에서 selector에 대해 이야기 할 때 setter 함수를 selector에 전달할 수 있다고 언급하였다. 이상해보이지만, 단지 네이밍 때문에 혼란스러운 것이다 (그리고 바뀌길 바란다). selector를 하나의 상태이지만 파생된 것으로 생각해보자. selector는 atom로부터 계산된 값을 얻을 수 있고, 또한 복수의 atom에게 영향을 줄 수도 있다.
아래 예시에서 selector는 파생된 상태(특정 색상 상자의 카운터 객체)를 반환한다.
이 setter 함수는 box atomFamily
로 생성된 모든 box들에 영향을 줄 수 있으며 값을 재설정할 수 있다.
const colorCounterState = selector({
key: "colorCounterState",
get: ({ get }) => {
let counter = { [COLORS.RED]: 0, [COLORS.BLUE]: 0, [COLORS.WHITE]: 0 };
for (let i = 0; i < BOX_NUM; i++) {
const box = get(boxState(i));
counter[box] = counter[box] + 1;
}
return counter;
},
set: ({ set }) => {
for (let i = 0; i < BOX_NUM; i++) {
set(boxState(i), COLORS.WHITE);
}
}
});
전체 데모 코드: https://082nu.codesandbox.io/
Recoil에는 더 많은 기능들이 존재하지만, 이 정도로 충분히 시작할 수 있다.
우리 스스로에게 "정말 다른 상태 관리 라이브러리가 필요한가"라고 자문할 수 있다. 이에 대한 나의 대답은 YES! 이다.
왜? 왜냐하면 React 처럼 행동하고 느끼는 상태 관리 라이브러리는 신선하기 때문이다. 만약 이전에 hook을 사용했다면 Recoil을 쉽게 이해할 수 있을 것이다. 새로운 구문을 배우거나 수많은 보일러 플레이트 코드를 설정할 필요가 없다 (selector get/set 구문이 약간 이상하지만, 정말 간단한 개념이다).
또한 비동기 데이터, 상태 지속성, 매개변수화된 selector를 처리할 수 있는 솔루션을 제공하기 때문에 많은 고통이 사라진다.
이 라이브러리가 대형 프로젝트에서 어떻게 확장될지, 많은 관심을 받고 앞으로 사라지지 않을지 알 수 없지만 더 많은 인기를 얻길 바란다. 우리는 Recoil을 통해 장점만 얻을 수 있다.