특정 범위에서만 전역상태 사용하기 (Zustand + Context API)
전역 상태 관리, 정말 필요한 곳에서만 사용하고 있을까?
전역 상태 관리의 문제점들
프론트엔드 개발을 하다 보면 컴포넌트 간 상태 공유가 필요한 순간들이 있습니다. 이때 대부분 전역 상태 관리 라이브러리를 도입하게 되는데, 시간이 지날수록 다음과 같은 문제들을 마주하게 됩니다.
1. 예측하기 어려운 상태 변화
전역 상태는 어느 컴포넌트에서든 접근하고 변경할 수 있기 때문에, 특정 상태가 어디에서 사용되고 변경되는지 파악하기 어려워집니다.
// 문제 상황 예시// 부모 컴포넌트에서 Practice 모달 상태를 변경const PracticeMainPage = () => {const setPracticeMode = useGlobalStore((state) => state.setPracticeMode);const resetPracticeData = useGlobalStore((state) => state.resetPracticeData);useEffect(() => {// Practice 페이지 진입 시 상태 초기화setPracticeMode('beginner');resetPracticeData();}, []);};// 이후 다른 개발자가 작성한 컴포넌트const UnrelatedSidebar = () => {const practiceMode = useGlobalStore((state) => state.practiceMode);const setPracticeMode = useGlobalStore((state) => state.setPracticeMode);// 이 코드가 Practice 페이지에 영향을 미치는지 알기 어려움const handleSomeEvent = () => {if (practiceMode === 'beginner') {setPracticeMode('advanced'); // 의도치 않은 상태 변경!}};};
이런 상황에서 버그가 발생하면 어느 컴포넌트에서 상태를 변경했는지 추적하기 매우 어려워집니다.
2. 상태 초기화 관리의 복잡성
지역 상태(useState)는 컴포넌트가 언마운트될 때 자동으로 초기화됩니다. 반면, 전역 상태는 개발자가 명시적으로 초기화 시점을 관리해야 합니다. (초기화가 필요하다면)
// 이런 초기화 로직이 필요합니다const PracticeComponent = () => {const resetPracticeStore = useGlobalStore((state) => state.resetPracticeStore);useEffect(() => {return () => {// 컴포넌트 언마운트 시 수동으로 초기화resetPracticeStore();};}, []);// 이 초기화를 깜빡하면 다음에 Practice 페이지 진입 시 이전 상태가 남아있음};// 더 복잡한 경우: 조건부 초기화const PracticeModal = () => {const isOpen = useGlobalStore((state) => state.isOpen);const practiceData = useGlobalStore((state) => state.practiceData);const resetOnlyModal = useGlobalStore((state) => state.resetOnlyModal);const resetPracticeStore = useGlobalStore((state) => state.resetPracticeStore);useEffect(() => {return () => {// 모달만 닫을지, 전체 데이터를 리셋할지 판단이 어려움if (someCondition) {resetOnlyModal();} else {resetPracticeStore();}};}, []);};
3. 디버깅의 어려움
1번과 일맥 상통하는 내용입니다. 여러 컴포넌트에서 동일한 상태를 변경할 수 있기 때문에, 버그 발생 시 원인을 추적하기 매우 어려워집니다.
// Practice 모달이 갑자기 닫히는 버그가 발생// 의심되는 원인 1: Practice 컴포넌트const PracticeMain = () => {const closePracticeModal = useGlobalStore((state) => state.closePracticeModal);};// 의심되는 원인 2: Header 컴포넌트const Header = () => {const closePracticeModal = useGlobalStore((state) => state.closePracticeModal);};// 의심되는 원인 3: Error Boundaryconst ErrorBoundary = () => {const resetAllModals = useGlobalStore((state) => state.resetAllModals);};// 누가 언제 상태를 변경했는지 알기 어렵다.
이런 문제들을 겪으면서 "Practice 기능의 상태는 Practice 컴포넌트 내에서만 관리할 수 없을까?"라는 고민이 생겼습니다.
Zustand + Context API: 범위가 제한된 상태 관리
Zustand 공식 문서에서 발견한 해결책을 프로젝트에 적용해보기로 했습니다. 이 방법을 통해 Zustand의 편리함은 유지하면서도 상태의 범위를 명확히 제한할 수 있었습니다. 해당 방법을 소개하겠습니다.
Context Provider로 제한된 범위에서 스토어 생성
// PracticeProvider.tsximport { createContext, useRef } from 'react';import { createStore } from 'zustand';import { devtools } from 'zustand/middleware';export interface PracticeState {isPracticeModalOpen: boolean;practiceModalContent: React.ReactNode | null;showPracticeModal: (practiceModalContent: React.ReactNode) => void;closePracticeModal: () => void;}const initialState = {isPracticeModalOpen: false,practiceModalContent: null,};const createPracticeStore = () => {return createStore<PracticeState>()(devtools((set) => ({...initialState,showPracticeModal: (practiceModalContent: React.ReactNode) =>set({ isPracticeModalOpen: true, practiceModalContent }),closePracticeModal: () => set({ isPracticeModalOpen: false, practiceModalContent: null }),}),{ name: 'practice-store' }));};type PracticeStore = ReturnType<typeof createPracticeStore>;export const PracticeContext = createContext<PracticeStore | null>(null);export default function PracticeProvider({ children }: { children: React.ReactNode }) {const store = useRef(createPracticeStore());return <PracticeContext.Provider value={store.current}>{children}</PracticeContext.Provider>;}
여기서 눈여겨볼 만한 점은, useRef에 store를 생성해 넣는다는 점입니다. useRef 내부 데이터는 변경되어도 리렌더링을 발생시키지 않기 때문에 context API를 사용하면서도 리렌더링 이슈를 피할 수 있습니다.
커스텀 훅 생성
// usePracticeStore.tsimport { useContext } from 'react';import { useStore } from 'zustand';import { PracticeContext, PracticeState } from './PracticeProvider';const usePracticeStore = <T>(selector: (state: PracticeState) => T): T => {const store = useContext(PracticeContext);if (!store) {throw new Error('usePracticeStore는 PracticeProvider 내부에서만 사용할 수 있습니다.');}return useStore(store, selector);};export default usePracticeStore;
이제 해당 context를 import하여 store 훅을 만들어줍니다. 이 훅은 PracticeProvider 외부에서 사용하면 error를 throw 합니다.
컴포넌트에서 사용
// 제한할 범위에서 Provider로 감싸주기import { PracticeProvider } from '@/store/PracticeProvider';import PracticeContent from './PracticeContent';export default function PracticeLayout() {return (<PracticeProvider><PracticeContent /></PracticeProvider>);}// components/PracticeContent.tsximport usePracticeStore from '@/hooks/usePracticeStore';const PracticeContent = () => {const showPracticeModal = usePracticeStore((state) => state.showPracticeModal);const setFooterContent = usePracticeStore((state) => state.setFooterContent);const handleShowModal = () => {showPracticeModal(<div><h2>연습 모달</h2><p>이 모달은 Practice 범위 내에서만 관리됩니다.</p></div>);};return (<div><button onClick={handleShowModal}>연습 모달 열기</button></div>);};
기존 방식과의 비교
Before: 전역 Zustand
// 전역 스토어 - 앱 어디서든 접근 가능const useGlobalStore = create<GlobalState>((set) => ({// Practice 기능에서만 사용하는 상태들이 전역에 노출isPracticeModalOpen: false,practiceModalContent: null,}));// 문제: Practice와 관련 없는 컴포넌트에서도 접근 가능const UnrelatedComponent = () => {const closePracticeModal = useGlobalStore((state) => state.closePracticeModal);// 의도하지 않은 접근// ...};
After: Context + Zustand
// Practice 관련 상태는 Practice 범위 내에서만 접근 가능const PracticeComponent = () => {const showPracticeModal = usePracticeStore((state) => state.showPracticeModal);// 안전한 접근// ...};// Practice Provider 밖에서는 접근 불가const UnrelatedComponent = () => {const showPracticeModal = usePracticeStore((state) => state.showPracticeModal);// 에러 발생!// ...};
실제 프로젝트에서 얻은 이점
1. 상태 변경 추적의 명확성
Provider 내부에서만 확인하면 되기 때문에 상태 변경 추적이 더 쉬워졌습니다.
const PracticeComponent = () => {// Practice 관련 상태는 오직 이 Provider 내에서만 변경 가능const showPracticeModal = usePracticeStore((state) => state.showPracticeModal);const closePracticeModal = usePracticeStore((state) => state.closePracticeModal);// 버그가 발생하면 이 Provider 내부만 확인하면 됩니다.};
2. 자동화된 상태 초기화
Provider가 언마운트되면 상태도 자동으로 소멸합니다.
const PracticePage = () => {return (<PracticeProvider>{/* 이 컴포넌트 트리가 언마운트되면 */}{/* Practice 관련 상태도 모두 자동으로 정리됨 */}<PracticeContent /></PracticeProvider>);};// 더 이상 수동 초기화 코드가 필요 없음!
3. Context API의 리렌더링 문제 해결
일반적인 Context API 사용 시 발생하는 불필요한 리렌더링이 useRef를 통해 해결되었습니다.
// 일반적인 Context 방식의 리렌더링const BadProvider = ({ children }) => {const [state, setState] = useState({ count: 0, name: 'test' });// state가 변경될 때마다 새로운 객체 생성// 모든 Consumer 컴포넌트가 리렌더링return <Context.Provider value={state}>{children}</Context.Provider>;};// useRef로 해결한 방식const PracticeProvider = ({ children }) => {const storeRef = useRef(createPracticeStore());// storeRef.current는 항상 동일한 참조값 유지// Context Provider 자체는 리렌더링되지 않음// 실제 리렌더링은 Zustand의 selector가 제어return (<PracticeContext.Provider value={storeRef.current}>{children}</PracticeContext.Provider>);};
4. Props Drilling 대비 개발 경험 향상
Props drilling과 비교했을 때 개발 경험이 향상되었습니다.
// Props Drilling의 문제: 하나 수정하면 여러 파일 수정 필요// 새로운 상태나 함수를 추가할 때마다 중간 컴포넌트들을 모두 수정해야 함// 1. 최상위에서 props 추가const PracticePage = () => {const [practiceData, setPracticeData] = useState();const [newProp, setNewProp] = useState();// 새로 추가된 상태return (<PracticeLayoutpracticeData={practiceData}setPracticeData={setPracticeData}newProp={newProp}// 추가setNewProp={setNewProp}// 추가/>);};// 2. 중간 컴포넌트도 수정const PracticeLayout = ({ practiceData, setPracticeData, newProp, setNewProp }) => {return (<PracticeContentpracticeData={practiceData}setPracticeData={setPracticeData}newProp={newProp}// 추가setNewProp={setNewProp}// 추가/>);};// 3. 실제 사용하는 컴포넌트도 수정const PracticeContent = ({ practiceData, setPracticeData, newProp, setNewProp }) => {// 이제야 새로운 상태 사용 가능};
// Context + Zustand 방식: 하나만 수정하면 끝// 1. 스토어에 새로운 상태 추가const createPracticeStore = () => {return createStore<PracticeState>()((set) => ({practiceData: null,newProp: null,// 추가setNewProp: (value) => set({ newProp: value }),// 추가// ...}));};// 2. 필요한 컴포넌트에서 바로 사용const PracticeContent = () => {const newProp = usePracticeStore((state) => state.newProp);const setNewProp = usePracticeStore((state) => state.setNewProp);// 바로 사용!// 중간 컴포넌트들은 수정할 필요 없음};
마치며
만약 정말로 전역적인 상태(예: 사용자 정보, 테마, 언어 설정 등)가 필요하다면, Provider 없이 전역에서 상태를 관리할 수 있는 zustand의 이점을 누릴 수 있습니다. 그러나 특정 범위에서만 관리되어야 할 상태라면 이 방법을 사용해서 국소적으로 상태 관리를 해보는 것도 좋을 것 같습니다. 이 방식을 통해 전역 상태 관리의 장점은 유지하면서도, 상태의 범위를 명확히 제한하여 더 예측 가능하고 관리하기 쉬운 코드를 작성할 수 있었습니다.
그러나 단순한 props drilling 만으로도 해결 가능한 상태는 여전히 props drilling을 통해서 해결할 수 있습니다. 국소적으로 사용한다고 하더라도, 지역 상태 보다는 더 상태 변화가 예측하기 어려운 측면이 존재하기 때문입니다. 결론적으로, 필요에 따라 장단점을 고려하여 적절한 도구를 선택하여 개발하면 될 것입니다.
참고 자료: