Skip to main content

Command Palette

Search for a command to run...

React Context와 overlay-kit 분석

Updated
•6 min read•View as Markdown
D

프론트엔드 개발자 doit입니다.

React에서 Context를 사용할 때 자주 겪는 대표적인 문제는 바로 불필요한 리렌더링입니다.

이 글에서는 Context의 리렌더링 구조를 짚어보고, toss의 overlay-kit에서는 이 문제를 어떻게 해결하였는지 그 구조와 원리를 자세히 살펴보겠습니다.

https://github.com/toss/overlay-kit/tree/main/packages


1️⃣ 일반적인 리렌더링 vs Context 리렌더링

  1. 일반적인 리렌더링

부모 컴포넌트가 랜더링되면 자식 컴포넌트들도 함께 랜더링 됩니다. (별도의 최적화가 없다면)

function Parent() {
  return <Child />;
}
  • Parent가 리렌더링되면 Child도 기본적으로 함께 리렌더링됩니다.

  • 단, React.memo 등으로 Child가 최적화되어 있다면 리렌더링을 건너뜁니다.

하지만 Context는 다릅니다

2, Context 리랜더링

<MyContext.Provider value={someValue}>
  <ComponentA />
  <ComponentB />
</MyContext.Provider>
  • ComponentA가 useContext(MyContext)로 Context를 구독 중이라면 → 리렌더링 됨

  • ComponentB가 Context를 구독하지 않았다면 → 리렌더링 안 됨

💡 Context 리렌더링의 핵심

Context는 value가 변경되었을 때,
해당 Context를 구독 중인 컴포넌트만 타겟팅해서 리렌더링합니다.


2️⃣ 일반적인 Context 사용 패턴의 문제점

대부분의 Context 예제는 상태와 상태 변경(명령) 함수를 value 안에 함께 전달합니다:

const CounterContext = createContext();

function CounterProvider({ children }) {
  const [count, setCount] = useState(0);

  return (
    <CounterContext.Provider value={{ count, setCount }}>
      {children}
    </CounterContext.Provider>
  );
}

이 방식의 문제는?

function CounterButton() {
  const { setCount, count } = useContext(CounterContext);
  return <button onClick={() => setCount(c => c + 1)}>+1</button>;
}
  • 실제로 필요한 건 setCount 뿐인데,

  • count 값도 함께 받다 보니, count가 바뀔 때마다 이 컴포넌트도 리렌더링됩니다.

(🍟 감자튀김만 먹고 싶은데 햄버거 세트로만 파는 느낌입니다.)


3️⃣ 명령과 상태를 분리하는 구조

해결책은 Context를 상태용 / 명령용 두 개로 분리하는 것입니다:

const CountStateContext = createContext();
const CountDispatchContext = createContext();

function CounterProvider({ children }) {
  const [count, setCount] = useState(0);

  return (
    <CountStateContext.Provider value={count}>
      <CountDispatchContext.Provider value={setCount}>
        {children}
      </CountDispatchContext.Provider>
    </CountStateContext.Provider>
  );
}

이제 컴포넌트는 필요한 것만 골라서 구독할 수 있습니다:

function CounterButton() {
  const setCount = useContext(CountDispatchContext); // '명령'만 구독
  return <button onClick={() => setCount(c => c + 1)}>+1</button>;
}

function CounterDisplay() {
  const count = useContext(CountStateContext); // '상태'만 구독
  return <p>Count: {count}</p>;
}

이렇게 하면, setCount만 필요한 컴포넌트는 상태 변경에 영향을 받지 않습니다.

하지만 useContext()는 React 컴포넌트나 훅 안에서만 호출 가능하므로, 현재 구조에서 명령은 항상 React 컴포넌트 내부에서만 가능합니다. 즉 React 외부(예: 전역 유틸, 에러 핸들러 등)에서는 명령을 보낼 수 없습니다.


4️⃣ overlay-kit: 구조적 분리 + 이벤트 시스템

overlay-kit은 명령과 상태의 분리를 넘어서,
React 컴포넌트 외부에서도 명령을 내릴 수 있도록 이벤트 시스템을 도입합니다.

핵심 아이디어

  1. Context를 사용하는 컴포넌트와 사용하지 않는 컴포넌트를 물리적으로 분리해, 불필요한 리렌더링을 원천 차단합니다.
