Context API 는 전역 상태 관리 도구일까? Redux, MobX, Recoil, Jotai, Zustand 등 많은 전역 상태 관리 라이브러리가 있는데 언제 Context API 를 사용하면 좋을까? Context API 는 성능에 안좋다는데 진짤까?
이 질문들은 내가 처음 Context API를 접했을 때부터 지금까지도 종종 헷갈리곤 했던 부분들이다. 그래서 이번 글에서는 Context API에 대한 개인적인 경험과 생각을 공유하며, 이 도구를 언제 사용하면 좋은지, 그리고 성능에 어떤 영향을 미치는지에 대해 이야기해보려고 한다.
혹시 잘못 이해한 내용이 있다면 댓글로 피드백 부탁드립니다🥹
"context API 를 사용하면 context 값이 변경될 때 context 를 구독하는 모든 컴포넌트가 리렌더링 되기 때문에 성능 문제를 초래할 수 있다."
이 말 자체가 틀리지는 않지만, 이 개념이 머릿속에 깊이 박혀 있어서 context api 를 사용하면 곧바로 성능 이슈가 생긴다라고 단순하게 연결지어 생각했던 적이 있었다.
그렇다면 실제로 context API 를 사용했을때 리렌더링이 어떻게 일어나는지 코드를 통해 살펴보면서 내가 그동안 오해했던 부분에 대해 정리해봤다.
const Parents = () => {
const [number, setNumber] = useState(0);
return (
<MyContext.Provider value={[number, setNumber]}>
<ChildOne />
</MyContext.Provider>
);
}
export default Parents;
export const useCount = () => useContext(CountContext);
const ChildOne = () => {
console.log("Child One 렌더링!!");
return <ChildTwo />;
};
export default ChildOne
const ChildTwo = () => {
const [number, setNumber] = useContext(MyContext);
console.log("Child Two 렌더링!!");
return (
<>
<span>{number}</span>
<button onClick={() => setNumber(prev => prev + 1)}>+</button>
</>
)
};
export default ChildTwo
위 코드에서 ChildOne 컴포넌트는 useContext를 사용하지 않는데도, ChildTwo 컴포넌트에서 버튼을 클릭하면 ChildOne 컴포넌트가 함께 리렌더링된다.
하지만, 이는 단순히 Context를 사용했기 때문이 아니라 부모 컴포넌트인 Parents의 number 값이 ChildTwo 컴포넌트에 의해 변경되면서, 부모 컴포넌트가 다시 렌더링되고, 그로 인해 모든 자식 컴포넌트가 리렌더링되는 현상이다.
Context를 사용하지 않더라도 부모 컴포넌트의 상태값이 바뀌면 자식 컴포넌트가 리렌더링되는 것은 매우 자연스러운 현상이다.
많은 글에서 이러한 상황을 두고 "Context 때문에 전체 리렌더링이 발생한다"고 설명하는 경우가 있는데, 이는 오해를 불러일으킬 수 있다. 정확히 말하자면, 리렌더링의 원인은 Parents 컴포넌트의 상태 변화이지, Context 자체가 아니다.
그렇다면 어떤 점을 두고 Context API 를 사용하면 성능 문제를 초래할 수 있다고 하는걸까?
위와 같은 구조의 컴포넌트가 있다고 하자.
여기서 A 컴포넌트는 Provider 컴포넌트로, 하위 컴포넌트들에 상태를 전달하는 역할을 한다.
만약 Context를 구독하고 있는 E 컴포넌트에서 상태를 변화시킨다면, A부터 F까지 모든 컴포넌트가 리렌더링된다. 이는 앞서 살펴본 것처럼, 부모 컴포넌트의 상태가 변경되면 하위 컴포넌트들이 리렌더링되는 자연스러운 현상이다.
그러나, 컴포넌트 트리의 깊이가 깊어지고 애플리케이션의 규모가 커질수록, Context를 구독하지 않는 컴포넌트들까지도 리렌더링에 영향을 받게 된다. 이러한 불필요한 리렌더링이 반복되면 성능에 문제가 발생할 수 있다.
만약 Context API를 사용하면서 불필요한 리렌더링이 발생한다고 느껴진다면, useMemo, useCallback 같은 React의 메모이제이션 훅을 사용해 리렌더링을 방지할 수 있다. 이러한 훅을 통해 컴포넌트가 필요할 때만 리렌더링되도록 설정하면 성능을 최적화할 수 있다.
아래 예제에서는 React.memo 와 같은 메모이제이션을 활용해 props 가 변경되지 않는 한 리렌더링 되지 않도록 설정해줬다. 이렇게 하면 ChildOne 컴포넌트는 더 이상 리렌더링이 발생하지 않는다.
import React from 'react';
import ChildTwo from './ChildTow';
const ChildOne = () => {
console.log('Child One 렌더링!!');
return <ChildTwo />;
};
export default React.memo(ChildOne);
따라서 규모가 큰 애플리케이션에서는 Context를 구독하지 않는 컴포넌트까지 불필요하게 리렌더링되지 않도록 최적화하는 작업이 중요해진다. 하지만 이러한 최적화 작업은 반복적이고 복잡할 수 있다.
또한, useMemo와 useCallback을 과도하게 사용하면 메모리 사용량이 증가할 수 있으므로, 최적화를 적용할 때는 신중하게 고려해야 하는 부분이다.
Context API 는 전역 상태 관리 도구일까?
이 질문에 답하기 위해서는 우선 Context API 라는 걸 왜 만들었을까를 생각해봐야 한다.
리액트 공식문서에서 Context API 를 아래와 같이 사용하라고 나와있다.
Usage
Passing data deeply into the tree
Updating data passed via context
..(생략)
- 트리 깊숙이 데이터를 전달할때
- context 를 통해 전달된 데이터를 업데이트 할때
그 어디에도 '전역' , '상태 관리' 라는 단어는 찾아볼 수 없다. 대신 '트리', '전달', '업데이트' 라는 단어가 눈에 들어온다.
정리하자면, Context API 는 리액트 트리 깊은 곳에 데이터를 전달하고, 데이터가 변경되면 UI 를 업데이트 하기 위해 사용한다.
즉, A 컴포넌트에서 F 컴포넌트로 데이터를 전달하려면 여러 중간 컴포넌트를 거쳐야 했던 것을 Context API 를 사용하면 하위 컴포넌트에 value 값을 직접적으로 전달할 수 있다.
props drilling : A 컴포넌트 -> B 컴포넌트 -> C 컴포넌트 -> D 컴포넌트 -> E 컴포넌트-> F 컴포넌트
Context API :
<Provider value={value}>{children}</Provider>
Provider를 어떻게 설정하느냐에 따라 다르겠지만, 만약 최상위에 Provider를 배치하면 그 하위의 모든 컴포넌트에서 Context에 접근할 수 있기 때문에 전역적으로 상태를 전달할 수 있다는 점에서 전역 상태 관리 도구라고 볼 수 있지만,
앞서 살펴본 테스트 코드에서 알 수 있듯이, Context를 구독하는 모든 컴포넌트에서 리렌더링이 발생한다. 따라서 Context API를 전역적으로 사용할 경우, 리렌더링 이슈를 무시할 수 없다. 전역 상태로 사용하려면 최적화 작업이 필수적이며, 어떤 컴포넌트가 Context를 구독하고 있는지 일일이 관리해야 하는 번거로움도 따르기 때문에 보통 소규모 프로젝트, 작은 단위로 사용하는 것을 권장한다.
따라서 나는 주로 재사용성이 높은 컴포넌트를 만들때나 전역이 아닌 비교적 작은 단위로 데이터를 공유할때 Context 를 고려한다.
예를들어 재사용 가능한 Dropdown 을 구현할때 드롭다운의 열림/닫힘 상태와 선택된 옵션을 Context API를 사용하여 관리했다. 이처럼 드롭다운의 상태를 중앙에서 관리하고 관련된 컴포넌트에서는 쉽게 접근해 사용할 수 있고, 규모가 크지 않기 때문에 상태를 효율적으로 관리할 수 있다.
// Context 타입 정의
type DropdownContextType = {
isOpen: boolean;
toggleDropdown: () => void;
selectedOption: string | null;
selectOption: (value: string) => void;
};
// Context 생성
export const DropdownContext = createContext<DropdownContextType>({
isOpen: false,
toggleDropdown: () => {},
selectedOption: null,
selectOption: () => {},
});
// Provider 생성
const Dropdown: FC<{ children: ReactNode; className?: string }> = ({ children, className }) => {
const [isOpen, setIsOpen] = useState(false);
const [selectedOption, setSelectedOption] = useState<string | null>(null);
const toggleDropdown = () => {
setIsOpen(!isOpen);
};
const selectOption = (value: string) => {
setSelectedOption(value);
setIsOpen(false);
};
return (
<DropdownContext.Provider value={{ isOpen, toggleDropdown, selectedOption, selectOption }}>
<div className={className}>{children}</div>
</DropdownContext.Provider>
);
};
전역 상태 관리를 위해 다양한 도구들이 존재한다. Redux, MobX, Recoil, Jotai, Zustand 등 이런 라이브러리와 Context API 를 비교해봤을때, Context API 는 비동기 처리를 위한 메커니즘이 부족하다.
예를들어 리덕스나 zustand 에서는 상태 변경을 감지하고, 로깅, 비동기 작업 등을 처리할 수 있는 미들웨어를 지원하는 반면, context api 는 비동기를 처리하는 내장 도구가 없다.
액션, 리듀서, 스토어 역할이 명확히 나눠져 있어 흐름을 파악하기 쉬웠고, 각 역할에 집중할 수 있다는 점이 좋다.
물론, 이러한 명확한 역할 분담으로 인해 코드가 다소 복잡해지고 양이 많아지긴 하지만, 오히려 이 과정이 전역 상태에 대한 이해를 돕는 데 큰 도움이 되었다. 리덕스가 러닝 커브가 크다는게 단점으로 뽑히기도 하지만 오히려 초보자가 처음 공부할때 리덕스를 공부하면 이후 다른 라이브러리를 사용할때 쉽게 느껴질 것이라 생각한다.
장점
단점
최근에 Zustand를 처음 사용해본 결과, 매우 간편하다는 느낌을 받았다. Redux의 보일러플레이트 문제를 크게 느끼지 않았던 나에게도, Zustand는 많은 양의 코드가 줄어드는 것을 체감시켜주었다. 사용하기 간편하며, 최적화가 잘 되어 있어 간단한 프로젝트에서 매우 유용하다는 생각이 들었다!!
장점
단점
Context API는 주로 작은 단위에서 상태를 전달하거나 재사용 가능한 컴포넌트를 구현할 때 유용하다. 별도의 설치 없이 React 내장 도구로 사용할 수 있다는 점이 큰 장점이라 생각한다. 그러나 상태 변경에 따라 구독하는 모든 컴포넌트가 리렌더링될 수 있어, 최적화 작업에 신경 써야 한다는 점이 단점이다.
장점
단점
결국 위 도구는 상태 관리를 조금 더 쉽게 하기 위해서 사용하는 것이며, 프로젝트 규모, 취향에 따라 선택해서 사용하면 되는 것이지 어떤 것이 정답이라는 건 없다. 사이드 프로젝트에서 사용해보고 느낀 후기라 깊이가 없을 수 있지만 사용하면서 각 라이브러리의 특징이 느껴질때마다 이런 점이 좋다, 불편하다를 정리해보고 싶었다.