[번역] Recoil — 새로운 리액트 상태 관리 라이브러리?

kelly woo
16 min readJun 21, 2020

--

이 글은 Sveta SlepnerAnother React State Management Library?’를 저자의 허락을 받고 번역한 글입니다.

원문: https://medium.com/swlh/recoil-another-react-state-management-library-97fc979a8d2b

주의: 본문을 해치지 않는 선에서 의역한 부분도 있습니다.

Recoil — 새로운 리액트 상태 관리 라이브러리?

리액트 상태 관리 라이브러리는 지속적으로 만들어져왔고 많은 라이브러리들이 존재한다. 하지만 페이스북이 직접 상태관리 솔루션을 소개한 적은 그리 많지 않다.
기존 라이브러리 보다 장점은 무엇인지, 이전엔 없던 새로운 것인지,
과연 당신이 시간을 들일만큼 가치가 있는지 알아보자. (스포일러: 물론 있다.)

Recoil —페이스북의 상태관리 라이브러리

페이스북의 소프트웨어 엔지니어 Dave McCabe가 2020년 유튜브를 통한 온라인 유럽 React 컨퍼런스에서 새로운 상태관리 라이브러리를 소개하는 모습에는 무언가 있었다.

물론 2020년 5월 현재 Recoil 는 여전히 실험단계이지만(페이스북의 내부 툴에는 이미 쓰였을거라 추측한다.) 여전히 McCabe와 그의 동료들이 이 라이브러리를 만들게되었는지 보는 것은 흥미로웠다.(현재는 오픈소스로 진행중이다.)

복잡한 UI를 작업하며 global 상태 관리의 해답을 찾는 과정에서 그들은 성능과 효율성의 벽에 부딪혔다. 그리고 가장 최적화된 해답은 자신들의 라이브러리를 직접 만드는 것이라고 결론지었다.

Redux 와 Mobx는 문제가 있는가?

기존 라이브버리들이 문제가 있는 것은 아니다.
하지만 React 자체 라이브러리가 아니기에 상태 저장소가 외부에서 처리되며, 이때문에 React 내부의 스케줄러에 대한 접근이 불가하다.
그럼 이 내부에 대한 접근이 왜 중요할까?
현재로는 중요하지 않다.

