모든 React 개발자는 결국 똑같은 벽에 부딪힙니다. 최상위 App 컴포넌트에서 사용자(user) 객체를 가져온 뒤, 이를 아래로, 또 아래로 전달합니다. 라우트 래퍼를 지나, 레이아웃 셸을 지나, 사이드바 컨테이너를 거쳐, 겨우 세 단계 아래에 있는 아주 작은 아바타 컴포넌트가 프로필 사진을 표시할 수 있게 말이죠. 중간에 있는 컴포넌트들은 해당 user 객체에 관심이 없습니다. 그저 택배를 전달할 뿐이죠. 이것이 바로 프롭 드릴링(prop drilling)이며, 이는 깔끔한 컴포넌트 트리를 짜증 나는 '전화기 게임'으로 변질시킵니다.
진짜 고통은 데이터의 형태가 바뀔 때 시작됩니다. 예를 들어, 백엔드에서 user.avatar 대신 user.profile.avatar와 같이 데이터를 중첩해서 보내기 시작한다고 가정해 봅시다. 갑자기 데이터를 직접 사용하지도 않는 다섯 개의 파일에 있는 TypeScript 인터페이스나 PropTypes를 수정해야 하는 상황이 벌어집니다. 바로 이때 React Context API가 등장합니다.
Context가 데이터 흐름을 재구성하는 방식
Context를 집 중앙에 놓인 와이파이 공유기라고 생각해보세요. 공유기가 없다면 노트북에 신호를 보내기 위해 모든 방에 이더넷 케이블을 길게 늘어뜨려야 할 것입니다. 하지만 공유기가 있다면, 공유기는 공중으로 신호를 송출하고 올바른 비밀번호를 가진 모든 기기는 직접 연결될 수 있습니다. 벽은 상관없습니다.
React 관점에서 보면, 앱의 루트(root)는 모든 계층에 배달원 역할을 요구하지 않고도 컴포넌트 트리를 통해 데이터를 브로드캐스트할 수 있습니다. 어떤 중첩된 컴포넌트라도 해당 브로드캐스트를 구독하여 자신에게 필요한 데이터를 정확히 받아올 수 있습니다.
세 가지 핵심 요소
Context API는 세 가지 구성 요소로 요약됩니다.
**React.createContext()**는 브로드캐스트 채널을 설정합니다. 이는 Provider와 (이전 코드의 경우) Consumer를 포함하는 객체를 반환합니다. 특정 기능에 대해 이 함수는 한 번만 호출하면 됩니다.
Provider는 트리의 특정 섹션을 감싸는 컴포넌트입니다. value라는 이름의 props를 하나 받습니다. 이 props에 담긴 내용은 그 깊이가 얼마나 깊든 상관없이 모든 하위 자손 컴포넌트에서 사용할 수 있게 됩니다.
useContext는 함수형 컴포넌트가 해당 브로드캐스트에 접속할 수 있게 해주는 Hook입니다. 컴포넌트 내부에서 생성한 context 객체를 useContext에 전달하면 현재 값을 반환합니다. 그게 전부입니다. 래퍼도, 추가적인 props도 필요 없습니다.
Hooks가 도입되기 전에는 render props와 함께 Consumer 패턴을 사용해야 했습니다. 작동은 했지만, 들여쓰기가 깊어지고 래퍼 컴포넌트들로 인해 코드가 지저분해졌습니다. useContext는 이 모든 것을 함수 본문 안의 단 한 줄로 단순화했습니다.
Context가 실제로 유용한 경우
습관적으로 Context를 사용하지는 마세요. Context는 트리의 서로 다른 가지에 있는, 서로 관련 없는 많은 컴포넌트가 공유하는 데이터를 위해 설계되었습니다. 적절한 사례는 다음과 같습니다:
- 테마 설정. 단순히 라이트 모드나 다크 모드뿐만 아니라, 간격 토큰(spacing tokens), 컬러 팔레트, 폰트 크기 등을 포함합니다. 이러한 값을 모든 스타일링된 버튼과 모달에 일일이 전달하는 것은 금방 지치는 일입니다.
- 사용자 인증. 로그인 상태, 권한 배열, 또는 현재 사용자 객체입니다. 헤더 바, 대시보드 위젯, 프라이빗 라우트 가드(private route guard)는 트리의 서로 다른 구석에 위치할 수 있습니다.
- 언어 설정. 로케일 문자열, 날짜 형식, 통화 기호 등입니다. 폼 라벨(form label)과 같은 깊은 곳의 리프(leaf) 컴포넌트들은 경로상의 모든 부모 컴포넌트가 이를 알 필요 없이 이 정보가 필요합니다.
- 장바구니 데이터. 아이템 개수, 총액, 장바구니 담기 함수 등입니다. 헤더 배지와 결제 페이지는 동일한 상태를 필요로 하지만, 보통 완전히 다른 레이아웃 가지 아래에 위치합니다.
실전 테마 스위처 예제
Context가 작동하는 모습을 확인하는 가장 명확한 방법 중 하나는 테마 토글을 만드는 것입니다. 실제로 중요한 세부 사항을 놓치지 않고 구현하는 방법은 다음과 같습니다.
첫째, ThemeContext.js 파일을 만듭니다. React.createContext()를 호출하고 그 결과를 저장합니다. 그런 다음 useState나 useReducer를 사용하여 현재 테마를 관리하는 ThemeProvider 컴포넌트를 구축합니다. 자식 컴포넌트들을 context의 Provider로 감싸고, 현재 테마와 이를 토글하는 함수를 모두 포함하는 객체를 전달합니다. ThemeProvider와 context 객체 자체를 모두 export 합니다.
둘째, 앱의 엔트리 포인트(entry point)로 이동합니다. ThemeProvider를 가져와서 앱 전체를 이 컴포넌트로 감쌉니다. 이 단계를 건너뛰면, 나중에 context를 읽으려는 모든 요소는 기본값(default value)만 보게 됩니다.
셋째, Header 또는 Content 컴포넌트 내부에서 context 객체와 useContext를 가져옵니다. Hook을 호출하여 테마와 토글 함수를 구조 분해 할당하고, CSS 클래스를 조건부로 적용합니다. 토글을 호출하는 버튼을 추가합니다. 이 컴포넌트는 부모로부터 theme props를 전혀 받지 않습니다. 공중에서 신호를 직접 끌어오는 것입니다.
프롭 드릴링, Context, 아니면 Redux?
이 도구들 사이의 선택은 충성도의 문제가 아니라, 상태(state)의 형태에 관한 문제입니다.
Prop drilling은 23단계 정도의 깊이에서는 전혀 문제가 없습니다. 명시적이고 IDE에서 추적하기 쉬우며, 의존성을 명확하게 유지해 줍니다. 문제는 동일한 prop을 67개 층을 거쳐 전달하기 시작할 때 발생합니다.
Context API는 React 자체에 포함되어 있습니다. 즉, 추가적인 번들 크기 증가나 외부 설정이 필요 없다는 뜻입니다. 테마나 사용자 프로필처럼 변경 빈도가 낮은 데이터를 포함하여, 소규모에서 중규모의 전역 상태를 처리하는 데 매우 훌륭합니다.
Redux는 추가 라이브러리 설치와 보일러플레이트(boilerplate) 작성이 필요합니다. 하지만 상태 로직이 복잡하거나, 여러 상태 슬라이스(slice)가 깊게 상호작용하거나, 타임 트래블 디버깅(time-travel debugging) 및 미들웨어가 필요한 경우에는 그 가치를 발휘합니다. 단순한 전역 데이터에 Redux를 사용하는 것은 과합니다(overkill).
아무도 말하지 않는 성능의 현실
주니어와 시니어의 구현을 가르는 결정적인 차이가 여기에 있습니다. Context Provider의 값이 변경되면, 해당 컨텍스트를 사용하는 모든 컴포넌트가 리렌더링됩니다. 해당 컴포넌트가 관심을 갖는 특정 슬라이스가 변하지 않았더라도 상관없습니다. React는 새로운 참조(reference)를 감지하고 업데이트를 예약합니다.
만약 애플리케이션의 전체 상태를 하나의 거대한 StoreContext에 쏟아부었다면, 사실상 UI 전체를 하나로 묶어버린 것과 같습니다. 테마 설정을 변경하는 것만으로도 쇼핑 카트, 대시보드 차트, 알림 목록이 모두 리렌더링됩니다. 이는 불필요한 작업입니다.
컨텍스트를 도메인별로 분리하세요. 시각적 설정을 위한 ThemeContext, 프로필 데이터를 위한 UserContext, 커머스 상태를 위한 CartContext를 각각 유지하는 식입니다. 사용자가 표시 이름을 수정하면, 제품 그리드에는 영향을 주지 않고 헤더만 업데이트됩니다. 또한, Provider의 value prop에 무엇을 전달하는지 주의해야 합니다. 렌더링 중에 { theme, toggleTheme }와 같은 객체 리터럴을 인라인으로 전달하면, 매 렌더링마다 새로운 참조가 생성되어 불필요한 업데이트를 유발합니다. 값이 함수나 비원시 데이터(non-primitive data)를 포함한다면 useMemo를 사용하여 해당 형태를 안정화하세요.
시간을 낭비하게 만드는 실수들
팀들이 반복해서 저지르는 두 가지 실수가 있습니다.
컨텍스트 객체를 export하는 것을 잊는 경우. ThemeProvider 컴포넌트만 export하고 나서 useContext(ThemeProvider)를 호출하려고 하는 실수를 하기 쉽습니다. 하지만 작동 방식은 그렇지 않습니다. Hook에는 래퍼 컴포넌트가 아니라 createContext가 반환한 컨텍스트 객체가 필요합니다. Provider만 export한다면, 이를 사용하는 컴포넌트(consumer)는 가져올(import) 대상이 없게 됩니다.
Provider 외부에서 useContext를 호출하는 경우. Hook은 createContext에 전달한 기본값을 반환합니다. 기본값을 전달하지 않았다면 undefined를 받게 됩니다. 컴포넌트 트리에서 consumer가 Provider보다 DOM 상단에서 렌더링되거나 Provider가 아예 누락되었다면, 데이터는 전달되지 않습니다. index 또는 root 파일에서 앱을 실제로 감싸고 있는지 다시 한번 확인하세요.
핵심 요약
React Context는 상태 관리의 혁명이 아닙니다. 이는 특정 공간적 문제, 즉 모든 계층을 우체국으로 만들지 않고도 멀리 떨어진 컴포넌트에 데이터를 전달하기 위한 타겟팅된 도구입니다. 진정으로 전역적인 데이터에만 사용하고, 렌더링 성능을 보호하기 위해 컨텍스트를 도메인별로 분리하며, 신호를 읽기 전에 항상 올바른 Provider로 트리를 감싸세요. 이러한 습관을 잘 들인다면, 컴포넌트 트리를 깔끔하고 빠르게, 그리고 이해하기 쉽게 유지할 수 있습니다.
