본문 바로가기
FE/모던 리액트 Deep Dive

5. 리액트와 상태관리 라이브러리 - 3

by Toddler_AD 2025. 10. 7.

5.2.3 useState와 Context를 동시에 사용해 보기

  • 앞서 useStore 내지는 useStoreSelector 훅을 활용해 useState로 관리하지 않는 외부 상태값을 읽어오고 리렌더링까지 일으켜서 마치 상태 관리 라이브러리처럼 사용하는 예제를 만들었다. 그러나 이 두가지 훅에도 한 가지 단점이 있다. 이 훅과 스토어를 사용하는 구조는 반드시 하나의 스토어만 가지게 된다는 것이다. 하나의 스토어를 자기면 이 스토어는 마치 전역 변수처럼 작동하게 되어 동일한 형태의 여러 개의 스토어를 가질 수 없게 된다. 만약 훅을 사용하는 서로 다른 스코프에서 스토어의 구조는 동일하되, 여러 개의 서로 다른 데이터를 공유해 사용하고 싶다면 어떻게 해야 할까?
  • 가장 먼저 떠오르는 방법은 createStore를 이용해 동일한 타입으로 스토어를 여러 개 만드는 것이다.  다음 예제를 보자.
const store1 = createStore({ count: 0 });
const store2 = createStore({ count: 0 });
const store3 = createStore({ count: 0 });

//....
  • 그러나 이 방법은 완벽하지도 않고 매우 번거롭다. 먼저 해당 스토어가 필요할 때마다 반복적으로 스토어를 생성해야 한다. 또한 훅은 스토어에 의존적인 1:1 관계를 맺고 있으므로 스토어를 만들 때마다 해당 스토어에 의존적인 useStore와 같은 훅을 동일한 개수로 생성해야 한다. 마지막으로 이러한 수고로움을 견디고 훅을 하나씩 만들었다고 하더라도 이 훅이 어느 스토어에 사용 가능한지를 가늠하려면 오직 훅의 이름이나 스토어의 이름에 의지해야 한다는 어려움이 있다. 이 문제를 해결하는 좋은 방법은 바로 리액트의 Context다. Context를 활용해 해당 스토어를 하위 컴포넌트에 주입한다면 컴포넌트에서는 자신이 주입된 스토어에 대해서만 접근할 수 있게 될 것이다. 스토어와 Context를 함께 사용하는 다음 예제를 보자.
// context 생성
// CounterStoreContext 에 어떤 context 를 만들지 타입과 함께 정의
export const CounterStoreContext = createContext<Store<CounterStore>>(
    createStore<CounterStore>({Count:0,text:'hello'}),
)


// 정의한 context 를 CounterStoreProvider 에 사용
// context 는 provider 에 의존한다.
export const CounterStoreProvider =({
    initialState,
    children,
}: PropsWithChildren<{
    initialState: ConterStore
}>)=>{
    // 불필요한 props 변경으로 리렌더링을 막기위해 storeRef 사용 
    // useRef 사용을 오직 최초 렌더링에서만 스토어 값을 만들어 내려주게 된다. 
    const storeRef=useRef<Store<CounterStore>>()

    // 스토어를 생성한 적이 없다면 최초에 한 번 생성한다. 
    if (!storeRef.current){
        storeRef.current=createStore(initialState)
    }

    return (
        // provider 를 선언해 감싸준다.
        <CounterStoreContext.Provider value={storeRef.current}>
            {children}
        </CounterStoreContext.Provider>
    )
}

 

  • CounterStoreContext를 통해 먼저 어떠한 Context를 만들지 타입과 함께 정의해 뒀다.이렇게 타입과 함께 정의된 Context를 사용하기 위해 CounterStoreProvider를 정의했다. 이 Provider에서는 storeRef를 사용해서 스토어를 제공하는데, 그 이유는 Provider로 넘기는 props가 불필요하게 변경돼서 리렌더링되는 것을 막기 위해서다. 이렇게 useRef를 사용했기 때문에 CounterStoreProvider는 오직 최초 렌더링에서만 스토어를 만들어서 값을 내려주게 될 것이다.
  • 이제 이 Context에서 내려주는 값을 사용하게 위해서는 useStore나 useStoreSelector 대신에 다른 접근이 필요하다. 기존의 두 훅은 스토어에 직접 접근하는 방식이지만 이제 Context에서 제공하는 스토어에 접근해야 하기 때문이다. useContext를 사용해 스토어에 접근할 수 있는 새로운 훅이 필요하다.
