본문으로 건너뛰기
Tech Blog

useContext가 모두를 깨우는 이유

글 복사 완료!

useContext는 부분 구독을 모르거든요. 그래서 selector가 따로 필요해요.

·16분·

폼 페이지를 짜다 보면 react-hook-form<FormProvider> 한 줄에 의지하게 돼요. Sidebar 에 글자 하나 더 적었을 뿐인데 Preview 영역이 깜빡일 때가 있거든요. useContext 는 부분 구독을 모르기 때문에, value 가 바뀌면 그 Context 를 보는 모든 자손이 같이 다시 그려져요. 이 자리에서 selector 패턴이 필요해져요.

FormProvider 한 줄에서 시작하는 함정

가벼운 예로 시작할게요. 좌측 Sidebar 에는 제목 input 과 색상 picker 가 있고, 우측 Preview 에는 그 값으로 만든 카드가 떠 있어요. 폼 값은 공통이라 Context 로 묶어 내려보내는 게 자연스러워요.

<FormProvider {...form}>
  <Layout>
    <Sidebar />
    <Preview />
  </Layout>
</FormProvider>

여기서 Sidebar 의 input 글자가 한 칸 늘었다고 해봐요. 폼 value 가 바뀌고, Provider 의 value 객체 참조도 새로 만들어져요. 그러면 useFormContext() 로 그 Context 를 구독하던 Preview 도 같이 리렌더돼요. Preview 안에 SVG 텍스처나 무거운 차트가 있으면 입력할 때마다 한 박자 끊기는 게 그대로 느껴져요.

여기서 보통 두 갈래 길이 보여요. 하나는 Context 를 잘게 쪼개는 길, 다른 하나는 selector 를 도입하는 길이에요. 쪼개기는 Context를 여러 개로 쪼개야 하는 이유에서 다뤘던 그 패턴인데, 폼처럼 값이 한 덩어리로 묶여 있는 경우엔 쪼개기만으로 안 풀리는 자리가 많아요. 그래서 selector 가 등장해요.

useContext가 모두를 깨우는 이유

왜 모든 자손이 다시 그려질까요. React 공식 문서가 정의 자체를 명확히 적어둬요.

"React automatically re-renders all the children that use a particular context starting from the provider that receives a different value. The previous and the next values are compared with the Object.is comparison." - React docs

Provider 가 받은 value 를 React 가 Object.is 로 이전 값과 비교해요. 다르면 그 Context 를 useContext 로 보는 자손 전부를 다시 그려요. 자손 컴포넌트 안에서 "어, 나는 그 값 안 쓰는데" 하고 거부할 방법이 없어요.

React.memo 로 감싸면 되지 않을까 싶지만, 같은 문서가 다음 줄을 못 박아둬요.

"Skipping re-renders with memo does not prevent the children receiving fresh context values." - React docs

memo 는 부모가 새 props 를 줄 때 리렌더를 건너뛰게 해주는 게 일이에요. Context 값이 바뀌어서 일어나는 리렌더는 props 와 무관하게 일어나거든요. 그래서 memo 로는 못 막아요.

해법은 두 가지예요. 첫째는 Provider value 를 useMemo 로 감싸 참조를 유지하는 거예요. 매 렌더마다 새 객체를 만들면 Object.is 가 늘 false 라 모든 구독자가 깨어나요. 둘째는 value 가 진짜로 바뀌는 게 맞을 때 그중 필요한 부분만 보는 컴포넌트만 깨우는 거예요. 두 번째가 selector 패턴이에요. 리액트는 언제 다시 그리나요에서 리렌더 트리거들을 정리했는데, Context 발 리렌더는 그중에서도 "거부할 수 없는" 쪽이에요.

selector가 하는 일

selector 는 한 줄로 줄이면 "store 에서 필요한 값을 뽑는 함수" 예요. 이 함수가 매 변경마다 실행되고, 이전 결과와 새 결과를 비교해서 다를 때만 그 컴포넌트가 리렌더돼요. react-reduxuseSelector 가 가장 친숙한 예고, 같은 발상이 Zustand 에도 들어가 있어요.

// react-redux
const userName = useSelector((state) => state.user.name);
 
// zustand
const userName = useUserStore((state) => state.user.name);

뽑은 값이 직전 값과 같으면 리렌더가 일어나지 않아요. name 만 보는 컴포넌트는 email 이 바뀌어도 가만히 있어요. 이게 부분 구독이에요. subscribe 한 줄에 숨은 옵저버 패턴에서 봤듯, selector 는 새 개념이 아니라 store 가 알림을 뿌릴 때 듣는 쪽이 필요한 값만 골라 듣는 옵저버의 변형이에요.

