Context API의 리렌더링에 대한 오해

dahyeon·2023년 10월 9일
14
post-thumbnail

리액트의 Context API를 사용할 경우 불필요한 리렌더링을 유발한다는 이야기를 리액트를 막 공부하기 시작할 때부터 익히 들어왔었다. 여러 블로그 글들을 통해 그 때 당시 이해했던 바로는 특정 Context의 값이 바뀌면 그 Context의 Consumer가 아닌 다른 컴포넌트까지도 리렌더링이 된다는 것이었다. 그런 이유로 인해 규모가 있는 프로젝트라면 Recoil과 같은 외부 상태 관리 라이브러리를 사용하는 것이 불가피하다고 생각했다.

하지만 최근, 내가 Context API에 대해 단단히 오해하고 있었다는 사실을 알게 되었다. 알고보니 내가 생각했던 만큼 비효율적인 API가 아니었고, 나처럼 오해하고 있었을 누군가의 이해를 바로잡고자 글을 작성하게 되었다.

Context API는 불필요한 리렌더링을 유발한다?

예시 코드를 통해 살펴보자. 코드는 velopert님의 글(링크)에서 가져왔다.

다음과 같이 작성된 counterContext가 있다고 하자. CounterValueContext, CounterActionContext로 각각 상태 값과 상태를 변경하는 함수를 나눠서 provide 해주고 있다.

사용처에서는 CounterProvider만 가져와서 사용할 수 있도록, 아래와 같이 묶어서 export 해준다.

counterContext.tsx

import { createContext, useState } from "react";

export const CounterValueContext = createContext(0);
export const CounterActionContext = createContext(() => {});

export function CounterProvider({children}: {children: React.ReactNode}){
    const [counter, setCounter] = useState(0);

    const increase = () => {
        setCounter((prev) => prev + 1);
    }

    console.log('Provider rendered')

    return (
        <CounterValueContext.Provider value={counter}>
            <CounterActionContext.Provider value={increase}>
                {children}
            </CounterActionContext.Provider>
        </CounterValueContext.Provider>
    )
}

그리고 아래와 같이 App.tsx 를 작성하였다.

export default function App() {
  return (
      <CounterProvider>
        <MyComponent1 />
        <MyComponent2>
            <MyComponent3 />
        </MyComponent2>
        <MyComponent4 />
        <MyButton />
      </CounterProvider>
  )
}

여기서 다양한 경우의 리렌더링 여부를 확인해보기 위해 컴포넌트를 5개 만들었다.

  • MyComponent1은 어떤 Context도 가져다 사용하지 않는다.
  • MyComponent2는 어떤 Context도 가져다 사용하지 않지만, Context를 가져다 사용하는 children 컴포넌트를 렌더링한다.
  • MyComponent3는 CounterValueContext를 가져다 사용한다.
  • MyComponent4는 CounterActionContext를 가져오지만 사용하진 않는다.
  • MyButton은 CounterActionContext를 가져오고 클릭 시 increase 함수를 실행한다.

코드는 다음과 같다.

function MyComponent1(){

  console.log('Component 1 rendered');

  return <div> Component 1 </div>
}

function MyComponent2({children}: {children: React.ReactNode}){
  console.log('Component 2 rendered');

  return (
		<div>
      Component 2
      {children}
	  </div>
	)
}

function MyComponent3() {
  const counter = useContext(CounterValueContext);

  console.log('Component 3 rendered');

  return <div> Component 3. Count: {counter} </div>
}

function MyComponent4(){
  const increase = useContext(CounterActionContext);

  console.log('Component 4 rendered')

  return <div> Component 4 </div>
}

function MyButton(){
  const increase = useContext(CounterActionContext);

  console.log('My Button rendered');

  return (
      <button onClick={() => increase()}> + </button>
  )
}

Q. 여기서 MyButton을 클릭해서 increase 함수를 실행시켜 counter값을 변경했을 때, 다음 중 리렌더링이 되는 컴포넌트는 무엇일까?

  • CounterProvider
  • MyComponent1
  • MyComponent2
  • MyComponent3
  • MyComponent4
  • MyButton

