전역 상태에 대한 고찰

내가 전역 상태를 관리하는 기준과 방법

2025-02-04 |

14


지금부터 언급되는 전역 상태는 클라이언트 상태만을 의미합니다.

한동안 전역 상태에 대해 쭉 관심이 없다가 다시 관심이 생겼습니다. 관심이 없었던 이유는, 많은 전역 상태 라이브러리와 방법들을 사용해봤지만 결국 Context API로 정착한지 꽤 됐기 때문입니다.

최근 들어, Toss의 Frontend Fundamentals 라는 문서를 보다가 전역 상태를 사용하는 기준이 있으신가요? 라는 토론을 보게 되었습니다. 질문 요점 자체는 단순히 어느 depth에서 전역 상태를 사용하면 좋을지에 대한 질문이였으나, 그에 대한 코멘트들을 정말 인상적이게 봤습니다.

코멘트를 읽으면서, 제가 전역 상태를 어떤 기준으로 선택하는지 한 번 정리해보는 계기가 되었던 것 같습니다. 그에 대한 생각을 정리해보려고 해요.

전역 상태를 사용하는 이유

보통 React에서 전역 상태를 사용하는 이유는 다음과 같습니다.

  1. 라우팅이 전환됐을 때도 상태값을 사용해야 하는 경우, 어플리케이션 전체에 의존
  2. 한 페이지 내 여러 컴포넌트에서 상태값을 공유해야 해서 Props Drilling을 피하고 싶은 경우
  3. 비즈니스 로직을 한 곳에 응축하고 싶은 경우
  4. 리렌더링을 최적화 하기 위해서

전역 상태의 문제점

1번 케이스의 경우 개발하면서 마주치는 빈도수가 드문 편이라고 생각합니다. 복잡한 어플리케이션일 경우 전역 상태가 가져다 주는 이점이 분명히 있을 것입니다. 라우팅이 전환되었을 때도 상태값에 의존해야 한다면 Funnel 패턴으로 해결할 수 있었어요. 하지만, 1번 케이스일 때 전역 상태를 사용했을 때 특정 시점에서 초기화된 값이 필요한데 초기화가 되지 않아 엉뚱한 값이 보여지는 문제가 발생한 경험이 생각납니다. 위에 링크한 토론에서도 그 케이스를 언급하고 있구요.

2번 케이스의 경우 지역적으로 사용됨에도 불구하고 스토어를 생성해서 대규모 프로젝트에서 스토어가 매우 방대해질 수 있습니다. 이런 경우, 어떤 스코프에서 전역 상태가 사용되는지 파악이 어렵고 개발자는 코드 흐름을 파악하기 어려워 디버깅 시간이 증가합니다. 또한, 지역적으로 사용해야하는 상태인데 전역으로 관리되어 메모리 낭비도 있다고 생각합니다.

4번 케이스는 아래에서 다뤄보도록 하겠습니다.

추가적인 케이스로, 특정 값으로 초기화를 해야하는 경우가 있을 것입니다. 하지만, store는 외부에 있기 때문에 주입이 불가능합니다. 따라서, useEffect를 사용해서 초기화를 해야 하는데 이는 두 번의 렌더링이 발생하게 됩니다. 이는 Next.js와 같은 SSR 환경에서 치명적인 문제입니다.

문제점을 정리해보자면,

  1. 상태를 초기화하기가 어려워 엉뚱한 값이 보여질 수 있다.
  2. 전역 상태가 어떤 스코프에서 사용되는지 파악이 어렵다.
  3. 대규모 프로젝트에서 스토어가 방대해질 수 있다. 따라서, 코드 흐름 파악이 어려워 디버깅 난이도가 증가한다.
  4. 구성원 모두가 전역 상태를 사용하는 관점이 다를 수 있다.
  5. 의존성 주입으로 initial state를 초기화 하는 것이 불가능하다.

전역 상태를 지역적으로 사용하자

1. Context API로 관리하기

