개발하다보면 로직을 재사용하기 위해 Provider를 사용하는 경우가 있는데,
동일한 Provider가 여러번 재사용 되는 경우 (테이블 안에 테이블이 있다던가.. 모달 에서 모달을 연다던가)
상태 핸들링에 어려움을 겪었습니다.
그래서 Radix-Ui에서 사용하는 스코프 개념을 본따 라이브러리도 만들었는데,
홍보 겸 블로그 글을 작성하려고 합니다
스코프란?
import { Dialog } from "radix-ui";
// Radix Ui 예시 코드, Root (Provider)를 기반으로 내부에서 상태를 공유해 사용합니다.
export default () => (
<Dialog.Root>
<Dialog.Trigger />
<Dialog.Portal>
<Dialog.Overlay />
<Dialog.Content>
<Dialog.Title />
<Dialog.Description />
<Dialog.Close />
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
Radix UI는 React 기반의 UI 컴포넌트 라이브러리로, Compound Components 패턴을 적극 활용합니다.
Compound Components는 여러 개의 하위 컴포넌트를 조합하여 하나의 큰 구성 요소를 만드는 패턴이에요. 예를 들어 Radix UI의 Dialog 컴포넌트는 Root, Trigger, Content 등 여러 부분으로 쪼개져 있습니다. 이렇게 하면 필요한 부분만 선택해서 조합할 수
있어 유연성이 높아지죠.
하지만 이런 구성 요소들을 여러 겹으로 중첩해서 사용할 때 문제가 생길 수 있습니다.
예를 들어, Dialog 컴포넌트를 기반으로 만든 AlertDialog가 있다고 상상해봅시다. AlertDialog 내부에서는 실제로 Dialog의 기능을 활용하지만, AlertDialog만의 추가 기능과 요구사항이 있어요.
만약 Dialog와 AlertDialog를 한 화면에 함께 쓰거나, 서로 포함하는 구조로 쓴다면 어떻게 될까요?
잘못하면 두 컴포넌트의 내부 상태나 컨텍스트가 섞여서 엉뚱한 동작이 일어날 수 있습니다.

Radix UI 팀은 이 문제를 해결하기 위해 스코프(scope)라는 개념을 도입했습니다.
간단히 말해, 컴포넌트 인스턴스마다 독립적인 컨텍스트를 부여하는 것입니다.
앞의 AlertDialog 예에서, AlertDialog는 자체적인 컨텍스트를 만들고 이를 내부에서 사용하는 Dialog에 넘겨줍니다.
그렇게 하면 Dialog와 AlertDialog 각각 자기만의 상태 공간을 가지므로 충돌이 발생하지 않아요.
실제로 Radix UI 개발자는 "AlertDialog에는 자체 컨텍스트가 있어서, 내부에 렌더링하는 Dialog에 그 컨텍스트를 전달하고 Dialog는 그것을 사용하게 만든다. 이렇게 하지 않으면 Dialog는 자기 자신의 DialogContext를 사용하게 되어 문제가 발생한다고 설명했습니다.
즉, 스코프를 이용해 컨텍스트의 범위를 한정함으로써 비슷한 구조의 컴포넌트들이 서로의 상태를 잘못 공유하지 않도록 막아주는 것이죠
참조) https://github.com/radix-ui/primitives/discussions/1091
쉽게 비유하자면, 여러 개의 라디오 채널이 있다고 생각해봅시다. 각 채널은 자기 주파수가 있어서 다른 채널 신호와 섞이지 않고 독립적으로 송출되죠. Radix의 스코프 개념도 이와 비슷하게, 컴포넌트별로 **자기만의 신호(컨텍스트)**를 주파수처럼 분리해준다고 볼 수 있습니다. 따라서 여러 인스턴스를 동시에 사용하더라도 각자 독립적인 상태를 유지하게 됩니다.
React와 Zustand로 Scope 개념 구현하기
이제 Radix UI의 스코프 아이디어를 우리만의 코드로 한 번 구현해보겠습니다. React에서는 보통 Context를 사용해서 컴포넌트 트리 전체에 걸쳐 상태를 공유할 수 있는데요. 여기서는 이 Context를 응용해서 컴포넌트마다 개별적인 상태 저장소(Zustand 스토어)를 제공하는 방법을 만들어볼 거예요.
Zustand는 원래 전역 상태를 관리하기 좋지만, 약간의 창의력을 더해 인스턴스별로 분리된 상태 관리도 할 수 있습니다.
전체적인 구현 전략은 다음과 같습니다:
- createScope 함수 – 새로운 스코프(컨텍스트와 Zustand 스토어)를 생성하는 유틸리티 함수를 만듭니다. 이 함수는 React Context를 만들고, 그 안에 독립적인 Zustand 스토어(상태)를 생성해 줄 거예요.
- ScopeProvider 컴포넌트 – createScope 함수가 반환하는 Provider 컴포넌트입니다. 이 컴포넌트를 사용하면 자식 컴포넌트들이 특정 스코프에 접근할 수 있도록 zustand 스토어를 컨텍스트로 공급해줍니다. 이 Provider를 매 인스턴스마다 사용하면 각 인스턴스마다 별도의 상태 저장소를 갖게 되겠죠.
- useScope 훅(Hook) – 해당 컨텍스트에서 Zustand 상태를 읽고 조작할 수 있는 커스텀 훅입니다. 이 훅을 사용하면 현재 스코프(컨텍스트)에 연결된 상태 값들과 업데이트 함수를 손쉽게 가져올 수 있어요.
이제 실제 코드로 확인해보겠습니다. 먼저 createScope 함수를 구현해보고, 그를 이용해 컴포넌트별 독립 상태를 가지는 예제를 만들어볼게요.
글쓰기에 앞서, zustand에 대한 정보가 필요하면 아래 공식 사이트 링크를 참고하세요.
https://zustand-demo.pmnd.rs/
zustand를 몰라도 "전역 상태"를 사용한다"는 개념만 이해하면 이해하기 쉽습니다.
provider를 사용해보기 위해 zustand store를 provider에 넣었는데, 어떻게 넣었는지는 아래 링크를 참고해주세요.
https://zustand.docs.pmnd.rs/guides/nextjs
// createZustandContextWithScope.ts
"use client";
import React, { createContext, ReactNode, useContext, useRef } from "react";
import type { StoreApi, UseBoundStore } from "zustand";
import { useStore } from "zustand";
import { useShallow } from "zustand/react/shallow";
/**
* createZustandContextWithScope
*
* zustand 스토어를 context Provider와 연계하는 유틸 함수에 스코프 개념을 추가한 버전입니다.
* Provider와 hook 모두 선택적으로 scope 값을 받을 수 있으며, 동일한 scope를 사용한 컴포넌트끼리 별도의 상태를 공유합니다.
*/
export function createZustandContextWithScope<TStore extends object>(
createStore: (initialState?: Partial<TStore>) => UseBoundStore<StoreApi<TStore>>
) {
// scope 값을 key로 하는 React Context들을 저장할 WeakMap (key는 객체여야 함)
const contexts = new WeakMap<object, React.Context<UseBoundStore<StoreApi<TStore>> | null>>();
// scope가 없는 경우에 사용될 기본 컨텍스트
let defaultContext: React.Context<UseBoundStore<StoreApi<TStore>> | null> | undefined;
// scope 값(객체)이 있으면 해당 scope의 컨텍스트, 없으면 기본 컨텍스트를 반환
function getContext(scope?: object) {
if (!scope) {
if (!defaultContext) {
defaultContext = createContext<UseBoundStore<StoreApi<TStore>> | null>(null);
}
return defaultContext;
} else {
if (!contexts.has(scope)) {
contexts.set(scope, createContext<UseBoundStore<StoreApi<TStore>> | null>(null));
}
return contexts.get(scope)!;
}
}
// Provider는 선택적 scope prop(객체)을 받고, 해당 scope에 맞는 컨텍스트 Provider로 감쌉니다.
const Provider = ({
children,
initialState,
scope,
}: {
children: ReactNode;
initialState?: Partial<TStore>;
scope?: object;
}) => {
const ContextToUse = getContext(scope);
const storeRef = useRef<UseBoundStore<StoreApi<TStore>>>();
if (!storeRef.current) {
storeRef.current = createStore(initialState);
}
return <ContextToUse.Provider value={storeRef.current}>{children}</ContextToUse.Provider>;
};
// hook도 선택적으로 scope 값을 받고, 동일한 scope에 대응하는 컨텍스트에서 zustand 스토어를 읽어옵니다.
const useStoreFromContext = <U,>(selector: (state: TStore) => U, scope?: object): U => {
const ContextToUse = getContext(scope);
const store = useContext(ContextToUse);
if (!store) throw new Error(`Zustand store is missing the Provider; required scope: ${scope}`);
return useStore(store, useShallow(selector));
};
return {
Provider,
useStore: useStoreFromContext,
};
}
코드가 이것저것 많은데요, 핵심 로직만 이해하면 쉽습니다.
위 코드에서 createZustandWithScope 함수는 새로운 Context와 Zustand 스토어 훅을 묶어서 제공하고 있습니다.
ScopeProvider가 마운트될 때 한 번만 create 함수가 실행되고, (Provider의 storeRef.current = createStore(initalState) 코드 부분)
컴포넌트가 언마운트되기 전까지 동일한 Zustand 스토어 인스턴스를 유지합니다. 이렇게 해야 Provider 컴포넌트가 리렌더되더라도 매번 새로운 스토어를 만들지 않고, 해당 스코프에 안정적인 상태 저장소가 할당되죠.
그리고 Scope를 외부에서 할당받아 주입해줍니다.
createScope함수는 다음과 같습니다.
import { useRef } from "react";
export function useScope<T extends object = {}>(): T {
const scopeRef = useRef<T>();
if (!scopeRef.current) {
scopeRef.current = {} as T;
}
return scopeRef.current;
}
객체를 사용하는 이유는, WeakMap이 unique한 키 값을 받아 set/get 및 가비지 콜렉팅을 관리하는데
참조하는 scope가 없으면 자동으로 가비지 콜렉팅 되도록 구현하려고 사용했습니다.
컴포넌트 예시
이제 createScope를 활용해서 실제로 컴포넌트별 독립적인 상태가 잘 동작하는지 확인해보겠습니다.
예시로 Counter(카운터) 컴포넌트를 만들어 볼게요. 이 Counter는 내부에 자신의 상태로 숫자를 하나 들고 있고, 버튼을 누르면 그 숫자가 증가합니다. 중요한 점은, 이런 Counter 컴포넌트를 여러 개 렌더링해도 서로 다른 숫자를 가지도록 하는 것입니다. 전역적으로 상태를 공유했다면 하나 올릴 때 모두 올라가겠지만, 우리는 각 인스턴스가 분리되어야 하니까요!
// App.tsx
"use client";
import React from "react";
import { create, type StoreApi, type UseBoundStore } from "zustand";
import { createZustandContextWithScope, useScope } from "@/shared/libs/zustand";
// 1. 스토어 타입 정의
type CounterStore = {
count: number;
increment: () => void;
};
// 2. zustand 스토어 생성 함수 정의
const createCounterStore = (initialState?: Partial<CounterStore>): UseBoundStore<StoreApi<CounterStore>> =>
create<CounterStore>((set, get) => ({
count: initialState?.count ?? 0,
increment: () => set({ count: get().count + 1 }),
}));
// 3. 스코프를 지원하는 zustand context 생성 (WeakMap 사용)
const { Provider: CounterProvider, useStore: useCounterStore } = createZustandContextWithScope(createCounterStore);
// 4. 카운터 컴포넌트 (선택적 scope prop을 받음)
function Counter({ scope }: { scope?: object }) {
const { count, increment } = useCounterStore((state) => ({ count: state.count, increment: state.increment }), scope);
return (
<div
style={{
border: "1px solid #ccc",
padding: "1rem",
margin: "1rem",
}}
>
<p>Count: {count}</p>
<button onClick={increment}>Increment</button>
</div>
);
}
// 5. App 컴포넌트에서 useScope를 통해 scope 객체를 생성
function App() {
// 이제 useScope 훅을 통해 scope 객체를 생성하므로, useMemo를 직접 쓸 필요가 없습니다.
const scope1 = useScope();
const scope2 = useScope();
return (
<div>
<h1>Scoped Zustand Example (useScope 활용)</h1>
<CounterProvider scope={scope2} initialState={{ count: 100 }}>
<CounterProvider scope={scope1} initialState={{ count: 10 }}>
{/* 첫 번째 인스턴스 - scope1 */}
<Counter scope={scope1} />
{/* 두 번째 인스턴스 - scope2 */}
<Counter scope={scope2} />
</CounterProvider>
</CounterProvider>
{/* scope prop을 전달하지 않은 경우 (기본 컨텍스트 사용) */}
<CounterProvider initialState={{ count: 0 }}>
<Counter />
</CounterProvider>
</div>
);
}
export default App;
위 예제에서 <CounterScopeProvider>는 각각 독립된 스토어를 만들어서 그 자식인 Counter에게 제공합니다. 따라서 화면에 Counter를 두 개 렌더링했지만, 각각 자기만의 count 상태를 가지게 됩니다. 첫 번째 버튼을 몇 번 눌러도 두 번째 Counter의 숫자에는 영향을 주지 않고, 반대의 경우도 마찬가지예요. 🎉

