[번역] 왜 리액트에서 리렌더링이 발생하는가

Jisu Yuk
16 min readAug 29, 2022

--

원문: https://www.joshwcomeau.com/react/why-react-re-renders/

솔직하게 말하겠습니다. 저는 리액트의 리렌더링 프로세스가 어떻게 작동하는지 제대로 이해하지 못한 채 수년간 리액트를 사용해 왔습니다. 😅

저는 이것이 많은 리액트 개발자에게 해당되는 사실이라고 생각합니다. 우리는 충분히 잘 이해하고 있지만, 리액트 개발자들이 모여있는 그룹에 “리액트에서 리렌더링을 유발하는 요인은 무엇인가요?”와 같은 질문을 남기면 모호한 여러 답변을 얻을 수 있습니다.

이 주제에 대해 많은 오해가 존재하고, 이는 많은 불확실성으로 이어질 수 있습니다. 리액트의 렌더링 주기를 이해하지 못한다면 React.memo를 사용하는 방법이나 함수를 useCallback으로 감싸야 하는 경우를 어떻게 이해할 수 있을까요?

이 튜토리얼에서 우리는 리액트가 다시 렌더링되는 시기와 이유에 대한 멘탈 모델을 구축할 것입니다. 또한 React devtools를 사용하여 특정 컴포넌트가 다시 렌더링되는 이유를 알려주는 방법도 배울 것 입니다.

이 글을 읽으면 좋은 사람들

이 튜토리얼은 초중급 리액트 개발자가 리액트에 더욱 익숙해지도록 돕기 위해 작성되었습니다. 리액트를 마주한지 얼마되지 않으신 분들은 이 글을 북마크에 추가하고 몇 주 후에 다시 읽어보는 것을 추천드립니다!

리액트의 핵심 루프

근본적인 사실부터 짚고 시작하겠습니다. 리액트의 모든 리렌더링은 상태 변경에서 시작됩니다. 이것은 리액트가 컴포넌트를 다시 리렌더링하는 유일한 “트리거”입니다.

지금은 없지만 이전에는 리렌더를 트리거하는 “forceUpdate()”라는 메서드가 존재했었습니다.

아마도 틀렸다고 생각하실 것 입니다… 결국, props가 변경될 때 컴포넌트가 다시 렌더링되지 않습니까? 또 컨텍스트는 어떻습니까??

핵심은 다음과 같습니다. 컴포넌트가 리렌더링될 때 모든 하위 요소들도 다시 렌더링됩니다.

예제를 한번 살펴 봅시다.

이 예제에서 3개의 컴포넌트를 확인할 수 있습니다. BigCountNumber, BigCountNumber를 렌더링 하는 Counter, Counter를 렌더링하는 상단의 App.

리액트에서 모든 상태 변수는 특정 컴포넌트 인스턴스에 연결되어 있습니다. 이 예제에서는 Counter 컴포넌트와 연결된 단일 상태 count가 있습니다.

count가 변경될 때마다 Counter가 다시 렌더링됩니다. 그리고 BigCountNumberCounter에 의해 렌더링되기 때문에 역시 다시 렌더링됩니다.

다음은 이 메커니즘의 실제 동작을 보여주는 대화형 그래프입니다. “Increment” 버튼을 클릭하면 상태 변경이 트리거됩니다. Counter 컴포넌트가 리렌더링되고 BigCountNumber 도 리렌더링 됩니다.

첫 번째 큰 오해상태 변수가 변경될 때마다 전체 앱이 리렌더링된다. 입니다. 이 오해에 대해 알아봅시다.

일부 개발자는 리액트에서 발생하는 모든 상태 변경이 애플리케이션 전체의 렌더링을 강제한다고 생각하지만 이는 사실이 아닙니다. 리렌더링은 상태를 가지고 있는 컴포넌트와 해당 컴포넌트의 하위 컴포넌트에만 영향을 줍니다. 이 예제에서 App 컴포넌트는 count 상태 변수가 변경될 때 리렌더링할 필요가 없습니다.

그러나 이것을 규칙으로 암기하기보다는 한 발 물러서서 왜 그런 식으로 작동하는지 알아야 합니다.

리액트의 “주요 작업”은 애플리케이션 UI를 리액트 상태와 동기화하여 유지하는 것입니다. 리렌더링의 요점은 변경해야 할 사항을 파악하는 것입니다.

위의 “Counter” 예제를 살펴보겠습니다. 애플리케이션이 처음 마운트되면 리액트는 모든 컴포넌트를 렌더링하고 DOM이 어떻게 생겼는지에 대한 다음 스케치를 제공합니다.