정답은 CounterProvider, MyComponent3, MyComponent4, MyButton이다.

하나씩 짚어보자.

  • 우선 CounterProvider가 리렌더링된 이유는 분명하다. 내부 상태값을 바꾸기 위한 setCounter 함수가 실행되었기 때문이다.
  • counter 값이 바뀌었으므로 CounterValueContext를 가져와서 사용하는 MyComponent3가 리렌더링되었다. 하지만 CounterValueContext의 consumer가 아닌 MyComponent1, MyComponent2는 리렌더링되지 않았다. 여기서 알 수 있는 사실은 Context의 값이 변경될 경우, 해당 Context의 Consumer만 영향을 받는다(리렌더링된다)는 것이다.
    물론 MyComponent3 내에 또 자식 컴포넌트가 있다면, 자식 컴포넌트는 Context를 직접적으로 가져와서 사용하고 있지 않더라도 리렌더링된다. 하지만 이는 부모 컴포넌트가 리렌더링되면서 유발되는 리렌더링이다.
  • MyComponent4와 MyButton은 왜 리렌더링되는가?
    약간의 함정을 심어봤는데 CounterProvider가 리렌더링되면서 increase 함수가 새로 생성된다. 따라서 increase의 참조가 달라졌으므로 CounterActionContext의 값도 변경되어버렸다. 따라서 CounterActionContext의 consumer인 두 컴포넌트도 리렌더링된다.
    아래와 같이 useCallback을 사용한다면 두 컴포넌트는 increase 함수를 실행시켜도 리렌더링되지 않는다.
    export function CounterProvider({children}: {children: React.ReactNode}){
        const [counter, setCounter] = useState(0);
    
        const increase = useCallback( () => {
            setCounter((prev) => prev + 1);
        }, [])
    
        console.log('Provider rendered')
    
        return (
            <CounterValueContext.Provider value={counter}>
                <CounterActionContext.Provider value={increase}>
                    {children}
                </CounterActionContext.Provider>
            </CounterValueContext.Provider>
        )
    }

그렇다면 Context API의 문제점은 무엇인가?

1. 정확한 의미의 ‘불필요한 리렌더링’

Context API를 사용했을 때 생길 수 있는 정확한 의미의 ‘불필요한 리렌더링’은 다음과 같은 상황에서 발생한다.

만약 Context가 객체일 때 객체의 일부 프로퍼티만 업데이트 된다하더라도 해당 Context를 가져다가 사용하는 모든 Consumer가 리렌더링된다는 것이다.

마찬가지로 예시 코드를 통해 살펴보자.

아래와 같이 userContext에서는 userName과 userEmail이라는 값을 객체로 감싸서 provide하고 있다. 그리고 위 상황을 실험해보기 위해 userName만 변경할 수 있도록 userActionContext를 따로 만들었다.

userContext.tsx

import {createContext, useState} from "react";

export const userContext = createContext({name: '', email: ''});
export const userActionContext = createContext((value: string) => {});

export function UserContextProvider({children}: {children: React.ReactNode}){
    const [userName, setUserName] = useState('John');
    const [userEmail, setUserEmail] = useState('john@example.com');

    return (
        <userContext.Provider value={{name: userName, email: userEmail}}>
            <userActionContext.Provider value={setUserName}>
                {children}
            </userActionContext.Provider>
        </userContext.Provider>
    )
}

그리고 userName, userEmail을 각각 가져와서 사용하는 컴포넌트와, userActionContext를 가져와서 userName을 변경하는 버튼 컴포넌트를 만들었다.

App.tsx

function UserNameComponent(){
  const {name} = useContext(userContext);
  console.log('UserNameComponent rendered');

  return <div> User Name: {name} </div>
}

function UserEmailComponent(){
  const {email} = useContext(userContext);
  console.log('UserEmailComponent rendered');

  return <div> User Email: {email} </div>
}

function UserNameButton(){
  const setName = useContext(userActionContext);
  console.log('UserNameButton rendered');

  return <button onClick={() => setName('Hello')}> Set Name </button>
}

