Skip to content
This page has been auto-translated and may contain errors.View in English

React가 렌더링하는 방식

"렌더링"은 React에서 가장 자주 사용되면서도 가장 덜 살펴지는 용어 중 하나입니다. 상태 업데이트와 화면의 픽셀 변화 사이에서 실제로 일어나는 일을 이해하면 많은 것이 명확해집니다. 컴포넌트가 언제 실행되는지, 대부분의 리렌더링이 실제로는 비용이 거의 없는 이유, 그리고 성능 최적화가 정말 필요한 부분이 어디인지 알 수 있습니다. 이 장에서 이러한 개념을 정립할 것입니다. 다음 두 장인 메모이제이션코드 분할에서는 이를 실제로 적용해봅니다.

렌더링은 함수 호출입니다

React가 컴포넌트를 "렌더링"할 때, 컴포넌트 함수를 호출합니다.

jsx
function Counter() {
  const [count, setCount] = useState(0)
  console.log('Counter 렌더링됨') // React가 이 함수를 호출할 때마다 실행됩니다

  return <button onClick={() => setCount(c => c + 1)}>{count}</button>
}

이것이 전부입니다. 함수가 위에서 아래로 실행되고, 함수 내의 모든 계산이 다시 실행되며, UI가 지금 어떻게 보여야 하는지 설명하는 JSX를 반환합니다. 여기까지는 브라우저의 DOM과 무관합니다. 렌더링은 설명을 생성할 뿐, 페이지를 변경하지 않습니다.

이러한 구분이 중요한 이유는 두 작업의 비용이 엄청나게 다르기 때문입니다. JavaScript 함수를 호출하고 설명을 구성하는 것은 빠릅니다. 실제 DOM을 변경해서 브라우저에서 레이아웃 및 그리기 작업을 트리거하는 것이 비용이 많이 드는 부분입니다. React의 전체 설계는 저렴한 작업은 자유롭게 하고, 비싼 작업은 최대한 적게 하는 원칙에 기반합니다.

세 가지 단계

하나의 "리렌더링"은 세 가지 단계를 거칩니다. 보통 상태 업데이트가 트리거지만, 초기 마운트, 부모 컴포넌트의 렌더링, 새로운 context 값도 동일한 메커니즘을 실행합니다.

  1. 렌더링. React는 상태가 변경된 컴포넌트 함수를 호출한 후, 그것이 반환하는 모든 컴포넌트를 재귀적으로 호출합니다. 결과는 새로운 가상 DOM입니다. 즉, UI의 해당 부분에 대한 가볍고 메모리 상의 설명입니다.
  2. 재조정. React는 diffing 알고리즘을 실행하여 새로운 가상 DOM을 이전 가상 DOM과 비교하고, 정확히 무엇이 변경되었는지 파악합니다.
  3. 커밋. 차이점, 오직 차이점만 실제 DOM에 적용됩니다. 이것이 사용자가 실제로 볼 수 있는 부분입니다.

이 과정을 설명하기에 좋은 비유가 있습니다. 건축가가 건물을 개선할 때, 먼저 청사진을 다시 그립니다(렌더링). 그 다음 새 청사진이 실제로 필요한 변경 사항을 나열합니다(재조정). 그리고 나서야 시공팀이 건물에 손을 대게 됩니다(커밋). 청사진을 다시 그리는 것은 빠릅니다. 시간과 비용이 드는 것은 시공 단계이므로, 팀은 목록에서 허용하는 최소한의 작업만 수행합니다.

렌더링과 재조정은 빠르게 유지되고, 커밋은 diff가 찾은 것으로만 제한되기 때문에 React는 기본적으로 빠르게 느껴집니다. 무언가가 느려지면 Profiler가 어느 단계에서 문제가 발생했는지 알려주므로, 추측할 필요가 없습니다.

Counter 버튼을 클릭할 때마다 세 단계가 모두 실행됩니다. 로그가 출력되고, React가 결과를 비교하며, 커밋이 버튼의 텍스트 노드만 업데이트하고 그 주변의 다른 것은 건드리지 않습니다.

렌더링은 재귀적이고, 그것은 보통 괜찮습니다

