본문 바로가기

프론트엔드 기타

Jotai vs Zustand vs Recoil 완벽 비교 - 프로젝트에 맞는 상태관리 라이브러리 선택 가이드

{'state'} {'atoms'} J Jotai Z Zustand R Recoil 상태 관리 라이브러리 비교 Jotai vs Zustand vs Recoil 프론트엔드 기타

도입부

Redux의 보일러플레이트에 지쳐 계신가요? 모던 React 개발에서 상태관리 라이브러리 선택은 프로젝트의 성능과 개발 경험을 좌우하는 중요한 결정이에요. 특히 Jotai, Zustand, Recoil은 각각 독특한 철학과 강점을 가진 차세대 상태관리 솔루션으로, Redux의 복잡함을 해결하면서도 강력한 기능을 제공해요. 이 글에서는 세 라이브러리의 핵심 차이점과 실전 활용법을 코드와 함께 비교 분석하여, 여러분의 프로젝트에 가장 적합한 선택을 할 수 있도록 도와드릴게요.

세 라이브러리의 핵심 철학과 특징

Zustand - 가장 간결한 Flux 패턴

Zustand는 "독일어로 상태"라는 뜻으로, 이름처럼 상태관리의 본질에 집중한 미니멀한 라이브러리예요. Flux 패턴을 따르면서도 Redux의 복잡함을 제거했죠.

핵심 특징:

  • 번들 사이즈가 1.2KB로 매우 가벼워요
  • Provider 불필요 - 컴포넌트 외부에 스토어 생성
  • TypeScript 지원이 우수하고 설정이 간단해요
  • 미들웨어 시스템으로 확장성 제공
// Zustand 스토어 생성 예시
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';

interface UserStore {
  user: { name: string; email: string } | null;
  isLoading: boolean;
  setUser: (user: UserStore['user']) => void;
  login: (email: string, password: string) => Promise<void>;
  logout: () => void;
}

// 미들웨어 체이닝으로 개발자 도구와 영속화 기능 추가
const useUserStore = create<UserStore>()(
  devtools(
    persist(
      (set) => ({
        user: null,
        isLoading: false,

        setUser: (user) => set({ user }),

        // 비동기 액션도 직관적으로 정의
        login: async (email, password) => {
          set({ isLoading: true });
          try {
            const response = await fetch('/api/login', {
              method: 'POST',
              body: JSON.stringify({ email, password }),
            });
            const user = await response.json();
            set({ user, isLoading: false });
          } catch (error) {
            set({ isLoading: false });
            throw error;
          }
        },

        logout: () => set({ user: null }),
      }),
      { name: 'user-storage' } // localStorage 키
    )
  )
);

// 컴포넌트에서 사용
function UserProfile() {
  // 필요한 상태만 선택적으로 구독 (성능 최적화)
  const user = useUserStore((state) => state.user);
  const logout = useUserStore((state) => state.logout);

  if (!user) return <div>로그인이 필요해요</div>;

  return (
    <div>
      <h2>{user.name}</h2>
      <button onClick={logout}>로그아웃</button>
    </div>
  );
}

Jotai - Atomic 상태관리의 진화

Jotai는 Recoil의 철학을 계승하면서도 더 가볍고 간결하게 만든 원자적(atomic) 상태관리 라이브러리예요. "상태"를 뜻하는 일본어에서 이름을 따왔죠.

핵심 특징:

  • 원자(atom) 단위로 상태를 분리하여 관리
  • 번들 사이즈 3.3KB로 경량
  • Bottom-up 접근 방식 - 작은 atom을 조합
  • React Suspense와 자연스럽게 통합
// Jotai atom 정의 예시
import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai';
import { atomWithStorage } from 'jotai/utils';

// 기본 atom
const userAtom = atom<{ name: string; email: string } | null>(null);

// localStorage와 동기화되는 atom
const themeAtom = atomWithStorage<'light' | 'dark'>('theme', 'light');

// 파생 atom - 다른 atom에서 계산된 값
const userNameAtom = atom((get) => {
  const user = get(userAtom);
  return user?.name || '게스트';
});

