관심 없는 컴포넌트 계층을 통해 props를 전달하는 것은 매우 번거로운 작업입니다. 어느 날은 기능을 출시하고 있는데, 다음 날은 단 하나의 prop 이름을 바꾸기 위해 파일 6개를 수정하고 있는 자신을 발견하게 됩니다. 이것이 바로 Prop Drilling의 핵심입니다. 부모는 데이터를 가지고 있고, 깊숙한 곳에 있는 자식은 그 데이터가 필요하며, 그 사이의 모든 컴포넌트는 전달자가 됩니다. 앱은 여전히 작동하지만, 코드베이스는 취약해집니다. 중간 계층 하나를 제거하면 트리 절반이 무너지고, 타입을 하나 바꾸면 TypeScript가 세 개의 디렉토리에 걸쳐 에러를 뱉어냅니다. React Context API는 이러한 중간 매개체들을 완전히 제거하기 위해 존재합니다.

Prop Drilling의 실제 모습

표준적인 앱 셸을 상상해 보세요. 현재 사용자를 가져오는 App 컴포넌트가 있습니다. App 내부에는 Layout이 있고, Layout 안에는 Sidebar가, Sidebar 안에는 Navigation이 중첩되어 있으며, 마지막으로 Navigation 안에는 실제 사용자 객체가 필요한 UserAvatar가 있습니다.

코드는 결국 다음과 같은 모습이 됩니다:

function App() {
  const user = { name: 'Aarav', role: 'admin' };
  return <Layout user={user} />;
}

function Layout({ user }) {
  return <Sidebar user={user} />;
}

function Sidebar({ user }) {
  return <Navigation user={user} />;
}

function Navigation({ user }) {
  return <UserAvatar user={user} />;
}

Layout, Sidebar, Navigation은 사용자 객체를 아래로 전달하는 일 외에는 아무것도 하지 않습니다. 이들은 소유하지도 않은 props를 쌓아두게 되고, 인터페이스는 비대해지며, 테스트를 하려면 한 번도 만지지 않는 데이터를 모킹(mocking)해야 합니다. 진짜 문제는 이 현상이 얼마나 빠르게 퍼지는가 하는 점입니다. isLoggedIn 플래그, locale 문자열, 또는 theme 값을 추가할 때마다 똑같은 과정이 반복됩니다.

Context API가 판도를 바꾸는 방법

Context API를 WiFi 공유기라고 생각하세요. 각 기기에 도달하기 위해 모든 방에 긴 케이블을 깔아두는 대신, 공유기는 공중으로 신호를 보냅니다. 범위 내에 있는 기기는 직접 연결할 수 있습니다. React 관점에서 보면, 공유기는 Provider이고, 신호는 상태(state)나 데이터이며, 기기는 useContext를 호출하는 모든 중첩된 컴포넌트입니다.

설정에는 세 가지 구성 요소가 있습니다:

  • React.createContext()는 데이터 채널을 구축합니다.
  • Provider는 트리의 특정 섹션을 감싸고 값을 전달합니다.
  • useContext 훅은 하위 컴포넌트들이 중간 props를 건드리지 않고도 그 값을 받을 수 있게 해줍니다.

여전히 트리는 존재하지만, 루트와 리프 사이의 가지들이 더 이상 '전달자 계약'에 합의할 필요는 없습니다.

처음부터 Context 구축하기

대부분의 앱이 언젠가는 라이트 모드나 다크 모드가 필요하므로, 테마 설정을 사용한 구체적인 예제를 만들어 보겠습니다.

먼저 context 객체를 생성합니다. 이것이 파이프 역할을 합니다:

import { createContext, useState, useMemo } from 'react';

const ThemeContext = createContext(null);

export function ThemeProvider({ children }) {
  const [theme, setTheme] = useState('light');

  return (
    <ThemeContext.Provider value={{ theme, setTheme }}>
      {children}
    </ThemeContext.Provider>
  );
}

export default ThemeContext;

그런 다음 애플리케이션을 provider로 감쌉니다. 보통 루트 근처에서 이 작업이 이루어집니다:

import { ThemeProvider } from './ThemeContext';

function App() {
  return (
    <ThemeProvider>
      <Layout />
    </ThemeProvider>
  );
}

이제 어떤 하위 컴포넌트든 신호를 직접 탭할 수 있습니다. 여기 UI 깊숙이 숨겨진 토글 버튼이 있습니다:

import { useContext } from 'react';
import ThemeContext from './ThemeContext';

function ThemeToggle() {
  const { theme, setTheme } = useContext(ThemeContext);

  return (
    <button
      onClick={() => setTheme(prev => prev === 'light' ? 'dark' : 'light')}
    >
      Current theme: {theme}
    </button>
  );
}

Layout, Sidebar, Navigationtheme prop을 전혀 보지 않는다는 점에 주목하세요. 이들은 정상적으로 렌더링되며, ThemeToggle은 context에서 필요한 것을 직접 가져옵니다. 배선이 외부에서는 보이지 않게 처리되는데, 이것이 바로 핵심입니다.

Context가 실제로 적합한 곳

Context는 많은 멀리 떨어진 컴포넌트들이 공유하지만, 단일 부모가 깔끔하게 소유하기 어려운 데이터에 가장 적합합니다. 좋은 후보는 다음과 같습니다:

  • 테마 설정 (라이트/다크 모드, 강조 색상, 폰트 크기 조절 등)
  • 인증 상태 (현재 사용자 객체, 로그인 상태, 세션 만료 등)
  • 언어 및 로케일 (국제화를 위한 설정)
  • 장바구니 데이터 (헤더 배지, 미니 장바구니 드롭다운, 결제 페이지 간에 동기화되어야 하는 데이터)