사용자가 버튼을 클릭하면 count 상태 변수가 0에서 1로 바뀝니다. 이것이 UI에 어떤 영향을 줄까요? 또 다른 렌더링을 확인하여 배울 수 있습니다!

리액트는 CounterBigCountNumber 컴포넌트에 대한 코드를 다시 실행하고 원하는 DOM의 새 스케치를 생성합니다.

각각의 렌더링 결과는 카메라로 찍은 사진과 같은 스냅샷으로, 현재 애플리케이션 상태를 기반으로 UI가 어떻게 생겼는지 보여줍니다.

리액트는 “차이점 찾기” 게임을 하여 이 두 스냅샷 간에 변경된 사항을 파악합니다. 이 경우 0에서 1로 변경된 텍스트 노드가 있음을 확인하고 스냅샷과 일치하도록 텍스트 노드를 편집합니다. 작업이 완료되면 리액트는 다음 상태 변경을 기다립니다.

이것이 리액트의 핵심 루프입니다.

이 프레임을 염두에 두고 렌더링 그래프를 다시 살펴보겠습니다.

count 상태는 Counter 컴포넌트와 연결되어 있습니다. 리액트 애플리케이션에서 데이터는 “위로” 전달될 수 없기 때문에 이 상태 변경이 <App />에 영향을 줄 수 없다는 것을 알고 있습니다. 따라서 해당 컴포넌트를 다시 렌더링할 필요가 없습니다.

하지만 Counter의 자식 BigCountNumber를 다시 렌더링해야 합니다. 실제로 count 상태를 표시하는 컴포넌트입니다. 렌더링하지 않으면 텍스트 노드가 0에서 1로 변경되어야 한다는 것을 알 수 없습니다. 스케치에 이 컴포넌트를 포함해야 합니다.

리렌더링의 요점은 상태 변경이 사용자 인터페이스에 어떻게 영향을 미치는지 파악하는 것입니다. 따라서 정확한 스냅샷을 얻으려면 잠재적으로 영향을 받을 수 있는 모든 컴포넌트를 다시 렌더링해야 합니다.

props 때문이 아닙니다.

두 번째 큰 오해컴포넌트는 props가 변경되기 때문에 다시 렌더링된다. 입니다.

변경된 예제를 가지고 이 오해를 해결해봅시다.

아래 코드에서 “Counter” 앱에 새로운 컴포넌트 Decoration이 추가되었습니다.

(모든 컴포넌트가 하나의 큰 파일에 포함되어 복잡해 보이기 때문에 컴포넌트 별로 파일을 재구성했습니다. 새로운 Decoration 컴포넌트를 제외하고 전체 컴포넌트 구조는 동일합니다.)

이제 카운터에는 Decoration 컴포넌트로 렌더링된 귀여운 요트가 구석에 있습니다. count에 의존하지 않으므로 count가 변경될 때 다시 렌더링되지 않을 것입니다. 그렇죠?

음, 그렇지는 않습니다.

컴포넌트가 다시 렌더링되면 props를 통해 특정 상태 변수가 전달되는지 여부에 관계없이 모든 하위 컴포넌트를 다시 렌더링하려고 시도합니다.

생각했던 것과 다른것 같습니다… <Decoration>에 props로 count를 전달하지 않는데 왜 다시 렌더링해야 할까요?

리액트에서 <Decoration>이 count 상태 변수에 직간접적으로 의존하는지 여부를 100% 확실하게 아는 것은 어렵습니다. 이것이 앞의 질문에 대한 답입니다.

이상적인 세계에서 리액트 컴포넌트는 항상 “순수”합니다. 순수한 컴포넌트는 동일한 props가 제공될 때 항상 동일한 UI를 생성하는 컴포넌트입니다.

현실 세계에서 우리의 컴포넌트들은 대부분 순수하지 않습니다. 순수하지 않은 컴포넌트를 만드는 것은 놀라울 정도로 쉽습니다.

이 컴포넌트는 현재 시간에 의존하기 때문에 렌더링될 때마다 다른 값을 표시합니다!

이 문제의 교묘한 버전은 ref와 관련이 있습니다. ref를 props로 전달하면 리액트는 마지막 렌더링 이후로 변경되었는지 여부를 알 수 없습니다. 그래서 안전하게 리렌더링하는 것을 선택합니다.

리액트의 첫번째 목표는 사용자가 보는 UI가 애플리케이션 상태와 “동기화” 상태를 유지하도록 하는 것입니다. 따라서 리액트에서는 지나치게 많은 렌더링이 발생할 수도 있습니다. 사용자에게 오래된 UI를 보여주는 위험을 감수하고 싶지 않기 때문입니다.

