우리는 React의 내부 메커니즘, 모범 사례, 디자인 패턴, 그리고 고급 개념들을 탐구합니다.
이 글들은 기본을 넘어 React가 내부적으로 어떻게 작동하는지 진정으로 이해하고자 하는 React 개발자들을 위해 작성되었습니다.
서론: ‘Prop Drilling’이라는 고통
상태 관리는 React 애플리케이션 개발의 근본적인 과제입니다.
우리는 이전 시리즈 글에서 useState
와 useReducer
를 탐구했지만, 이 훅들은 주로 컴포넌트의 ‘지역 상태’를 관리하도록 설계되었습니다.
만약 컴포넌트 트리에서 멀리 떨어져 있는 여러 컴포넌트가 동일한 상태에 접근해야 한다면 어떻게 될까요?
이때 우리는 ‘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)’ 메커니즘에 의존하여, 컨텍스트 값이 변경되었을 때 소비하는 컴포넌트들에게 알림을 보냅니다.
마치 라디오 방송국과 같습니다.
Provider
가 렌더링되면, 자신의 하위 트리(subtree)에 대해 ‘현재 컨텍스트’라는 방송 채널을 설정하고 값을 송출합니다.useContext
나Consumer
를 사용하는 각 컴포넌트는 이 방송 채널에 ‘구독’ 신청을 합니다.Provider
의value
가 변경되면, 방송국은 “주파수 청취자 여러분, 새로운 소식입니다!”라고 알리고, 구독한 모든 컴포넌트는 알림을 받아 리렌더링됩니다.
여기서 매혹적인 점은 이 전파가 중간에 있는 컴포넌트들을 완전히 건너뛴다는 것입니다.
부모 컴포넌트가 컨텍스트를 제공하고, 아주 깊숙이 중첩된 컴포넌트가 그것을 소비할 수 있으며, 그 사이의 컴포넌트들은 이 통신에 대해 전혀 영향을 받지도, 알지도 못합니다.
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이 변경될 때 소비자 컴포넌트의 업데이트를 유발합니다.
Provider
는 Object.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의 핵심적인 단점을 해결합니다.
- ‘선택적 구독(Selectors)’: Zustand의 가장 큰 강점입니다.
컴포넌트는 전체 스토어가 아닌, 자신이 필요한 ‘특정 상태 조각’만 구독할 수 있습니다.
따라서 관련 없는 상태가 변경되어도 리렌더링이 발생하지 않습니다. - ‘단순함’: Redux보다 훨씬 적은 보일러플레이트와 간단한 API를 제공합니다.
- ‘Provider 불필요’: 앱을
Provider
로 감쌀 필요가 없습니다.
Context vs. Redux
Redux는 React 생태계에서 가장 확고한 상태 관리 솔루션입니다.
- ‘예측 가능성’: 단방향 데이터 흐름과 불변 상태를 통해 상태 변화를 예측하고 추적하기 쉽습니다.
- ‘풍부한 생태계’: 수많은 미들웨어, 디버깅 도구(Redux DevTools의 타임 트래블 디버깅 등), 확장 기능이 존재합니다.
- ‘장황함’: 간단한 기능에도 많은 보일러플레이트 코드가 필요하며, 학습 곡선이 가파릅니다.
언제 무엇을 사용해야 할까?
- ‘Context API를 사용할 때’:
- 자주 변경되지 않는 데이터를 공유할 때 (예: 테마, 사용자 인증 정보)
- 상태 구조가 비교적 단순할 때
- 외부 라이브러리 의존성을 최소화하고 싶을 때
- 소규모에서 중간 규모의 애플리케이션
- 자주 변경되지 않는 데이터를 공유할 때 (예: 테마, 사용자 인증 정보)
- ’Zustand나 Redux 같은 외부 라이브러리를 사용할 때’:
- 애플리케이션 상태가 복잡하고 상호작용이 많을 때
- 타임 트래블 디버깅, 미들웨어 등 고급 기능이 필요할 때
- 성능이 매우 중요하여, 불필요한 리렌더링을 최소화해야 할 때
- 여러 개발자가 참여하는 대규모 애플리케이션에서 엄격한 아키텍처가 필요할 때
- 애플리케이션 상태가 복잡하고 상호작용이 많을 때
결론
React의 Context API는 전역 상태 관리를 위한 강력한 도구이며, 컴포넌트 트리를 통해 데이터를 전파하는 정교한 내부 메커니즘을 가지고 있습니다.
적절한 최적화 기법을 사용하면 성능과 유연성을 모두 잡을 수 있습니다.
하지만 복잡한 애플리케이션이나 특정 사용 사례에서는 Zustand나 Redux와 같은 라이브러리가 성능, 기능, 아키텍처 측면에서 상당한 이점을 제공할 수 있습니다.
어떤 솔루션을 선택하든, 근본적인 메커니즘을 이해하고 최적화 모범 사례를 적용하는 것이 성능 좋고 유지보수하기 쉬운 React 애플리케이션을 만드는 데 도움이 될 것입니다.