컴포넌트가 렌더링될 때, React는 그것이 반환하는 모든 것을 렌더링하고, 그 컴포넌트들이 반환하는 모든 것을 렌더링하며, 각 가지를 끝까지 내려간 후 다음 가지로 이동합니다. 따라서 부모의 상태 변화는 기본적으로 그것이 변경된 상태를 props로 받는지 여부와 관계없이 모든 자식을 리렌더링합니다.

단계를 다시 분리하면 낭비처럼 보이지 않습니다. 자식 렌더링은 설명을 생성하는 함수 호출입니다. 재조정 중에 React는 그들 중 어느 것도 변경된 속성을 찾지 못하므로, 커밋은 그 부분 트리에 대해 DOM 작업을 할 필요가 없습니다. 사용자가 볼 수 있는 DOM 작업은 실제로 변경된 것으로만 범위가 제한됩니다. 아무것도 커밋하지 않는 부분 트리를 리렌더링하는 것은 정상이고 건강한 경우이며, 대다수의 컴포넌트에서는 측정하기에도 너무 저렴합니다.

두 가지 상황에서 문제가 됩니다. 컴포넌트가 렌더링 중에 비용이 많이 드는 작업을 수행하거나, 부분 트리가 너무 커서 저렴한 렌더링도 누적되는 경우입니다. 이것들이 정확히 메모이제이션이 다루는 경우들입니다. 증거가 있기 전에 그것에 손을 대려는 충동을 참으세요. 이제 측정 방법을 알아봅시다.

최적화하기 전에 측정하세요

React는 이미 빠르고, 재조정은 이미 DOM 업데이트를 최소한으로 유지합니다. 이 과정의 조언은 명확합니다. 기술을 적용하기 전에 성능 문제를 측정할 수 있어야 하고, 모든 최적화를 그것이 복잡하게 만드는 코드의 가독성과 비교해야 합니다. 유지보수할 수 없는 영리함도 비용입니다.

두 가지 도구가 대부분의 측정 필요를 충당합니다.

  • 브라우저의 Performance 탭. 세션을 기록하고, 느린 상호작용을 수행한 후, 중단하고, 무엇이 실행되었는지와 얼마나 오래 걸렸는지 검사합니다. CPU 스로틀링(4배 또는 6배 슬로우다운을 시도해보세요)과 네트워크 스로틀링을 사용하면 빠른 개발 머신에서 사용자가 실제로 경험하는 느린 기기와 연결을 모방할 수 있습니다. 노트북에서는 보이지 않는 문제들이 즉시 나타납니다.
  • React DevTools Profiler. React 팀의 브라우저 확장 프로그램으로, 자체 Profiler 탭에 있습니다. 같은 기록-작동-중단 흐름이지만, 출력이 React 관점입니다. 어떤 컴포넌트가 렌더링되었는지, 왜 렌더링되었는지, 각각 얼마나 오래 걸렸는지, 커밋이 얼마나 오래 걸렸는지 보여줍니다. React 앱을 진단하기 위해서는 보통 더 직접적인 도구입니다.

StrictMode: 개발을 위한 안전장치

StrictMode는 앱(또는 어떤 부분 트리) 주위에 감싸서 개발 전용 확인 사항들을 켤 수 있는 컴포넌트입니다. Setupmain.jsx에서 <App />을 감싸는 방식입니다.

개발 중에 StrictMode는 의도적으로 컴포넌트 함수를 두 번 호출하며, React가 순수하기를 기대하는 다른 함수들도 마찬가지입니다. useStateuseReducer 초기화 함수, setter에 전달하는 업데이터, reducer, useMemo 계산식입니다. 각 effect를 여러 번 실행하고, 정리한 후 다시 설정하며, ref 콜백을 다시 연결합니다. 또한 더 이상 사용되지 않는 API에 대해서도 경고합니다. 프로덕션 빌드에서는 아무것도 하지 않으므로, 사용자가 보는 앱을 절대 느리게 하거나 이중으로 렌더링하지 않습니다.