우리의 오해에 대해 다시 이야기해봅시다. props는 리렌더링과 아무 관련이 없습니다. <BigCountNumber> 컴포넌트는 count props가 변경되었기 때문에 다시 렌더링 되는 것이 아닙니다.

상태 변수 중 하나가 업데이트되어 컴포넌트가 다시 렌더링되면, 리액트가 새 스케치의 세부 정보를 채우고 새 스냅샷을 캡처할 수 있도록 해당 리렌더링이 트리 아래로 계단식으로 진행됩니다.

이것은 표준 작동 절차이지만 약간 보완할 수 있는 방법이 있습니다.

순수 컴포넌트 만들기

React.memo 또는 React.PureComponent와 같은 클래스 컴포넌트에 익숙할 것입니다. 이 두 도구를 사용하면 특정 리렌더링 요청을 무시할 수 있습니다.

아래와 같이 작성할 수 있습니다.

Decoration 컴포넌트를 React.memo로 래핑하여 리액트에게 “이 컴포넌트는 순수합니다. props가 변경되지 않는 한 다시 렌더링할 필요가 없습니다.”라고 알려줍니다.

이것은 메모이제이션이라고 알려진 기술을 사용합니다.

일종의 “암기 “라고 생각할 수 있습니다. 아이디어는 리액트가 이전 스냅샷을 기억한다는 것입니다. props가 하나도 변경되지 않은 경우 리액트는 오래된 스냅샷을 재사용하여 완전히 새로운 스냅샷을 생성하는 문제를 피할 수 있습니다.

BigCountNumberDecorationReact.memo로 래핑한다고 가정해 보겠습니다. 이것이 리렌더링에 미치는 영향은 다음과 같습니다.

count가 변경되면 Counter를 다시 렌더링하고 리액트는 두 하위 컴포넌트를 모두 렌더링하려고 시도합니다.

BigCountNumbercount를 props로 사용하고 해당 props가 변경되었기 때문에 BigCountNumber가 다시 렌더링됩니다. 그러나 Decoration의 props는 변경되지 않았기 때문에(아무 것도 없기 때문에) 원본 스냅샷이 대신 사용됩니다.

저는 React.memo를 약간 게으른 사진작가로 비유하는 것을 좋아합니다. 똑같은 사진 5장 찍어달라고 하면 1장 찍어서 5장 줍니다. 사진사는 지시 사항이 변경될 때만 새 사진을 찍을 것입니다.

직접 살펴보고 싶은 분들을 위해 실제 코드도 준비했습니다. 메모된 각 컴포넌트에 console.info 호출이 추가되어서 각 컴포넌트가 렌더링되는 정확한 시간을 콘솔에서 확인할 수 있습니다.

다음과 같은 궁금증이 생길 수도 있습니다. 이것이 기본 동작이 아닌 이유는 무엇인지? 대부분의 경우 이것이 우리가 원하는 것이 아닌가? 렌더링할 필요가 없는 렌더링 컴포넌트를 건너뛰면 성능이 확실히 향상되나?

개발자로서 우리는 리렌더링에 드는 비용을 과대평가하는 경향이 있다고 생각합니다. Decoration 컴포넌트의 경우 리렌더링이 번개처럼 빠릅니다.

컴포넌트에 많은 props가 있고 자손이 많지 않은 경우 컴포넌트를 다시 렌더링하는 것과 비교하여 props가 변경되었는지 확인하는 것이 실제로 더 느릴 수 있습니다.

이 주장에 대한 출처는 없지만 Dan Abramov 같은 저명한 개발자가 트위터에서 이 사례를 만드는 것을 보았습니다.

따라서 우리가 만드는 모든 단일 컴포넌트를 메모하는 것은 비생산적입니다. 리액트는 스냅샷을 정말 빠르게 캡처하도록 설계되었습니다! 그러나 특정 상황에서 자손이 많은 컴포넌트나 내부 작업이 많은 컴포넌트의 경우 React.memo가 꽤 도움이 될 수 있습니다.

미래에는 바뀔 것 입니다!

리액트 팀은 컴파일 단계에서 코드를 “자동 메모화”할 수 있는지 여부를 적극적으로 조사하고 있습니다. 아직 연구 단계이지만 초기 실험이 유망해 보입니다.
자세한 내용은 Xuan Huang의 “React without memo” 강연을 확인하세요.

컨텍스트는 어떨까요?

우리는 아직 컨텍스트에 대해 전혀 이야기하지 않았지만 다행히도 복잡하지는 않습니다.

