React 상태 관리 Recoil 사용법: Atom과 Selector 정리

3/17/2025 ·impact

React 상태 관리 Recoil 사용법: Atom과 Selector 정리

React 프로젝트에서 화면이 늘어나면 로그인한 사용자 정보처럼 여러 페이지가 함께 보는 값을 어디에 둘지가 문제가 됩니다. 값을 쓰지도 않는 중간 컴포넌트가 props를 전달만 하는 코드가 쌓이고, 상태를 고칠 지점이 흩어집니다. Recoil은 이 문제를 atom이라는 작은 상태 단위로 쪼개서 다루는 상태 관리 라이브러리입니다. React.js와 Tailwind CSS 기반 프로젝트에 Recoil을 적용해본 경험을 바탕으로 atom과 selector의 개념과 예제 코드를 정리하고, 마지막에 이 라이브러리를 지금 새로 도입해도 되는지까지 짚습니다.

Recoil이 다루는 상태 관리 문제

React의 기본 상태는 컴포넌트 안에 갇혀 있습니다. 헤더와 프로필 화면이 동시에 참조하는 로그인 정보라면 공통 조상 컴포넌트로 끌어올린 뒤 props로 내려보내야 합니다. 화면이 서너 개일 때는 감당할 만합니다. React Router로 라우트를 나눈 앱이라면 공통 조상이 App 근처까지 올라가고, 값을 쓰지도 않는 중간 컴포넌트가 전달만 하는 코드도 그만큼 늘어납니다. React 공식 문서는 이 상황을 prop drilling이라 부르며, 가장 가까운 공통 조상이 데이터를 필요로 하는 컴포넌트에서 멀리 떨어져 있을 수 있다고 설명합니다.

Context API로도 전역 값을 내려보낼 수 있습니다. 대신 값이 바뀌면 그 Context를 쓰는 하위 컴포넌트가 함께 다시 렌더링됩니다. useContext 문서는 다른 value를 받은 provider부터 시작해 해당 context를 사용하는 모든 자식을 React가 자동으로 다시 렌더링하며, memo로 감싸도 이 갱신은 막히지 않는다고 적어두었습니다. 사용자 이름과 사이드바 열림 상태를 한 Context에 묶어두면, 사이드바를 토글할 때마다 이름만 쓰는 컴포넌트까지 갱신되는 식입니다. 이를 피하려고 Context를 잘게 쪼개면 이번에는 Provider가 겹겹이 쌓입니다.

Recoil은 상태를 하나의 큰 스토어가 아니라 독립된 단위로 나눕니다. 구독의 기준이 Provider가 아니라 atom입니다. atom 문서는 Recoil이 atom의 상태 변화를 관리해 그 atom을 구독하는 컴포넌트에 다시 렌더링을 알린다고 설명하고, 컴포넌트를 atom에 구독시키는 훅으로 useRecoilState와 useRecoilValue를 듭니다. 앞의 예를 그대로 가져오면, 사용자 이름과 사이드바 상태를 서로 다른 atom에 두었을 때 사이드바를 토글해도 이름만 읽는 컴포넌트는 그 atom을 구독하고 있지 않으니 갱신 대상에서 빠집니다. Context를 쪼갤 때와 달리 Provider를 겹쳐 쌓지도 않습니다. 여기에 selector가 붙어, atom에서 계산되는 값도 같은 구독 구조로 다뤄집니다.

훅의 모양은 useState와 가깝습니다. useRecoilState 문서는 이 API가 기본값 대신 Recoil 상태를 인자로 받는다는 점을 빼면 React의 useState 훅과 비슷하며, 현재 값과 setter로 이루어진 튜플을 돌려준다고 적습니다. 액션과 리듀서 같은 개념 층을 먼저 익힐 일이 없다는 점은 이 프로젝트에서 실제로 편했던 부분입니다.

atom으로 전역 상태 정의하기

atom은 key와 기본값을 가진 상태 조각입니다. 사용자 정보를 여러 컴포넌트에서 공유하는 상황을 예로 들면, 먼저 atom을 따로 정의합니다.

