🎈 간단 설명
Context API는 리액트 라이브러리에 내장 된 API로, 중첩된 컴포넌트에서 부모 컴포넌트의 상태를 자식 컴포넌트에게 좀 더 쉽고 간편하게 전달하기 위해서 제공된다.
Context의 값이 변경되는 경우 동일한 Context를 사용하는 모든 컴포넌트에서 리렌더링 되는 문제가 존재한다.
👓 Context API 성능 개선의 오해
구글에서 Context API의 성능 문제 해결로 검색해보면 React.memo 또는 useCallback, useMemo를 사용하면 성능 개선이 된다는 글이 많이 보인다.
그래서 나는 React.memo 또는 useCallback, useMemo 를 사용하면, 위에서 동일한 Context를 사용하는 경우에 대한 문제를 해결할 수 있을 거라고 오해했었다. 다음 코드의 예제로 해결이 되지 않는 것을 알아보자.
코드
import { useState, createContext, useContext, useEffect } from 'react';
const MyContext = createContext();
const MyProvider = ({ children }) => {
const [value1, setValue1] = useState(0);
const [value2, setValue2] = useState(0);
return (
<MyContext.Provider value={{
value1,
value2,
setValue1,
setValue2,
}}>
{children}
</MyContext.Provider>
);
};
const ComponentC = () => {
console.log('ComponentC 렌더링');
return <div>ComponentC</div>;
};
const ComponentB = () => {
const { value1, setValue1 } = useContext(MyContext);
useEffect(() => {
const timer = setInterval(() => setValue1((prev) => prev + 1), 1000);
return () => clearInterval(timer);
}, [setValue1]);
console.log('ComponentB 렌더링');
return <div>Value: {value1}</div>;
};
const ComponentA = () => {
const { value2 } = useContext(MyContext);
console.log('ComponentA 렌더링');
return <div>Value: {value2}</div>;
};
const App = () => {
return (
<>
<MyProvider>
<h1>React Context API 최적화 예제</h1>
<ComponentA />
<ComponentB />
<ComponentC />
</MyProvider>
</>
);
};
export default App;
위의 코드를 실행해보면 ComponentB에서는 매초마다 value1 값을 1증가시키고 있다. ComponentA는 value1을 사용하지 않지만 동일한 컨텍스트를 사용하기 때문에 value1의 값이 바뀔때마다 함께 리렌더링 된다.
ComponentC는 Provider 내부에 존재하지만 리렌더링은 되지 않는다.
리액트 Dev Tools의 Highlight updates when components render 옵션으로는 하이라이트 되지만 Profiler에서는 리렌더링 되지 않은것으로 표기된다. 실제 콘솔도 출력되지 않는다.
1️⃣ React.memo 사용하기
아래와 같이 ComponentA를 React.memo로 감싸주었다. 일반적인 React.memo의 사용방법처럼 ComponentA가 사용하지 않는 상태가 변경될 때는 ComponentA가 리렌더링 되지 않을 걸 기대하였다.
const ComponentA = React.memo(() => {
const { value2 } = useContext(MyContext);
console.log('ComponentA 렌더링');
return <div>Value: {value2}</div>;
});
하지만 실제로 적용해보았을 때 리렌더링이 계속되는 것을 확인하였다. 아래와 같이 콘솔에 계속 출력되는 것을 확인할 수 있었다.
추가로 공식문서의 내용 중 일부이다. memo를 사용하더라도 컨텍스트가 변경되는 경우 리렌더링이 될 수 있다는 내용이다. 부모로부터 Props를 전달받는 경우에만 리렌더링을 방지할 수 있다.
Even when a component is memoized, it will still re-render when a context that it’s using changes. Memoization only has to do with props that are passed to the component from its parent.
즉, 이 방법은 컨텍스트 API가 가진 문제를 해결하기 위해서 사용하는 것이 아니었다(아래에 나오지만 Context API 사용시 최적화를 위한 방법일뿐...).
2️⃣ useCallback, useMemo 사용하기
두번째 방법은 useCallback, useMemo 사용하기이다. 컨텍스트의 value로 사용할 상태를 useMemo를 사용해서 반환해준 값을 사용한다. MyProvider에서 관리하는 상태를 다음과 같이 수정하였다.
const MyProvider = ({ children }) => {
const [value1, setValue1] = useState(0);
const [value2, setValue2] = useState(0);
const contextValue = useMemo(() => ({
value1,
setValue1,
value2,
setValue2,
}), [value1, value2]);
return (
<MyContext.Provider value={contextValue}>
{children}
</MyContext.Provider>
);
};
이 방법 역시 적용했을 때 ComponentA의 리렌더링을 막을 수 없었다. 조금만 생각해봐도 value1이 변경되면 contextValue가 변경이 되기때문에 이는 동일 컨텍스트를 가진 모든 컴포넌트를 리렌더링하는 것은 피하는 코드로는 보이지 않는다.
📯 Context API 최적화
그럼 위의 두가지 방법이 왜 언급되고 있느냐. 그 이유는 두가지 방법이 Context API의 문제를 해결해주지는 못하지만 Context API를 사용할 때 최적화할 수 있는 방법이기 때문이다.
1️⃣ React.memo 사용하기
ComponentA의 자식 컴포넌트인 ComponentA_1을 생성하였다.
const ComponentA_1 = () => {
console.log('ComponentA_1 렌더링');
return <div>Value: ComponentA_1</div>;
};
const ComponentA = () => {
const { value2 } = useContext(MyContext);
console.log('ComponentA 렌더링');
return (
<div>
Value: {value2}
<ComponentA_1 />
</div>
)
};
아래와 같이 코드를 수정하면 1초마다 콘솔에 ComponentA_1 렌러딩 글자가 출력되는 것을 확인할 수 있다.
이때 ComponentA_1을 React.memo를 통해 감싸준다. 그러면 ComponentA_1 렌더링이 한번만 출력되고 더 이상 출력되지 않는 것을 볼 수 있다.
const ComponentA_1 = React.memo(() => {
console.log('ComponentA_1 렌더링');
return <div>Value: ComponentA_1</div>;
});
이를 통해 컨텍스트를 사용하는 ComponentA의 리렌더링을 막을 수는 없지만 자식 컴포넌트의 리렌더링은 방지할 수 있게되었다.
2️⃣ useCallback, useMemo 사용하기
성능 개선이 되는 것을 확인하기 위해 ComponentB의 코드를 아래와 같이 먼저 변경한다.
const ComponentB = () => {
const { value1, setValue1 } = useContext(MyContext);
console.log('ComponentB 렌더링');
return <div>Value: {value1}</div>;
};
그리고 App 컴포넌트는 아래와 같이 변경한다. App 컴포넌트에서 컨텍스트 Provider와 더블어 button 컴포넌트가 추가되었다. 버튼을 클릭 하는 경우 count 상태가 1씩 증가한다.
const App = () => {
const [count, setCount] = useState(0);
return (
<>
<MyProvider>
<h1>React Context API 최적화 예제</h1>
<ComponentA />
<ComponentB />
<ComponentC />
</MyProvider>
<button onClick={() => setCount((prev) => prev + 1)}>{count} 증가</button>
</>
);
};
화면상에서 버튼을 클릭 할때마다 아래와 같이 콘솔이 출력되는 것을 확인할 수 있다. App 컴포넌트에서 관리하는 상태가 변경이 되는 것이니 자식 컴포넌트인 MyProvider가 리렌더링 되는 것은 당연하다.
MyProvider는 아래와 같이 useMemo를 사용해준다.
const MyProvider = ({ children }) => {
const [value1, setValue1] = useState(0);
const [value2, setValue2] = useState(0);
const contextValue = useMemo(() => ({
value1,
setValue1,
value2,
setValue2,
}), [value1, value2]);
return (
<MyContext.Provider value={contextValue}>
{children}
</MyContext.Provider>
);
};
그리고 MyContext의 자식 컴포넌트들은 React.memo로 감싸준다.
const ComponentC = React.memo(() => {
console.log('ComponentC 렌더링');
return <div>안녕? ContextC</div>;
});
const ComponentB = React.memo(() => {
const { value1, setValue1 } = useContext(MyContext);
console.log('ComponentB 렌더링');
return <div>Value: {value1}</div>;
});
const ComponentA = React.memo(() => {
const { value2 } = useContext(MyContext);
console.log('ComponentA 렌더링');
return <div>Value: {value2}</div>;
});
이를통해, Provider가 있는 부모 컴포넌트가 리렌더링되더라도 실제로 컨텍스트 값이 바뀌지 않았다면, 컨텍스트를 사용하는 자식 컴포넌트가 불필요하게 리렌더링되는것을 방지할 수 있다.
🛒 Context API 문제 해결하기
다시 돌아와서 Context API의 문제를 해결해보자.
1️⃣ 컨텍스트 분리
하나의 컨텍스트에서 여러 상태를 관리하는 것이 아닌 동일한 라이프 사이클을 가진 컴포넌트에서 사용되는 상태끼리 컨텍스트를 분리한다.
즉, ComponentA와 ComponentB의 리렌더링 시점은 다른데 하나의 컨텍스트에서 관리되기 때문에 문제가 되고 있다. 그래서 두개의 컴포넌트를 위한 컨텍스트를 따로 생성한다.
const MyContext1 = createContext();
const MyContext2 = createContext();
그리고 각 상태는 useMemo를 사용해서, 상태가 바뀌지 않는다면 변경되지 않도록 해준다.
const MyProvider = ({ children }) => {
const [value1, setValue1] = useState(0);
const [value2, setValue2] = useState(0);
const context1 = useMemo(() => ({ value1, setValue1 }), [value1]);
const context2 = useMemo(() => ({ value2, setValue2 }), [value2]);
return (
<MyContext1.Provider value={context1}>
<MyContext2.Provider value={context2}>
{children}
</MyContext2.Provider>
</MyContext1.Provider>
);
};
이제 콘솔을 확인해보면 ComponentB만 출력되는 것을 확인할 수 있다.
2️⃣ set 함수 분리하기
위의 예제에서는 컨텍스트로 객체를 전달하기 때문에 useMemo를 사용하였지만, 만약 원시값을 전달하고 set 함수는 다른 컨텍스트로 관리하면 다음과 같이 작성할 수 있다.
const MyContext1 = createContext();
const MyContext2 = createContext();
const MyContext3 = createContext();
const MyProvider = ({ children }) => {
const [value1, setValue1] = useState(0);
const [value2, setValue2] = useState(0);
return (
<MyContext1.Provider value={value1}>
<MyContext2.Provider value={value2}>
<MyContext3.Provider value={{ setValue1, setValue2 }}>
{children}
</MyContext3.Provider>
</MyContext2.Provider>
</MyContext1.Provider>
);
};
ComponentB에서는 다음과 같이 데이터와 set 함수 컨텍스트 2개를 사용하도록 수정한다(Component A도 변경되었다).
const ComponentB = () => {
const value1 = useContext(MyContext1);
const { setValue1 } = useContext(MyContext3);
useEffect(() => {
const timer = setInterval(() => setValue1((prev) => prev + 1), 1000);
return () => clearInterval(timer);
}, [setValue1]);
console.log('ComponentB 렌더링');
return <div>Value: {value1}</div>;
};
const ComponentA = () => {
const value2 = useContext(MyContext2);
console.log('ComponentA 렌더링');
return <div>Value: {value2}</div>;
};
마찬가지로 콘솔을 확인해보면 ComponentB만 출력되는 것을 확인할 수 있다.
이 방법 외에도 불필요한 리렌더링을 방지 하기 위한 방법으로 다음과 같은 방법을 사용할수도 있다.
- Provider의 위치를 컨텍스트를 사용하는 부분으로만 한정하기(루트에 사용하는 것이 아닌 실제 사용하는 컴포넌트만 감싸기)
- 컴포넌트를 중첩해서 사용하는것 자제하기
💦 마무리
최근에는 상태 관리를 위한 다양한 라이브러리들이 등장하며 선택지가 풍부해졌다. 하지만 리액트에 내장된 Context API는 외부 라이브러리에 의존하지 않고도 상태를 관리할 수 있다는 강점이 있다.
이러한 특징 덕분에 Context API는 별도의 라이브러리를 설치하지 않아도 되는 간편함과 유연성을 제공하며, 특히 라이브러리를 직접 개발하거나 특정 기능을 구현하기 위한 모듈을 생성할 때 높은 활용도를 자랑한다.
따라서 Context API를 사용하기 전에 주의해야 할 점들을 충분히 숙지한 후 활용하는 것이 중요할 것 같다.
'React' 카테고리의 다른 글
[React] 함수를 상태에 저장하기 (0) | 2025.03.19 |
---|---|
[Zustand] Zustand 리렌더링 (0) | 2025.03.07 |
리액트 팁 및 클린코드 - 4. 기타 (0) | 2024.11.02 |
리액트 팁 및 클린코드 - 3. Props (0) | 2024.10.26 |
리액트 팁 및 클린코드 - 2. 상태 (0) | 2024.10.25 |