Recoil
React State Management Library
Facebook이 개발한 React 상태 관리 라이브러리. Atoms과 Selectors로 전역 상태를 관리하며 React Suspense와 자연스럽게 통합됩니다.
React State Management Library
Facebook이 개발한 React 상태 관리 라이브러리. Atoms과 Selectors로 전역 상태를 관리하며 React Suspense와 자연스럽게 통합됩니다.
Recoil은 Facebook(현 Meta)이 2020년에 공개한 React 전용 상태 관리 라이브러리입니다. React 팀의 내부 프로젝트에서 시작되어, Redux나 MobX 같은 기존 상태 관리 솔루션의 복잡성을 해결하고자 개발되었습니다. Recoil의 가장 큰 특징은 React의 동작 방식과 철학에 맞게 설계되어, 별도의 보일러플레이트 코드 없이도 직관적으로 전역 상태를 관리할 수 있다는 점입니다.
Recoil의 핵심 개념은 Atom과 Selector입니다. Atom은 상태의 최소 단위로, 어떤 컴포넌트에서든 구독하고 업데이트할 수 있는 공유 상태입니다. 하나의 Atom이 업데이트되면 해당 Atom을 구독하는 컴포넌트만 리렌더링됩니다. Selector는 Atom이나 다른 Selector를 입력으로 받아 파생 상태를 계산하는 순수 함수입니다. 비동기 로직도 Selector 내에서 자연스럽게 처리할 수 있어, 데이터 페칭과 상태 관리를 통합할 수 있습니다.
Recoil은 React Context API의 한계를 극복하기 위해 설계되었습니다. Context API는 전역 상태 공유에 유용하지만, Context 값이 변경되면 해당 Context를 구독하는 모든 컴포넌트가 리렌더링되는 성능 문제가 있습니다. Recoil은 상태를 세분화된 Atom 단위로 관리하여, 실제로 변경된 상태를 구독하는 컴포넌트만 선택적으로 리렌더링합니다. 또한 Redux처럼 액션, 리듀서, 디스패치 등의 복잡한 패턴 없이 useState처럼 간단한 API로 전역 상태를 다룰 수 있습니다.
Recoil은 React Suspense와 완벽하게 통합됩니다. Selector에서 비동기 데이터를 반환하면, 데이터가 로딩 중일 때 자동으로 Suspense의 fallback UI가 표시됩니다. 이를 통해 선언적인 비동기 상태 관리가 가능하며, React 18의 Concurrent 기능과도 잘 어울립니다. 또한 Atom Effects를 통해 로컬 스토리지 동기화, 로깅, 상태 초기화 등의 사이드 이펙트를 Atom 레벨에서 관리할 수 있습니다.
// Recoil Atom 정의 - 상태의 최소 단위
import { atom } from 'recoil';
// 사용자 정보 Atom
export const userState = atom({
key: 'userState', // 고유 식별자 (필수)
default: {
name: '',
email: '',
isLoggedIn: false
}
});
// 장바구니 Atom
export const cartState = atom({
key: 'cartState',
default: [],
// Atom Effects로 로컬 스토리지 동기화
effects: [
({setSelf, onSet}) => {
// 초기화 시 로컬 스토리지에서 불러오기
const savedCart = localStorage.getItem('cart');
if (savedCart) {
setSelf(JSON.parse(savedCart));
}
// 상태 변경 시 로컬 스토리지에 저장
onSet((newValue) => {
localStorage.setItem('cart', JSON.stringify(newValue));
});
}
]
});
// 테마 설정 Atom
export const themeState = atom({
key: 'themeState',
default: 'light'
});
// Selector - Atom에서 파생된 상태 계산
import { selector } from 'recoil';
import { cartState, userState } from './atoms';
// 장바구니 총액 계산 Selector
export const cartTotalSelector = selector({
key: 'cartTotalSelector',
get: ({get}) => {
const cart = get(cartState);
return cart.reduce((total, item) =>
total + item.price * item.quantity, 0
);
}
});
// 비동기 Selector - API 데이터 페칭
export const userOrdersSelector = selector({
key: 'userOrdersSelector',
get: async ({get}) => {
const user = get(userState);
if (!user.isLoggedIn) {
return [];
}
// 비동기 데이터 페칭 (Suspense 자동 통합)
const response = await fetch(
`/api/users/${user.email}/orders`
);
return response.json();
}
});
// 쓰기 가능한 Selector
export const cartItemCountSelector = selector({
key: 'cartItemCountSelector',
get: ({get}) => {
const cart = get(cartState);
return cart.reduce((count, item) =>
count + item.quantity, 0
);
},
set: ({set}, newValue) => {
// 전체 초기화 등 특별한 동작
if (newValue === 0) {
set(cartState, []);
}
}
});
// 컴포넌트에서 Recoil 상태 사용하기
import { Suspense } from 'react';
import {
useRecoilState,
useRecoilValue,
useSetRecoilState
} from 'recoil';
import { cartState, cartTotalSelector, userOrdersSelector } from './store';
function Cart() {
// useState처럼 [값, setter] 반환
const [cart, setCart] = useRecoilState(cartState);
// 읽기 전용 (리렌더링 최적화)
const total = useRecoilValue(cartTotalSelector);
// 쓰기 전용 (해당 컴포넌트는 값 변경에 리렌더 안됨)
const setCartOnly = useSetRecoilState(cartState);
const addItem = (product) => {
setCart((prev) => {
const existing = prev.find(item => item.id === product.id);
if (existing) {
return prev.map(item =>
item.id === product.id
? { ...item, quantity: item.quantity + 1 }
: item
);
}
return [...prev, { ...product, quantity: 1 }];
});
};
return (
<div>
<h2>장바구니 ({cart.length}개 상품)</h2>
{cart.map(item => (
<div key={item.id}>
{item.name} x {item.quantity}
</div>
))}
<p>총액: {total.toLocaleString()}원</p>
{/* 비동기 Selector + Suspense */}
<Suspense fallback={<p>주문 내역 로딩 중...</p>}>
<OrderHistory />
</Suspense>
</div>
);
}
function OrderHistory() {
// 비동기 Selector 사용 - 자동으로 Suspense 처리
const orders = useRecoilValue(userOrdersSelector);
return <div>{orders.length}개의 주문 내역</div>;
}
프론트엔드 리드 박과장:
"새 대시보드 프로젝트 상태 관리를 어떤 라이브러리로 할지 정해야 합니다. 현재 Redux와 Recoil이 후보인데요."
주니어 개발자 김대리:
"저는 Recoil을 추천합니다. Redux처럼 액션, 리듀서, 미들웨어 설정 없이 바로 atom 정의하고 useState처럼 쓸 수 있어요. 러닝 커브가 낮고 보일러플레이트도 적습니다."
시니어 개발자 이차장:
"잠깐요, Recoil은 Facebook에서 더 이상 적극 개발하지 않고 있어요. 2024년 이후로 메이저 업데이트가 없고 메인테이너도 떠났습니다. 새 프로젝트에 도입하기엔 리스크가 있어요."
프론트엔드 리드 박과장:
"그럼 대안은 뭐가 있죠? Recoil의 장점은 살리면서요."
시니어 개발자 이차장:
"Jotai가 Recoil과 거의 동일한 API에 더 가볍고 활발히 유지보수됩니다. 아니면 Zustand도 좋아요. 더 간결하고 Redux DevTools 연동도 되고요. 장기 유지보수 관점에서 이쪽을 검토해보시죠."
리뷰어:
"이 PR에서 하나의 거대한 Atom에 모든 상태를 다 넣었는데, 이러면 Recoil의 장점을 살릴 수 없어요. 일부만 변경돼도 전체 구독 컴포넌트가 리렌더링됩니다."
작성자:
"아, Redux 패턴처럼 하나의 스토어로 관리하려 했는데요. Atom을 어떻게 나눠야 할까요?"
리뷰어:
"Recoil에선 관심사별로 작은 Atom을 여러 개 만드는 게 좋아요. 예를 들어 userAtom, cartAtom, uiAtom처럼요. 그리고 파생 상태는 Selector로 만들고요. cartTotalSelector처럼 cart에서 계산되는 값은 따로 만들어두면 자동으로 의존성 추적이 됩니다."
작성자:
"비동기 데이터는요? 지금은 useEffect에서 fetch 하고 setState 하고 있는데요."
리뷰어:
"비동기 Selector를 사용하세요. get 함수에서 async/await로 fetch하면 자동으로 Suspense 통합됩니다. 로딩 상태 따로 관리할 필요 없이 Suspense fallback으로 처리하면 코드가 훨씬 깔끔해져요."
면접관:
"Recoil을 사용해본 경험이 있다고 하셨는데, Redux와 비교해서 어떤 점이 좋았나요?"
지원자:
"Recoil은 atom 단위로 상태를 관리해서 필요한 컴포넌트만 리렌더링됩니다. Redux는 전역 스토어 하나를 구독하다 보니 불필요한 리렌더링이 생기기 쉬웠어요. 또한 비동기 selector가 Suspense와 자연스럽게 통합되어 로딩 상태 처리가 간편했습니다."
면접관:
"Recoil이 개발 중단 논란이 있는데, 새 프로젝트에서는 어떻게 하시겠어요?"
지원자:
"맞습니다. 신규 프로젝트라면 Jotai를 추천드려요. Recoil과 API가 거의 동일하면서 번들 사이즈가 작고 활발히 유지보수됩니다. 기존 Recoil 경험으로 Jotai 전환도 쉬웠고요. 또는 상태가 복잡하지 않다면 Zustand도 좋은 선택입니다."
Recoil은 2024년 이후 메이저 업데이트가 없으며, 핵심 메인테이너들이 Facebook을 떠났습니다. 실험적(experimental) 태그도 유지되고 있어, 새 프로젝트에 도입 시 장기 유지보수 리스크를 고려해야 합니다. 기존 Recoil 프로젝트는 당장 문제없지만, 신규 프로젝트는 대안을 검토하세요.
Jotai는 Recoil과 유사한 atomic 패턴에 더 가볍고 활발히 유지보수됩니다. Zustand는 더 단순한 API로 Redux DevTools 지원까지 제공합니다. React Query/TanStack Query는 서버 상태 관리에 특화되어 있습니다. 프로젝트 요구사항에 맞는 라이브러리를 선택하세요.
기존 Recoil 프로젝트는 Jotai로 마이그레이션하기 쉽습니다. atom, selector 개념이 거의 동일하고 API도 유사하기 때문입니다. 점진적 마이그레이션이 가능하므로 한 번에 전체를 변경하지 않고 모듈별로 전환할 수 있습니다.
모든 Atom과 Selector의 key는 애플리케이션 전체에서 고유해야 합니다. key가 중복되면 런타임 에러가 발생합니다. 네이밍 컨벤션(예: 'feature/atomName')을 정해서 팀 전체가 일관되게 사용하세요.