// atoms.js
import { atom } from 'recoil';

// 사용자 상태를 저장하고 공유하는 atom
export const userState = atom({
 key: 'userState',
 default: null,
});

atom 문서는 key를, atom을 내부적으로 식별하며 애플리케이션 전체의 다른 atom·selector와 겹치지 않아야 하는 문자열이라고 정의합니다. 서로 다른 파일에서 같은 key를 쓰면 충돌합니다. default는 초기값이며 여기서는 로그인하지 않은 상태를 뜻하는 null을 넣었습니다. 빈 객체를 기본값으로 두면 null 체크 없이 프로퍼티에 접근할 수 있어 편하지만, 로그인 여부를 값 자체로 구분하기는 어려워집니다. 어느 쪽을 택하든 화면 코드의 분기 방식과 맞춰두는 편이 좋습니다.

atom을 쓰는 컴포넌트는 RecoilRoot 아래에 있어야 합니다. RecoilRoot 문서는 이 컴포넌트를 Recoil 훅을 사용하는 모든 컴포넌트의 조상에 두어야 하며, atom이 값을 갖는 컨텍스트를 제공한다고 정의합니다. 보통 앱 진입점에서 전체를 감쌉니다.

// index.js
import ReactDOM from 'react-dom/client';
import { RecoilRoot } from 'recoil';
import App from './App';

const root = ReactDOM.createRoot(document.getElementById('root'));

root.render(
 <RecoilRoot>
 <App />
 </RecoilRoot>
);

RecoilRoot는 상태가 실제로 저장되는 범위를 만듭니다. 라우트 전환으로 언마운트되는 컴포넌트 안쪽에 두면 화면을 옮길 때마다 값이 초기화되므로, 라우트 엘리먼트보다 위쪽에 두어야 합니다. React Router로 라우트를 구성한 앱이라면 BrowserRouter 바깥을 RecoilRoot로 감싸는 배치가 무난합니다.

반대로 특정 영역만 격리된 상태를 쓰고 싶을 수도 있습니다. RecoilRoot 문서는 여러 RecoilRoot가 공존하면서 atom 상태의 독립된 provider·store 역할을 하고, atom이 루트마다 다른 값을 갖는다고 설명합니다. 중첩했을 때 안쪽 루트가 바깥 루트를 가리는데, 이 동작을 결정하는 것이 기본값 true인 override prop입니다. 같은 문서 기준으로 override가 true면 새 Recoil 스코프를 만들고, false면 자식을 렌더링하는 것 말고는 아무 일도 하지 않아 자식들이 가장 가까운 조상 RecoilRoot의 값을 그대로 씁니다.

컴포넌트에서 상태 읽고 쓰기

회원가입 폼에서 입력값을 받아 atom을 갱신하는 코드입니다. 값을 읽을 필요 없이 쓰기만 한다면 useSetRecoilState를 씁니다.

// SignUp.js
import React, { useState } from 'react';
import { useSetRecoilState } from 'recoil';
import { userState } from './atoms';

function SignUp() {
 const [name, setName] = useState('');
 const [email, setEmail] = useState('');
 const setUserState = useSetRecoilState(userState);

 const handleSignUp = () => {
 setUserState({ // userState atom 업데이트
 name: name,
 email: email,
 });
 };

 return (
 <div>
 <h2>회원가입</h2>
 <input type="text" onChange={(e) => setName(e.target.value)} placeholder="이름" />
 <input type="email" onChange={(e) => setEmail(e.target.value)} placeholder="이메일" />
 <button onClick={handleSignUp}>회원가입</button>
 </div>
 );
}

export default SignUp;

useSetRecoilState는 구독하지 않습니다. 그래서 setter만 돌려줍니다. useSetRecoilState 문서는 이 훅을 쓰면 값이 바뀔 때 컴포넌트가 다시 렌더링되도록 구독하지 않은 채로 값을 설정할 수 있다고 적고 있습니다. 위 컴포넌트는 userState를 읽지 않으므로 다른 화면이 사용자 정보를 바꿔도 다시 그려지지 않습니다.