하지만 페이스북 소프트웨어 개발자들은 최근 개발된 concurrent mode(https://ko.reactjs.org/docs/concurrent-mode-intro.html)를 사용하면서 React에 맞추어 행동하고 concurrency를 쉽게 지원 가능한 솔루션을 만드는 것이 중요했을 것이다. (Recoil은 리액트 내부에서 리액트 상태를 사용하며 곧 concurrency의 지원이 추가될 것이다.)

그리고 Redux 같은 라이브러리들은 견고한 기능을 제공하지만 그에 반해 높은 비용을 발생시킨다. 기본 스펙 사용을 위해서도 번거로운 작업과 많은 코드 작성이 필요하다.
또한 비동기적인 데이터 처리를 위한 부분이나 계산된 값을 저장하는 selector는 라이브러리의 기본사양이 아닌 다른 라이브러리를 사용해야만 한다. 특히 변경이 많은 props를 받는 셀렉터의 경우 정확한 값을 기억(memoize)하는 것 역시 어렵다.

Context API를 이용하면 어떨까?

React의 네이티브 상태 공유 솔루션인 Context Api 역시 한계가 있다.

복잡하거나 자기 호출을 반복(recurring)하는 상태의 변경에서 효율적이지 않다. 페이스북의 엔지니어 Sebastian Markbage는 이렇게 말했다.

새로운 Context Api는 언어(locale)나 특정 테마처럼 업데이트가 거의 없는 정적인 데이터를 위해 사용하는 것이 좋다. React 내부의 context처럼 정적인 값에 사용하고 구독을 통해 변화를 전파하는 방식으로 사용하는 것도 괜찮다. 하지만 Flux 패턴을 대체하는 방식으로 사용하는 것은 아직 맞지 않다.

React-Redux 팀 역시 버전 6에서 이전 버전과 비교해 크게 성능이 저하되어 context api로 사용된 부분을 수정해야 했다.(현재 React-Redux는 store reference를 내려보내기 위한 context만을 사용하고 있다.)

여러개의 이미지를 보여주는 앱 시나리오를 생각해보자.
각각은 이미지와 이미지에 대한 메타 정보로 이루어져 있고
클릭시 DetailComponent의 자식인 ImageComponentMetadataComponent에 표시 되며 이미지 이름을 변경할수도 있다.

이름 변경시 대상 이미지와 DetailComponent만 렌더링되는 것이 가장 바람직한 결과이다.
하지만 Context Api로는 불가능하다.
Context Api는 자신이 관리하는 데이터의 일부만 구독하도록 하지 못하기 때문이다.

provider의 자식인 모든 consumer는 provider의 value가 바뀔때마다 다시 render 된다.(https://reactjs.org/docs/context.html#before-you-use-context)

provider가 array나 object를 값으로 가지는경우 여기에 변경을 가하면 provider의 context를 구독하고 있던 모든 자식들을 렌더링 시킨다.
아무리 작고 관계가 없는 값을 사용하더라도 말이다.
이에 대해 Javier Calzado 가 구현한 데모 (by Javier Calzado)를 확인해 보자.

모든 이미지를 하나의 context에 저장하기때문에 이미지 이름 변경 시 전체를 다시 렌더링하게 만드는 것이다. 좋은 방법은 아니다. (물론 memoize를 사용할 수 있겠지만 이 역시 마법이 아니기에 한계가 존재한다.)

그렇다면 각 이미지마다 각자의 context를 가진다면 어떻게 될까?
우리가 정확한 이미지 개수를 알고 있다면 이는 문제 될 것이 없다.
하지만 이미지의 개수가 변하고, 추가도 가능해야한다면?

각 이미지마다 각자의 Context Provider를 추가해야하기 때문에 컴포넌트 트리를 새롭게 짜야하고 이로 인해 각 하위 컴포넌트들은 모두 다시 마운트를 해야하는 더욱 악화된 경우를 만나게 될 것이다.
다음의 GIF 이미지는 그 이슈를 시각화 한 것이다.

Provider를 동적으로 추가하는 것은 모든 하위 트리를 다시 마운트 시킨다.
GIF의 3번째 이미지를 보면 이 Context Provider가 얼마나 큰 영향을 주는 지 알 수 있을 것이다.

성능의 약점은 둘째치고 컴포넌트 트리와 provider 간의 커플링을 강화시키는 것도 문제이다.

Recoil은 무엇이 다른가?

우선 Recoil 는 배우기 쉽다. 간단하며 hooks을 사용하는 개발자들에게는 친숙한 API이다.
시작에서 RecoilRoot로 감싸기만 하면 되며 data 선언은 atom 을 통해하고 useState를 Recoil의 useRecoilState로 바꾸며 된다.

두번째로 실제 컴포넌트에서 사용하고 있는 데이터만 구독하는 것이 가능하고 비동기적 데이터 흐름도 직접 제공해준다.

동적인 키로 필요할때 atom을 생성하는 것과 selectors에 보내는 것도 쉽다.
리액트의 concurrent mode 지원도 미리 언급한 것처럼 몇 주 후면 가능할 것이다.

Recoil을 배워보자.

Atom — atom은 하나의 작은 상태이다. 일반적인 리액트의 상태로 컴포넌트든 구독이 가능하다. atom의 값을 변경하는 것은 atom을 구독하는 모든 컴포넌트의 렌더링을 약기시킨다. atom 생성을 위해 앱에서 고유한 키 값과 기본 값을 제공해야한다. 기본 값은 정적인 값이나 함수 또는 비동기 함수도 가능하다.(이부분은 나중에 설명하겠가.)

export const nameState = atom({
key: 'nameState',
default
: 'Jane Doe'
});

useRecoilState — atom의 값을 구독하고 update하는 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 — 셀렉터는 atom의 상태에 의존한 동적인 데이터를 파생시킨다.
일반적으로 생각하는 selector 와 다르게 redux의 reselect나 MobX의 @compute 와 같은 역활을 하는 get 함수를 가져야 한다.
set 함수를 통해 하나 또는 그 이상의 atom을 업데이트도 가능하다. (이 부분은 나중에 다루겠다.)
그럼 selector를 먼저 살펴보자.

// Animals list state
const animalsState = atom({
key: 'animalsState',
default: [{
name: 'Rexy',
type: 'Dog'
}, {
name: 'Oscar',
type: 'Cat'
}],
});
// Animals filter state
const animalFilterState = atom({
key: 'animalFilterState',
default: 'dog',
});
// Derived filtered animals list
const filteredAnimalsState = selector({
key: 'animalListState',
get: ({get}) => {
const filter = get(animalFilterState);
const animals = get(animalsState);
return animals.filter(animal => animal.type === filter);
}
});
// Component that consumes the filtered animals list
const Animals = () => {
const animals = useRecoilValue(filteredAnimalsState);
return animals.map(animal => (
<div>{ animal.name }, { animal.type }</div> )
);
}

실제 적용된 코드이다.

생각보다 간단하다.

이제 더 복잡한 걸 처리해보자.

다시 앞서 이야기한 이미지앱으로 돌아가서 Recoil을 사용해보자.

앱의 요구사항은 다음과 같다.

1. 이미지를 추가할 수 있다.
2. 이름변경시 선택된 이미지와 메타데이타 컴포넌트만 rerendering 되어야 한다.
3. 이미지와 데이터는 비동기적으로 로드된다.

처음 두 요구사항을 만족시키기 위해 각 이미지를 개개의 atom에 저장하며 이는atomFamily 를 통해 처리한다.

atomFamily 는 atom과 동일하지만 인스턴스간의 구분이 가능하도록 매개변수를 받을 수 있다. 아래의 두개는 같은 코드이다.

// atom
const itemWithId = memoize(id => atom({
key: `item-${id}`,
default: ...
}))
// atomFamily
const itemWithId = atomFamily({
key: 'item',
default: ...
});

다른 점은 atomFamily 가 memoization을 처리하며 각 인스턴스마다 따로 고유의 키를 할당하지 않아도 된다. 그 역시 atomFamily 가 대신 해줄 것이다.

atomatomFamily 는 기본값을 함수로 대체할 수 있다. atomFamily 는 자신이 가진 id 값을 이 함수로 넘겨준다.

export const imageState = atomFamily({
key: "imageState",
default: id => getImage(id)
});

어느 컴포넌트든 해당 id와 함께 imageState를 호출하는 곳에서 기본값을 생성하게 된다.

또한 비동기 함수의 사용도 가능하다.
React’s Suspense 를 이용 Recoil이 대신 처리해준다.

  • 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)
});
  • 컴포넌트 코드