export default function App(){
  return (
    <UserContextProvider>
      <UserNameComponent />
      <UserEmailComponent />
      <UserNameButton />
    </UserContextProvider>
  )
}

여기서 버튼을 눌러서 userName을 변경한다면?
UserNameComponent와, UserEmailComponent가 리렌더링된다.

리액트 팀이 위 문제와 관련해서 ‘context selector’의 프로토타입을 만들었다고 하는데 아직은 사용할 수 없는 기능인 듯하다.

리액트의 렌더링 관련해서 더 알고 싶다면, 아래 컨퍼런스 영상을 보는 것을 추천한다.

A Guide to React Rendering Behavior by Mark Erikson - GitNation


아래서부터는 Recoil 팀에서 해결하고자한 Context API의 문제점이다.

아래 영상을 참고하였다.

Recoil: State Management for Today's React - Dave McCabe aka @mcc_abe at @ReacteuropeOrgConf 2020

2. 상태를 런타임에 새로 생성해야 한다면?

1의 문제로 인해 상태를 하나의 value로 provide 해줄 경우 불필요한 리렌더링이 야기된다.

그렇다면 상태가 여러 개일 경우 provider의 개수도 그에 따라 그만큼 많아져야 한다.

이것 자체는 큰 문제가 아니지만, 진짜 문제는 몇 개의 provider가 필요한 지 모르는 상황이 발생할 수 있다는 것이다. 만약 런타임에 새로운 provider가 필요하다면? 새로운 node를 트리에 중간에 삽입해야 하는데, 큰 렌더링 이슈가 발생할 수 있다.

3. Provider(root)와 Consumer(leaves)의 강결합

또한 Provider(root)와 Consumer(leaves)가 강결합된다. 무조건 Provider가 Consumer의 상위에 있어야 한다.

이는 코드 스플리팅을 어렵게 한다는 문제점이 있다.

정리

Context API를 사용할 때 특정 Context의 값이 변경되면, 기본적으로 해당 Context의 Consumer만, 그러나 해당 Context의 Consumer라면 전부 리렌더링된다. 다만 Consumer의 자식 컴포넌트는 리액트의 리렌더링 매커니즘에 따라 직접적으로 Context를 가져와서 사용하지 않아도 리렌더링 된다. Context API의 ‘진짜’ 문제점은 특정 Context의 값 중 일부만 가져와서 사용하더라도 다른 값이 업데이트 될 경우 리렌더링된다는 것이다. 그렇기 때문에 적절히 Context를 분리해서 생성해주는 것이 중요하다.

또다른 Context API의 문제점은 런타임에 새로운 상태를 생성해주기가 까다롭다는 것이다. 이는 근본적으로 Provider(root)가 Consumer(leaves)의 부모 컴포넌트여야 한다는 점에서 발생하며, 두 컴포넌트가 강결합되므로 코드 스플리팅도 어려워진다.

profile
https://github.com/dahyeon405

2개의 댓글

comment-user-thumbnail
2023년 12월 13일

항상 실제로 동작하는 방식을 알고 있음에도 불필요한 리렌더링을 유발한다는 설명들이 워낙 많아 헷갈렸는데, 이렇게 같은 생각을 가지고 다양한 케이스로 테스트해본 글이 있네요. 좋은 참고가 되었습니다. 감사합니다!

답글 달기
comment-user-thumbnail
2024년 7월 31일

위의 예제를 실제로 돌려보면 CounterProvider의 '모든' 자식이 렌더링되는 것을 볼 수 있습니다. CounterProvider의 Context와 관계없이, 상태가 바뀌었기 때문에, 그 컴포넌트의 자식은 어쨌든 렌더링됩니다.

만약 위의 예제와 같은 결과를 얻으려면, CounterProvider 컴포넌트에서 자식 컴포넌트를 넘겨줄 때 memo로 감싼 컴포넌트를 넘겨주어야 합니다. memo로 감싼 컴포넌트는 props가 변하지 않았으므로 렌더링을 하지 않지만, 그것과 별개로 Context.Provider의 value가 바뀌었기에 context를 소비하는 컴포넌트는 렌더링을 수행합니다.

답글 달기