여기서 흔한 함정 하나만 짚을게요. selector 가 매번 새 객체를 반환하면 비교가 늘 false 라 무한 리렌더가 시작돼요.

// 매번 새 배열, 무한 리렌더
const users = useStore((s) => s.users.filter((u) => u.active));
 
// 얕은 비교로 동일성 판단
const users = useStore(useShallow((s) => s.users.filter((u) => u.active)));

react-redux 공식 문서가 한 줄로 정리해요.

"A useSelector call returning the entire root state is almost always a mistake, as it means the component will rerender whenever anything in state changes." - react-redux docs

selector 의 본질은 "뽑아내기" 예요. 통째로 가져오면 selector 를 안 쓴 거랑 같아요.

selector를 구현하는 네 갈래 길

selector 패턴을 도입할 때 손에 잡히는 옵션은 보통 네 가지예요. 각각 다른 트레이드오프가 있어요.

use-context-selector

Daishi Kato 가 만든 라이브러리예요. React Context 와 가장 가까워요. 기존 useContext(MyContext) 자리를 useContextSelector(MyContext, selector) 로 바꾸기만 하면 돼요.

import { createContext, useContextSelector } from "use-context-selector";
 
const FormCtx = createContext(initialForm);
 
function Preview() {
  const title = useContextSelector(FormCtx, (v) => v.title);
  return <Card title={title} />;
}

흥미로운 디테일이 하나 있어요. 이 라이브러리 README 가 "단순히 리렌더만 피하고 싶다면 Zustand 또는 useSyncExternalStore 를 직접 쓰는 걸 권한다" 고 적어둬요. 라이브러리 자체는 본래 Concurrent React 호환을 위한 Context API 에뮬레이션이 목적이거든요. 그러니 Context 형태를 꼭 유지해야 하는 이유가 있을 때 1순위예요.

Zustand

Zustand 는 처음부터 selector 가 기본 API 예요.

import { create } from "zustand";
import { useShallow } from "zustand/react/shallow";
 
const useFormStore = create((set) => ({
  title: "",
  color: "#000",
  setTitle: (v) => set({ title: v }),
}));
 
function Preview() {
  const title = useFormStore((s) => s.title);
  return <Card title={title} />;
}

Provider 가 아예 없어요. store 가 컴포넌트 트리 밖에 살아서 import 만 하면 어디서든 구독해요. 객체나 배열을 selector 가 반환할 때만 useShallow 로 감싸면 돼요.

Jotai

Jotai 는 selector 를 별도 개념으로 두지 않아요. atom 자체가 selector 의 단위예요.

import { atom, useAtomValue } from "jotai";
 
const titleAtom = atom("");
const colorAtom = atom("#000");
 
function Preview() {
  const title = useAtomValue(titleAtom);
  return <Card title={title} />;
}

titleAtom 만 구독하니까 colorAtom 이 바뀌어도 Preview 는 가만히 있어요. derived atom 으로 합성도 자유로워요. selector 라는 별도 함수를 안 쓰는 게 매력이고, 동시에 작은 atom 을 많이 만들어야 한다는 게 부담이에요.

useSyncExternalStore 직접 구현

라이브러리 없이 React 표준 훅만으로도 selector 를 짤 수 있어요. React 18 에 들어온 useSyncExternalStore 가 그 자리에 있어요.

function useFormSelector(selector) {
  return useSyncExternalStore(
    formStore.subscribe,
    () => selector(formStore.getState()),
  );
}

getSnapshot 의 반환값이 Object.is 로 다를 때만 React 가 리렌더해요. selector 가 부분 구독이 되는 원리가 여기서 드러나요. Zustand 도 내부에서 같은 훅을 써요. 다만 직접 쓰려면 getSnapshot 이 객체를 새로 만들지 않게 메모이즈 하는 일까지 직접 해야 해서, 실전에서는 라이브러리에 맡기는 쪽이 편해요. 이 훅 자체의 함정과 동작은 Zustand 밑에 깔린 훅의 정체에서 자세히 다뤘어요.

도입에서 시작한 시나리오로 돌아가서 같은 화면을 두 갈래로 만들어볼게요. selector 도구 없이 useContext 만 쓰는 길과, react-hook-form 의 useWatch 로 selector 를 쓰는 길이에요. 같은 결과를 다른 모양으로 보여줄 뿐, 메시지는 하나예요. 필요한 컴포넌트만 깨우는 거.

