lottie
Seungjun's blog
blog
react && rendering (3)

컨텍스트(Context)와 렌더링 동작

리액트의 Context API는 주어진 <MyContext.Provider/> 내에 모든 하위 컴포넌트에서 단일한 사용자 지정 값을 사용하라 수 있도록 하는 메커니즘이다. 이를 사용하면, **prop**을 번거롭게 넘길 필요 없이 하위 컴포넌트에서 값을 사용할 수 있다.


Context API는 절대 상태관리 도구가 아니다 상황에 맞게 전달되는 값을 직접 관리 해야 한다. 이는 일반적으로 리액트 컴포넌트 state 내부의 값을 유지하고, 해당 데이터를 기반으로 context 값을 만드는 데 사용된다.

Context API 기초

Context provider는 <MyContext.Provider value={42}>와 같은 형태로 value prop을 받는다. 자식 컴포넌트는 컨텍스트 consumer를 렌더링하고 prop을 전달받음으로서 해당 값을 사용할 수 있다.


<MyContext.Consumer>{(value) => <div>{value}</div>}</MyContext.Consumer>
//useContext()를 사용하면 다음과 같이 쓸 수 있다.
const value = useContext(MyContext)

Context 값 업데이트

리액트는 감싸져 있는 컴포넌트가 provider를 렌더링 할 때, 컨텍스트 provider에 새로운 값이 지정되어 있는지 확인한다. 만약 해당 값이 새로운 참조인 경우, 리액트는 값이 변경되었으며 해당 컨텍스트를 사용하는 컴포넌트를 업데이트 해야 한다는 사실을 알게 된다.

 이제 컨텍스트 provider에 새로운 값을 전달하면 다음과 같이 업데이트가 진행된다.

function GrandchildComponent() {
  const value = useContext(MyContext)
  return <div>{value.a}</div>}
function ChildComponent() {
  return <GrandchildComponent />}
function ParentComponent() {
  const [a, setA] = useState(0)
  const [b, setB] = useState('text')
  const contextValue = { a, b }
  return (
    <MyContext.Provider value={contextValue}>
      <ChildComponent />
    </MyContext.Provider>)
}

위 예제에서, **ParentComponent**가 렌더링 될 때 마다 리액트는 해당 값을 **MyContext.Provider**에 기록하고, 아래로 루프를 돌면서 **MyContext**를 사용하는 컴포넌트를 찾는다. Context Provider에 새로운 값이 있다면, 해당 컨텍스트를 사용하는 모든 중첩 컴포넌트가 강제로 리렌더링 된다.


리액트 관점에서 각 Context Provider는 단일 값만 가진다. 객체, 배열, 원시 값이든 상관 없이 하나의 컨텍스트 값일 뿐이다. 현재로서는 해당 컨텍스트를 사용하는 모든 컴포넌트는 새 값의 일부만 변경되었다 하더라도, 새 컨텍스트 값으로 인한 업데이트를 건너 뛸 수 없다.

state 업데이트, 컨텍스트, 그리고 리렌더링

  • setState()를 호출하면 컴포넌트 렌더링을 큐에 집어넣는다.

  • 리액트는 재귀적으로 하위 컴포넌트를 렌더링한다.

  • Context provider는 컴포넌트에 의해 렌더링해야할 값을 받는다.

  • 위에서 언급했던 값은 보통 부모 컴포넌트의 state에 기반한다.

기본적으로 Context Provider를 구성하는 상위 컴포넌트에 대한 state 업데이트는 모든 하위 항목이 해당 Context 값을 읽는지 여부에 상관없이 다시 렌더링 되도록 한다.


위 예제에서 살펴본다면, Parent/Child/Grandchild**의 경우, GrandchildComponent는 컨텍스트가 업데이트 되어서가 아니라 **ChildComponent가 리렌더링되는 것 만으로도 리렌더링 될 수 있다는 것이다. 위 예제에서는, 불필요한 리렌더링을 최적화하려는 것이 없으므로, 리액트는 ParentComponent가 렌더링 할 때마다 ChildComponent GrandchildComponent를 렌더링 한다.

 부모가 새 컨텍스트 값을 넣는 경우, GrandchildComponent는 그 값을 사용하기 때문에 리렌더링 된다. 그러나 이는 어차피 상위 컴포넌트가 리렌더링되기 때문에 발생할 일이었을 뿐이다.

Context 업데이트와 렌더링 최적화

위 예시를 최적화 해보는 동시에, GreatGrandChildComponent를 하나 더 만들어서 살펴보자.

function GreatGrandchildComponent() {
  return <div>Hi</div>}
function GrandchildComponent() {
  const value = useContext(MyContext)
  return (
    <div>
      {value.a}
      <GreatGrandchildComponent />
    </div>)
}
function ChildComponent() {
  return <GrandchildComponent />}