function App() {
  return (
    <OverlayProvider>
      <HomePage /> {/* 메인 콘텐츠 */}
    </OverlayProvider>
  );
}
function OverlayProvider({ children }) {
  const [overlayState, dispatch] = useReducer(...);

  return (
    <OverlayContext.Provider value={overlayState}>
      {/* A. 메인 콘텐츠: Context에 관심 없음 */}
      {children}

      {/* B. 오버레이만 별도로 렌더링 (Context 사용) */}
      {overlayState.overlayOrderList.map(item => (
        <ContentOverlayController key={item.id} {...item} />
      ))}
    </OverlayContext.Provider>
  );
}
  • <HomePage />는 오버레이 상태를 읽지도 않고 구독하지도 않습니다.

  • 리렌더링 대상이 아니므로, overlayState가 바뀌어도 영향을 전혀 받지 않습니다.

  • 오직 <ContentOverlayController /> 만 Context를 구독합니다.

💡 즉, 렌더링 대상 자체를 분리함으로써, 불필요한 리렌더링을 구조적으로 원천 차단합니다.

  1. 이벤트 시스템 도입

React 내부가 아닌, 외부에서도 명령을 보낼 수 있도록 overlay-kit은 Emitter 기반 이벤트 시스템을 사용합니다.
이 시스템은 크게 "신호를 보내는 쪽"과 "신호를 듣는 쪽"으로 나뉩니다.

  • 신호를 듣는 쪽 (OverlayProvider)
// "open 이벤트가 오면, dispatch 함수를 실행해서 상태를 변경해줘" 라고 등록
useOverlayEvent({
  open: (data) => dispatch({ type: 'ADD', payload: data }),
  close: () => dispatch({ type: 'CLOSE' }),
});

앱이 시작 , OverlayProvider는 useOverlayEvent 훅을 통해 'open', 'close' 같은 이벤트가 발생하면 어떤 dispatch 함수를 실행해서 상태를 변경할지를 이벤트 버스(emitter)에 등록합니다.

// HomePage.jsx
import { overlay } from 'overlay-kit';
import { MyModalContent } from './MyModal';

function HomePage() {
  const handleClick = () => {
  // "open 이벤트 발생!" 신호를 이벤트 버스에 보냄
    overlay.open(<MyModalContent />);
  };

  return <button onClick={handleClick}>모달 열기</button>;
}

사용자가 overlay.open을 호출하면, 내부적으로 emitter.emit('open', data) 형식으로 이벤트를 발생시킵니다.

// emitter.js
const listeners = new Map();

export const emitter = {
  on(eventName, callback) {
    if (!listeners.has(eventName)) {
      listeners.set(eventName, []);
    }
    listeners.get(eventName).push(callback);
  },

  emit(eventName, data) {
    const eventListeners = listeners.get(eventName);
    if (eventListeners) {
      eventListeners.forEach((cb) => cb(data));
    }
  },
};

emitter는 "open" 신호를 받아서, 미리 등록되어 있던 OverlayProvider의 open 함수를 실행시킵니다. 결과적으로 dispatch가 호출되어 상태가 변경되고, 화면에 모달이 나타납니다.

  • overlay.open은 내부적으로 이벤트 버스(emitter)를 통해 OverlayProvider에 신호를 보냅니다.

  • 따라서 <HomePage />는 Context도, 상태도 몰라도 됩니다.


5️⃣ overlay-kit의 오버레이 렌더링 흐름

  1. 앱 시작시 이벤트등록
<OverlayProvider>
  <mian/>
<OverlayProvider/>
function OverlayProvider() {
  const [overlayState, overlayDispatch] = useReducer(overlayReducer, {
     current: null,
     overlayOrderList: [],
     overlayData: {},
  });

  // open 함수를 직접 만듦!
  const open = useCallback(({ controller, overlayId, componentKey }) => {
    // 오버레이 상태를 바꾸는 코드
    overlayDispatch({ type: 'ADD', overlay: { ... } });
  }, []);

   const close= useCallback((overlayId: string) => {
    // 오버레이 상태를 바꾸는 코드
    overlayDispatch({ type: 'REMOVE', overlayId });
  }, []);

  // 이 함수들을 useOverlayEvent에 전달!
  // "이벤트가 오면, dispatch 함수를 실행해서 상태를 변경해줘" 라고 등록
  useOverlayEvent({ open, close, ... });
}

앱 시작 시 OverlayProvider 컴포넌트가 처음 렌더링됩니다.
이때, OverlayProvider 내부에서는 open, close 등 이벤트에 대한 핸들러(=dispatch 함수)를 emitter에 등록합니다. 이때 등록되는 함수들은 오버레이 상태를 변경하는 함수입니다.

useOverlayEvent({ open, close, unmount, closeAll, unmountAll });

이 코드는 내부적으로 emitter.on을 호출해서, open, close 등 각각의 이벤트가 오면 각 이벤트에 따라 어떤 새로운 오버레이(모달) 정보를 overlayState에 추가할지를 미리 등록(약속)해둡니다.
이 시점부터, emitter는 "open" 이벤트가 오면 OverlayProvider의 open 함수를 실행해야 한다는 것을 "알고 있습니다".


  1. 사용자 명령