두 번 실행하는 것이 폭탄처럼 들리지만, 그것이 잡아내는 것을 보면 이해가 됩니다. 컴포넌트 함수는 순수해야 합니다. 같은 입력이면 같은 출력, 렌더링 중에 부작용이 없어야 합니다. 두 번 렌더링하는 것이 한 번 렌더링하는 것과 다른 결과를 생성하면, 컴포넌트가 버그를 숨기고 있던 것입니다.

이 과정은 렌더링 중에 자신 밖에서 정의된 배열에 push를 호출하는 컴포넌트로 이를 보여줍니다. 페이지는 괜찮아 보이지만 상태가 변경되고 함수가 다시 실행되면 중복이 나타납니다. StrictMode 아래에서는 중복이 첫 로드에서 즉시 나타나고, 해결책(배열을 수정하기 전에 복사하거나, 작업을 렌더링 밖으로 이동)이 따라옵니다.

순수성은 업데이터도 포함합니다

setCount(c => c + 1)의 업데이터도 같은 순수성 요구사항을 충족해야 하며, 그 함수도 두 번 실행되기 때문입니다.

effect 재실행 테스트는 다른 쪽에서 같은 계약을 테스트합니다. Effects는 정리 규칙을 다루고, StrictMode에서 정리가 없으면 어떻게 되는지 보여줍니다. 지워지지 않은 setInterval은 2씩 증가하게 하고, 마운트될 때마다 타이머를 누수시킵니다.

개발에서 더 많은 버그를 보는 것이 처음에는 거꾸로 느껴지지만, 이것이 전체 포인트입니다. StrictMode가 드러내는 모든 버그는 이미 존재했던 것이며, 그렇지 않았으면 프로덕션에서 자신을 드러낼 때까지 기다렸을 것입니다.

버전 참고

effect 동작은 React 18에서 도입되었습니다. 마운트 시 StrictMode는 각 effect의 설정을 실행한 후 정리를 실행하고, 다시 설정을 합니다. 더 오래된 글들은 StrictMode를 컴포넌트 이중 렌더링으로만 설명합니다. 이 과정은 래퍼를 React.StrictMode로 씁니다. 명명된 import를 사용하면 <StrictMode>입니다.

diffing 단계는 "두 개의 트리를 비교"하는 것처럼 보이는 것보다 저렴합니다. React는 일반적인 경우에 O(n³)가 될 수 있는 전체 트리 비교를 거부하기 때문입니다. 휴리스틱은 다음과 같습니다. 다른 타입의 요소는 이전 부분 트리를 완전히 제거하고 다시 구축하고, 같은 타입의 요소는 유지되며 변경된 속성만 패치되고, 리스트 재조정은 key로 안내됩니다. 이것이 key가 안정적인 정체성이어야 하는 이유입니다.

React는 업데이트를 중단하는 비교에서 Object.is에 의존합니다. 상태를 같은 값으로 설정할 때 업데이트를 중단하고, 의존성 배열을 확인하고, memo의 얕은 props 비교입니다. 깊게는 같지만 다른 정체성을 가진 두 객체는 이 세 가지 모두 실패합니다. 이것이 다음 장을 관통하는 실마리입니다. 부모가 {}를 전달하거나 인라인 화살표 함수를 전달하면 매번 렌더링할 때마다 새로운 정체성이 생성되는데, 이는 기본적으로 무해하지만 하나라도 정체성 기반 최적화를 추가하면 패배합니다.

React 자체 문서는 처음 두 단계를 단일 렌더 단계로 그룹화합니다. diffing은 React가 트리를 요소별로 탐색할 때 발생하므로, 완전한 설명이 먼저 구축될 때까지 기다릴 필요가 없습니다. 따라서 중단이 가지를 절반까지 내려갈 수 있습니다. 재조정을 분리하는 것은 diff가 하는 것을 설명하기 위한 교육상의 편의이며, 이분 렌더/커밋 모델이 Profiler와 React 소스가 사용하는 것입니다.

그 렌더 단계는 또한 설계상 버려질 수 있습니다. React는 렌더링을 시작하고, 작업을 버리고, 커밋 전에 다시 렌더링할 수 있으며, 동시성 기능은 메인 스레드를 반응적으로 유지하는 것에 의존합니다.

