Menu

리액트 렌더링: 리렌더링이 일어나는 원인과 이유

React 컴포넌트는 state가 바뀔 때, 부모가 렌더링될 때, 또는 읽는 컨텍스트가 바뀔 때 다시 렌더링됩니다. 렌더링과 커밋의 차이, 가상 DOM이 실제로 뜻하는 것, 필요 없는 렌더링을 멈추는 방법을 배웁니다.

이 페이지에는 실행 가능한 에디터가 있습니다 - 편집하고 실행하면 결과를 바로 볼 수 있습니다.

React 컴포넌트는 자기 state가 바뀔 때, 부모가 다시 렌더링될 때, 또는 읽는 컨텍스트가 바뀔 때 다시 렌더링됩니다. 렌더링은 React가 새 JSX를 얻기 위해 컴포넌트 함수를 다시 호출한다는 뜻이며, 그다음 페이지에서 실제로 바뀐 부분만 업데이트합니다.

미리보기 아래의 Console을 열고 버튼을 클릭해 보세요. props를 전혀 받지 않는 Title을 포함해 세 컴포넌트가 모두 로그를 남깁니다. 부모가 렌더링되었기 때문에 렌더링된 것입니다.

렌더링을 일으키는 것

React는 정확히 다음 이유로 컴포넌트를 렌더링합니다.

  1. 첫 렌더링. 앱이 시작되거나, 컴포넌트가 트리에 처음 나타날 때입니다.
  2. 자기 state가 바뀌었을 때. useState의 setter나 useReducer의 dispatch를 새 값으로 호출했을 때입니다.
  3. 부모가 렌더링되었을 때. 기본적으로 컴포넌트가 렌더링되면, 그것이 반환하는 모든 컴포넌트가 맨 아래까지 함께 렌더링됩니다.
  4. 읽는 컨텍스트가 바뀌었을 때. useContext(SomeContext)를 호출하는 컴포넌트는 가장 가까운 Provider가 새 value를 넘기면 렌더링됩니다(useContext 참고).

컴포넌트가 "props가 바뀌었기 때문에" 렌더링된다는 믿음이 흔하지만, 원인과 결과가 뒤바뀐 것입니다. props는 부모가 렌더링하는 동안 넘기는 인자이므로, 새 props는 부모가 렌더링될 때만 도착할 수 있습니다. 그리고 부모가 렌더링되는 것만으로 충분합니다. 위의 Title은 props가 없는데도 매번 렌더링됩니다.

state를 이미 가진 값과 같은 값으로(Object.is 비교) 설정하면 자식의 렌더링이 시작되지 않습니다. React가 아무것도 바뀌지 않았다는 것을 알아채기 전에 그 컴포넌트 하나를 한 번 호출할 수는 있지만, 그 결과는 버립니다.

렌더링과 커밋

모든 업데이트는 두 단계를 거칩니다.

  • 렌더링. React가 컴포넌트를 호출합니다. 컴포넌트는 화면에 보여야 할 것을 설명하는 객체일 뿐인 JSX를 반환합니다. 페이지에서는 아직 아무것도 바뀌지 않으며, 그래서 렌더링은 순수해야 합니다. DOM 쓰기, 요청, 컴포넌트 바깥 변수 변경이 없어야 합니다.
  • 커밋. React가 새 출력을 이전 출력과 비교하고 그 차이를 DOM에 적용합니다. 바뀐 노드만 삽입하거나, 제거하거나, 업데이트합니다. 그다음 브라우저가 그리고, 그 뒤에 React가 이펙트를 실행합니다.

아래 예제는 클릭할 때마다 렌더링되지만 React는 같은 <input> 요소를 유지합니다. 커밋마다 이펙트가 실행되어 그것을 확인합니다.

input에 무언가를 입력하고 몇 번 클릭해 보세요. React가 input을 교체한 적이 없으므로 입력한 텍스트가 남아 있습니다. 클릭마다 일어나는 DOM 변경은 <p> 안의 숫자뿐입니다. 로그는 순서도 보여 줍니다. render가 먼저, 이펙트는 커밋 뒤입니다.

가상 DOM을 쉽게 설명하면

"가상 DOM"은 컴포넌트가 반환하는 객체를 흔히 부르는 이름입니다. <p>Items: {count}</p>는 { type: 'p', props: { children: ['Items: ', 1] } } 같은 객체를 만드는 호출로 컴파일됩니다. 렌더링 후 React는 이런 객체로 된 새 트리를 이전 트리와 나란히 훑으며, 타입과 위치가 일치하는 곳에서는 기존 DOM 노드를 유지하고 바뀐 속성과 텍스트만 업데이트합니다. 이 비교를 재조정(reconciliation)이라고 합니다.

이 용어가 느슨한 이유는 두 가지입니다. React는 DOM의 두 번째 사본을 두고 두 DOM을 비교하지 않습니다. 요소 객체를 컴포넌트의 내부 트리(파이버 트리)와 비교합니다. 그리고 같은 과정이 React Native처럼 DOM이 전혀 없는 대상도 움직입니다. React 문서는 이 표현을 대부분 피하고 대신 렌더링과 커밋이라고 말합니다. 실제로 중요한 것은 그 결과입니다. 대부분의 렌더링은 작은 DOM 업데이트로 끝나거나 아무 업데이트도 없으므로, 렌더링은 DOM 작업에 비해 비용이 적습니다.

