Recoil? 상태관리?
React로 개발을 진행하다 보면 부모 -> 자식으로 state를 전달해야 하는 경우가 생긴다.
컴포넌트 트리가 깊지 않다면 props를 통해 전달하면 쉽다. 하지만 그렇지 않은 경우가 대부분이다.
A->B->C->D->E와 같은 컴포넌트 트리구조가 있고, A에서 E로 state를 전달해야 한다면 우리는 B, C, D 모두에 props로 전달하는 과정을 거쳐야 한다. 또한, 여러 컴포넌트가 동일한 state를 필요로 하고, 그 컴포넌트들이 서로 다른 계층에 있다면, props로 전달하기가 매우 번거로워진다. 상태를 필요로 하는 컴포넌트가 서로 연관되지 않은 곳에 위치한다면, 상태 공유는 어렵고 복잡해진다.
React에는 Redux, Recoil, Zustand... 등 많은 상태관리 툴이 있다.
오늘은 그중 FaceBook에서 2020년 발표한 React 전용 상태관리 툴 Recoil을 알아보자.
데이터 바인딩(단방향, 양방향)
데이터 바인딩
두 데이터 혹은 정보의 소스를 동기화(일치)시키는 방법, 화면에 보이는 데이터와 브라우저 메모리에 있는 데이터를 일치시키는 것
HTML에서 변경된 내용이 데이터에 영향을 미치는 가에 대한 차이를 기준으로 양방향, 단방향 바인딩 방식으로 나눌 수 있다.
양방향 바인딩
컴포넌트 내에서 JavaScript(Model)와 HTML(View) 사이에 ViewModel이 존재하여 하나로 바인딩되어 둘 중 하나만 변경되면 나머지도 함께 동기화되는 것을 말한다.
컴포넌트 간, 부모 -> 자식에서는 Props를 통해 데이터를 전달하고, 자식 -> 부모에서는 Emit Event를 통해 데이터를 전달한다.
대표적으로 Vue.js, Angular가 있다.
장점
자동으로 데이터를 동기화시키므로 코드가 간단해진다.
단점
데이터 변화에 따라 DOM 객체 전체를 다시 렌더링 하는 작업은 성능 감소를 초래할 수 있다.
(특히 대규모는 이러한 비용이 많이 들 수 있다.)
또한, 흐름이 복잡할 경우 디버깅이 어려울 수 있다. 자동으로 변경되기 때문에 어떤 요소가 영향을 주는지 추적이 어렵다.
단방향 바인딩
컴포넌트 내에서 JavaScript(Model)에서 HTML(View)로 한 방향으로만 데이터를 동기화하는 것을 말한다.
단방향이기 때문에 역으로 HTML(View)에서 JavaScript(Model)로 직접적 데이터 갱신은 불가능하다. 때문에 onClick, onChange와 같은 이벤트 함수를 통해 데이터를 변경해야 한다.
컴포넌트 간, 단방향 바인딩은 부모 -> 자식으로만 데이터가 전달되는 구조이다.
이는 우리가 주로 사용하는 React에서 사용하는 방식이다.
때문에 장단점은 양방향 바인딩과 반대이다.
React는 단방향 바인딩이기 때문에 Props drilling과 같은 문제가 발생한다. 이러한 문제를 해결하기 위해 Context가 등장하긴 했지만 여전히 한계는 존재했다. 때문에 상태관리 라이브러리가 등장했다.
Recoil!
Recoil에는 크게 Atoms, Selector의 2가지 핵심 개념이 있다.
Atoms
Atoms는 상태의 단위이다.
Atom이 업데이트되면 해당 Atom을 구독하고 있던 모든 컴포넌트들이 새로운 값으로 리렌더 된다. 또한, 여러 컴포넌트에서 같은 Atom을 구독하고 있으면 그 컴포넌트들이 상태를 동일하게 공유한다.
(나는 Atoms를 전역 변수를 관리하는 저장소 정도로 이해했다. 다른 개발자 분들도 비슷한 개념을 생각하며 이해하신 것 같다.)
// recoil/atoms/counterState
const counterState = atom({
key: "counterState",
default: 0
});
atom은 unique 한 key값과 default라는 초기값을 가지게 된다.
Selector
Selector는 다른 Atom들 혹은 Selector들을 받아 사용하는 순수 함수이다.
받은 Atom들 혹은 Selector들 중 어떤 것이 업데이트되면, Selector 함수는 re-evaluate 하게 된다.
이 부분이 나는 잘 이해가 되지 않았다.
그래서 그냥 일단 사용해 보고 이해한 내용은 Atom에 업데이트되는 State를 기반으로 selector는 이를 동적으로 계산하여 도출해 주는 도구라 이해했다.
const doubledState = selector({
key: "doubledState",
get: ({ get }) => {
const count = get(counterState);
return count * 2;
}
});
여기서 key 또한 Unique 한 값을 가지고, get을 통해 Atom의 값을 받아서 원하는 동작을 하게 만들어 준다.
사용 예시(Counter)
Recoil을 사용해서 Counter를 하나 만들어 줄 것이다.
recoil 폴더 안에 atoms와 selectors 폴더를 만들어서 각각 생성해 줬다.
// atoms/counterState.jsx
import { atom } from "recoil";
export const counterState = atom({
key: "counterState",
default: 0
});
counterState를 counterState라는 key값으로 초기값 0으로 설정했다.
// selectors/counterSelector.jsx
import { selector } from "recoil";
import { counterState } from "../atoms/counterState"
export const doubledState = selector({
key: "doubledState",
get: ({ get }) => {
const count = get(counterState);
return count * 2;
}
});
doubledState는 counterState를 받아서 2배 해준 값을 return 한다.
Recoil을 사용할 컴포넌트를 하나 만들어준다.
// components/recoil-test/Counter.jsx
import React from 'react'
import { useRecoilState, useRecoilValue } from "recoil"
import { counterState } from "../../recoil/atoms/counterState"
import { doubledState } from '../../recoil/selectors/counterSelector';
export const Counter = () => {
const [count, setCount] = useRecoilState(counterState);
const doubledCount = useRecoilValue(doubledState);
const increment = () => {
setCount(count + 1);
};
const decrement = () => {
setCount(count -1);
};
return (
<>
<p>Count : {count}</p>
<p>DoubledCount : {doubledCount}</p>
<button onClick={increment}> Increment </button>
<button onClick={decrement}> Decrement </button>
</>
)
};
App.jsx를 RecoilRoot로 감아준다.
// App.jsx
import { RecoilRoot } from 'recoil';
import './App.css';
import { Counter } from './components/recoil-test/Counter';
function App(props) {
return (
<RecoilRoot>
<Counter/>
</RecoilRoot>
)
}
export default App
하위 컴포넌트들이 Recoil 상태를 이용할 수 있도록 해준다.
이후 동작은
Increment를 누르면 atom의 counterState가 1씩 올라가게 된다.
DoubledCounter는 counterState를 받아서 2배 해준걸 return 하기 때문에 Count * 2 가 결과값으로 나오게 된다.
문제점
문제 파악
Recoil을 사용해 본 사람이라면 누구나 겪었을 문제, 새로고침시 데이터 증발 현상이다.
위 결과 화면에서 새로고침을 하면 당연히 남아야 할 데이터 상태가 아래와 같이 0으로 초기화된다.
나는 이런 현상을 프로젝트 당시 경험했다.
그때는 어떻게 해결할까 엄청 고민했던 것 같다. (내가 알기론 session storage와 local storage를 사용했었다... 2023년 9월 당시)
Recoil로 상태를 관리하면 클라이언트 사이드에서만 유지가 되기 때문에, 새로고침을 하면 서버 측에서 초기상태 즉, default값을 가져와서 다시 렌더링 하게 되기 때문에 0으로 적용된다.
문제 해결
이런 현상이 많이 문제(당연함)가 되었던지 개발자들이 라이브러리를 만들었다.
recoil-persist는 내부적으로 값을 localStorage에 저장하여 활용하는 방식이라고 한다.
프로젝트 당시에도 storage를 활용하여 문제를 해결했는데 이를 좀 더 쉽게 만들어준 것 같다.
당연하게도 recoil-persist 라이브러리를 추가해야 한다.
yarn add recoil-persist
이후 Atoms로 가서 해당 jsx파일에 적용시켜 준다.
// recoil/atoms/counterState.jsx
import { atom } from "recoil";
import { recoilPersist } from 'recoil-persist';
const { persistAtom } = recoilPersist();
export const counterState = atom({
key: "counterState",
default: 0,
effects_UNSTABLE: [persistAtom],
});
recoilPersist를 import트 해주고 persistAtom이라는 변수로 선언한 다음
effects_UNSTABLE: [persistAtom], <- 한 줄을 추가하면 된다.
이후 다시 새로고침을 해도 상태가 유지되는 것을 알 수 있다.
개발자 도구에서 Application - Local storage로 들어가 보면 counterState가 저장되는 걸 볼 수 있다.
고려해야 할 점
당연하게도 local storage에 저장되는 정보인 만큼 민감한 데이터를 다룰 땐 다시 한번 생각할 필요가 있다.