// Images list
const Images = () => {
const imageList = useRecoilValue(imageListState);
return (
<div className="images">
{imageList.map(id => (
<Suspense key={id} fallback="Loading...">
<Image id={id} />
</Suspense>
))}
</div>
);
};
// Single image
const Image = ({ id }) => {
const { name, url } = useRecoilValue(imageState(id));
return (
<div className="image">
<div className="name">{name}</div>
<img src={url} alt={name} />
</div>
);
};
  • 전체코드를 이용한 데모

마지막으로, Selector도 data를 저장할 수 있다고?

앞서 Selector에 대해 이야기할 때 set 함수에 대해 언급했다. Selector와 set이 함께라니 혼란스럽겠지만 단지 네이밍 때문이다. (이름은 변경되었으면 한다.)

Selector를 파생된 스테이트로 생각해보자. 여러 atom을 통한 계산 결과값을 얻고, 여러개의 atom에 영향도 미친다.

다음의 예제에서 selector는 파생된 값을 제공한다.
특정 색을 가진 박스의 counter객체가 그것이다.

setter 함수는 boxState라는 atomFamily의 모든 박스에 영향을 주거나 재설정할 수 있다.

const boxState = atomFamily({
key: "boxState",
default: COLORS.WHITE
});
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);
}
}
});

예제

Recoil은 이 외에도 많은 기능을 제공하지만, 여기까지의 내용으로도 시작 하는 것에는 충분하다 생각한다.

그래서 Recoil은 시간을 투자할 가치가 있는가?

또 다른 상태관리 라이브러리가 필요한가? 라는 물음에 나의 대답은 ‘그렇다’이다.

리액트처럼 행동하고 리액트처럼 느껴지는 상태관리 라이브러리를 가지는 것만으로도 가치가 있다. 이는 hooks를 사용해본 개발자에게는 새로운 라이브러리를 배우는 러닝커브를 최소화할 수 있다는 의미이다. 새로운 문법이나 새로운 환경을 제공하기 위한 boilerplate 코드를 배울 필요가 없다.(selectors의 get/set 문법이 이상하다 생각하지만 어렵지는 않다.).

또한 비동기적인 데이터나 상태 지속성, 매개변수화된 selector를 처리 하는 솔루션을 제공받는 다는 것은 이미 많은 어려움을 해소해준다.

아직 이 라이브러리가 큰 프로젝트에서 어떤 영향을 줄지, 인기를 얻기전 사라질지 알 수 없지만 나는 Recoil이 많은 인기를 얻었으면 한다.
Recoil이 많은 장점을 제공해 줄 것이라고 믿기 때문이다.

그외의 읽을 거리

--

--