// 읽기/쓰기 모두 가능한 atom
const userStatsAtom = atom(
  (get) => {
    const user = get(userAtom);
    // API 호출이나 복잡한 계산 로직
    return user ? { loginCount: 5, lastLogin: new Date() } : null;
  },
  (get, set, newStats: { loginCount: number }) => {
    // atom 업데이트 로직
    set(userStatsAtom, newStats);
  }
);

// 비동기 atom - Suspense와 함께 사용
const postsAtom = atom(async () => {
  const response = await fetch('/api/posts');
  return response.json();
});

function UserGreeting() {
  // 읽기 전용으로 사용
  const userName = useAtomValue(userNameAtom);
  const theme = useAtomValue(themeAtom);

  return <h1 className={theme}>안녕하세요, {userName}님!</h1>;
}

function ThemeToggle() {
  // 쓰기 전용으로 사용 (리렌더링 최소화)
  const setTheme = useSetAtom(themeAtom);

  return (
    <button onClick={() => setTheme((prev) => prev === 'light' ? 'dark' : 'light')}>
      테마 변경
    </button>
  );
}

function UserDashboard() {
  // 읽기/쓰기 모두 필요할 때
  const [user, setUser] = useAtom(userAtom);

  const handleLogin = async () => {
    const userData = await loginAPI();
    setUser(userData);
  };

  return <div>{/* 대시보드 UI */}</div>;
}

Recoil - Facebook의 실험적 상태관리

Recoil은 Facebook(Meta)에서 개발한 상태관리 라이브러리로, React의 동시성 모드(Concurrent Mode)를 염두에 두고 설계됐어요.

핵심 특징:

  • Graph 구조의 상태 의존성 관리
  • 다양한 내장 유틸리티 제공
  • React DevTools와 긴밀한 통합
  • 아직 실험 단계로 API 변경 가능성 존재
// Recoil atom과 selector 예시
import { atom, selector, useRecoilState, useRecoilValue } from 'recoil';

// Atom 정의 - 고유한 key 필수
const userState = atom({
  key: 'userState', // 전역적으로 고유해야 함
  default: null as { name: string; email: string } | null,
});

const todoListState = atom({
  key: 'todoListState',
  default: [] as Array<{ id: number; text: string; completed: boolean }>,
});

// Selector - 파생 상태 계산
const todoStatsState = selector({
  key: 'todoStatsState',
  get: ({ get }) => {
    const todoList = get(todoListState);
    const totalNum = todoList.length;
    const completedNum = todoList.filter((item) => item.completed).length;
    const uncompletedNum = totalNum - completedNum;
    const percentCompleted = totalNum === 0 ? 0 : (completedNum / totalNum) * 100;

    return {
      totalNum,
      completedNum,
      uncompletedNum,
      percentCompleted,
    };
  },
});

// 비동기 selector
const currentUserDataState = selector({
  key: 'currentUserData',
  get: async ({ get }) => {
    const user = get(userState);
    if (!user) return null;

    const response = await fetch(`/api/users/${user.email}`);
    return response.json();
  },
});

function TodoStats() {
  // selector를 일반 atom처럼 사용
  const stats = useRecoilValue(todoStatsState);

  return (
    <div>
      <p>전체: {stats.totalNum}</p>
      <p>완료: {stats.completedNum}</p>
      <p>진행률: {Math.round(stats.percentCompleted)}%</p>
    </div>
  );
}

function TodoList() {
  const [todoList, setTodoList] = useRecoilState(todoListState);

  const addTodo = (text: string) => {
    setTodoList((oldTodoList) => [
      ...oldTodoList,
      { id: Date.now(), text, completed: false },
    ]);
  };

  const toggleTodo = (id: number) => {
    setTodoList((oldTodoList) =>
      oldTodoList.map((todo) =>
        todo.id === id ? { ...todo, completed: !todo.completed } : todo
      )
    );
  };

  return (
    <div>
      {todoList.map((todo) => (
        <div key={todo.id} onClick={() => toggleTodo(todo.id)}>
          {todo.text} {todo.completed ? '✓' : ''}
        </div>
      ))}
    </div>
  );
}

