Recoil 사용을 왜 안하는지 궁금했다
Why People Avoid Using Recoil in 2025
Recoil은 2020년 Facebook 팀이 공개한 React 전용 상태 관리 라이브러리입니다. 기존에 많이 쓰이던 Redux나 MobX, Context API 등은 각기 장단점이 있었지만, 몇 가지 어려움을 안고 있었죠. 예를 들어 Redux의 경우 단방향 데이터 흐름(Flux 아키텍처)을 채택해 예측 가능성을 높였지만, 액션/리듀서/스토어 등 보일러플레이트 코드가 많고 복잡했습니다.
또한 비동기 작업을 처리하려면 Redux-thunk나 Redux-saga 같은 미들웨어를 추가로 사용해야 했죠. 반면 Context API는 비교적 간단하게 전역 상태를 만들 수 있지만, 컨텍스트 값이 변경되면 해당 컨텍스트를 사용하는 모든 컴포넌트가 리렌더링되는 문제가 있어 성능상 비효율이 발생할 수 있습니다.
이런 배경에서 Recoil은 기존 상태 관리의 단점을 보완하고자 등장했습니다. Recoil은 React 팀이 만든 라이브러리답게 React 훅과 유사한 간결한 API로 상태를 다룹니다. . Redux처럼 복잡한 리듀서 설정 없이도 atom
과 selector
라는 개념을 통해 보일러플레이트 없이 get/set으로 상태를 사용할 수 있고, React의 useState
처럼 직관적인 사용성을 제공합니다. 특히 Recoil은 비동기 상태 처리를 별도 미들웨어 없이 지원하는데, selector
에서 Promise를 리턴하거나 Suspense
와 연계하는 방식으로 내장된 비동기 흐름 관리가 가능합니다. 또한 atom 단위로 상태를 쪼개어 관리하므로, 필요한 컴포넌트만 해당 atom을 구독하게 되어 불필요한 리렌더링을 줄이고 선택적 렌더링이 가능하다는 장점이 있었습니다. 이처럼 Recoil은 Redux 대비 러닝 커브를 낮추고, Context 대비 세밀한 상태 구독을 제공함으로써 등장 당시 “Redux의 왕좌를 위협할” 새로운 대안으로 주목받았습니다.
Redux와 Recoil의 가장 큰 차이는 상태 관리 방식과 범위입니다. Redux는 하나의 전역 스토어에 모든 상태를 모아두고 action을 통해 변경하며, React와 독립적으로 동작하는 외부 스토어입니다. 반면 Recoil은 React 내부에서 동작하는 훅 기반 상태관리로, 애플리케이션을 RecoilRoot로 감싼 내부 컨텍스트에서 상태를 저장합니다. 이 때문에 Redux는 React 바깥에서도 store.getState()
로 접근 가능하지만, Recoil 상태는 React 컴포넌트 안에서 훅을 통해서만 접근할 수 있습니다. 이러한 구조 차이로 Recoil은 Redux처럼 복잡한 설정 없이 컴포넌트에서 바로 상태를 공유할 수 있지만, 동시에 React 라이프사이클에 종속되는 제약이 생깁니다. 또 상태 업데이트 면에서 Redux는 불변성을 지키며 리듀서를 통해 새 상태를 만들어내지만, Recoil은 그냥 훅으로 제공된 setter를 호출해 해당 atom 값을 업데이트하면 자동으로 구독 중인 컴포넌트들을 리렌더링시킵니다. 이 점은 로컬 state (useState
)를 쓰는 것과 유사한 개발 경험을 줍니다. 그리고 Redux DevTools 등 강력한 디버깅 도구가 Redux에는 있지만 Recoil에는 공식 지원되지 않아 개발 편의성 면에서 차이가 있습니다
Context API와 Recoil의 차이는 무엇일까요? Context는 Provider를 통해 하위 트리에 값을 공급하고 useContext
로 사용하는 구조인데, 하나의 Context 값이 변경되면 그 값을 사용하는 모든 컴포넌트가 갱신됩니다. Recoil은 여러 atom을 독립적으로 두고 각 atom을 구독하는 컴포넌트만 업데이트되므로, Context보다 더 미세한 단위로 상태 변경에 반응합니다. 예를 들어 Context 하나에 여러 값이 들어있다면 그 중 하나만 바뀌어도 전체 context consumer가 리렌더링되지만, Recoil에서는 바뀐 atom에 대응하는 컴포넌트들만 리렌더링되는 식입니다. 다만 Context는 React 내장 기능이라 비교적 단순한 구조인데 비해, Recoil은 별도의 서드파티 라이브러리로서 번들 크기가 수 MB 수준으로 증가하는 부담이 있습니다.
MobX와 Recoil은 어떻게 차이가 있을까요? MobX는 관찰자 패턴을 활용하여 상태 변경을 자동으로 반영해주는 라이브러리입니다. MobX는 클래스나 데코레이터 등을 활용해 React에 종속되지 않고 동작하며, 가변 상태를 직접 mutate해도 추적하는 방식이 특징입니다. Recoil은 불변성을 유지하면서 React 상태처럼 다루는 부분에서 MobX와 접근법이 다르지만, 둘 다 필요한 곳만 반응한다는 점에서는 유사합니다. 다만 MobX는 React Hooks 시대 이전에 주로 쓰였고, Hook 기반의 Recoil이 등장하면서 조금 다른 철학으로 비교됩니다.
Recoil의 구조와 동작 방식 (Atom과 Selector)
Recoil의 핵심 개념은 Atom과 Selector입니다. Atom은 상태의 최소 단위로서 하나의 값(숫자, 문자열, 객체 등)을 보유합니다. . Atom을 만들 때는 고유한 key
와 초기값(default
)을 지정하고, 이를 여러 컴포넌트에서 import하여 사용할 수 있습니다. 컴포넌트에서 atom 값을 읽고 쓰려면 useRecoilState(atom)
혹은 useRecoilValue(atom)
등의 Recoil 훅을 사용해야 합니다. 이를 통해 해당 컴포넌트는 atom의 구독자(subscriber)가 되며, atom의 값이 바뀌면 자동으로 최신 값을 받아와 컴포넌트를 리렌더링합니다. 이 동작은 React의 state와 비슷하지만, 여러 컴포넌트가 하나의 atom을 공유할 수 있다는 점이 다릅니다. 예를 들어, countState
라는 atom을 5개의 컴포넌트에서 사용한다면, 하나에서 setCountState
로 값을 바꾸면 나머지 4개 컴포넌트도 모두 새로운 값으로 다시 그려집니다.
Selector는 파생 상태(derived state)를 생성하는 개념입니다. 특정 atom들이나 다른 selector들을 입력으로 받아 순수 함수 형태로 계산된 값을 반환합니다. Redux로 치면 mapStateToProps
나 reselect
라이브러리로 메모이제이션된 셀렉터를 만드는 것과 유사하지만, Recoil에서는 이 기능이 내장돼 있습니다. 예를 들어 여러 atom을 합성한 값이나, atom의 값을 가공한 값(필터링, 합계 등)을 selector로 정의할 수 있습니다. Selector도 훅(useRecoilValue(selector)
등)으로 읽으면, 의존한 atom이 바뀔 때 selector 결과도 다시 계산되어 구독 중인 컴포넌트를 업데이트합니다. 또한 selector 함수 내에서 Promise를 반환하면 해당 상태를 비동기적으로 취급할 수 있고, React Suspense
를 통해 로딩 상태 관리도 가능해집니다. 이처럼 Recoil은 동기/비동기 상태를 모두 하나의 통일된 인터페이스로 다룰 수 있도록 설계되었습니다.
Recoil 애플리케이션은 <RecoilRoot>
컴포넌트로 전체 트리를 감싸서 사용합니다. RecoilRoot는 내부적으로 React Context를 활용하여 전역 상태 저장소와 구독자 리스트를 관리합니다. 각 atom은 RecoilRoot 컨텍스트 안에서 자신을 사용하는 컴포넌트들을 알고 있으며, atom의 값이 set
으로 변경되면 RecoilRoot가 해당 컴포넌트들을 다시 렌더링하도록 트리거합니다.
해당 동작은 React의 상태 변경과 마찬가지로 예약된 업데이트로 batch 처리되며, React의 이벤트 루프나 배치 전략과 함께 동작합니다. React 18의 concurrent 모드에서도 기본적으로 동작은 하지만, Recoil 상태 업데이트를 transition으로 래핑하여 우선순위를 낮추는 등의 React 최신 기능과의 완전한 호환은 아직 실험적입니다.
Recoil의 상태 저장 구조는 기본적으로 메모리 내에 존재하며, 페이지를 리로드하면 상태가 초기화됩니다. 필요하다면 RecoilPersist
같은 커뮤니티 라이브러리를 활용하여 localStorage 등에 atom 값을 지속시키는 방법도 있지만, 이는 별도 작업입니다.
Recoil 상태는 기본적으로 React 컴포넌트의 state와 유사한 생명주기를 가집니다. 컴포넌트가 마운트될 때 useRecoilValue
나 useRecoilState
를 통해 atom 구독을 시작하고, 언마운트될 때 자동으로 구독이 해제됩니다. 이는 Recoil이 내부적으로 Context 구독 메커니즘을 활용하기 때문입니다. 따라서 메모리 관리 측면에서 컴포넌트 언마운트 시 Recoil이 알아서 해당 구독자를 제거하지만, 만약 어떤 이유로 구독이 해제되지 않거나 특정 패턴의 사용으로 인한 버그가 있다면 메모리 누수가 발생할 수 있습니다. 실제로 Recoil 0.x 버전에서는 selector의 기본값으로 atom을 생성하는 특정 경우에 메모리 누수가 생기는 버그가 보고되었고, 이후 패치되기도 했습니다. 토스팀 역시 “특정 상황에서의 메모리 누수 문제 등 패키지의 불안정성”을 Recoil 사용 중 겪었다고 언급합니다.
리렌더링 동작을 보면, Recoil의 atom 값이 업데이트되면 해당 atom을 사용하는 모든 컴포넌트가 다시 렌더링됩니다. 이 때 React는 기존 DOM과의 변경점을 비교하여 실제 화면을 업데이트하므로, 값이 바뀌지 않으면 UI는 결국 그대로지만 불필요한 계산은 일어날 수 있습니다. 만약 atom을 너무 광범위하게 사용하면 Context와 마찬가지로 리렌더링 범위가 넓어지는 단점이 나타날 수 있습니다. 예를 들어 앱 전체에서 공유하는 userProfileAtom
이 있다고 할 때, 프로필의 한 부분이 바뀌면 이 atom을 참조하는 모든 컴포넌트 (헤더의 사용자명, 사이드바의 프로필 사진, 설정 페이지 등)가 모두 리렌더링됩니다. 이런 현상을 Recoil은 atom을 더 잘게 쪼개거나 selector로 구분하는 방식으로 완화할 수 있지만, 개발자가 설계를 잘 해야 효과가 있습니다. 반면 useState는 해당 컴포넌트에만 국한되므로 의도치 않은 광범위 렌더를 일으키지 않는다는 장점이 있습니다. 따라서 상태를 어떻게 쪼개서 어떤 범위로 공유할지를 결정하는 설계가 Recoil 사용 시 매우 중요하며, 잘못 설계하면 오히려 성능 저하나 복잡도를 높일 수 있습니다.
또한, Recoil 상태는 React의 StrictMode 등에서 두 번 렌더링되는 이슈에 대해 안전해야 합니다. React 18+ StrictMode에서 개발 모드로 컴포넌트를 두 번 마운트-언마운트하는 동작이 있는데, Recoil은 이로 인한 부작용을 크게 일으키지 않지만, 만약 selector 등의 초기화 로직에 부작용이 있다면 중복 실행될 수 있어 주의해야 합니다. 이 점은 useState도 마찬가지지만요.
React 라이프사이클 외부에서 Recoil 상태에 접근하기 어려운 제약도 있습니다. 예를 들어 Redux의 스토어는 모듈화된 함수나 서비스 레이어에서 직접 불러와 상태를 읽거나 dispatch할 수 있지만, Recoil은 반드시 컴포넌트 내부나 훅 내에서만 useRecoilValue/setRecoilState
를 사용할 수 있습니다. Node 환경의 서버사이드 렌더링(SSR)에서도 Recoil 상태를 다루려면 <RecoilRoot>
컨텍스트를 만들고 그 안에서 컴포넌트를 렌더링해야 하므로 번거롭습니다. 토스 블로그에 따르면 “Recoil의 구조상 React lifecycle 외부에서 데이터에 접근하기 어려워 점진적 마이그레이션 호환 레이어를 만들기 힘들었다”고 지적하기도 했습니다. 이는 Recoil이 React에 밀접하게 통합된 설계이기에 발생하는 한계로, 장점이자 단점이 됩니다. (이 제약 때문에 Toss는 Recoil과 다른 상태관리(Jotai)를 한꺼번에 사용하는 호환 레이어 대신, 코드 변환 스크립트를 이용해 한 번에 Jotai로 갈아타는 전략을 썼습니다.
토스 사내 어드민 팀이 선택한 것은 Jotai인데, Jotai는 Recoil의 아이디어에서 영감을 받아 만든 오픈소스 상태관리로 개발이 활발하고(Recoil 대비), Recoil과 사용법이 유사해 마이그레이션 시 코드 수정량을 최소화할 수 있다는 판단 때문이었다고 하네요. AST 변환 도구를 이용해 useRecoilValue
등을 일괄적으로 useAtomValue
로 변경하는 등 자동화 스크립트를 작성하여 Recoil → Jotai 전환을 완료했다고 합니다.
물론 Recoil 같은 전역 상태관리 라이브러리를 쓰지 않고, React 내장 훅만으로도 많은 상태 관리 요구사항을 충족시킬 수 있습니다. useState는 가장 기본적인 상태 훅으로 컴포넌트 단위 상태를 관리합니다. 규모가 작은 애플리케이션에서는 useState로도 충분하며, 상태가 해당 컴포넌트 내부에서만 유효하기 때문에 직관적이고 간편합니다. 또한 한 컴포넌트의 useState는 다른 컴포넌트에 영향을 주지 않으므로 (그 상태를 props로 넘기지 않는 한) 예기치 않은 부작용이 없습니다. 단점은, 컴포넌트 간에 상태를 공유하려면 공통 조상으로 상태를 끌어올려서(props drilling) 전달하거나 Context를 써야 하기에 구조가 복잡해질 수 있다는 점입니다. 예를 들어 모달 열림 여부를 여러 컴포넌트에서 알아야 한다면, useState만으로는 힘들고 상위 컴포넌트에 상태를 두고 하위로 내려주어야 하죠.
“Escape Hatch”란, React의 기본 데이터 플로우에서 벗어나야 할 때 사용하는 탈출구(escape hatch) 역할의 Effect를 의미합니다. 하지만 필요 이상으로 Effect를 사용하면 코드가 복잡해지고, 성능 측면에서 불필요한 재렌더링이나 버그가 발생할 수 있습니다. Effect를 사용하면 렌더링 후 또 다른 업데이트 사이클이 발생하게 되어 불필요한 렌더링이 발생할 수 있습니다.
따라서 계산할 수 있는 값들을 렌더링 시점에 바로 계산하면, 상태 변수와 별도의 Effect로 값을 동기화하는 코드를 줄이는 방법이나, prop 변화에 따라 컴포넌트를 재생성하여 state를 초기화하거나, useMemo를 쓰는게 방법이 될 수 있겠습니다.
useRef는 상태라기보다는 변수를 저장하기 위한 훅입니다. React 렌더링과 별개로 유지되어야 하는 값을 담거나 DOM 요소에 접근하기 위해 쓰입니다. useRef에 넣은 값은 변경되어도 컴포넌트를 리렌더링하지 않기 때문에, 렌더링 사이에서 유지해야 하나 UI 갱신을 트리거할 필요는 없는 값에 적합합니다.
예를 들어 컴포넌트 내부에서 타이머 ID나 이전 상태값, 또는 외부 라이브러리 인스턴스를 저장할 때 사용합니다. 상태 관리 관점에서 useRef는 불필요한 렌더링을 방지하는 용도로 활용될 수 있습니다. 어떤 값이 자주 바뀌지만 그때마다 화면을 다시 그릴 필요가 없다면 고려해보겠죠. 예를 들면 입력값을 useRef에 저장하면서 onChange
이벤트마다 ref.current를 업데이트하면, 컴포넌트는 리렌더링되지 않고 내부 변수만 바뀌므로 300ms 정도 디바운스(delay) 후에 ref값을 useState로 올려서 실제 렌더링과 API 호출을 트리거하면, 불필요한 중간 렌더를 막을 수 있겠네요.
또한 전역 상태가 꼭 필요한가? 를 고민해볼 필요도 있습니다. 종종 개발자들이 편의 때문에 모든 상태를 전역으로 관리하려고 하면 오히려 복잡도가 올라갑니다. React의 대부분의 상태는 해당 컴포넌트 혹은 가까운 상위에서 관리하는 게 이상적입니다. 전역 상태는 여러 페이지나 컴포넌트에서 공통으로 쓰이는 최소한의 값에만 적용하고, 그렇지 않은 경우 로컬 state로 두는 편이 성능이나 구조 면에서 낫습니다. useState/Context 조합으로도 상당수 요구사항은 해결되며, 오히려 전역 상태를 도입하면 불필요한 결합도가 생길 수 있습니다. 가령 쇼핑몰에서 장바구니 상태는 전역이 좋지만, 개별 폼 입력 상태까지 모두 Recoil로 전역화하면 이해하기 어려운 구조가 되겠죠.
따라서 정리해보면 다음과 같습니다.
- Recoil의 atom은 어떤 컴포넌트에서도 자유롭게 구독하고 업데이트할 수 있습니다. 이는 장점이기도 하지만, 여러 곳에서 같은 atom을 수정하면 예상치 못한 상호작용이 발생할 수 있습니다. 예컨대 서로 다른 두 컴포넌트가 같은 atom 값을 교대로 변경하는 경우, 컴포넌트 간 의존성이 생겨버려 버그를 유발할 수 있습니다. Redux처럼 명시적인 액션 로그가 쌓이지 않으므로, 디버깅 시 어떤 순서로 상태 변경이 일어났는지 추적이 힘든 경우도 있습니다.
- Redux에는 강력한 Redux DevTools가 있어 시간여행 디버깅이나 상태 변경 추적이 용이합니다. 그러나 Recoil은 이에 상응하는 공식 디버깅 도구가 부족합니다. Recoil 상태 변화를 추적하려면 개발자가
useEffect
로 로그를 남기거나, React DevTools의 Context 값으로 들여다보는 정도에 그칩니다. 한 블로그에서도 Recoil의 단점으로 “Redux DevTools와 같은 디버깅 툴이 완벽하게 지원되지 않는다”는 점을 들고 있습니다. 대규모 앱에서는 상태 변경을 한눈에 파악하기 어려우면 생산성에 큰 지장이 생기므로, 이 부분은 Recoil의 실무 적용을 망설이게 하는 요소였다고 하네요. - Recoil은 패키지 크기가 약 2.2MB로 비교적 무거운 편입니다. 물론 요즘 번들 크기 수 MB는 치명적이진 않지만, Zustand 등 몇십 KB 수준의 경량 상태관리와 비교되곤 합니다. 또한 Recoil은 React에 얹혀 동작하지만 결국 별도의 연산을 거칩니다. 수백 개의 atom이나 selector를 사용하면 초기화나 업데이트 시 어느 정도 오버헤드가 있습니다. 반면 다른 경량 라이브러리는 최소한의 기능만 제공하여 더 빠른 경우가 있습니다. 즉, 앱이 커질수록 Recoil의 구조적 오버헤드가 누적될 수 있습니다.
- Recoil은 여전히 공식 버전 1.0이 나오지 않은 실험적(experimental) 프로젝트입니다. Meta(페이스북) 내부에서도 한때 일부 프로덕션에 사용되었지만, 2022년 이후로 핵심 개발팀의 리소스가 줄었습니다. 실제 Meta의 구조조정 당시 Recoil 핵심 개발자가 퇴사하여 프로젝트가 정체되었다는 소식이 업계에 퍼졌습니다.
- Recoil의 라이프사이클은 React 앱 내부에 한정됩니다. 만약 상태를 URL(히스토리)과 동기화한다거나(이를 위해 Recoil Sync 부가 라이브러리가 나오긴 했지만 복잡함), 혹은 웹스토리지/IndexedDB, 또는 다른 JS 애플리케이션과 공유해야 할 경우 제약이 따릅니다. 반면 Redux나 Zustand 등은 React 바깥에서도 동작하므로 다양한 환경에서 상태 공유/동기화가 비교적 자유롭습니다. 이 부분에서도 Recoil은 범용 솔루션이라기보단 React 전용 솔루션의 한계를 가집니다.
- 마지막으로 새 라이브러리를 도입할 때, 기존 코드에 점진적으로 적용하거나 상황에 따라 대체할 수 있어야 하는데, Recoil은 그 경로가 좁습니다. Redux 스토어와 병행하거나, 일부 화면만 Context를 쓴다거나 하는 식으로 섞어서 쓰기 어렵습니다. 앞서 Toss 사례에서도 보았듯, Recoil에서 다른 것으로 옮길 때 중간 계층을 만들어 호환하기가 쉽지 않아 큰 비용을 치렀습니다. 즉, 한번 Recoil로 전역 상태 관리 구조를 잡으면 탈출하기 어렵게 되는 락인(lock-in) 효과도 있습니다.