폼 입력값 자체는 useState로 컴포넌트 안에 두었습니다. 화면을 벗어나면 의미가 없는 값까지 전역으로 올릴 이유는 없습니다. 다른 화면이 이 값을 봐야 하면 atom에, 그 화면 안에서만 쓰는 값이면 useState에 둡니다.

읽는 쪽은 다음과 같습니다.

// UserProfile.js
import React from 'react';
import { useRecoilState } from 'recoil';
import { userState } from './atoms';

function UserProfile() {
 const [user] = useRecoilState(userState);

 return (
 <div>
 {user ? (
 <div>
 <h2>환영합니다, {user.name}!</h2>
 <p>이메일: {user.email}</p>
 </div>
 ) : (
 <p>사용자 정보를 보려면 로그인 해주세요.</p>
 )}
 </div>
 );
}

export default UserProfile;

여기서는 튜플의 값만 쓰므로 useRecoilValue로 바꿔도 동작은 같습니다. user가 null인 경우를 분기해두면 로그인 전후 화면을 한 컴포넌트에서 처리할 수 있습니다. 개념 확인용 최소 예제라 입력값 검증과 에러 처리는 생략했습니다.

selector로 파생 상태 계산하기

atom에 원본 값을 담았다면 selector는 그 값에서 계산되는 값을 담습니다. 로그인 여부나 장바구니 총액처럼 원본이 바뀌면 따라 바뀌어야 하는 값이 대상입니다.

// selectors.js
import { selector } from 'recoil';
import { userState } from './atoms';

export const isLoggedInState = selector({
 key: 'isLoggedInState',
 get: ({ get }) => {
 const user = get(userState);
 return user !== null;
 },
});

get 안에서 다른 atom이나 selector를 읽으면 Recoil이 그 의존 관계를 기록합니다. selector 문서는 get 함수에 전달된 모든 atom과 selector가 암묵적으로 의존성 목록에 추가되고, 의존성 중 하나라도 바뀌면 selector가 다시 계산된다고 설명합니다. userState가 바뀌면 isLoggedInState가 다시 계산되고, 컴포넌트에서는 atom과 똑같이 useRecoilValue로 읽으므로 호출부 코드는 달라지지 않습니다.

실익은 계산 로직이 한 곳에 모인다는 데 있습니다. 로그인 여부를 각 컴포넌트에서 user !== null로 직접 판단하면 나중에 조건이 바뀔 때 모든 컴포넌트를 찾아 고쳐야 합니다. selector에 정의를 두면 한 파일만 수정하면 됩니다.

selector의 key도 atom과 같은 이름 공간을 쓰므로 전역에서 유일해야 합니다. get이 돌려줄 수 있는 것은 값 하나로 한정되지 않습니다. selector 문서는 값을 직접 돌려줘도 되고 비동기 Promise, Loadable, 또는 다른 atom이나 selector를 돌려줘도 된다고 적어두었습니다. 비동기 데이터를 selector로 다룰 수 있는 근거가 여기입니다. set을 함께 넘기면 쓰기 가능한 selector가 되어, 들어온 변경을 데이터 흐름 그래프의 위쪽 의존성으로 되돌려 보냅니다.

key 중복이 실제로 터지는 자리

key 규칙은 문서 한 줄이지만 개발 중에는 경고로 먼저 만나게 됩니다. Recoil은 중복된 key를 등록하면 이 문자열을 그대로 출력합니다.

Duplicate atom key "userState". This is a FATAL ERROR in production. But it is safe to ignore this warning if it occurred because of hot module replacement.

메시지 뒷부분이 함정입니다. hot module replacement 때문이라면 무시해도 된다고 적혀 있는데, Fast Refresh를 쓰는 개발 환경에서는 파일을 저장할 때마다 이 경고가 올라옵니다. Recoil 0.7.6 릴리스 노트도 이 검사가 NextJS나 Fast Refresh를 쓰는 일부 개발 환경에서 너무 시끄러웠다고 인정하면서, 끄는 플래그를 열어줬습니다. 코드에서는 recoil 패키지의 RecoilEnv를 가져와 RecoilEnv.RECOIL_DUPLICATE_ATOM_KEY_CHECKING_ENABLED = false, NextJS 같은 NodeJS 환경에서는 process.env.RECOIL_DUPLICATE_ATOM_KEY_CHECKING_ENABLED=false. 같은 문서가 덧붙인 경고도 함께 읽어야 합니다. 이 플래그는 진짜 중복까지 포함해 모든 검사를 꺼버립니다.