번들 사이즈와 성능 비교

실제 프로젝트에서 가장 중요한 기준 중 하나가 바로 번들 사이즈예요. 초기 로딩 속도와 직결되기 때문이죠.

번들 사이즈 비교 (minified + gzipped):

  • Zustand: 1.2KB (가장 경량)
  • Jotai: 3.3KB (경량)
  • Recoil: 21KB (상대적으로 무거움)

성능 측면에서 세 라이브러리 모두 불필요한 리렌더링을 방지하는 최적화 메커니즘을 제공해요.

// 성능 최적화 비교 예시

// 1. Zustand - selector로 필요한 값만 구독
const useUserStore = create<UserStore>((set) => ({
  user: { name: 'Kim', age: 30, email: 'kim@test.com' },
  settings: { theme: 'dark', notifications: true },
  updateUser: (updates) => set((state) => ({ 
    user: { ...state.user, ...updates } 
  })),
}));

function UserName() {
  // name이 변경될 때만 리렌더링 (age나 email 변경 시 리렌더링 안됨)
  const name = useUserStore((state) => state.user.name);
  return <div>{name}</div>;
}

// 얕은 비교로 최적화
function UserInfo() {
  const user = useUserStore(
    (state) => ({ name: state.user.name, age: state.user.age }),
    shallow // zustand/shallow 임포트 필요
  );
  return <div>{user.name}, {user.age}</div>;
}

// 2. Jotai - atom을 작게 나누어 관리
const userNameAtom = atom('Kim');
const userAgeAtom = atom(30);
const userEmailAtom = atom('kim@test.com');

function UserNameJotai() {
  // userNameAtom이 변경될 때만 리렌더링
  const name = useAtomValue(userNameAtom);
  return <div>{name}</div>;
}

// 3. Recoil - atomFamily로 동적 atom 생성
const todoItemState = atomFamily({
  key: 'todoItem',
  default: (id: number) => ({ id, text: '', completed: false }),
});

function TodoItem({ id }: { id: number }) {
  // 해당 id의 todo만 구독 - 다른 todo 변경 시 리렌더링 안됨
  const [todo, setTodo] = useRecoilState(todoItemState(id));

  return (
    <div onClick={() => setTodo({ ...todo, completed: !todo.completed })}>
      {todo.text}
    </div>
  );
}

성능 측정 팁:
React DevTools Profiler를 사용하여 각 라이브러리의 리렌더링 패턴을 직접 확인해보세요. 대부분의 경우 Zustand와 Jotai가 더 적은 리렌더링을 발생시켜요.

개발 경험과 러닝커브

각 라이브러리의 학습 난이도와 개발 생산성을 비교해볼게요.

Zustand의 장점

  • 가장 낮은 러닝커브: 기존 useState 사용법과 거의 동일
  • 보일러플레이트 최소: Provider, action 타입 정의 불필요
  • 직관적인 API: 5분 만에 시작 가능

Jotai의 장점

  • 선언적 접근: React의 사고방식과 일치
  • 유연한 조합: 작은 atom을 조합하여 복잡한 상태 표현
  • 점진적 도입: 필요한 부분부터 하나씩 적용 가능

Recoil의 단점

  • 복잡한 설정: RecoilRoot 필수, key 관리 번거로움
  • 실험 단계: 프로덕션 사용에 주의 필요
  • 상대적으로 큰 번들: 작은 프로젝트에는 오버스펙
// 동일한 기능을 세 라이브러리로 구현 비교

// 시나리오: 장바구니 관리
interface CartItem {
  id: number;
  name: string;
  price: number;
  quantity: number;
}