function MyModal({ isOpen, close, overlayId, unmount }) {
  return (
    isOpen && (
      <div>
        <h1>모달입니다!</h1>
        <button onClick={close}>닫기</button>
      </div>
    )
  );
}

// 오버레이 열기!
overlay.open(MyModal);
// open 함수는 이렇게 동작
const open = (controller) => {
  // controller = MyModal
  const overlayId = randomId();
  const componentKey = randomId();
  const dispatchOpenEvent = createEvent('open');
  // 오버레이를 띄워달라는 이벤트를 보냄
  dispatchOpenEvent({ controller, overlayId, componentKey });
};

사용자가 overlay.open을 호출하면, 내부적으로 dispatchOpenEvent 를 통해 emitter.emit('open', { ... })가 실행됩니다.


  1. 연결: emitter가 약속을 실행
// emitter.js
const listeners = new Map();

export const emitter = {
  on(eventName, callback) {
    if (!listeners.has(eventName)) {
      listeners.set(eventName, []);
    }
    listeners.get(eventName).push(callback);
  },

  emit(eventName, data) {
    const eventListeners = listeners.get(eventName);
    if (eventListeners) {
      eventListeners.forEach((cb) => cb(data));
    }
  },
};

emitter는 'open' 이벤트가 발생하면, 등록했던 OverlayProvider의 open 함수를 실행합니다.
이 함수는 overlayDispatch로 상태를 변경합니다.

4. 실제 오버레이 렌더링