이것이 렌더가 순수해야 하는 더 깊은 이유입니다. React는 커밋된 작업이 정확히 한 번 발생함을 보장할 뿐입니다. 컴포넌트가 렌더 단계 중에 하는 모든 것(뮤테이션, 구독, 신뢰하는 로깅)은 0번, 1번, 또는 여러 번 발생할 수 있습니다. StrictMode의 이중 호출은 개발에서 그러한 현실의 저렴한 시뮬레이션이며, 이를 깨뜨리는 코드는 동시성 렌더링을 실제로 깰 수 있는 코드입니다.

Profiler가 실제 문제를 보여줄 때, 도구를 단계와 맞추세요. 컴포넌트의 긴 렌더 막대는 무거운 계산을 하고 있다는 것을 가리키므로 useMemo를 가리킵니다. 넓은 부분 트리가 리렌더링되고 커밋할 것이 없다는 것은 memo를 가리킵니다. 둘 다 메모이제이션에 있습니다. 느린 초기 로드(문제가 렌더링이 시작되기 전에 도착하는 JavaScript)는 렌더 문제가 아니라 번들 문제이며, 코드 분할이 해결책입니다.

때로는 해결책이 구조적입니다. 상태를 그것을 사용하는 컴포넌트로 이동하거나, 비싼 부분 트리를 children으로 전달해서 상태 있는 부모의 리렌더링이 같은 요소 객체를 보고 그 가지를 중단하도록 해서, 메모이제이션 없이 렌더를 제거합니다.

Juno렌더는 저렴하고, 커밋은 정밀합니다 렌더는 React가 컴포넌트 함수를 다시 호출해서 화면이 어떻게 보여야 하는지 묻는 것을 의미합니다. 새 답변을 이전 답변과 비교하고 실제로 변경된 페이지 부분만 업데이트합니다. 부모가 렌더링되면 모든 자식도 렌더링되고, 이것은 정상이며 거의 항상 빠릅니다.

개발할 때 앱을 StrictMode로 감싸세요. 개발에서 의도적으로 두 번 실행해서 조기에 버그를 찾는 데 도움을 주고, 완성된 앱에서는 자동으로 꺼집니다.

Juno렌더는 저렴하고, 커밋은 정밀합니다 세 단계입니다. 렌더는 컴포넌트 함수를 호출하고 새로운 가상 DOM을 구축하고, 재조정은 이전 것과 비교해서 차이를 찾고, 커밋은 실제 DOM에 오직 차이점만 적용합니다. 부모 렌더는 기본적으로 자식으로 전파되고, 이러한 렌더의 대부분이 아무것도 커밋하지 않으므로 거의 비용이 들지 않습니다.

무엇이든 최적화하기 전에, React DevTools Profiler에서 느린 상호작용을 기록하고 CPU를 스로틀 해서 사용자가 보는 것을 확인하세요.

StrictMode를 계속 켜세요. 이중 호출 렌더는 비순수 컴포넌트를 노출하고, effect 재실행은 setInterval 정리 누락 같은 것을 노출합니다.

Juno렌더는 저렴하고, 커밋은 정밀합니다 재조정은 휴리스틱입니다. 타입 변화는 부분 트리를 다시 구축하고, 같은 타입 요소는 제 자리에 패치되고, key가 리스트 diffing을 안내하고, 업데이트를 중단하는 비교(상태 중단, 의존성 배열, memo)는 Object.is에서 실행되므로, 새로운 객체와 함수 정체성이 변화로 읽힙니다.

렌더 단계 작업은 계약상 버려질 수 있으며, 이것이 순수성이 중요한 이유이고 왜 StrictMode가 동시성 렌더링이 생성할 수 있는 이중 실행을 시뮬레이션하는 이유입니다.

먼저 프로파일링하고, 그 다음 단계에 맞는 해결책을 선택하세요. 비싼 렌더는 메모이제이션하고, 느린 로드는 번들을 분할하거나, children으로 재구성해서 작업이 아예 일어나지 않게 합니다.

다음: 메모이제이션, 참조 동등성이 React가 건너뛸 수 있는 것을 결정하는 곳입니다.