기본적으로 컴포넌트의 상태가 변경되면 컴포넌트의 모든 하위 컴포넌트가 리렌더링됩니다. 따라서 컨텍스트를 통해 모든 자손에게 해당 상태를 제공하면 실제로 아무 것도 바뀌는게 없습니다. 어느 쪽이든, 해당 컴포넌트는 다시 렌더링됩니다!

이제 순수한 컴포넌트의 관점에서 컨텍스트는 “보이지 않는 props” 또는 “내부 props”와 같습니다.

예를 들어 보겠습니다. 여기에 UserContext 컨텍스트를 사용하는 순수한 컴포넌트가 있습니다.

이 예에서 GreetUser는 props가 없는 순수한 컴포넌트이지만 “보이지 않는” 또는 “내부의” 종속성이 존재합니다. user는 리액트 상태로 저장되고 컨텍스트를 통해 전달됩니다.

해당 user 상태 변수가 변경되면 리렌더링이 발생하고 GreetUser는 오래된 사진에 의존하지 않고 새 스냅샷을 생성합니다. 리액트는 이 컴포넌트가 UserContext 컨텍스트를 소비하고 있음을 알 수 있습니다. 그래서 user를 props인 것처럼 취급합니다.

아래의 코드와 같다고 생각하시면 됩니다.

실제 예제를 확인해 보겠습니다.

순수 컴포넌트가 React.useContext 훅을 사용하여 컨텍스트를 소비하는 경우에만 발생합니다. 컨텍스트를 사용하지 않는 순수 컴포넌트 무리를 깨는 것에 대해 걱정할 필요가 없습니다.

리액트 Devtool로 프로파일링하기

한동안 리액트로 작업해 왔다면 특정 컴포넌트가 다시 렌더링되는 이유를 파악하려고 시도하는 데 좌절감을 느꼈을 것입니다. 실제 상황에서는 종종 명확하지 않습니다! 다행히도 리액트 Devtool이 도움이 될 수 있습니다.

먼저 리액트 Devtool 브라우저 확장 프로그램을 다운로드해야 합니다. 현재 ChromeFirefox에서 사용할 수 있습니다. 이 글에서는 Chrome을 사용하고 있다고 가정합니다.

Ctrl + Alt + I(또는 MacOS의 경우 + Option + I)를 사용하여 devtool을 엽니다. 두 개의 새 탭이 표시되어야 합니다.

우리는 “Profiler”에 관심이 있습니다. 해당 탭을 선택합니다.

작은 톱니바퀴 아이콘을 클릭하고 “Record why each component rendered while profiling” 옵션을 활성화합니다.

일반적인 흐름은 다음과 같습니다.

1. 작은 파란색 “녹화” 원을 눌러 녹화를 시작합니다.

2. 애플리케이션에서 몇 가지 작업을 수행합니다.

3. 녹화를 중지합니다.

4. 녹화된 스냅샷을 보고 무슨 일이 일어났는지 자세히 알아보세요.

각 렌더는 별도의 스냅샷으로 캡처되며 화살표를 사용하여 탐색할 수 있습니다. 사이드바에서 왜 컴포넌트가 렌더링 되었는지에 대한 정보를 확인할 수 있습니다

관심 있는 컴포넌트를 클릭하면 특정 컴포넌트가 다시 렌더링된 이유를 정확히 알 수 있습니다. 순수 컴포넌트의 경우 업데이트를 담당하는 props를 알려줍니다.

저는 개인적으로 이 도구를 자주 사용하지 않지만 사용할 때는 아주 귀중하게 잘 쓰고 있습니다!

리렌더링 강조 표시

한 가지 더 작은 트릭이 있습니다. 리액트 프로파일러에는 다시 렌더링되는 컴포넌트를 강조 표시할 수 있는 옵션이 있습니다.

설정은 다음과 같습니다.

이 설정을 활성화하면 다시 렌더링되는 컴포넌트 주위에 녹색 사각형이 깜박이는 것을 볼 수 있습니다.

이는 상태 업데이트가 얼마나 광범위하게 발생하는지 정확히 이해하고, 순수 컴포넌트의 리렌더링이 발생되지 않는지 테스트하는 데 도움이 될 수 있습니다!

자세히 알아보기

프로파일러를 사용하기 시작하면 알아차릴 수 있는 것 중 하나가 바로 순수한 컴포넌트가 아무 것도 변경되지 않는 경우에도 다시 렌더링된다는 것입니다!

리액트의 컴포넌트는 자바스크립트 함수입니다. 즉, 컴포넌트를 렌더링할 때 함수를 호출합니다.

클래스 컴포넌트의 경우 클래스의 render 메서드를 호출하기 때문에 같은 개념으로 인식해도 무방합니다.