// === Zustand 구현 ===
const useCartStore = create<{
  items: CartItem[];
  addItem: (item: Omit<CartItem, 'quantity'>) => void;
  removeItem: (id: number) => void;
  updateQuantity: (id: number, quantity: number) => void;
  getTotalPrice: () => number;
}>((set, get) => ({
  items: [],

  addItem: (item) => set((state) => {
    const existing = state.items.find(i => i.id === item.id);
    if (existing) {
      return {
        items: state.items.map(i =>
          i.id === item.id ? { ...i, quantity: i.quantity + 1 } : i
        ),
      };
    }
    return { items: [...state.items, { ...item, quantity: 1 }] };
  }),

  removeItem: (id) => set((state) => ({
    items: state.items.filter(i => i.id !== id),
  })),

  updateQuantity: (id, quantity) => set((state) => ({
    items: state.items.map(i =>
      i.id === id ? { ...i, quantity } : i
    ),
  })),

  getTotalPrice: () => {
    return get().items.reduce((sum, item) => sum + item.price * item.quantity, 0);
  },
}));

// === Jotai 구현 ===
const cartItemsAtom = atom<CartItem[]>([]);

const cartActionsAtom = atom(
  null,
  (get, set, action: { type: string; payload: any }) => {
    const items = get(cartItemsAtom);

    switch (action.type) {
      case 'ADD_ITEM': {
        const item = action.payload;
        const existing = items.find(i => i.id === item.id);
        if (existing) {
          set(cartItemsAtom, items.map(i =>
            i.id === item.id ? { ...i, quantity: i.quantity + 1 } : i
          ));
        } else {
          set(cartItemsAtom, [...items, { ...item, quantity: 1 }]);
        }
        break;
      }
      case 'REMOVE_ITEM':
        set(cartItemsAtom, items.filter(i => i.id !== action.payload));
        break;
      case 'UPDATE_QUANTITY':
        set(cartItemsAtom, items.map(i =>
          i.id === action.payload.id ? { ...i, quantity: action.payload.quantity } : i
        ));
        break;
    }
  }
);

const totalPriceAtom = atom((get) => {
  const items = get(cartItemsAtom);
  return items.reduce((sum, item) => sum + item.price * item.quantity, 0);
});

// === Recoil 구현 ===
const cartItemsState = atom({
  key: 'cartItems',
  default: [] as CartItem[],
});

const totalPriceState = selector({
  key: 'cartTotalPrice',
  get: ({ get }) => {
    const items = get(cartItemsState);
    return items.reduce((sum, item) => sum + item.price * item.quantity, 0);
  },
});

// 사용 예시는 비슷하지만 Zustand가 가장 간결해요

실전 활용 시나리오별 추천

프로젝트 특성에 따라 최적의 라이브러리가 달라요. 실무에서 자주 마주치는 시나리오별로 추천해드릴게요.

작고 빠른 프로젝트 → Zustand

추천 상황:

  • MVP나 프로토타입 개발
  • 번들 사이즈가 중요한 모바일 웹
  • 간단한 전역 상태만 필요한 경우
// 간단한 모달 관리
const useModalStore = create<{
  isOpen: boolean;
  content: React.ReactNode;
  openModal: (content: React.ReactNode) => void;
  closeModal: () => void;
}>((set) => ({
  isOpen: false,
  content: null,
  openModal: (content) => set({ isOpen: true, content }),
  closeModal: () => set({ isOpen: false, content: null }),
}));

// 어디서든 쉽게 사용
function AnyComponent() {
  const openModal = useModalStore((state) => state.openModal);

  return (
    <button onClick={() => openModal(<div>모달 내용</div>)}>
      모달 열기
    </button>
  );
}

복잡한 상태 의존성 → Jotai

추천 상황:

  • 상태 간 의존성이 복잡한 대시보드
  • 폼 상태 관리가 많은 어드민 페이지
  • 서버 상태와 클라이언트 상태를 함께 관리
// 필터와 검색이 있는 상품 목록
const searchQueryAtom = atom('');
const categoryFilterAtom = atom<string[]>([]);
const priceRangeAtom = atom({ min: 0, max: 1000000 });

// 모든 필터를 조합한 파생 atom
const filteredProductsAtom = atom(async (get) => {
  const query = get(searchQueryAtom);
  const categories = get(categoryFilterAtom);
  const priceRange = get(priceRangeAtom);

  // API 호출 - 의존성이 변경될 때만 재실행
  const response = await fetch('/api/products', {
    method: 'POST',
    body: JSON.stringify({ query, categories, priceRange }),
  });

  return response.json();
});