<OverlayContextProvider value={overlayState}>
    {overlayState.overlayOrderList.map((item) => {
   const { id: currentOverlayId, componentKey, isOpen, controller: Controller } = overlayState.overlayData[item];

    return (
        // 오버레이를 실제로 렌더링할 때
        <ContentOverlayController
          isOpen={isOpen}
          overlayId={overlayId}
          controller={controller} // = MyModal
          overlayDispatch={...}
        />
        })}
</OverlayContextProvider>

상태가 변경되면, OverlayProvider가 context로 내려주는 overlayState가 바뀌고,
overlayOrderList를 순회하며, 각 오버레이에 대해 ContentOverlayController를 렌더링합니다.

// controller(MyModal)를 실제로 화면에 렌더링할 때
<Controller
  isOpen={isOpen}
  overlayId={overlayId}
  close={() => overlayDispatch({ type: 'CLOSE', overlayId })}
  unmount={() => overlayDispatch({ type: 'REMOVE', overlayId })}
/>

open 함수에 넘긴 함수(컴포넌트)는, 실제로 화면에 렌더링될 때,
ContentOverlayController가 props(overlayProps)를 만들어서 넣어줍니다.
이때, isOpen, overlayId, close, unmount 등의 props가 전달됩니다.

<MyModal
  isOpen={true}
  overlayId="랜덤ID"
  close={함수}
  unmount={함수}
/>

결국 MyModal이 이렇게 호출됨:

6️⃣ openAsync의 동작

const open = (controller: OverlayControllerComponent, options?: OpenOverlayOptions) => {
  const overlayId = options?.overlayId ?? randomId();
  const componentKey = randomId();
  const dispatchOpenEvent = createEvent('open');

  dispatchOpenEvent({ controller, overlayId, componentKey });
  return overlayId;
};

const openAsync = async <T>(controller: OverlayAsyncControllerComponent<T>, options?: OpenOverlayOptions) => {
  return new Promise<T>((resolve) => {
    open((overlayProps) => {
      const close = (param: T) => {
        resolve(param);
        overlayProps.close();
      };

      const props = { ...overlayProps, close };
      return controller(props);
    }, options);
  });
};

참고로 openAsync 안에 open함수에서 (overlayProps) => { ... } 이 화살표 함수가 open 함수의 props에서 controller 컴포넌트를 뜻합니다. 즉 open에 넘기는 함수는 "컴포넌트"다.

open((overlayProps) => { ... }, options);

open(controller, options?)

아무튼 openAsync는 "오버레이가 닫힐 때, 닫힐 때의 값을 Promise로 돌려주고 싶다”가 핵심입니다.원래 overlayProps.close는 "오버레이를 닫는 함수"입니다.
하지만 openAsync에서는 "오버레이를 닫으면서, 동시에 Promise도 resolve(완료)하고 싶다!"이런 특별한 동작이 필요합니다. 그래서 close를 덮어씌워 아래처럼 새로운 close 함수를 만듭니다

const close = (param: T) => {
  resolve(param);           // 1. Promise를 완료시킴
  overlayProps.close();     // 2. 실제로 오버레이를 닫음
};

그리고 이 close를 기존 overlayProps.close 대신 props로 넘깁니다:
오버레이 내부(MyModal 등)에서 props.close(param)을 호출하면,

  1. Promise가 resolve됨 (openAsync의 결과값이 됨)

  2. 오버레이가 닫힘

즉, openAsync로 띄운 오버레이는 닫힐 때 값을 외부로 전달할 수 있다!
resolve의 파라미터는 Promise가 성공했을 때 반환하고 싶은 값입니다. 이 값은 .then() 또는 await에서 받아서 사용할 수 있어요.

정리

  • Context는 구독 기반 구조지만, 불필요한 구독은 리렌더링 문제를 유발합니다.

  • Context 두 개로 나누면 어느 정도 해결되지만, 명령은 여전히 React 내부에서만 가능합니다.

  • overlay-kit은 구조적 분리와 이벤트 시스템을 통해, 성능과 유연성 모두를 확보한 설계를 보여줍니다.

27 views

More from this blog

Agent Skill로 README 자동화하기

저는 요즘 업무에서 AI 에이전트를 적극적으로 활용하여 반복적인 업무를 최대한 AI로 자동화하기 위해 공부 중인데요. 이번에 했던 작업 중 하나가 README 파일 생성 자동화입니다. AI에게 README를 써달라고 하면 꽤 그럴싸한 결과물이 나옵니다. 하지만 여러 프로젝트에 반복하다 보면 프롬프트가 모호할 때마다 매번 다른 방식으로 문제를 풀기 때문에 들

Mar 31, 20268 min read8

배열을 처리하는 세 가지 방법 (chaining, reduce, for loop)

개발을 하다 보면 배열 데이터를 가공할 일이 정말 많은데요.저는 평소에는 filter, map 같은 메서드 체이닝 방식을 주로 사용합니다. 읽기 쉽고, 데이터 흐름이 단계별로 명확하게 보이기 때문이죠. 하지만 어떠한 계기로(?) 인해 이런 의문을 가지게 되었습니다.. "만약 데이터가 수천만 건으로 늘어난다면? 배열을 여러 번 순회하는 체이닝 방식이 성능 병목을 일으키지는 않을까?" 혹은 "reduce 하나로 합치는 게 베스트일까?" 이 질문을 ...

Feb 4, 20263 min read19

[React] 좋은 추상화란 무엇인가(2) 잘못된 추상화는 중복보다 비싸다

지난 글을 보면 성급했던 재사용 컴포넌트 경험으로 고생을 한 적이 있었는데요.(아직 안 보셨다면? [1편: 좋은 추상화란 무엇인가(1)?] ) 애초에 왜 이런 고생을 했을까를 생각해 보면이유는 단순했습니다. 중복 코드가 거슬리니 바로 추상화를 해버린 것이었죠..똑같은 실수를 반복하지 않기 위해 저만의 기준이 필요하다고 느꼈습니다. 이에 대해 이미 깊이 고민하셨던 Cher Scarlett님과 Kent C. Dodds님의 글을 보면서, 제가 취해야...

Dec 16, 20253 min read25

[React] 좋은 추상화란 무엇인가(1) ? 유연한 컴포넌트 설계를 위한 제어의 역전

재사용을 위해 만든 컴포넌트가, 시간이 지날수록 오히려 재사용하기 어려워진 경험이 있으신가요? 저는 최근 프로젝트에서 SearchFilter 컴포넌트가 그러한데요 프로젝트에는 여러 목록 페이지가 존재했고, 페이지마다 검색 조건이 제각각이었습니다. 그래서 처음에는 Configuration 배열만 넘기면 알아서 그려주는 컴포넌트를 만들었습니다. // ❌ 초기의 설정 기반 접근 (Configuration) const filterFields = [ ...

Dec 9, 202510 min read36

React 시대의 함수형 프로그래밍

바닐라 JS처럼 개발자가 DOM과 상태 변경을 직접 제어하던 시기에는 불변성·순수 함수 같은 함수형 프로그래밍 철학이 지금처럼 중요하지는 않았습니다. 하지만 React(Vue/Redux/SWR/Recoil 등)처럼 선언적·상태 기반 UI가 등장하면서 상태를 “데이터 흐름의 스냅샷”으로 다뤄야 했고, 그때부터 함수형 프로그래밍이 중요해지기 시작했습니다. 🔥 바닐라 JS에서는 왜 FP가 별로 중요하지 않았을까? 바닐라 JS 시대의 UI 개발은...

Dec 8, 20255 min read35
D

Dlog!

39 posts

배우고, 나누며, 함께 성장하는 개발자 🚀