모든 로컬 상태를 Context에 쏟아붓고 싶은 유혹을 뿌리치세요. 두 단계 아래에 있는 폼 입력값(form input)까지 전역 방송을 할 필요는 없습니다. 진짜 횡단 관심사(cross-cutting concerns)를 위해 Context를 남겨두고, 나머지는 일반적인 props로 유지하세요.

성능 함정과 이를 피하는 방법

Context는 공짜가 아닙니다. context 값이 업데이트되면, 해당 context에 연결된 모든 컴포넌트는 자신이 관심을 갖는 값의 일부가 변경되지 않았더라도 리렌더링됩니다. 전형적인 실수는 부모가 렌더링될 때마다 Provider에 새로운 객체 리터럴을 던져 넣는 것입니다.

테마 예제에서, ThemeProvider의 부모가 업데이트되어 ThemeProvider가 리렌더링될 때마다 { theme, setTheme } 표현식은 완전히 새로운 객체를 생성합니다. React는 새로운 참조(reference)를 감지하고, 모든 소비자(consumer)가 업데이트됩니다. 테마는 거의 바뀌지 않는데 앱 상태가 자주 바뀐다면, 불필요한 렌더링 비용을 지불하게 되는 것입니다.

해결 방법은 두 가지입니다.

업데이트 빈도에 따라 context를 분리하세요. 로그인할 때 한 번 바뀌는 UserContext를 몇 초마다 업데이트되는 NotificationContext와 같은 provider로 공유해서는 안 됩니다. 정적 데이터가 휘발성 데이터와 같은 리렌더링 열차를 타지 않도록 분리해 두세요.

값이 객체나 배열인 경우 useMemo로 감싸세요. React에 안정적인 참조를 제공하세요:

export function ThemeProvider({ children }) {
  const [theme, setTheme] = useState('light');

  const value = useMemo(() => ({ theme, setTheme }), [theme]);

  return (
    <ThemeContext.Provider value={value}>
      {children}
    </ThemeContext.Provider>
  );
}

이제 theme이 실제로 변경될 때만 객체 참조가 변경됩니다. 컨텍스트를 필요로 하지만 하위에서 React.memo로 보호된 하위 컴포넌트들은 불필요한 작업을 건너뛰게 됩니다.

시간을 낭비하게 만드는 실수들

프로덕션 코드에서 여전히 발생하는 두 가지 오류는 예방하기 쉽습니다.

첫째, 컨텍스트 자체를 export하는 것을 잊는 경우입니다. Provider 래퍼만 export하고 컨텍스트 객체를 private으로 유지하면, 새로운 기능을 작성하는 개발자가 모듈을 리팩토링하지 않고는 useContext를 호출할 수 없습니다. 컨슈머가 provider와 consumer hook을 모두 깔끔하게 import할 수 있도록 컨텍스트를 export하세요.

둘째, 해당 Provider 외부에서 useContext를 호출하는 경우입니다. 만약 ThemeToggleThemeProvider로 감싸져 있지 않은 트리 분기에 렌더링된다면, hook은 createContext에 전달된 기본값을 반환하거나, 아무것도 전달하지 않았다면 undefined를 반환합니다. 이는 cannot read property of undefined와 같은 조용한 실패(silent failure)로 이어집니다. 적절한 기본값을 할당하거나 hook 호출 초기에 명확한 에러를 던짐으로써 이를 방지할 수 있습니다.

Context vs Redux: 단순함을 유지하세요

항상 Redux가 필요한 것은 아닙니다. 중간 규모의 프로젝트에서는 Context를 useState 또는 useReducer와 함께 사용하면 대부분의 상태 공유를 해결할 수 있습니다. Redux는 타임 트래블 디버깅(time-travel debugging), 복잡한 미들웨어, 또는 순차적으로 롤백되어야 하는 글로벌 트랜잭션이 필요할 때 빛을 발합니다. 만약 전체 상태 구조가 사용자 객체, 테마 문자열, 그리고 장바구니 배열 정도라면, 상태 관리 라이브러리는 활용하지도 않을 보일러플레이트(boilerplate)만 추가할 뿐입니다.

그렇다고 해서 Context가 그 자체로 완전한 상태 관리 시스템인 것은 아닙니다. Context는 단일 글로벌 스냅샷을 제공하지 않으며, 서로 관련 없는 컨텍스트 간의 업데이트를 배치(batch) 처리하지도 않습니다. Context를 데이터 레이어 전체를 위한 운영 체제로 사용하지 말고, prop drilling을 대체하는 용도로 사용하세요.

핵심 요약

관심 없는 컴포넌트들을 통해 props를 전달하는 것을 멈추세요. 트리 전체에 걸쳐 실제로 필요한 데이터를 위해 집중된 컨텍스트를 만들고, 컨슈머를 포함할 수 있을 만큼 충분히 높은 위치에 provider를 감싸며, 컬렉션이나 함수를 전달할 때는 항상 value 객체를 안정화(stabilize)하세요. Context API는 React 코드를 직관적으로 유지해 줍니다. props는 로컬에 머물고, 글로벌 데이터는 무선으로 전달되며, 컴포넌트 경계는 깔끔하게 유지됩니다.