function ProductList() {
  // Suspense와 함께 사용
  const products = useAtomValue(filteredProductsAtom);

  return (
    <div>
      {products.map(product => (
        <ProductCard key={product.id} product={product} />
      ))}
    </div>
  );
}

대규모 팀 프로젝트 → Zustand or Jotai

Recoil 사용 시 주의:
Recoil은 아직 1.0 버전이 나오지 않았고, API 변경 가능성이 있어 장기 프로젝트에는 리스크가 있어요. Meta 내부에서는 활발히 사용하지만, 외부 커뮤니티 지원은 Zustand나 Jotai에 비해 부족해요.

// 팀 협업을 위한 Zustand 구조화 예시
// stores/user.store.ts
export const useUserStore = create<UserStore>((set) => ({
  // ... user 관련 상태
}));

// stores/cart.store.ts
export const useCartStore = create<CartStore>((set) => ({
  // ... cart 관련 상태
}));

// stores/index.ts
export { useUserStore } from './user.store';
export { useCartStore } from './cart.store';

// 명확한 구조로 팀원들이 쉽게 이해 가능

마이그레이션과 통합 전략

기존 프로젝트에 새로운 상태관리를 도입하거나, Redux에서 마이그레이션하는 방법을 알아볼게요.

Redux에서 Zustand로 마이그레이션

Zustand는 Redux와 철학이 비슷하여 마이그레이션이 가장 수월해요.

// Before: Redux
// actions.ts
export const setUser = (user) => ({ type: 'SET_USER', payload: user });

// reducer.ts
const userReducer = (state = null, action) => {
  switch (action.type) {
    case 'SET_USER':
      return action.payload;
    default:
      return state;
  }
};

// After: Zustand (훨씬 간결)
const useUserStore = create((set) => ({
  user: null,
  setUser: (user) => set({ user }),
}));

// 사용법도 거의 동일
// Before: const user = useSelector(state => state.user);
// After: const user = useUserStore(state => state.user);

Context API와 병행 사용

점진적 도입을 위해 기존 Context API와 함께 사용할 수 있어요.

// 기존 Context API (유지)
const ThemeContext = React.createContext<'light' | 'dark'>('light');

// 새로운 기능은 Zustand로 (추가)
const useNotificationStore = create<{
  notifications: Array<{ id: string; message: string }>;
  addNotification: (message: string) => void;
}>((set) => ({
  notifications: [],
  addNotification: (message) => set((state) => ({
    notifications: [...state.notifications, { id: Date.now().toString(), message }],
  })),
}));

function App() {
  return (
    <ThemeContext.Provider value="light">
      {/* Context와 Zustand 동시 사용 가능 */}
      <YourApp />
    </ThemeContext.Provider>
  );
}

여러 라이브러리 혼용 (비추천이지만 가능)

특별한 경우 Jotai와 Zustand를 함께 사용할 수도 있어요. 하지만 팀 혼란을 야기할 수 있으니 가급적 하나로 통일하는 게 좋아요.

// Zustand: 글로벌 앱 상태
const useAppStore = create((set) => ({
  user: null,
  theme: 'light',
}));

// Jotai: 컴포넌트 로컬 상태 (복잡한 폼 등)
const formDataAtom = atom({
  name: '',
  email: '',
  preferences: {},
});

// 명확한 용도 분리가 필요해요

흔한 실수와 주의사항

실무에서 자주 발생하는 안티패턴과 해결 방법을 소개할게요.

실수 1: Zustand에서 객체 상태 직접 수정

// ❌ 잘못된 예시 - 상태 직접 변경
const useStore = create((set) => ({
  user: { name: 'Kim', settings: { theme: 'dark' } },
  updateTheme: (theme) => set((state) => {
    state.user.settings.theme = theme; // 직접 수정 금지!
    return state;
  }),
}));

