React 프로젝트를 진행하면서, 적절한 상태관리 라이브러리를 선택하는 것은 프로젝트의 성공에 중대한 영향을 미치는 결정이라고 할 수 있습니다. 다양한 상태관리 라이브러리들이 존재하는 가운데 Recoil은 어떤 구조로 이뤄져있고 어떻게 작동하는지, 그리고 다른 라이브러리들과 비교해 장단점은 무엇인지 알아보도록 하겠습니다.
Recoil은 React 애플리케이션에서 데이터가 어떻게 흘러가고 변경되어야 하는지를 관리하는 특별한 툴입니다. 다른 라이브러리들과 달리 Recoil은 처음부터 React의 상태관리를 위해 개발되었으며, React에 특화되어 있어 통합과 사용이 더 쉽습니다.
React의 함수형 컴포넌트에서는 상태관리를 위해 useState 훅을 이용하곤 하는데, 이 방식은 매우 직관적이고 간결한 편입니다. Recoil도 이러한 패턴을 따라 간결하고 쉽게 전역 상태관리를 할 수 있게 도와줍니다. 상태관리를 위한 기본 단위인 atom과 seletor가 있고, 이들과 상호작용하기 위한 여러 훅들이 함께 제공됩니다.
Atom은 Recoil 상태 관리의 기본적인 구성 요소로, Atom은 상태 변수를 나타내며, 이 값이 변경되면 해당 atom을 구독하고 있는 모든 컴포넌트가 자동으로 다시 렌더링됩니다. Recoil은 필요한 컴포넌트만 업데이트 되도록 최적화 하며 불필요한 렌더링을 방지합니다.
// atoms.js
import { atom } from 'recoil';
export const todoListState = atom({
key: 'todoListState', // 고유한 ID (must be unique)
default: [], // 기본값
});
// todolist
import { RecoilRoot, useRecoilState } from 'recoil';
import { todoListState } from './atoms';
function TodoList() {
const [todos, setTodos] = useRecoilState(todoListState);
const addTodo = (text) => {
setTodos((oldTodos) => [
...oldTodos,
{
id: Date.now(),
text,
completed: false,
},
]);
};
const toggleTodo = (id) => {
setTodos((oldTodos) =>
oldTodos.map((todo) =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
)
);
};
return (
<div>
<button onClick={() => addTodo('새로운 할 일')}>할 일 추가</button>
<ul>
{todos.map((todo) => (
<li
key={todo.id}
style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}
onClick={() => toggleTodo(todo.id)}
>
{todo.text}
</li>
))}
</ul>
</div>
);
}
이 코드는 간단한 todolist를 구현한 것입니다. useRecoilState로 React에서 useState를 이용해 상태관리를 하는 것처럼 todo list도 관리할 수 있도록 제공합니다. 이 과정이 React 사용자에게는 매우 친숙하기 때문에 쉽게 이용할 수 있습니다.
Atom은 고유한 키가 필요합니다. 이 키는 디버깅, 지속성, 그리고 모든 atom의 맵을 볼 수 있게 해주는 특별한 API들에 사용됩니다. 두 개의 atom이 같은 키를 가지는 것은 오류이므로, 반드시 전역에서 고유한 키 값을 가지고 있어야 합니다.
컴포넌트에서 이 Atom을 읽고 쓰기 위해서 앞서 밝힌바와 같이 useRecoilState라는 훅을 사용합니다.
selector는 atom이나 다른 selector를 입력 받는 순수 함수입니다.
아래 예시를 한 번 보겠습니다.
const fontSizeLabelState = selector({
key: 'fontSizeLabelState',
get: ({get}) => {
const fontSize = get(fontSizeState);
const unit = 'px';
return `${fontSize}${unit}`;
},
});
이 함수는 전달받은 get인자를 사용해 atom과 다른 selector의 값에 접근할 수 있습니다. 다른 atom이나 selector에 접근할 때마다 의존관계가 만들어지고, 다른 atom과 selector가 업데이트 되면 이 함수가 다시 계산되게 됩니다.
위 예시에서는 selector가 fontSizeState atom 하나의 의존성을 가집니다. fontSizeLabelState selector는 fontSizeState를 입력으로 받아 특정한 글꼴 크기 레이블을 반환하는 순수 함수처럼 동작합니다.
Selector는 아래와 같이 useRecoilValue를 사용하여 읽을 수 있습니다. 이 훅은 atom이나 selector를 인자로 받아 해당하는 값을 반환합니다. fontSizeLabelState selector는 쓰기가 불가능하므로 useRecoilState를 사용하지 않습니다.
function FontButton() {
const [fontSize, setFontSize] = useRecoilState(fontSizeState);
const fontSizeLabel = useRecoilValue(fontSizeLabelState);
return (
<>
<div>Current font size: {fontSizeLabel}</div>
<button onClick={() => setFontSize(fontSize + 1)} style={{fontSize}}>
Click to Enlarge
</button>
</>
);
}
React의 useState와 비슷하게, atom의 현재 상태와 이를 업데이트 하는 함수를 반환합니다.
atom이나 selector의 현재 값만을 반환합니다. 상태를 읽기만 할 때 사용합니다.
현재 값을 읽지 않고 atom의 상태를 업데이트하는 함수를 반환합니다. 상태를 먼저 읽을 필요 없이 업데이트 할 때 유용합니다.
atom의 상태를 기본값으로 초기화하는 함수를 반환합니다.
아래는 위 훅들을 사용한 간단한 예제입니다.
// state.js
import { atom, selector } from 'recoil';
export const counterState = atom({
key: 'counterState',
default: 0,
});
export const doubledState = selector({
key: 'doubledState',
get: ({get}) => {
const count = get(counterState);
return count * 2;
},
});
// App.js
import React from 'react';
import {
RecoilRoot,
useRecoilState,
useRecoilValue,
useSetRecoilState,
useResetRecoilState,
} from 'recoil';
import { counterState, doubledState } from './state';
function Counter() {
const [count, setCount] = useRecoilState(counterState);
return (
<div>
<h2>카운터: {count}</h2>
<button onClick={() => setCount(count + 1)}>증가</button>
<button onClick={() => setCount(count - 1)}>감소</button>
</div>
);
}
function DoubledValue() {
const doubledValue = useRecoilValue(doubledState);
return <div>두 배 값: {doubledValue}</div>;
}
function IncrementOnlyCounter() {
const setCount = useSetRecoilState(counterState);
return (
<div>
<button onClick={() => setCount((prev) => prev + 1)}>
증가만 가능
</button>
</div>
);
}
function ResetCounter() {
const resetCount = useResetRecoilState(counterState);
return <button onClick={resetCount}>카운터 초기화</button>;
}
function App() {
return (
<RecoilRoot>
<div style={{ padding: '20px' }}>
<h1>Recoil 카운터 예제</h1>
<Counter />
<DoubledValue />
<IncrementOnlyCounter />
<ResetCounter />
</div>
</RecoilRoot>
);
}
export default App;
앞서 언급한 useRecoilState, useRecoilValue 같은 훅들은 React 컴포넌트 내에서 사용되도록 만들어졌으며, 이를 통해 상태 관리를 다른 React 훅들을 사용하는 것처럼 간단하게 만들 수 있었습니다.
반면, get, set, reset은 주로 atom이나 selector가 정의된 파일 내에서 상태를 정의하거나 조작하는 데 사용됩니다. 복잡한 로직의 경우, get과 set 메서드를 직접 사용하는 커스텀 훅을 만들 수 있습니다.
아래는 예시입니다.
// state.js
import { atom, selector } from 'recoil';
export const counterState = atom({
key: 'counterState',
default: 0,
});
export const counterHistoryState = atom({
key: 'counterHistoryState',
default: [],
});
export const counterStatsState = selector({
key: 'counterStatsState',
get: ({get}) => {
const count = get(counterState);
const history = get(counterHistoryState);
return {
current: count,
average: history.length
? history.reduce((acc, val) => acc + val, 0) / history.length
: 0,
max: history.length ? Math.max(...history) : count,
min: history.length ? Math.min(...history) : count,
};
},
});
// hooks.js
import { useCallback } from 'react';
import { useRecoilCallback, useRecoilValue, useSetRecoilState } from 'recoil';
import { counterState, counterHistoryState, counterStatsState } from './state';
export const useCounterActions = () => {
// 직접 상태를 조작하는 콜백들을 정의
const incrementBy = useRecoilCallback(({set, get}) => (amount) => {
const currentCount = get(counterState);
const currentHistory = get(counterHistoryState);
set(counterState, currentCount + amount);
set(counterHistoryState, [...currentHistory, currentCount + amount]);
});
const reset = useRecoilCallback(({reset, set}) => () => {
reset(counterState);
set(counterHistoryState, []);
});
const setCustomValue = useRecoilCallback(({set, get}) => (value) => {
const currentHistory = get(counterHistoryState);
set(counterState, value);
set(counterHistoryState, [...currentHistory, value]);
});
return {
incrementBy,
reset,
setCustomValue,
};
};
// App.js
import React, { useState } from 'react';
import { RecoilRoot, useRecoilValue } from 'recoil';
import { counterState, counterStatsState } from './state';
import { useCounterActions } from './hooks';
function Counter() {
const count = useRecoilValue(counterState);
const stats = useRecoilValue(counterStatsState);
const { incrementBy, reset, setCustomValue } = useCounterActions();
const [customValue, setCustomValueInput] = useState('');
const handleCustomValueSubmit = (e) => {
e.preventDefault();
const value = parseInt(customValue);
if (!isNaN(value)) {
setCustomValue(value);
setCustomValueInput('');
}
};
return (
<div style={{ padding: '20px' }}>
<h2>고급 카운터</h2>
<div>현재 값: {count}</div>
<div style={{ marginTop: '10px' }}>
<button onClick={() => incrementBy(1)}>+1</button>
<button onClick={() => incrementBy(-1)}>-1</button>
<button onClick={() => incrementBy(5)}>+5</button>
<button onClick={() => incrementBy(-5)}>-5</button>
<button onClick={reset}>초기화</button>
</div>
<form onSubmit={handleCustomValueSubmit} style={{ marginTop: '10px' }}>
<input
type="number"
value={customValue}
onChange={(e) => setCustomValueInput(e.target.value)}
placeholder="직접 값 입력"
/>
<button type="submit">설정</button>
</form>
<div style={{ marginTop: '20px' }}>
<h3>통계</h3>
<div>평균: {stats.average.toFixed(2)}</div>
<div>최대값: {stats.max}</div>
<div>최소값: {stats.min}</div>
</div>
</div>
);
}
function App() {
return (
<RecoilRoot>
<Counter />
</RecoilRoot>
);
}
export default App;
Recoil은 앞서 언급한 것과 같이 React를 위한 상태관리 라이브러리로 등장했기 때문에 React에 최적화된 설계를 제공합니다. 다른 라이브러리들과 비교했을 때 간단한 API, 러닝커브가 짧다는 장점도 있습니다. 또한 React의 동시성 기능과 잘 동작해 1) 비동기 데이터 처리, 2) 선택적 일시 중단, 3) 데이터의 일관성 유지, 4) 성능 최적화 등의 특징을 가집니다.
다른 상태관리 라이브러리들과 비교했을 때 비교적 작은 커뮤니티를 형성하고 있고 최근 2년간 업데이트가 이뤄지지 않고 있어 사용자 수가 지속적으로 감소하고 있는 단점이 있습니다.
Redux와 비교 했을 때 미들웨어의 제한적 기능을 단점으로도 꼽을 수 있습니다.
1) Redux는 모든 액션과 상태 변화를 쉽게 추적하고 로깅할 수 있는 미들웨어를 제공하는 반면, Recoil은 이러한 기능을 위한 내장 도구가 부족하며, 상태 변화를 추적하려면 별도의 작업이 필요합니다.
2) Redux는 redux-saga, redux-thunk같은 미들웨어로 복잡한 사이드 이펙트를 체계적으로 관리하는 반면, Recoil은 selector와 비동기 selector를 통해 사이드 이펙트를 관리하지만, 복잡한 흐름 제어가 상대적으로 어렵다는 단점이 있습니다.
3) Redux는 액션이 리듀서에 도달하기 전에 가로채서 수정할 수 있는 미들웨어 체인을 제공하지만, Recoil은 이런 종류의 중간 처리 단계를 구현하기 어렵습니다.
4) 이 밖에도 Redux는 미들웨어를 통해 상태를 로컬 스토리지와 쉽게 동기화하고 지속할 수 있는데, Recoil도 이런 기능을 구현할 수 있지만 더 많은 수동설정이 필요하고, 디버깅 도구가 상대적으로 제한적이라는 단점도 있습니다.
Zustand와 비교했을 때도 아래와 같은 단점들을 생각해 볼 수 있습니다.
1) Recoil은 atom, selector 개념을 이해해야 하며, React Context API와 유사한 방식을 사용하므로 이에 대한 이해가 있어야 하는 반면, Zustand는 훨씬 더 단순한 API를 제공하며, Redux와 비슷하지만 보일러플레이트가 훨씬 적습니다.
2) Recoil은 React 전용 상태관리 라이브러리로 React에 종속되어 있는 반면, Zustand는 React외의 환경에서도 사용이 가능합니다.
3) Recoil은 Zustand와 비교했을 때 상대적으로 큰 번들 사이즈를 가지는 반면, Zustand는 약 1KB로 매우 가볍습니다.
4) 앞서 언급한 바와 같이 Recoil은 2년 넘게 업데이트 사항이 없고 실험적인 단계이며, 메모리 누수 등의 문제가 있는 등 안정성이 완전히 검증되지 않은 반면, zustand는 꾸준히 업데이트 되고 있어 더 안정적이고 검증된 솔루션이라는 평가를 받습니다.
Recoil은 Redux에 비해 여러가지 장점을 가지고 있지만, Redux의 미들웨어 기능, Zustand 등 다른 상태관리 라이브러리들과 비교했을 때는 그 단점 또한 상당해 보입니다.
프로젝트를 진행할 때 어떤 상태관리 라이브러리를 선택하느냐는 프로젝트의 확장성과 유지보수, 성능의 측면을 고려할 때 중요한 사안이 아닐 수 없습니다. React에 최적화된 라이브러리가 필요하거나 대규모 프로젝트가 아닌 경우는 Recoil을 고려할 수 있을 것 같습니다.
https://medium.com/@shuntaro-okuma/understanding-recoils-get-set-and-recoil-hooks-when-and-how-to-use-them-4ea66df3e08b
https://recoiljs.org/docs/introduction/core-concepts
https://cubettech.com/resources/blog/redux-vs-recoil-choosing-the-right-state-management-for-react-js/
https://velog.io/@adultlee/Recoil%EC%97%90-%EB%8C%80%ED%95%B4%EC%84%9C-%EA%B0%80%EB%B3%8D%EA%B2%8C-%ED%86%BA%EC%95%84%EB%B3%B4%EA%B8%B0