저는 위의 문제를 해결하기 위해 Context API를 사용해왔습니다. Provider로 감싸면 해당 컴포넌트 트리 내에서 전역 상태를 사용할 수 있고 1~5번 문제를 모두 해결할 수 있습니다.

typescript

const CountContext = createContext(null); export const useCount = () => useContext(CountContext); function CountProvider({ children, count, }: PropsWithChildren<{ count: number }>) { const [__count, __setCount] = useState(count); const value = useMemo( () => ({ count: __count, setCount: __setCount }), [__count], ); return ( <CountContext.Provider value={value}>{children}</CountContext.Provider> ); } function App() { return ( <CountProvider> <CountView /> <Dispatcher /> </CountProvider> ); } function CountView() { const context = useCount(); return <Text>{context.count}</Text>; } function Dispatcher() { const context = useCount(); return ( <Button onClick={() => context.setCount((prev) => prev + 1)}> +1 증가 </Button> ); }

하지만, 이 방법은 좋지 못합니다. TodoContext를 구독하는 TodoProvider 하위 모든 컴포넌트가 리렌더링이 발생하기 때문입니다. Dispatcher 컴포넌트는 count 값이 변경되지 않아도 리렌더링이 발생합니다.

2. Context API + useReducer로 관리하기

Context APIuseReducer를 사용하면 이러한 문제를 해결할 수 있을 뿐만 아니라, 상태 관리 라이브러리들이 제공하는 기능들을 대체할 수 있습니다.

typescript

const CountContext = createContext(null); const CountDispatchContext = createContext(null); export const useCount = () => useContext(CountContext); export const useCountDispatch = () => useContext(CountDispatchContext); const initialState = { count: 0 }; function reducer(state, action) { switch (action.type) { case 'INCREMENT': return { count: state.count + 1 }; case 'DECREMENT': return { count: state.count - 1 }; default: throw new Error('Invalid action'); } } function CountProvider({ children }: PropsWithChildren) { const [state, dispatch] = useReducer(reducer, initialState); return ( <CountContext.Provider value={count}> <CountDispatchContext.Provider value={dispatch}> {children} </CountDispatchContext.Provider> </CountContext.Provider> ); } function App() { return ( <CountProvider> <CountView /> <Dispatcher /> </CountProvider> ); } function CountView() { const count = useCount(); console.log('CountView 리렌더링'); return <Text>{count}</Text>; } function Dispatcher() { const dispatch = useCountDispatch(); console.log('Dispatcher 리렌더링'); return ( <Button onClick={() => dispatch({ type: 'INCREMENT' })}>+1 증가</Button> ); }

Context를 두개로 분리했기 때문에, 이제 콘솔에는 "CountView 리렌더링"이라는 문구만 출력될 것입니다.

3. Zustand를 Provider로 감싸서 지역적으로 사용하기

Context APIuseReducer는 라이브러리에 의존하지 않고 React스럽게 사용할 수 있으며 분명 좋은 방법입니다. 하지만, 작성해야 할 코드가 많아지고 자칫하면 Redux의 열화판이 될 수 있습니다.

Zustand를 사용하면 Selector 패턴을 이용해서 원하는 상태만 구독할 수 있습니다. 또한, Zustand는 pub/sub 패턴과 함께 useSyncExternalStore를 사용하여 해당 상태를 구독하는 컴포넌트만 리렌더링 하게 되어 보다 최적화 할 수 있습니다.

Context API를 결합하여 사용하면 이 장점과 Context API를 같이 사용했을 때의 장점의 시너지를 모두 누릴 수 있게 돼요. 해당 패턴은 공식 문서에서도 소개하고 있습니다.

typescript