만약 우리가 이렇게 스코프를 분리하지 않고 하나의 전역 Zustand 스토어를 두 Counter가 공유했다면 어떤 일이 벌어질까요? 아마도 하나의 버튼을 클릭할 때 두 Counter 컴포넌트가 모두 같은 상태를 참조하고 있기 때문에 같이 증가했을 거예요. 하지만 createScope로 인해 Provider마다 별도의 스토어 인스턴스를 쓰고 있으니 이러한 충돌이 사라집니다.
정리하면, 우리는 Radix UI의 "한 컴포넌트 그룹만을 위한 독립적인 컨텍스트"라는 개념을 React+Zustand 조합으로 구현했습니다. 이렇게 컨텍스트와 상태 저장소를 컴포넌트 단위로 캡슐화하면 재사용성과 상태 격리가 훨씬 쉬워집니다. 실제 사례로도, Zustand를 전역으로 쓰는 대신 각 컴포넌트 subtree별로 컨텍스트를 통해 스토어를 주입하면, 각 컴포넌트가 자신의 상태를 갖게 되어 테스트나 초기화가 간편해집니다. 여러 곳에서 동일한 컴포넌트를 렌더링할 때도 모두 독립적인 동작을 하게 되는 것이죠.
(zustand가 아니고 단순한 context api등 다른 전역 상태 라이브러리/방식을 사용해도 동일합니다.)
마무리: Scope로 인한 독립성의 가치
Radix UI의 scope 개념과 그것을 활용한 상태 격리 방법을 살펴보았습니다. 핵심 아이디어는 **"컨텍스트를 복제하여 인스턴스별로 분리한다"**라고 요약할 수 있겠네요. 이번에 만든 createScope와 useScope 패턴은 작은 예제이지만, 이러한 원리를 이해하면 더 복잡한 UI를 만들 때도 유사한 접근법을 활용할 수 있어요. 🙂
React와 Zustand를 사용한 이 방법으로 컴포넌트를 구성하면, 필요한 곳에만 국한된 상태를 가질 수 있어서 애플리케이션의 유지보수성이 높아집니다. Radix UI가 멋진 점은 이러한 문제들을 미리 고민하고 API에 녹여냈다는 것이고, 우리는 그 아이디어를 배우고 응용해 본 것이죠.
처음엔 조금 헷갈릴 수 있지만, 천천히 코드를 살펴보고 직접 따라 해보세요. 궁금한 점이 생기면 공식 문서나 레퍼런스도 참고하면 큰 도움이 됩니다. 부디 이번 튜토리얼로 스코프 개념에 대한 이해와 독립 상태 관리 기법을 얻어가시길 바랍니다. 🚀
아 참고로, 저는 이 scope 개념을 활용해 하나의 라이브러리를 만들었습니다.
https://github.com/lodado/react-namespace
zustand를 사용하지 않고 직접 전역 상태 관리 방법을 구현한것 이외에는 위 예시 코드와 거의 동일합니다.
그런데 전역 라이브러리를 만들어서 직접 배포해보니 경쟁자(zustand, redux 등)들이 너무 쟁쟁해서 사람들이 별로 사용하진 않는거 같네요.. ㅋㅋ
참고 자료: Radix UI 공식 논의에서 컨텍스트 스코프 설명 github.comgithub.com,
Zustand와 React Context 활용에 대한 블로그 포스트 tkdodo.eu.
직접 구현한 코드 예시)
https://github.com/lodado/OXVoter/tree/main/src/shared/libs/zustand
'Front-end > React' 카테고리의 다른 글
Next 14 tree shaking 관련 조사 (0) | 2024.02.27 |
---|---|
jotai 내부 구조 훑어보기 (0) | 2023.11.25 |
유연한 컴포넌트 만들기 - 모듈화와 추상화 (0) | 2023.06.10 |
react HOC와 decorator (0) | 2023.05.14 |
react Dialog tab trap 만들기 (1) | 2023.05.01 |