Context API는 왜 쓰고, 그렇다면 Redux는 필요없을까?
안녕하세요 오늘은 제가 평소에 궁금했던 주제로 글을 써보려고 합니다.
Context API의 기능과 왜 사용하는지, Context API의 리렌더링 이슈란 무엇인지 그리고 Redux를 대체할 수 있는지.
위 내용을 설명하는 글을 작성할려고 합니다.
우선 Context API를 사용할려면 그 개념을 알아야 합니다.
Context가 나온 이유
React는 단방향 데이터 흐름입니다. 즉, 부모 컴포넌트에서 자식 컴포넌트로 데이터를 직접 전달해야 합니다.
이 데이터 흐름은 간단해 보입니다. 그저 위에서 아래로 흐르니 말입니다. 실제로 단방향 데이터 흐름은 코드를 이해하기 쉽고 데이터 추적과 디버깅이 쉽습니다.
하지만, 이러한 흐름이 깊어진다고 생각해봅시다.
위 그림처럼 1이라는 컴포넌트에서 6이라는 컴포넌트에 데이터를 넘기는 행위를 하고 싶습니다.
하지만 저렇게 한 번에 넘겨지지 않죠, 트리 구조상 1 -> 2 -> 4 -> 6의 순서로 넘겨주어야 합니다.
뿐만 아닙니다. 다음 그림을 보도록 하겠습니다.
오른쪽의 보라색 컴포넌트가 상태를 가지고 있습니다.
그러면 어찌어찌 데이터를 하위에 넘겨서 하단에 컴포넌트에 전달할 수 있습니다.
하지만 그 상태를 왼쪽에 존재하는 컴포넌트에 필요로 하게 되는 경우는 어떡할까요? 상태 끌어올리기를 해야 합니다.
상태 끌어올리기란 상위의 컴포넌트로 상태를 올리는 행위를 뜻합니다. 즉 위 그림에선 Root 컴포넌트로 상태를 올리는 행위를 하는 거죠.
이렇게 단방향 데이터 흐름은 간단하지만 위와 같은 상황이 되어버리면 매우 복잡해집니다. 이런 상황을 해결해야 합니다.
그리서 Context API가 나왔습니다. 데이터를 전역적으로 사용할 수 있는 방법입니다.
이러면 위의 문제를 해결할 수 있습니다.
그럼, Context API는 어떤 문제점이 있을까?
문제점을 이야기하려면 기본 동작을 알아야 합니다.
먼저 Context를 만들어야 합니다.
const MyContext = React.createContext();
그러면 MyContext라는 context가 생깁니다.
그러면 하기와 같이 Provider 컴포넌트를 만들 수 있습니다.
funcrtion MyContextProvider({ children }) {
const [state, setState] = useState("Context Value");
return (
<MyContext.Provider
value={
state,
setState
}
>
{children}
</MyContext.Provider>
)
}
export default MyContextProvider;
이제 위 Provider를 사용할 컴포넌트에 묶어서 사용하면 됩니다.
<MyContextProvider>
<AComponent />
<BComponent />
</MyContextProvider>
이제 A컴포넌트와 B컴포넌트는 context의 상태를 사용할 수 있습니다.
import { MyContext } from '@/contexts/MyContext';
function AComponent() {
const { state, setState } = useContext(MyContext);
return <>{state}</>
}
export default AComponent;
혹은 다음과 같이 사용할 수 있습니다.
import { MyContext } from '@/contexts/MyContext';
function AComponent() {
return <MyContext.Consumer>{state => <>{state}</>}</MyContext.Consumer>
}
export default AComponent;
둘 동작은 동일하지만 Hook(useContext)를 사용하는 것이 좀 더 직관적이고 읽기 쉽다고 생각됩니다.
동작을 이해했으니 여기서 생기는 문제점을 알아보도록 하겠습니다.
우선 Context API를 이야기하면 가장 먼저 나오는 문제인 렌더링 이슈입니다.
이 렌더링 이슈가 왜 발생하는지 알아보도록 하겠습니다.
위 예제의 MyContextProvider 컴포넌트는 매번 Provider에 새로운 값이 지정되어 있는지 확인합니다.
그 값이 새로운 참조일 경우에 리렌더링이 발생하게 되고 해당 컨텍스트를 둘러싸고 있는 모든 컴포넌트가 리렌더링됩니다.
위의 말은 너무 추상적이라 이해가 잘 가지 않습니다. 코드로 설명해보겠습니다.
import { useState, createContext, useContext, useEffect } from "react";
import "./styles.css";
const MyContext = createContext(null);
const ChildComponentOne = () => {
useEffect(() => {
console.log("Child Component 1 is Rendered");
});
return <ChildComponentTwo />;
};
const ChildComponentTwo = () => {
const [number, setNumber] = useContext(MyContext);
useEffect(() => {
console.log("Child Component 2 is Rendered");
});
return <>
<p>{number}</p>
<button onClick={() => setNumber(prev => prev + 1)}>+</button>
<button onClick={() => setNumber(prev => prev - 1)}>-</button>
</>
};
export default function App() {
const [number, setNumber] = useState(0);
return (
<MyContext.Provider value={[number, setNumber]}>
<ChildComponentOne />
</MyContext.Provider>
);
}
Context를 이용한 간단한 카운터 예제입니다.
위의 코드에서 ChildComponentOne과 ChildComponentTwo가 있습니다.
Provider에 ChildComponentOne을 넘겨주었고 ChildComponentOne은 ChildComponentTwo를 렌더링 합니다.
그리고 렌더링이 일어날 때마다 콘솔을 찍어봤습니다. 결과는 어땠을까요?
위와 같이 ChildComponentOne은 Context를 사용하지도 않았고 그저 Context를 사용하는 컴포넌트를 렌더링 했을 뿐인데 리렌더링이 발생합니다.
Provider 하위에서 context를 구독하는 모든 컴포넌트는 Provider의 value prop가 바뀔 때마다 다시 렌더링 됩니다. Provider로부터 하위 consumer(.contextType와 useContext을 포함한)로의 전파는 shouldComponentUpdate 메서드가 적용되지 않으므로, 상위 컴포넌트가 업데이트를 건너뛰더라도 consumer가 업데이트됩니다.
컴포넌트의 state가 변경되면 해당 컴포넌트의 하위도 리렌더링 됩니다.
위에서 설명하는 shoudComponentUpdate는 최적화를 위한 라이프 사이클입니다. 이전 state, props과 현재 state, props을 비교해 달라지지 않았다면 리렌더링을 하지 않게 하는 최적화 기법인데 그것이 기본적으로 적용되지 않았으므로 Provider 내부의 컴포넌트들이 리렌더링되는 것입니다.
https://ko.reactjs.org/docs/react-component.html#shouldcomponentupdate
그래서 쓸데없는 리렌더링이 동작하는 문제를 해결하기 위해서 어떤 최적화 기법이 필요한지 알아보도록 하겠습니다.
import React, { useState, createContext, useContext, useEffect } from "react";
import "./styles.css";
const MyContext = createContext(null);
const ChildComponentOne = React.memo(() => {
useEffect(() => {
console.log("Child Component 1 is Rendered");
});
return <ChildComponentTwo />;
});
const ChildComponentTwo = () => {
const [number, setNumber] = useContext(MyContext);
useEffect(() => {
console.log("Child Component 2 is Rendered");
});
return <>
<p>{number}</p>
<button onClick={() => setNumber(prev => prev + 1)}>+</button>
<button onClick={() => setNumber(prev => prev - 1)}>-</button>
</>
};
export default function App() {
const [number, setNumber] = useState(0);
return (
<MyContext.Provider value={[number, setNumber]}>
<ChildComponentOne />
</MyContext.Provider>
);
}
위 코드는 ChildComponentOne에 React.memo를 했습니다.
React.memo는 PureComponent를 상속받는 것과 같은 동작을 합니다 (shouldComponentUpdate를 이용한 최적화 기법)
그럼 결과는 다음과 같습니다.
의도한 대로 필요한 컴포넌트만 렌더링 되었습니다. (useContext를 사용한 컴포넌트만)
그럼 된 걸까요? 이걸 검증해야 해결이 된 거겠죠. 자식 컴포넌트를 더 만들어보겠습니다.
import React, { useState, createContext, useContext, useEffect } from "react";
import "./styles.css";
const MyContext = createContext(null);
const ChildComponentOne = React.memo(() => {
useEffect(() => {
console.log("Child Component 1 is Rendered");
});
return <ChildComponentTwo />;
});
const ChildComponentTwo = () => {
const [number, setNumber] = useContext(MyContext);
useEffect(() => {
console.log("Child Component 2 is Rendered");
});
return <>
<p>{number}</p>
<button onClick={() => setNumber(prev => prev + 1)}>+</button>
<button onClick={() => setNumber(prev => prev - 1)}>-</button>
<ChildComponentThree />
</>
};
const ChildComponentThree = () => {
useEffect(() => {
console.log("Child Component 3 is Rendered");
});
return null;
}
export default function App() {
const [number, setNumber] = useState(0);
return (
<MyContext.Provider value={[number, setNumber]}>
<ChildComponentOne />
</MyContext.Provider>
);
}
음 의도한 대로라면 3은 렌더링 되면 안 되는데 렌더링이 되어버렸습니다.
네 감이 잡히는 것 같습니다. 부모가 React.memo를 적용했다 하더라도 그 자식이 context를 참조하고 변경했다면 context를 참조한 컴포넌트의 자식 컴포넌트도 리렌더링이 됩니다. 그래서 이를 최적화 하기 위해서
const ChildComponentOne = React.memo(() => {
useEffect(() => {
console.log("Child Component 1 is Rendered");
});
return <ChildComponentTwo />;
});
const ChildComponentTwo = () => {
const [number, setNumber] = useContext(MyContext);
useEffect(() => {
console.log("Child Component 2 is Rendered");
});
return <>
<p>{number}</p>
<button onClick={() => setNumber(prev => prev + 1)}>+</button>
<button onClick={() => setNumber(prev => prev - 1)}>-</button>
<ChildComponentThree />
</>
};
const ChildComponentThree = React.memo(() => {
useEffect(() => {
console.log("Child Component 3 is Rendered");
});
return null;
})
이렇게 memo로 묶어주면 딱 사용되는 컴포넌트인 2만 리렌더링이 동작하게 됩니다.
그럼 여기서 의문이 생깁니다.
그럼 React.memo를 Provider 내부 모든 컴포넌트에 사용해야 하나?
과유불급이라는 말이 적절한 것 같습니다.
같은 props로 렌더링이 자주 일어나는 경우라면 React.memo가 적절합니다. 그런 경우에는 성능상 이점을 볼 수 있습니다.
그런 경우가 아닌, props가 자주 바뀌는 경우는 React.memo의 성능상 이점을 보기 힘들 것 같습니다.
그 이유는 props가 다른 경우에 컴포넌트를 생각해보면 props 비교 함수가 매번 동작하게 됩니다.
그래서 적절하게 잘 사용하는 것이 하나의 역량이라고 생각됩니다.
그럼 Redux랑 차이는 무엇인가?
우선, Context API는 상태 관리 도구가 아닙니다. 상태 관리 자체는 직접 관리해야 합니다. Context API는 그저 전역적으로 상태를 공유해주는 기능만 수행합니다.
그에 반해 Redux는 상태를 관리해줍니다.
이게 무슨 의미인가를 생각해봅시다.
아까 저희가 Context Provider value에 어떤 값을 넣어주었죠? React.useState의 반환 값을 넣어주었습니다.
사용하는 측에서 setState를 직접 관리해주었어야 했습니다. 즉, Context API가 상태 관리를 해주는 것이 아닌 실질적인 상태 관리는 useState와 useReducer로 동작하게 되는 겁니다.
상태 관리의 정의는 다음과 같습니다.
- 초기 값을 저장한다.
- 현재 값을 읽을 수 있다.
- 값 업데이트가 가능하다.
Context API가 그런 동작은 수행하지 않습니다.
그럼 Redux는 어떤 식으로 동작 하나요?
우선 위의 상태 관리 정의에 들어맞습니다.
초기 값을 저장한다 => store의 초기 값을 지정할 수 있습니다.
현재 값을 읽을 수 있습니다 => mapStateToProps나 useSelector를 통해서 state를 읽을 수 있습니다.
값 업데이트가 가능하다 => Reducer에 Action 객체를 전달(Dispatch)해서 state를 업데이트할 수 있습니다.
그래서 결론은 Redux와 Context API는 비교 대상이 아니다는 것입니다.
추가로 Redux는 리렌더링 관련 이슈도 최적화를 해줍니다.
컴포넌트에 상태 값을 종속시키지 않고 바깥에서 할 수 있습니다.
리덕스의 흐름은 그림으로 표현하면 다음과 같습니다.
오늘은 Context API는 왜 쓰고, 그렇다면 Redux는 필요 없을까? 에 대한 글을 정리해 보았습니다.
제가 놓치고 있는 부분이나 추가로 보충하고 싶은 내용 있으시면 댓글 남겨주세요. 긴 글 읽어주셔서 감사합니다.