export const useCounterContextSelector = <State extends unknown>(
    selector:(state:CounterStore)=>State,
) => {
    // 스토어에 접근하기 위해 useContext 를 사용했다. -> 스토어에서 값을 찾는게 아니라 Context.Privider 에서제공된 스토어를 찾게 만드는 것이다.
    const store = useContext(CounterStoreContext)
    // 불필요한 반복을 제거하기 위해 
    // useStoreSelector 대신 리엑트에서 제공하는  useSubscription 를 사용했다. 
    const subscription = useSubscription(
        useMemo(
            ()=>({
                getCurrentValue:()=>selector(store.get()),
                subscribe:store.subscribe,
            }),
            [store,selector],
        ),
    )
    return [subscription,store.set] as const
}
  • 앞서 작성한 useStoreSelector와 비슷하지만 몇 가지 눈에 띄는 차이점이 보인다. 먼저 앞서 잠들었던 useStoreSelector 대신에 리액트에서 제공하는 useSubscription을 사용했다. useStoreSelector를 사용해도 본 예제 수준에서는 크게 상관이 없지만 예제의 불필요한 반복을 제거하기 위해 기존에 리액트에서 제공하는 useSubscription을 사용했다. 그리고 스토어에 접근하기 위해 useContext를 사용했다. 즉, 스토어에서 값을 찾는 것이 아니라 Context.Provider에서 제공된 스토어를 찾게 만드는 것이다. 이제 이 새로운 훅과 Context를 사용하는 예제를 살펴보자.
// ContextCounter
const ContextCounter = () =>{
    const id = useId()

    const[counter,setStore]=useConterContextSelector(
        useCallback((state:CounterStore)=>state.count,[]),

        function handleClick(){
            setStore((prev)=>({...prev,count:prev.count+1}))
        }
        useEffect(()=>{
            console.log(`${id} Counter Rendered`);
        })
        
        return (
            <div>
                {counter} <button onClick={handleClick}>+</button>
            </div>
        )
    )
}