Context 를 쪼개서 여러 Provider 로

부분 구독은 selector 만의 답이 아니에요. Context 를 도메인별로 쪼개두면 useContext 만으로도 부분 구독 효과가 나요. Preview 가 TitleCtx 만 보면 ColorCtx 가 바뀌어도 가만히 있거든요.

const TitleCtx = createContext("");
const ColorCtx = createContext("#000");
const SetTitleCtx = createContext<(v: string) => void>(() => {});
const SetColorCtx = createContext<(v: string) => void>(() => {});
 
function FormProviders({ children }) {
  const [title, setTitle] = useState("");
  const [color, setColor] = useState("#000");
  return (
    <TitleCtx.Provider value={title}>
      <ColorCtx.Provider value={color}>
        <SetTitleCtx.Provider value={setTitle}>
          <SetColorCtx.Provider value={setColor}>{children}</SetColorCtx.Provider>
        </SetTitleCtx.Provider>
      </ColorCtx.Provider>
    </TitleCtx.Provider>
  );
}
 
function Preview() {
  const title = useContext(TitleCtx); // color 가 바뀌어도 가만
  return <Card title={title} />;
}

장점은 의존성이 코드에 그대로 드러난다는 거예요. Preview 가 어떤 값을 보는지 import 줄과 useContext 호출만 봐도 알 수 있어요. 단점은 필드가 늘면 Provider 가 그만큼 늘고 중첩이 깊어진다는 거고요. 폼이 5~10 필드만 넘어가도 답답해져요.

한 Context 에 selector 를 얹기

같은 화면을 useWatch 로 풀면 Provider 는 하나로 충분해요.

import { FormProvider, useForm, useFormContext, useWatch } from "react-hook-form";
 
function App() {
  const form = useForm({ defaultValues: { title: "", color: "#000" } });
  return (
    <FormProvider {...form}>
      <Layout>
        <Sidebar />
        <Preview />
      </Layout>
    </FormProvider>
  );
}
 
function Sidebar() {
  const { register } = useFormContext();
  return (
    <>
      <input {...register("title")} />
      <input type="color" {...register("color")} />
    </>
  );
}
 
function Preview() {
  const title = useWatch({ name: "title" });
  return <Card title={title} />;
}

Provider 한 줄로 폼 상태가 깊은 자손까지 흘러가요. Preview 는 useWatch({ name: "title" }) 로 title 필드만 구독해서, color 가 바뀌면 Sidebar 의 color input 만 다시 그려지고 Preview 는 가만히 있어요. useFormContext().watch() 와 다르게 호출한 컴포넌트 레벨로 리렌더가 격리돼요. selector 패턴이 도메인 라이브러리 안에 흡수된 모양이에요.

react-hook-form 외 다른 selector 도구도 모양만 살짝 다를 뿐 같은 자리에 들어가요. use-context-selector 면 useContextSelector(FormCtx, v => v.title), Zustand 면 useFormStore(s => s.title) 이에요. store 의 위치 (Context 안 vs 트리 밖) 와 setter 호출 방식만 차이가 있고, 부분 구독이라는 핵심은 같아요.

어느 길을 고를까

폼 필드가 적고 도메인이 깔끔하게 갈리면 길 1 (Context 쪼개기) 이 가장 가벼워요. 필드가 늘거나 동적이거나, 이미 react-hook-form 같은 도메인 라이브러리를 쓰고 있다면 길 2 (selector) 가 자연스럽고요.

selector 도구 자체의 선택을 짧게요. Context 형태를 유지해야 하면 use-context-selector, 새 store 가 필요하면 Zustand, 작은 단위 합성이 우선이면 Jotai, 외부 시스템과 직접 동기화하면 useSyncExternalStore 직접 구현이에요. Zustand 와 Jotai 가 어디서 갈리는지는 따로 한 글로 풀었어요.

React 공식 문서가 Context 챕터 시작에서 던지는 말로 닫을게요. "props 가 몇 단계 깊게 전달된다고 해서 그 정보를 context 에 넣어야 한다는 뜻은 아니에요." Context 를 너무 쉽게 꺼내지 말고, 정말 깊은 트리에 값을 흘려보내야 할 때만 꺼내요. 그리고 그 자리에 도착했다면, useContext 한 줄로 끝내지 말고 selector 한 줄을 더 떠올리세요.

참고 자료

관련 글