목록 항목처럼 개수가 정해지지 않은 대상에 atom이 필요하면, key 문자열을 직접 조립하지 말고 atomFamily를 씁니다. atomFamily 문서는 패밀리에 key 하나만 주면 내부 atom마다 유일한 key를 만들어준다고 설명합니다. 요소별로 atom을 나누는 쪽이 전체를 map 하나에 담는 것보다 나은 이유로는 각 atom이 자기 구독을 따로 유지한다는 점을 듭니다. 한 요소의 값을 바꾸면 그 atom을 구독한 컴포넌트만 다시 그려집니다.

key를 리팩터링 중에 가볍게 바꾸는 것도 피하는 편이 좋습니다. selector 문서는 key가 persistence에 쓰인다면 실행 사이에 안정적으로 유지돼야 한다고 적어두었습니다.

도입 전에 확인할 것

Recoil은 페이스북(Meta)에서 만든 라이브러리입니다. 원문은 여기에 지속적인 업데이트와 커뮤니티 지원으로 미래가 밝다는 전망을 붙였는데, 이 전망은 시점부터 어긋나 있습니다.

GitHub 저장소에는 2025년 1월 1일 소유자가 저장소를 아카이브해 이제 읽기 전용이라는 배너가 붙어 있습니다. 원문이 공개된 것은 2025년 3월입니다. 저장소는 그보다 두 달 앞서 이미 닫혀 있었으니, 나중에 상황이 바뀐 게 아니라 읽기 전용이 된 뒤에 쓰인 전망인 셈입니다.

마지막 릴리스는 2023년 3월에 나온 0.7.7이고, npm 레지스트리의 latest 태그도 같은 버전에 멈춰 있습니다. 이 메타데이터의 peerDependencies는 "react": ">=16.13.1" 하나뿐이고 상한이 없습니다. react-dom과 react-native는 peerDependenciesMeta에서 optional로 표시돼 있습니다. 상한이 없으니 최신 React에서도 설치 자체가 막히지는 않습니다. 다만 이 글의 예제는 React 19에서 돌려보지 않았고, SSR과 함께 쓰는 조합도 확인하지 않았습니다. 저장소가 읽기 전용인 이상 새 React 버전에 맞춘 수정이 추가로 올라오지도 않으니, 실제 동작은 쓰는 React 버전에서 확인해야 알 수 있습니다.

이미 Recoil로 돌아가는 코드라면 당장 걷어낼 이유는 없습니다. 위 예제도 그대로 동작합니다. 새로 시작하는 프로젝트라면 얘기가 다릅니다. Jotai 문서는 컴포넌트가 모여 React 앱을 이루듯 atom을 조합해 앱 상태를 만든다고 설명하고, 더 단순한 atom에서 파생 atom을 만들어 올리는 방식을 내세웁니다. Recoil의 atom 단위와 가깝습니다. Zustand는 create()로 만든 스토어가 곧 훅이고, 컴포넌트가 셀렉터로 고른 조각이 바뀔 때만 다시 렌더링됩니다. 변화 감지는 기본적으로 엄격 동등 비교(old === new)이고, Provider를 두지 않습니다. 구조 자체가 다릅니다.

후보를 고를 때는 훅 호출부의 모양부터 보는 편이 낫습니다. useState와 비슷한 API라면 지금 Recoil로 짜둔 컴포넌트를 옮길 때 고칠 줄이 그만큼 적습니다. 교체 가능성을 열어두고 싶다면 useRecoilState 호출을 얇은 커스텀 훅으로 감싸두는 방법도 있습니다. 상태를 공유하는 화면이 두세 개뿐이라면 Context와 useState 조합으로 충분한 경우도 많습니다.