// ✅ 올바른 예시 - 불변성 유지
const useStore = create((set) => ({
  user: { name: 'Kim', settings: { theme: 'dark' } },
  updateTheme: (theme) => set((state) => ({
    user: {
      ...state.user,
      settings: {
        ...state.user.settings,
        theme, // 새 객체 생성
      },
    },
  })),
}));

// 또는 Immer 미들웨어 사용 (추천)
import { immer } from 'zustand/middleware/immer';

const useStore = create(
  immer((set) => ({
    user: { name: 'Kim', settings: { theme: 'dark' } },
    updateTheme: (theme) => set((state) => {
      state.user.settings.theme = theme; // Immer가 불변성 처리
    }),
  }))
);

실수 2: Jotai atom의 과도한 분리

// ❌ 너무 세밀하게 분리 - 관리 부담 증가
const userNameAtom = atom('');
const userEmailAtom = atom('');
const userAgeAtom = atom(0);
const userPhoneAtom = atom('');
const userAddressAtom = atom('');
// ... 20개의 atom

// ✅ 적절한 단위로 그룹화
const userProfileAtom = atom({
  name: '',
  email: '',
  age: 0,
});

const userContactAtom = atom({
  phone: '',
  address: '',
});

// 필요한 경우만 파생 atom 생성
const userDisplayNameAtom = atom((get) => {
  const profile = get(userProfileAtom);
  return profile.name || '익명';
});

실수 3: Recoil의 key 중복

// ❌ 잘못된 예시 - 동적으로 생성되는 key
function TodoItem({ id }) {
  const todoState = atom({
    key: `todo-${id}`, // 컴포넌트가 렌더링될 때마다 새로 생성됨
    default: { text: '', completed: false },
  });
  // ... 에러 발생 가능
}

// ✅ 올바른 예시 - atomFamily 사용
const todoItemState = atomFamily({
  key: 'todoItem', // 고정된 key
  default: (id: number) => ({ text: '', completed: false }),
});

function TodoItem({ id }) {
  const [todo, setTodo] = useRecoilState(todoItemState(id));
  // ... 정상 작동
}

실수 4: 불필요한 전역 상태 사용

// ❌ 모든 상태를 전역으로 - 과도한 사용
const useFormStore = create((set) => ({
  tempInputValue: '', // 이건 useState로 충분
  hoveredItemId: null, // 이것도 useState로
  // ...
}));

// ✅ 적절한 구분
function MyForm() {
  // 로컬 상태는 useState 사용
  const [tempValue, setTempValue] = useState('');

  // 다른 컴포넌트와 공유 필요한 상태만 전역
  const submitForm = useFormStore((state) => state.submitForm);

  return <form>{/* ... */}</form>;
}

성능 최적화 실전 팁

프로덕션 환경에서 반드시 고려해야 할 최적화 방법들이에요.

Zustand 성능 최적화

// 1. selector로 필요한 값만 구독
const useStore = create((set) => ({
  user: { name: 'Kim', email: 'kim@test.com', age: 30 },
  settings: { theme: 'dark', lang: 'ko' },
}));

function UserName() {
  // ❌ 전체 구독 - user나 settings 변경 시 모두 리렌더링
  const state = useStore();
  return <div>{state.user.name}</div>;

  // ✅ 필요한 값만 구독
  const name = useStore((state) => state.user.name);
  return <div>{name}</div>;
}

// 2. 여러 값이 필요할 때 shallow 비교
import { shallow } from 'zustand/shallow';

function UserInfo() {
  const { name, email } = useStore(
    (state) => ({ name: state.user.name, email: state.user.email }),
    shallow // 객체 내용 비교
  );
  return <div>{name} ({email})</div>;
}

// 3. 액션은 분리하여 구독
function UpdateButton() {
  // 상태 변화에 영향 받지 않음
  const updateUser = useStore((state) => state.updateUser);
  return <button onClick={updateUser}>업데이트</button>;
}

Jotai 성능 최적화

// 1. atom 세분화로 리렌더링 최소화
const userNameAtom = atom('Kim');
const userEmailAtom = atom('kim@test.com');

function UserName() {
  // email이 변경되어도 리렌더링 안됨
  const name = useAtomValue(userNameAtom);
  return <div>{name}</div>;
}

