Skip to content

React Context API 완전 정복 - Prop Drilling을 넘어 최적화와 상태 관리 철학까지

Published: at 오전 08:48

우리는 React의 내부 메커니즘, 모범 사례, 디자인 패턴, 그리고 고급 개념들을 탐구합니다.

이 글들은 기본을 넘어 React가 내부적으로 어떻게 작동하는지 진정으로 이해하고자 하는 React 개발자들을 위해 작성되었습니다.

서론: ‘Prop Drilling’이라는 고통

상태 관리는 React 애플리케이션 개발의 근본적인 과제입니다.

우리는 이전 시리즈 글에서 useStateuseReducer를 탐구했지만, 이 훅들은 주로 컴포넌트의 ‘지역 상태’를 관리하도록 설계되었습니다.

만약 컴포넌트 트리에서 멀리 떨어져 있는 여러 컴포넌트가 동일한 상태에 접근해야 한다면 어떻게 될까요?

이때 우리는 ‘prop drilling’이라는 고통스러운 문제에 직면하게 됩니다.

마치 할아버지가 손자에게 용돈을 주기 위해, 중간에 있는 아들에게 “이거 네 아들한테 좀 전해줘”라고 부탁해야 하는 상황과 같습니다.

중간에 있는 컴포넌트들은 정작 자신은 그 데이터(prop)를 사용하지도 않으면서, 오직 자식에게 전달하기 위해 불필요하게 props를 계속해서 넘겨줘야 합니다.

바로 이 지점에서 React의 ‘Context API’가 등장합니다.

이것은 전역 상태 관리를 위한 React의 내장 솔루션입니다.

이번 글에서는 Context의 내부 작동 방식, 최적화 기법, 그리고 애플리케이션에서 효과적으로 사용하는 방법을 깊이 있게 탐구해 보겠습니다.

Context API의 내부 동작 원리

React의 Context API는 종종 ‘prop drilling’을 피하기 위한 간단한 해결책으로 제시됩니다.

하지만 그 이면에서는 실제로 어떤 일이 벌어지고 있을까요?

컨텍스트 생성하기

createContext를 호출하면, React는 두 개의 중요한 컴포넌트를 포함하는 객체를 생성합니다.

const MyContext = React.createContext(defaultValue);
// MyContext 객체는 사실 { Provider, Consumer } 형태입니다.



defaultValue는 트리에서 해당 컨텍스트를 소비하는 컴포넌트 위에 상응하는 부모 Provider가 없을 때만 사용됩니다.

실제 애플리케이션에서는 드문 경우이며, 보통은 버그의 원인이 되곤 합니다.

전파 메커니즘: ‘방송국과 라디오’

Context 시스템은 ‘구독(subscription)’ 메커니즘에 의존하여, 컨텍스트 값이 변경되었을 때 소비하는 컴포넌트들에게 알림을 보냅니다.

마치 라디오 방송국과 같습니다.

  1. Provider가 렌더링되면, 자신의 하위 트리(subtree)에 대해 ‘현재 컨텍스트’라는 방송 채널을 설정하고 값을 송출합니다.

  2. useContextConsumer를 사용하는 각 컴포넌트는 이 방송 채널에 ‘구독’ 신청을 합니다.

  3. Providervalue가 변경되면, 방송국은 “주파수 청취자 여러분, 새로운 소식입니다!”라고 알리고, 구독한 모든 컴포넌트는 알림을 받아 리렌더링됩니다.

여기서 매혹적인 점은 이 전파가 중간에 있는 컴포넌트들을 완전히 건너뛴다는 것입니다.

부모 컴포넌트가 컨텍스트를 제공하고, 아주 깊숙이 중첩된 컴포넌트가 그것을 소비할 수 있으며, 그 사이의 컴포넌트들은 이 통신에 대해 전혀 영향을 받지도, 알지도 못합니다.

function App() {
  const [theme, setTheme] = useState("light");

  return (
    // 'ThemeContext' 방송국이 'light'라는 값을 송출합니다.
    <ThemeContext.Provider value={theme}>
      <Layout>
        {" "}
        {/* Layout은 이 컨텍스트를 전혀 모릅니다. */}
        <ThemeToggle onChange={setTheme} />
      </Layout>
    </ThemeContext.Provider>
  );
}

// 이 컴포넌트는 아주 깊이 중첩될 수 있습니다.
function ThemeToggle({ onChange }) {
  // 'ThemeContext' 채널을 구독하여 현재 값을 받습니다.
  const theme = useContext(ThemeContext);

  return (
    <button onClick={() => onChange(theme === "light" ? "dark" : "light")}>
      Current: {theme}
    </button>
  );
}