이것은 리액트 컴포넌트 내부에 정의된 모든 것이 모든 단일 렌더링에서 다시 생성된다는 것을 의미합니다.

아래 예제를 보고 생각해보세요.

App 컴포넌트를 렌더링할 때마다 우리는 완전히 새로운 객체 dog를 생성합니다. 이것은 우리의 순수 컴포넌트에 큰 영향을 줄 수 있습니다. 이 DogProfile 컴포넌트는 React.memo로 래핑되었는지 여부에 관계없이 다시 렌더링됩니다!

몇 주 안에 이 블로그에 “2부”를 게시할 예정입니다. 우리는 useMemouseCallback이라는 두 가지 유명하고 이해하기 어려운 리액트 훅을 파헤칠 것입니다. 그리고 우리는 이 문제를 해결하기 위해 그것들을 사용하는 방법을 알아 볼 것입니다.

한가지 또 말씀드릴 것이 있습니다. 이 튜토리얼은 곧 진행되는 “Joy of React” 교육과정에서 발췌한 것입니다.

저는 7년 넘게 리액트로 사용해왔고, 그것을 효과적으로 사용하는 방법에 대해 많은 것을 배웠습니다. 저는 리액트로 작업하는 것을 너무 좋아합니다. 태양 아래 거의 모든 프론트엔드 프레임워크를 사용해봤다고 자부하지만 리액트만큼 생산적인 느낌을 주는 것은 없습니다.

“Joy of React”에서 우리는 이 튜토리얼에서와 같이 개념을 파헤쳐서 리액트가 실제로 어떻게 작동하는지에 대한 멘탈 모델을 구축할 것입니다. 그러나 이 블로그의 게시물과 달리 제 과정은 다양한 양식의 접근 방식을 사용하여 서면 콘텐츠를 비디오 콘텐츠, 연습 문제, 대화형 탐색 및 일부 미니 게임과 혼합해서 제공할 예정입니다!

저는 앞으로 몇 달 안에 교육과정을 런칭할 것 입니다. 관심 있으신 분들은 홈페이지에서 업데이트 신청을 하시면 됩니다. 💖

보너스: 성능 팁

리액트의 성능 최적화는 큰 주제이며 이에 대해 여러 블로그 게시물을 작성했습니다. 이 튜토리얼이 리액트 성능에 대해 배울 수 있는 견고한 토대를 구축하는 데 도움이 되었기를 바랍니다!

리액트 성능 최적화에 대해 배운 몇 가지 간단한 팁을 공유하겠습니다.

  • 리액트 Profiler는 렌더링에 걸린 밀리초를 보여주지만 이 수치는 신뢰할 수 없습니다. 우리는 일반적으로 “개발 모드”에서 프로파일링하고 리액트는 “프로덕션 모드”에서 훨씬 더 빠릅니다. 애플리케이션의 성능을 진정으로 이해하려면 배포된 프로덕션 애플리케이션에서 “Performance” 탭을 사용하여 측정해야 합니다. 이것은 리렌더링뿐만 아니라 레이아웃/페인트 변경에 대한 실제 수치도 보여줍니다.
  • 90번째 백분위수 사용자 경험이 어떤 것인지 알아보기 위해 저사양 하드웨어에서 애플리케이션을 테스트하는 것이 좋습니다. 구축하는 제품에 따라 다르지만 이 블로그는 몇 년 전 인도에서 인기가 있었던 저가형 스마트폰인 Xiaomi Redmi 8을 사용하여 주기적으로 테스트합니다. 이 경험을 Twitter에도 공유했습니다.
  • Lighthouse 성능 점수는 실제 사용자 경험을 정확하게 반영하지 않습니다. 자동화된 도구가 보여주는 통계보다 애플리케이션 사용의 질적 경험을 훨씬 더 신뢰합니다.
  • 몇 년 전 React Europe에서 리액트의 성능에 대한 거의 모든 이야기를 했습니다! 많은 개발자들이 간과하는 영역인 “로드 이후” 경험에 더 중점을 둡니다. 유튜브에서 시청하실 수 있습니다.
  • 과도하게 최적화하지 마세요! 리액트 프로파일러에 대해 배울 때 가능한 한 렌더링 수를 줄이는 것을 목표로 최적화를 계속하고 싶은 마음이 들지만 솔직히 기본적으로 리액트는 이미 매우 잘 최적화되어 있습니다. 리액트 프로파일러는 약간 느려지는 것 같다고 생각하기 시작할 때에 성능 문제에 대한 해답으로 사용되는 것이 가장 적절하다고 생각합니다.

--

--