비교 방식에서 두 가지 규칙이 나옵니다. 같은 자리에 다른 요소 타입이 오면(<div>가 <section>으로, ComponentA가 ComponentB로 바뀌면) 이전 하위 트리와 그 state가 파괴됩니다. 그리고 목록에서는 key가 어느 항목이 어느 항목인지 React에 알려 주므로, 노드를 다시 만들지 않고 옮길 수 있습니다.

필요 없는 렌더링 멈추기

대부분의 추가 렌더링은 눈에 띄는 비용이 없습니다. 페이지의 다른 곳에서 키를 누를 때마다 렌더링되는 큰 목록처럼 정말 문제가 될 때는 다음을 순서대로 시도하세요.

state를 아래로 옮기기

화면의 작은 부분 하나만 어떤 state를 쓴다면, 그 state를 그 부분만 감싸는 컴포넌트에 두세요. 이 버전은 키를 누를 때마다 ProductList를 다시 렌더링합니다.

export default function App() {
    const [text, setText] = useState('');
    return (
        <>
            <input value={text} onChange={(e) => setText(e.target.value)} />
            <ProductList />
        </>
    );
}

input과 그 state를 별도의 컴포넌트로 옮기면, 목록은 더 이상 렌더링되는 컴포넌트 안에 있지 않습니다.

글자를 몇 개 입력해 보세요. SearchBox만 로그를 남깁니다.

대신 children 넘기기

때로는 강조 표시를 토글하는 패널처럼, state가 비용이 큰 부분을 감싸는 래퍼에 있어야 합니다. 래퍼가 children을 받게 하세요. 자식 요소는 부모가 만들고, 래퍼의 state가 바뀔 때 부모는 렌더링되지 않으므로 그 요소들은 이전과 같은 객체이고, React는 그것들을 건너뜁니다.

토글을 클릭하면 Highlighter만 로그를 남깁니다. 이제 <Article />을 {children} 자리에 넣어 Highlighter의 JSX 안으로 옮기면, 클릭할 때마다 render Article도 기록됩니다.

메모이제이션

구조를 바꾸는 방법이 맞지 않을 때는 자식을 memo로 감싸세요. 그러면 React가 props를 이전 것과 비교하고 모두 같으면 렌더링을 건너뜁니다. 렌더링 중에 만든 객체와 함수는 매번 새것이므로, memo는 보통 그런 props를 위한 useMemo나 useCallback과 함께 쓰입니다. React.memo 페이지에서 실행되는 예제로 보여 줍니다.

import { memo } from 'react';

const ProductList = memo(function ProductList({ category }) {
    // skipped while category stays the same
});

React Compiler가 빌드 시점에 이런 메모이제이션을 대신 추가해 줄 수 있지만, 그래도 가장 먼저 시도할 것은 state 구조를 바꾸는 것입니다. 작업을 캐시하는 대신 없애기 때문입니다.

렌더링은 대개 괜찮습니다

렌더링은 객체를 반환하는 함수 호출입니다. React는 수천 번의 렌더링을 빠르게 실행하며, 같은 출력을 만드는 렌더링은 DOM을 전혀 바꾸지 않습니다. "만일을 위해" 모든 곳에 memo를 붙이지 마세요. 비교마다 자기 비용이 있고 코드를 읽기 어렵게 만듭니다. 먼저 측정하세요. React DevTools Profiler는 어떤 컴포넌트가 렌더링되었는지, 왜 그랬는지, 각각 얼마나 걸렸는지 보여 줍니다.

자주 묻는 질문

React 컴포넌트가 다시 렌더링되는 원인은 무엇인가요?

세 가지입니다. 자기 state가 바뀌거나, 부모가 렌더링되거나, useContext로 읽는 컨텍스트가 새 값을 받을 때입니다. props는 별도의 원인이 아닙니다. 새 props는 부모가 렌더링되었기 때문에 도착할 뿐입니다.

부모가 다시 렌더링되면 자식도 다시 렌더링되나요?

네, 기본적으로 렌더링되는 부모 안의 모든 컴포넌트가 props가 바뀌지 않았어도 함께 렌더링됩니다. 자식을 memo로 감싸면 props가 지난번과 같을 때 React가 건너뛸 수 있습니다.

React의 가상 DOM이란 무엇인가요?

컴포넌트가 반환하는 일반 자바스크립트 객체(React 요소)의 트리를 느슨하게 부르는 이름입니다. React는 새 트리를 이전 트리와 비교하고, 달라진 실제 DOM 노드만 바꿉니다.

리렌더링은 성능에 나쁜가요?

보통은 아닙니다. 렌더링은 객체를 만드는 함수 호출이며, React는 출력이 바뀐 곳의 DOM만 건드립니다. 예를 들어 React DevTools Profiler로 렌더링이 측정 가능할 정도로 느리다는 것을 확인했을 때 최적화하세요.

렌더링과 커밋의 차이는 무엇인가요?

렌더링은 화면이 어떻게 보여야 하는지 알아내기 위해 React가 컴포넌트를 호출하는 것입니다. 커밋은 React가 그 차이를 DOM에 적용하는 것입니다. 같은 출력을 만드는 렌더링은 DOM 변경을 커밋하지 않습니다.

Coddy 프로그래밍 언어 일러스트

Coddy로 코딩 배우기

시작하기