최근 프로젝트를 진행하면서 다양한 상태관리 라이브러리를 사용해봤습니다.
그중에서도 가장 자주 사용한 건 Zustand였고, 최근에는 Redux Toolkit도 경험해봤습니다.
사실 React에서는 Context API만으로도 상태 공유가 가능합니다.
그런데도 사람들이 굳이 Zustand나 Redux 같은 상태관리 라이브러리를 사용하는 이유는 뭘까요?
Context API를 피하는 이유
Context API는 Provider로 컴포넌트를 감싸서 상태를 공유할 수 있게 해줍니다. 문제는 하나의 값만 바뀌더라도 value 전체가 바뀐 것으로 간주되어, 이를 구독하고 있는 모든 컴포넌트가 리렌더링된다는 점입니다.
이 문제를 해결하려면 memoization, context 분리, useMemo 등을 활용해야 하지만, 솔직히 번거롭고 복잡한 작업이 많습니다.
그래서 자연스럽게 Zustand 같은 라이브러리로 눈이 가게 됩니다.
그런데 나 Zustand 잘못 쓰고 있었더라
Zustand는 리렌더를 최소화해서 성능 최적화에 유리하다고 알려져 있습니다.
저도 이 장점 때문에 사용해왔습니다. 그런데 최근에야 깨달았어요. 제가 Zustand를 잘못 쓰고 있었다는 걸요.
예전에는 아래처럼 상태를 한번에 가져와서 사용했습니다.
const { checkAuth, isCheckingAuth, accessToken } = useAuthStore();
겉보기에는 깔끔해 보이지만, 이 방식은 실제로 성능상 좋지 않습니다.
그 이유는 Zustand가 상태 변경 여부를 판단할 때 객체의 내부 값이 아닌 참조값을 기준으로 비교하기 때문입니다.
예를 들어 checkAuth, isCheckingAuth, accessToken을 구조분해 할당으로 한 번에 가져오면, 이 중 하나라도 값이 바뀌면 전체 객체의 참조가 변경됩니다.
만약 다른 컴포넌트에서 여기서 꺼내오지도 않은 authUser를
const { authUser } = useAuthStore();
처럼 가져왔다면, 이 컴포넌트는 상태 전체를 구독하는 것과 같아서,
위 컴포넌트에서 isCheckingAuth가 변경되는 등 전혀 관련 없는 상태 변화에도 불필요하게 리렌더링됩니다.
즉, 객체를 통째로 구독하는 방식은 내부 값 하나만 바뀌어도 전체가 바뀐 것으로 간주되기 때문에, Zustand의 성능 최적화 이점을 제대로 누릴 수 없습니다.
Redux Toolkit에서도 동일한 원칙이 적용됩니다.
최근까지 다음처럼 주로 썼습니다.
const { accessToken, isCheckingAuth } = useSelector(
(state: RootState) => state.auth
);
마찬가지로 이 방식도 성능 면에서는 비효율적입니다.
왜냐하면 state.auth 객체 자체가 참조 변경으로 간주되기 때문에, 내부 필드가 하나만 바뀌어도 이 값을 사용하는 모든 컴포넌트가 리렌더링되기 때문입니다.
올바른 사용법
리렌더링을 최소화하려면 아래처럼 각각의 상태를 선택자로 분리해서 사용하는 것이 좋습니다.
이렇게 하면 오직 선택한 값이 변경될 때만 컴포넌트가 리렌더링됩니다.
const checkAuth = useAuthStore(state => state.checkAuth);
const isCheckingAuth = useAuthStore(state => state.isCheckingAuth);
const accessToken = useAuthStore(state => state.accessToken);
Redux Toolkit을 쓸 때도 다음과 같이 필드 단위로 셀렉터를 분리하는 것이 좋습니다.
const accessToken = useSelector((state: RootState) => state.auth.accessToken);
const isCheckingAuth = useSelector((state: RootState) => state.auth.isCheckingAuth);
그런데 매번 이렇게 분리하는 게 귀찮다?
사용하는 값이 많아질수록 위처럼 하나하나 나눠서 쓰는 건 비효율적일 수 있습니다.
이럴 때 사용할 수 있는 게 바로 useShallow입니다.
Zustand는 공식적으로 useShallow이라는 훅을 제공합니다.
이 훅을 활용하면 여러 값을 객체 형태로 한번에 가져오면서도, 내부 값이 변경될 때만 리렌더가 발생하도록 해줍니다.
https://zustand.docs.pmnd.rs/hooks/use-shallow
useShallow ⚛️ - Zustand
How to memoize selector functions
zustand.docs.pmnd.rs
import { useShallow } from 'zustand/shallow';
const { checkAuth, isCheckingAuth, accessToken } = useAuthStore(
useShallow((state) => ({
checkAuth: state.checkAuth,
isCheckingAuth: state.isCheckingAuth,
accessToken: state.accessToken,
}))
);
이렇게 하면 checkAuth, isCheckingAuth, accessToken 중 실제로 변경된 값이 있을 때만 해당 컴포넌트가 리렌더링됩니다.
Redux에서는 useSelector와 함께 shallowEqual을 사용하면 비슷한 방식으로 최적화할 수 있습니다.
이렇게 하면 accessToken이나 isCheckingAuth 중 하나만 변경될 경우에만 해당 컴포넌트가 리렌더링됩니다.
즉, Redux에서도 객체 전체가 아니라 내부 값 변경 여부를 기준으로 리렌더링 여부를 판단하게 되는 셈입니다.
import { shallowEqual, useSelector } from 'react-redux';
const { accessToken, isCheckingAuth } = useSelector(
(state: RootState) => ({
accessToken: state.auth.accessToken,
isCheckingAuth: state.auth.isCheckingAuth,
}),
shallowEqual
);
결론적으로, 구조분해 할당은 피하고 필요한 상태만 선택적으로 구독하는 것이 좋습니다.
여러 값을 한꺼번에 가져와야 할 때는 Zustand에서는 useShallow 훅을, Redux Toolkit에서는 shallowEqual을 사용해 얕은 비교를 적용하는 것이 성능 최적화에 도움이 됩니다.
'React > 정리' 카테고리의 다른 글
ESLint로 리액트 import 순서 자동 정리하기 (0) | 2025.05.30 |
---|---|
URL 기반 모달 라우팅 구현 (0) | 2025.05.23 |
React Query로 페이징된 데이터 로드 구현하기 (1) | 2024.08.31 |
useRef로 DOM 요소 참조 및 관리하기 (1) | 2024.08.31 |
React Query를 통한 데이터 관리 (0) | 2024.08.22 |