특정 범위에서만 전역상태 사용하기 (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 Boundary
const ErrorBoundary = () => {
const resetAllModals = useGlobalStore((state) => state.resetAllModals);
};
// 누가 언제 상태를 변경했는지 알기 어렵다.

이런 문제들을 겪으면서 "Practice 기능의 상태는 Practice 컴포넌트 내에서만 관리할 수 없을까?"라는 고민이 생겼습니다.

Zustand + Context API: 범위가 제한된 상태 관리

Zustand 공식 문서에서 발견한 해결책을 프로젝트에 적용해보기로 했습니다. 이 방법을 통해 Zustand의 편리함은 유지하면서도 상태의 범위를 명확히 제한할 수 있었습니다. 해당 방법을 소개하겠습니다.

Context Provider로 제한된 범위에서 스토어 생성

// PracticeProvider.tsx
import { 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.ts
import { 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.tsx
import 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 (
<PracticeLayout
practiceData={practiceData}
setPracticeData={setPracticeData}
newProp={newProp}// 추가
setNewProp={setNewProp}// 추가
/>
);
};
// 2. 중간 컴포넌트도 수정
const PracticeLayout = ({ practiceData, setPracticeData, newProp, setNewProp }) => {
return (
<PracticeContent
practiceData={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을 통해서 해결할 수 있습니다. 국소적으로 사용한다고 하더라도, 지역 상태 보다는 더 상태 변화가 예측하기 어려운 측면이 존재하기 때문입니다. 결론적으로, 필요에 따라 장단점을 고려하여 적절한 도구를 선택하여 개발하면 될 것입니다.


참고 자료:

  • Zustand 공식 문서 - Wrapping the context provider
  • React Context API 공식 문서