React Compiler가 Zustand에 구조적으로 유리한 이유와 Jotai·Valtio 호환성 현황
2025년 10월, React Compiler 1.0이 드디어 stable로 공개됐습니다. useMemo, useCallback, React.memo를 손으로 일일이 달던 시절을 생각하면 꽤 큰 변화인데요, 저도 처음 소식을 들었을 때 바로 든 생각이 "그러면 지금 쓰고 있는 상태 관리 라이브러리는 어떻게 되는 거지?" 였습니다. 아마 비슷한 궁금증을 가진 분들이 많을 것 같습니다.
결론부터 말씀드리면, 라이브러리마다 상황이 꽤 다릅니다. Zustand는 구조적으로 호환이 잘 되고, Jotai는 대부분 괜찮지만 파생 아톰에서 주의가 필요하며, Valtio는 useSnapshot은 안전하지만 useProxy 패턴에서 충돌이 생깁니다. 실제 프로덕션에서도 효과가 검증됐습니다. Sanity Studio는 React Compiler 적용 후 렌더 시간이 20~30% 감소했고, Wakelet은 LCP 10%, INP 15% 개선을 확인했습니다. 이렇게 성능 개선이 실무에서 현실화된 시점에서, 지금 쓰는 상태 관리 라이브러리가 컴파일러와 얼마나 잘 맞는지는 이제 이론이 아니라 실무 문제입니다. 이 글에서는 각 라이브러리가 왜 그런 결과를 보이는지 내부 구조를 짚어보고, 어떤 패턴을 쓰면 안전한지 코드로 확인해봅니다.
핵심 개념
React Compiler가 기대하는 것: 순수 함수와 불변 입력
React Compiler는 빌드 타임에 코드를 분석해서 useMemo, useCallback, React.memo를 자동으로 삽입해주는 도구입니다. Babel 또는 SWC 플러그인으로 동작하며, 각 표현식의 값 의존성(value dependency)을 추적해서 의존성이 변하지 않았다면 캐싱된 결과를 재사용하도록 코드를 변환합니다.
그런데 이 분석이 제대로 작동하려면 전제 조건이 있습니다.
컴파일러의 핵심 가정: 컴포넌트 렌더는 props와 state라는 입력값을 받아 UI를 반환하는 순수 함수여야 합니다. 훅이 반환한 값을 직접 변경하거나, 렌더 경로에 부작용이 섞이면 컴파일러는 "여기는 안전하게 최적화할 수 없다"고 판단하고 해당 컴포넌트를 조용히 건너뜁니다.
상태 관리 라이브러리와의 호환성이 갈리는 지점이 바로 여기입니다. 라이브러리가 **불변성(immutability)**을 얼마나 지키느냐, 그리고 렌더 경로를 얼마나 깨끗하게 유지하느냐에 따라 컴파일러와의 궁합이 결정됩니다.
비호환 패턴 자동 감지: eslint-plugin-react-compiler
eslint-plugin-react-compiler 패키지에는 incompatible-library 규칙이 포함되어 있습니다. 컴파일러가 안전하게 최적화할 수 없는 API 사용을 감지하면 해당 컴포넌트의 컴파일을 자동으로 건너뜁니다.
중요한 건 이게 조용히 일어난다는 점입니다. 에러가 나는 게 아니라 그냥 최적화가 적용 안 되는 거라서, 2025년 후반에 "왜 컴파일이 안 되지?" 하는 이슈들이 커뮤니티에 꽤 올라왔습니다. TanStack Table, React Hook Form 일부 패턴이 대표적인 사례이고, Valtio의 useProxy도 여기에 해당합니다.
이 원칙을 기반으로 각 라이브러리가 어떻게 동작하는지 코드로 확인해보겠습니다.
실전 적용
예시 1: Zustand — 외부 스토어 구조가 만드는 자연스러운 호환
Zustand가 React Compiler와 잘 맞는 이유는 구조 자체에 있습니다. 스토어가 React 렌더 사이클 바깥에 존재하고, 컴포넌트는 선택자를 통해 필요한 슬라이스만 구독합니다.
import { create } from 'zustand'
interface CountState {
count: number
increment: () => void
}
// 스토어 — React 외부에 존재하는 상태
const useStore = create<CountState>((set) => ({
count: 0,
increment: () => set((s) => ({ count: s.count + 1 })),
}))
// 컴포넌트: 선택자를 통해 필요한 값만 구독
function Counter() {
const count = useStore((s) => s.count)
const increment = useStore((s) => s.increment)
return <button onClick={increment}>{count}</button>
}내부적으로는 useSyncExternalStoreWithSelector가 동작합니다. 이건 React 18에서 공식 추가된 API로, React 렌더 사이클 밖에 있는 외부 스토어를 안전하게 구독할 수 있게 해줍니다. Concurrent Mode에서 발생할 수 있는 tearing(여러 렌더에서 UI 상태가 불일치하는 현상)을 방지하는 게 핵심 역할이기도 합니다.
처음 React Compiler를 팀 프로젝트에 적용했을 때 Zustand 스토어는 별도 설정 없이 그냥 됐는데, 돌이켜보면 당연한 결과였습니다. 스토어에서 변경이 발생하면 선택된 슬라이스의 값이 바뀌었을 때만 리렌더가 트리거되고, 반환되는 스냅샷은 불변 객체입니다. 컴파일러 입장에서는 count라는 값이 변하지 않는 한 재계산할 이유가 없다는 걸 정적 분석으로 확인할 수 있는 구조입니다.
| 분석 항목 | Zustand의 동작 | 컴파일러 호환 여부 |
|---|---|---|
| 상태 위치 | React 외부 스토어 | ✅ 렌더 경로 분리 |
| 반환값 형태 | 불변 스냅샷 | ✅ 의존성 추적 가능 |
| 훅 반환값 변경 | 불가능 (set 함수로만 변경) | ✅ 규칙 준수 |
| 구독 방식 | useSyncExternalStoreWithSelector |
✅ React 공식 API |
예시 2: Jotai — 기본은 호환, 파생 아톰에서 조심
Jotai의 기본 아톰과 useAtom은 컴파일러와 잘 동작합니다. Jotai v2는 내부적으로 store 개념을 중심으로 구성되어 있어 React 렌더 모델과 자연스럽게 맞아떨어집니다.
import { atom, useAtomValue } from 'jotai'
const countAtom = atom(0)
// 안전한 파생 아톰 — 순수 읽기 함수
const doubleAtom = atom((get) => get(countAtom) * 2)
function DoubleCounter() {
const double = useAtomValue(doubleAtom) // 컴파일러 친화적
return <div>{double}</div>
}문제는 파생 아톰의 getter에 부작용이 섞일 때입니다. 디버깅하다가 아래처럼 console.log를 getter 안에 넣으면, 이것만으로도 비순수 함수가 됩니다.
import { atom } from 'jotai'
// 주의 — getter 안의 부작용은 컴파일러 최적화 범위를 좁힘
const debugAtom = atom((get) => {
console.log(get(countAtom)) // 부작용 포함 → 컴파일러 최적화 제한
return get(countAtom)
})솔직히 이런 패턴을 실무에서 의도적으로 쓰는 경우는 드물지만, 복잡한 파생 아톰 체인이 쌓이다 보면 어느 순간 비순수 함수가 끼어드는 경우가 생깁니다. 파생 아톰의 getter는 순수하게 유지하고, 사이드이펙트가 필요하다면 atomEffect 같은 별도 메커니즘을 활용하는 것을 권장합니다.
Jotai 작성자인 Daishi Kato는 React Compiler 시대에서 Jotai의 방향을 서버 사이드 아톰 모델 확장으로 잡고 있다고 밝혔습니다. 단순 렌더 최적화보다는 복잡한 상태 의존성 표현에 집중하겠다는 뜻으로 읽히는데, 컴파일러가 단순 최적화를 흡수하는 시대에 맞는 포지셔닝처럼 보입니다.
예시 3: Valtio — useSnapshot vs useProxy의 차이
Valtio는 두 가지 훅을 제공하는데, 컴파일러 호환성이 완전히 갈립니다.
import { proxy } from 'valtio'
import { useSnapshot } from 'valtio'
const myProxy = proxy({ count: 0 })
// ✅ 권장 — useSnapshot (불변 스냅샷 반환, 컴파일러 친화적)
function SafeCounter() {
const snap = useSnapshot(myProxy)
return (
<div>
<span>{snap.count}</span>
{/* 상태 변경은 프록시에 직접 — snap은 읽기 전용 */}
<button onClick={() => myProxy.count++}>+1</button>
</div>
)
}import { proxy } from 'valtio'
import { useProxy } from 'valtio' // 레거시/실험적 패턴
const myProxy = proxy({ count: 0 })
// ⚠️ 주의 — useProxy (컴파일러가 뮤테이션을 인식 못해 최적화 스킵)
function UnsafeCounter() {
const state = useProxy(myProxy)
// 훅 반환값을 직접 변경 → React 규칙 위반으로 판단
return <button onClick={() => state.count++}>{state.count}</button>
}useProxy는 실험적(experimental) 성격의 API로, useSnapshot과 대등한 공식 패턴이 아닙니다. useProxy가 반환한 Proxy 객체를 컴포넌트 내부에서 직접 변경하는 건 컴파일러 입장에서 "훅 반환값 변경 = 규칙 위반"입니다. 렌더 중에 상태가 바뀔 수 있다는 신호를 정적으로 감지하면 그 컴포넌트의 최적화를 포기하게 됩니다. Valtio v2에서는 useSnapshot 중심의 API 설계를 강화하는 방향으로 개선됐지만, useProxy 패턴이 코드베이스에 혼용되어 있다면 점검이 필요합니다.
장단점 분석
세 라이브러리를 한눈에 비교하면 이렇습니다.
| 라이브러리 | React Compiler 호환성 | 주의 패턴 | 권장 상황 |
|---|---|---|---|
| Zustand | ✅ 구조적으로 호환 | 인라인 selector 남용 | 대부분의 전역 상태 관리 |
| Jotai | ✅ 기본 호환 (파생 아톰 주의) | 비순수 getter | 복잡한 상태 의존성 표현 |
| Valtio | ⚠️ 패턴에 따라 다름 | useProxy 직접 변경 |
useSnapshot 패턴으로 한정 |
Zustand는 구조 자체가 컴파일러 친화적이라 추가 설정 없이도 잘 됩니다. Jotai는 아톰 모델이 복잡한 상태 의존성을 표현하는 데 강점이 있지만, 파생 아톰 getter를 순수하게 유지하는 주의가 필요합니다. Valtio는 뮤터블 스타일이 직관적이라 Vue나 MobX 경험자에게 친숙한데, useSnapshot 패턴으로 사용하는 한 컴파일러와 문제없이 동작합니다.
실무에서 가장 흔한 실수
useProxy를 쓰면서 컴파일러가 적용됐다고 착각하는 경우 — 에러가 나지 않고 조용히 최적화가 스킵되기 때문에 빌드 로그를 확인하지 않으면 모를 수 있습니다.eslint-plugin-react-compiler의incompatible-library규칙을 활성화해두면 이런 상황을 사전에 감지할 수 있습니다.- Zustand selector를 인라인 함수로 작성하는 경우 — 컴파일러가 있어도
useStore((s) => s.count)처럼 매 렌더마다 새 함수를 만드는 패턴은 불필요한 재구독을 유발할 수 있습니다. selector는 컴포넌트 외부에 선언하거나useShallow를 함께 사용하는 것을 권장합니다. - Jotai 파생 아톰 getter에 디버깅 코드를 넣는 경우 —
console.log하나만 들어가도 비순수 함수가 되어 컴파일러 최적화 범위가 줄어듭니다. 파생 아톰 getter는 순수하게 유지하고, 사이드이펙트는atomEffect같은 별도 메커니즘을 활용하는 것을 권장합니다.
마치며
React Compiler 시대에 상태 관리 라이브러리를 선택하는 기준은 결국 "불변성을 구조적으로 보장하는가"로 수렴됩니다. Zustand는 그 기준을 가장 자연스럽게 충족하고, Jotai는 파생 아톰을 순수하게 관리하는 한 잘 동작하며, Valtio는 useSnapshot 패턴으로 전환하면 안전하게 사용할 수 있습니다.
지금 바로 시작해볼 수 있는 3단계입니다.
eslint-plugin-react-compiler설치 및 규칙 활성화 —pnpm add -D eslint-plugin-react-compiler후 ESLint 설정에incompatible-library규칙을 추가해두면 비호환 패턴을 사전에 감지할 수 있습니다.- Valtio를 사용 중이라면
useProxy사용 현황 점검 — 프로젝트 전체에서useProxy호출을 그레핑해보고(grep -r "useProxy" src/), 발견된 패턴을useSnapshot+ 프록시 직접 변경 방식으로 전환하는 것을 권장합니다. - Zustand selector 패턴 점검 — 인라인으로 작성된 selector를 컴포넌트 외부로 분리하거나, 객체를 반환하는 selector에는
useShallow를 적용해보시면 React Compiler와의 시너지를 최대한 끌어낼 수 있습니다.
참고 자료
- Thoughts on State Management Libraries in the React Compiler Era | Daishi Kato's blog
- React Compiler v1.0 | 공식 React 블로그
- React Compiler 소개 | 공식 문서
- incompatible-library 규칙 | React 공식 ESLint 플러그인 문서
- useSyncExternalStore | React 공식 문서
- Will React Compiler affect Zustand? | Zustand GitHub Discussion #2562
- How to mark valtio's useProxy | React GitHub Issue #31311
- Meta's React Compiler 1.0 Brings Automatic Memoization to Production | InfoQ
- useSnapshot | Valtio 공식 문서
- React Compiler Deep Dive: Automatic Memoization | DEV Community