// 2. 쓰기 전용 atom 활용
function UserForm() {
  const setUserName = useSetAtom(userNameAtom); // 리렌더링 없음

  return (
    <input onChange={(e) => setUserName(e.target.value)} />
  );
}

// 3. 비동기 atom에 Suspense 사용
const postsAtom = atom(async () => {
  const res = await fetch('/api/posts');
  return res.json();
});

function PostList() {
  const posts = useAtomValue(postsAtom);
  return <div>{posts.map(/* ... */)}</div>;
}

function App() {
  return (
    <Suspense fallback={<Loading />}>
      <PostList />
    </Suspense>
  );
}

공통 최적화 기법

// 1. 메모이제이션 활용
function ExpensiveComponent() {
  const data = useStore((state) => state.data);

  // 계산 비용이 큰 작업은 useMemo
  const processedData = useMemo(() => {
    return heavyCalculation(data);
  }, [data]);

  return <div>{processedData}</div>;
}

// 2. 디바운싱으로 불필요한 업데이트 방지
import { debounce } from 'lodash';

const useSearchStore = create((set) => ({
  query: '',
  setQuery: debounce((query: string) => {
    set({ query });
    // API 호출도 디바운싱됨
  }, 300),
}));

// 3. 조건부 구독
function ConditionalSubscription({ shouldSubscribe }) {
  const data = shouldSubscribe 
    ? useStore((state) => state.data)
    : null;

  return <div>{data}</div>;
}

결론

Jotai, Zustand, Recoil 중 어떤 라이브러리를 선택해야 할까요? 정답은 프로젝트의 특성과 팀의 상황에 따라 다르지만, 실무 경험을 바탕으로 다음과 같이 추천드려요.

Zustand를 선택하세요:

  • 빠른 개발과 낮은 러닝커브가 중요할 때
  • 번들 사이즈를 최소화해야 할 때
  • Redux 스타일의 단일 스토어 패턴을 선호할 때
  • 간단하지만 확장 가능한 솔루션이 필요할 때

Jotai를 선택하세요:

  • 복잡한 상태 의존성을 우아하게 관리하고 싶을 때
  • React의 선언적 사고방식을 선호할 때
  • Suspense와 Concurrent Mode를 적극 활용하려 할 때
  • 점진적으로 상태관리를 개선하고 싶을 때

Recoil은 신중하게:

  • Meta 생태계를 적극 활용하는 대규모 프로젝트
  • 실험적 기능을 감수하고 최신 React 기능을 써볼 의향이 있을 때
  • 하지만 장기 프로젝트라면 안정성이 검증된 Zustand나 Jotai 추천

개인적으로는 대부분의 프로젝트에 Zustand를 먼저 고려하고, 상태 의존성이 복잡해지면 Jotai로 전환하는 전략을 추천해요. 두 라이브러리 모두 경량이고 배우기 쉬우며, 필요에 따라 병행 사용도 가능하니까요.

실무 적용 로드맵

  1. 1주차: 작은 기능 하나를 선택한 라이브러리로 구현
  2. 2주차: 팀원들과 코드 리뷰하며 패턴 정립
  3. 1개월차: 점진적으로 적용 범위 확대
  4. 3개월차: 성능 측정 및 최적화, 필요시 다른 라이브러리 검토

함께 보면 좋은 주제

  • React Query와 상태관리 라이브러리 통합: 서버 상태는 React Query, 클라이언트 상태는 Zustand/Jotai로 분리하는 패턴
  • 상태관리 아키텍처 설계: 도메인별 스토어 분리, 의존성 관리, 테스트 전략
  • Next.js와 상태관리: SSR 환경에서 Hydration 이슈 해결, 서버 컴포넌트와의 통합

마지막으로, 어떤 라이브러리를 선택하든 일관성 있는 패턴을 유지하는 것이 가장 중요해요. 팀 전체가 이해하고 유지보수할 수 있는 코드가 최고의 코드랍니다. 여러분의 프로젝트에 딱 맞는 선택을 하시길 바라요! 🚀