const MemoizedChildComponent = React.memo(ChildComponent)
function ParentComponent() {
  const [a, setA] = useState(0)
  const [b, setB] = useState('text')
  const contextValue = { a, b }
  return (
    <MyContext.Provider value={contextValue}>
      <MemoizedChildComponent />
    </MyContext.Provider>)
}

여기에서 이제 setA(100)를 호출하면 다음과 같은 일들이 일어난다.

  • ParentComponent가 렌더링됨

  • 새로운 contextvalue가 세팅

  • 리액트는 MyContext.Provider에 새로운 값이 들어왔음을 감지하고, MyContext을 사용하는 컴포넌트에 업데이트가 필요하다고 표시

  • **MemoizedChildComponent**를 렌더링하려고 한다. 그리고 이는 **memo**로 메모이즈 되어 있고, **props**가 전혀 넘어가지 않으므로 변경이 일어나지 않은 것으로 간주된다. 따라서 **ChildComponent**의 렌더링을 스킵한다.

  • 하지만 **MyContext.Provider**는 업데이트 되었으므로, 이 아래에는 아마 업데이트가 되어야할 컴포넌트가 있을 수도 있다.

  • 리액트는 자식 컴포넌트를 순회하다가 **GrandchildComponent**를 만난다. 해당 컴포넌트는 컨텍스트를 사용하므로, 새로운 값으로 렌더링 되어야 하므로 새로운 context 값으로 렌더링 한다.

  • **GrandchildComponent**가 렌더링 되었으므로, 하위 컴포넌트인 **GreatGrandchildComponent**도 리렌더링 된다.


Context Provider 하위에 있는 컴포넌트는 React.memo가 되어 있어야 한다.


이렇게 최적화한다면, 부모 컴포넌트의 state 업데이트는 더이상 모든 컴포넌트의 리렌더링을 강요하지 않고, 단순히 context를 사용하는 컴포넌트만 리렌더링 하게 된다. 그러나, GrandchildComponent의 경우에는 Context의 값을 사용하였기 때문에 리렌더링 되었고, 그 자식인 GreatGrandchildComponent는 Context를 사용하지 않았다 하더라도 리렌더링 된다.

요약

  • 리액트는 기본적으로 재귀적으로 컴포넌트를 렌더링 한다. 그러므로, 부모가 렌더링 되면 자식도 렌더링 된다.

  • 렌더링 그 자체로는 문제가 되지 않는다. 렌더링은 리액트가 DOM의 변화가 있는지 확인하기 위한 절차일 뿐이다.

  • 그러나 렌더링은 시간이 소요되며, UI 변화가 없는 불필요한 렌더링은 시간을 소비한다.

  • 콜백함수와 객체에 새로운 참조로 값을 전달하는 것은 대부분 괜찮다.

  • React.memo를 사용하면, props가 변하지 않는다면 렌더링을 막는다.

  • 그러나 항상 새로운 참조 값을 props로 React.memo()를 전달하면 렌더링을 스킵할 수 없으므로, 이러한 값들은 적절히 메모이제이션 해야 한다.

  • Context를 사용하면 해당 값에 관심이 있는 컴포넌트들이 중첩되어있는 상태에서도 props 없이 엑세스할 수 있게 해준다.

  • Context Provider는 값이 변하였는지 확인하기 위해 참조를 비교한다.

  • 새로운 Context 값은 중첩된 모든 컨슈머들의 리렌더링을 야기한다.

  • 그러나 이러한 Context의 값의 변화가 아닌 일반적인 부모 > 자식 리렌더링 프로세스로 인해 리렌더링 되는 경우가 많다.

  • 이를 방지하기 위하여 Context Provider 하위 컴포넌트에 React.memo를 사용하거나 {props.children}을 사용해야 한다.

  • 하위 컴포넌트가 Context 값을 사용하고 있다며느 그 하위 컴포넌트 또한 순차적으로 리렌더링 된다.


Context API, 상태관리 언제 써야 할까?

Context API로만 충분한 경우

  • 자주 변하지 않는 간단한 값만 전달하는 경우

  • 애플리케이션 일부에 일부 state나 함수를 전달하지만, 이 값이 props로 많은 부분 넘기고 싶지 않은 경우

  • 추가적인 라이브러리 없이 리액트 기능만으로 구현하고 싶을때

상태관리 솔루션이 필요할때

  • 애플리케이션 여러 위치에 많은 양의 애플리케이션의 상태 값이 필요한 경우

  • 애플리케이션의 상태가 시간에 따라 자주 업데이트 되는 경우

  • 상태 관리 로직이 복잡한 경우

  • 애플리케이션이 매우 크고, 많은 사람이 개발하는 경우