//ContextInput
const ContextInput = () => {
    const id = useId()
    const [text, setStore] = useCounterContextSelector(
        useCallback((state:CounterStore) => state.text,[]),
    )
    
    function handleChange(e:ChangeEvent<HTMLInputElement>){
        setStore((prev)=>({...prev,text:e.target.value}))
    }

    useEffect(()=>{
        console.log(`${id} Counter Rendered`);
    })

    return(
        <div>
            <input value={text} onChange={handleChange} />
        </div>
    )
}
<!-- ----------------------------------------------------------------------- -->
// 위 의 함수를 이제 return 에서 사용
export default function App(){
    return(
        <>
        {/* Provider 가 없는 상황에서 전역으로 생성된 스토어를 바라보기 때문에 
        CounterStoreContext 가 존재하지 않아도 ContextCounter와ContextInput는 초깃값을 가져올수 있다.  */}
            <ContextCounter />
            <ContextInput />

            <CounterStorePrivider initialState={{count:10}, text:'hello'}>
                {/* ContextCounter와ContextInput는 초기화된 {count:10}, text:'hello'} 를 값으로 가져오게 된다. */}
                <ContextCounter />
                <ContextInput />

                <CounterStorePrivider initialState={{count:20}, text:'welcome'}>
                    {/* ContextCounter와ContextInput는 가장 가까운 {count:20}, text:'welcome' 를 값으로 가져오게 된다. */}
                    <ContextCounter />
                    <ContextInput />
                <CounterStorePrivider/>
            <CounterStorePrivider/>
        </>
    )
}
  • 먼저 컴포넌트 트리 최상단에 있는 <ContextCounter />와 <ContextInput />부터 살펴보자. 두 컴포넌트는 부모에 <CounterStoreProvider />가 존재하지 않아도 각각 초기값을 가져오는 것을 볼 수 있다. 그 이유는 CounterStoreContext의 작동방식 때문이다. 앞서 CounterStoreContext를 만들 때 초기값을 인수로 넘겨줬는데, 이 인수를 Provider가 없을 경우 사용하게 된다. 즉, Provider가 없는 상황에서는 앞선 스토어 예제와 마찬가지로 전역으로 생성된 스토어를 바라보게 될 것이다.
  • 두 번째 <ContextCounter /> 와 <ContextInput />은 {count: 10, text: 'hello'}로 초기화된 CounterStoreProvider 내부에 있다. 따라서 각 컴포넌트는 10, hello를 값으로 가져오게 된다.
  • 마지막 <ContextCounter />와 <ContextInput />은 앞선 Provider 외에도 또 가까운 Provider를 하나 더 가지고 있는데, 이 Provider는 {count: 20, text: 'welcome'}으로 초기화 되어 있다. Context는 가장 가까운 Provider를 참조하므로 여기서 각 컴포넌트는 20, welcome을 값으로 가지게 된다.
  • 이렇게 Context와 Provider를 기반으로 각 store 값을 격리해서 관리했다. 이렇게 작성한 코드는 어떤 장점이 있을까? 먼저 스토어를 사용하는 컴포넌트는 해당 상태가 어느 스토어에서 온 상태인지 신경 쓰지 않아도 된다. 단지 해당 스토어를 기반으로 어떤 값을 보여줄지만 고민하면 되므로 좀 더 편리하게 코드를 작성할 수 있다. 또한 Context와 Provider를 관리하는 부모 컴포넌트의 입장에서는 자신이 자식 컴포넌트에 따라 보여 주고 싶은 데이터를 Context로 잘 격리하기만 하면 된다. 이처럼 부모와 자식 컴포넌트의 책임과 역할을 이름이 아닌 명시적인 코드로 나눌 수 있어 코드 작성이 한결 용이해진다.
  • 지금까지 useState와 useReducer가 관리하는 지역 상태가 아닌, 조금 더 넓은 스코프에서 사용할 수 있는 상태 관리를 예제 코드와 함께 만들어 봤다. 다양한 상태 관리 라이브러리가 많이 등장하고 있는 요즘, 실제 상태 관리 라이브러리가 어떤 식으로 작동하고 있는 지를 알아보거나, 혹은 직접 상태 관리와 렌더링을 일으킬수 있는 코드를 고민해 본 적은 많지 않을 것이다. 현재 리액트 생태계에는 많은 상태 관리 라이브러리가 있지만 이것들이 작동하는 방식은 결국 다음과 같이 요약할 수 있다.
    • useState, useReducer가 가지고 있는 한계, 컴포넌트 내부에서만 사용할 수 있는 지역 상태라는 점을 극복하기 위해 외부어딘가에 상태를 둔다. 이는 컴포넌트의 최상단 내지는 상태가 필요한 부모가 될 수도 있고, 혹은 격리된 자바스크립트 스코르 어딘가 일 수도 있다.
    • 이 외부의 상태 변경을 각자의 방식으로 감지해 컴포넌트의 헨더링을 일으킨다.
  • 위 두가지를 염두에 두고 코드를 작성하거나 라이브러리를 설치해 본다면 리액트에서의 상태 관리와 렌더링에 대해 좀 더 넓은 시야를 가질 수 있게 될 것이다. 그리고 앞에 예제 상태 관리는 실제 운영 중인 프로젝트에서 사용해 본 경험도 있어 상태 관리 라이브러리르 설치하는 대신에 직접 사용해 보고 싶은 개발자들에게도 추천한다.