'use client'; import { createSafeContext } from '@/components/common/create-safe-context'; import useSetCoords from '@/hooks/useSetCoords'; import { useEffect, useState } from 'react'; import { createStore, type StoreApi, useStore } from 'zustand'; type MapState = { location: { lat: number; lng: number; }; map: { lat: number; lng: number; }; level: number; pin: string; }; type MapActions = { actions: { setLocation: (location: { lat: number; lng: number }) => void; setMap: (map: { lat: number; lng: number }) => void; setLevel: (level: number) => void; setPin: (pin: string) => void; onCenter: (pinLat?: number, pinLng?: number) => void; }; }; type MapStore = StoreApi<MapState & MapActions>; const [MapContextProvider, useMap] = createSafeContext<MapStore>('MapContext'); export default function MapProvider({ children, map }: PropsWithStrictChildren<{map: MapState['map]}>) { const [lat, lng] = useSetCoords(); const [store] = useState(() => createStore<MapState & MapActions>((set, get) => ({ location: { lat: 33.450701, lng: 126.570667, }, map, // map 주입 level: 3, pin: '회사위치', actions: { setLocation: (location) => set({ location }), setMap: (map) => set({ map }), setLevel: (level) => set({ level }), setPin: (pin) => set({ pin }), onCenter: (pinLat, pinLng) => { if (pinLat && pinLng) { set({ map: { lat: pinLat, lng: pinLng } }); } else { set({ level: 9 }); set({ map: { lat: get().location.lat, lng: get().location.lng } }); } }, }, })), ); useEffect(() => { store.getState().actions.setLocation({ lat, lng, }); store.getState().actions.setMap({ lat, lng }); }, [lat, lng]); return <MapContextProvider value={store}>{children}</MapContextProvider>; } export function useMapStore<T>(selector: (state: MapState & MapActions) => T) { const store = useMap(); return useStore<MapStore, T>(store, selector); } function App() { return ( <MapProvider map={{ lat: 33.450701, lng: 126.570667 }}> <MapView /> </MapProvider> ); } function MapView() { const { map, level, bounds, location, actions: { onCenter }, } = useMapStore((state) => ({ map: state.map, level: state.level, bounds: state.bounds, location: state.location, actions: state.actions, })); return <Button onClick={() => onCenter()}></Button>; }

실무에서 사용한 코드를 거의 유사하게 가져와 보았습니다.

위에서 언급한 5번의존성 주입으로 initial state를 초기화 하는 것이 불가능하다. 라는 문제도 props로 값을 받으면서 해결할 수 있습니다.

리렌더링이 꼭 나쁜걸까요?

현재 여러분이 보고 계시는 페이지를 Profiler를 통해 측정해보았습니다.

Prose가 마크다운 전체의 컴포넌트인 것을 감안하면 Flex와 같은 유틸성 컴포넌트가 0.5ms 안팎으로 측정되는데, 성능에 전혀 지장이 없는 수준입니다.

그리고 해당 링크에서도 같은 의견을 나누고 있는 걸 확인할 수 있는데요.

컴포넌트가 많아질수록 그리고 깊어질수록 ContextAPI를 썼을 때 성능 저하가 일어난다면 그것은 단순히 컴포넌트의 수가 많기 때문이 아니라 리렌더링이 일어나면서 컴포넌트에 존재하는 무거운 로직을 여러 번 다시 실행하기 때문이라고 생각합니다.

그리고 가장 좋은 해결법은 그 로직 자체의 개선이지, 단순히 리렌더링 횟수를 줄이는 것은 그 문제를 덮어두는 것으로 생각되어서 리렌더링 횟수를 줄이기 위해서 전역 상태 관리 라이브러리를 사용하는 것을 선호하지는 않습니다.

저는 문제를 고치는 근본적인 해결법이 로직의 개선이라는 것과, 단순히 리렌더링 횟수를 줄인다는 것은 문제를 덮어둔다는 의견이 너무 와닿았습니다.

Zustand, Jotai와 같은 전역 상태 라이브러리들을 도입했을 때 현재 상황과 프로젝트에서 가져다주는 이점들을 고려해야지, 전역 상태를 도입하는 기준이 리렌더링이 기준이 되어서는 안 된다는 것입니다.

저는 전역 상태를 이렇게 사용할 거에요

제가 내린 결론은 이러합니다.

Context APIuseReducer로 웬만한 상황에서 커버를 하되, 상태가 방대해졌을 경우에 ZustandProvider로 감싸서 사용할 것 같습니다.

지금까지 긴 글 읽어주셔서 감사합니다.