Provider의 역할과 성능 함정

Provider 컴포넌트는 value prop이 변경될 때 소비자 컴포넌트의 업데이트를 유발합니다.

ProviderObject.is() 비교 알고리즘을 사용하여 값이 변경되었는지 판단합니다.

이는 이해해야 할 중요한 포인트인데, 왜냐하면 매 렌더링마다 새로 생성된 객체를 전달하는 것이 왜 성능 문제를 일으키는지 설명해 주기 때문입니다.

// ❌ 문제점: 매 렌더링마다 새로운 객체
function App() {
  const [user, setUser] = useState({ name: "Alice" });

  return (
    // App이 리렌더링될 때마다, 이 { user, setUser } 객체는 새로 생성됩니다.
    // 이전 객체와 내용은 같아도 '참조(reference)'가 다르므로, React는 값이 변경되었다고 판단합니다.
    <UserContext.Provider value={{ user, setUser }}>
      <UserProfile />
    </UserContext.Provider>
  );
}



이 예제에서, user 객체의 내용이 변경되지 않더라도, 매 렌더링마다 새로운 { user, setUser } 객체가 생성되어 소비자 컴포넌트의 불필요한 리렌더링을 유발합니다.

Context를 위한 최적화 기법

1. useMemo로 값 메모이제이션하기

가장 효과적인 기법 중 하나는 컨텍스트 값을 메모이제이션하여 매 렌더링마다 새로운 객체가 생성되는 것을 방지하는 것입니다.

function App() {
  const [user, setUser] = useState({ name: "Alice" });

  // 컨텍스트 값의 메모이제이션
  const userContextValue = useMemo(() => {
    return { user, setUser };
  }, [user]); // user가 변경될 때만 새로운 객체가 생성됩니다.

  return (
    <UserContext.Provider value={userContextValue}>
      <UserProfile />
    </UserContext.Provider>
  );
}



2. 컨텍스트 분리하기: ‘상태’와 ‘액션’의 분리

또 다른 강력한 접근 방식은 자주 변경되는 데이터와 거의 변경되지 않는 데이터를 분리하는 것입니다.

가장 일반적인 패턴은 ‘상태(state)‘와 그 상태를 변경하는 ‘액션(actions)’ 또는 ‘디스패치(dispatch)’ 함수를 별개의 컨텍스트로 나누는 것입니다.

// 두 개의 개별 컨텍스트로 분리
const UserStateContext = createContext(null);
const UserActionsContext = createContext(null);

function UserProvider({ children }) {
  const [user, setUser] = useState({ name: "Alice" });

  // 액션 함수들은 한 번만 생성되고 거의 변하지 않습니다. (useCallback 사용)
  const actions = useMemo(
    () => ({
      updateName: name => setUser(prev => ({ ...prev, name })),
      logout: () => setUser(null),
    }),
    []
  );

  return (
    <UserActionsContext.Provider value={actions}>
      <UserStateContext.Provider value={user}>
        {children}
      </UserStateContext.Provider>
    </UserActionsContext.Provider>
  );
}



이 접근 방식은 컴포넌트가 필요한 컨텍스트만 소비하게 하여 리렌더링 횟수를 줄여줍니다.

예를 들어, 이름만 업데이트하는 버튼 컴포넌트는 UserActionsContext만 소비하면 됩니다.

user 상태가 다른 이유로 변경되더라도, 이 버튼 컴포넌트는 리렌더링되지 않습니다.

Context API vs. 외부 라이브러리 (Zustand, Redux)

Context API가 강력하지만, 다른 인기 있는 전역 상태 관리 솔루션도 있습니다.

Context vs. Zustand

Zustand는 미니멀한 상태 관리 라이브러리로, Context API의 핵심적인 단점을 해결합니다.

Context vs. Redux

Redux는 React 생태계에서 가장 확고한 상태 관리 솔루션입니다.

언제 무엇을 사용해야 할까?

결론

React의 Context API는 전역 상태 관리를 위한 강력한 도구이며, 컴포넌트 트리를 통해 데이터를 전파하는 정교한 내부 메커니즘을 가지고 있습니다.

적절한 최적화 기법을 사용하면 성능과 유연성을 모두 잡을 수 있습니다.

하지만 복잡한 애플리케이션이나 특정 사용 사례에서는 Zustand나 Redux와 같은 라이브러리가 성능, 기능, 아키텍처 측면에서 상당한 이점을 제공할 수 있습니다.

어떤 솔루션을 선택하든, 근본적인 메커니즘을 이해하고 최적화 모범 사례를 적용하는 것이 성능 좋고 유지보수하기 쉬운 React 애플리케이션을 만드는 데 도움이 될 것입니다.