HAMA DEVELOP

또 컴포넌트가 지멋대로 리렌더링 된다.

September 20, 2021

썸네일

의미 없지만 고양이를 넣어보았다. 귀여우니까

내 블로그에는 항상 마음에 들지 않던 부분이 있었다. gatsby-starter-blog 템플릿을 수정해 가면서 만든 블로그인데, 상단에 태그 페이지를 바꿀 때 마다 저자 소개에 들어가 있는 이미지가 깜빡거리는 것 이었다. 개츠비도 React를 기반으로 만들어진 사이트인데, 마치 SPA가 아닌 것처럼 행동하는 모습이 늘 눈에 거슬렸다.

결론부터 말하자면, 의도하지 않은 리렌더링이 문제였다. 개츠비 2.0 부터는 페이지가 변할 때 마다 Layout 컴포넌트 전체를 언마운트 하고 다시 마운팅하는 동작이 추가되었고 Layout 컴포넌트의 자식 컴포넌트인 저자 소개 컴포넌트가 리 렌더링 되고 있는 것이 원인이었다. 플러그인을 하나 깔아서 해결했다.

React를 사용해서 개발하다 보면 이런 비슷한 일들을 한 번쯤은 겪게 된다. 기본적으로 React는 똑똑하게 리렌더링을 처리하지만, 컴포넌트 구조가 복잡해 짐에 따라 의도치 않은 리렌더링이 발생하기도 한다. 의도치 않은 리렌더링은 의도치 않은 사용자 경험을 만들 수도 있고 퍼포먼스에 악영향을 미칠 수 있다. 이 때문에 특정 상태 변화에 따라 리렌더 되어야 할 것과 그렇지 않은 것을 구분하는 것은 중요하다.

리렌더가 왜 발생하는지, 리렌더가 발생하는 것을 어떻게 하면 알 수 있는지, 리렌더를 막기 위한 방법은 어떤 것이 있는지에 대해서 간단히 알아보자.

리렌더는 React가 데이터의 변화에 따라서 화면을 다시 그리는 현상이다.

React는 특정 데이터(element type, state 혹은 props 등)가 변하는 것을 관찰하고 있다가 변하면 그 데이터에 맞춰서 다시 화면을 그려주는 라이브러리다. 리렌더링은 화면이 다시 그려지는 현상을 말한다.

어떤 기준에 따라서 컴포넌트를 다시 그릴지 말지 결정하기 위해 사용되는 것이 virtualDOM이다.

React는 virtualDOM의 상태를 효율적으로 비교한다.

React는 virtualDOM의 이전 상태와 이후의 데이터를 비교하여 리 렌더링 할지 말지를 결정한다. 이 과정을 조금만 더 자세히 이해해 보자.

이 virtualDOM은 트리구조로 이루어져 있다. 두 트리구조의 모든 노드를 탐색하면서 다른 점을 찾기 위해서는 O(n^3)의 시간복잡도를 소모하게 된다. (최소한 지금까지 개발된 알고리즘으로는 그렇다고 한다.)

즉 1,000개 구성요소를 가지고 있는 virtualDOM의 상태 변화를 완전히 알아내기 위해서는 약 1,000,000,000번의 연산이 필요하다는 것이다.

그래서 React는 virtualDOM의 비교에서 완전 비교를 하지 않는다. O(n^3)의 효율성으로는 상태 변화에 신속하게 대응할 수 없기 때문이다.

모두 비교하는 대신 React는 O(n)의 시간복잡도를 가지는 휴리스틱 알고리즘을 사용한다. (자세한 동작 방식은 자세히 작성된 포스팅 을 참고하면 좋다.)

구체적인 알고리즘을 이해하지 않아도 아래의 사항 정도는 이해하고 있으면 좋다.

완전히 다른 타입의 엘리먼트가 되면 그 아래는 전부 변했다고 여긴다.

예를 들어 <div> 였던게 <p> 로 변하면 해당 엘리먼트의 자식 노드는 모두 언마운트 되었다가 다시 마운팅 된다.

<div>
<Cart/>
</div>

// 위에서 아래처럼 바뀌는 경우 Counter 컴포넌트는 완전히 새것으로 리렌더링 된다.

<span>
<Cart/>
</span>

같은 타입의 엘리먼트인 경우 해당 노드의 attributes변화를 감지해서 업데이트 한다.

attributes 변화에 따라 필요한 부분을 리렌더 시킨다. 부모가 리렌더 되는 경우 자식들도 다시 렌더링 된다. (자식이 부모의 변화에 따라서 시각적으로 바뀌는게 없을 수도 있지만 React는 일단 다시 계산해서 리렌더링을 한다.)

<div class ="abc" title = "efg"/>

//위에서 아래로 바뀌는 경우 엘리먼트의 class만 갱신한다.

<div class ="hij" title = "efg"/>

React 컴포넌트 업데이트인 경우 props와 state변화를 감지해서 업데이트 한다.

props나 state변화에 따라 필요한 부분을 리렌더 시킨다. 부모가 리렌더 되는 경우 자식들도 다시 렌더링 된다. (자식이 부모의 변화에 따라서 시각적으로 바뀌는게 없을 수도 있지만 React는 일단 다시 계산해서 리렌더링을 한다.)

<Parent>
<Children isTrue = {false}/>
</Parent>

// 자식 컴포넌트의 변화를 감지한다.

<Parent>
<Children isTrue = {true}/>
</Parent>

리액트 디버깅 툴을 통해 리렌더링을 파악할 수 있다.

화면상으로 보이는 게 차이가 없으면 리렌더링을 쉽게 알아차리지 못할 수도 있다. 하지만 무의미한 리렌더링은 퍼포먼스에 부정적인 영향을 미칠 수도 있기에 사전 방지하는 것이 필요하다.

눈으로 보이지 않는 리렌더링을 잡아내는 방법은 여러 가지가 있지만 제일 편한 방법은 개인적으로는 React Developer Tools를 제일 선호한다. 설치하기 쉽고 직관적으로 알 수 있기 때문이다.

다른 여러 좋은 기능들이 있지만, 오늘은 리 렌더링 감지만 사용해 보도록 하자. 절차는 간단하다.

  1. 링크를 방문해서 크롬 익스텐션을 다운로드 받는다.
  2. 크롬 검사창을 띄우고 상단에 Components 탭을 누른다.
  3. 톱니바퀴 모양을 누르고 Highlight updates when components render를 체크한다.
  4. 검사창을 켠 상태에서 확인하고자 하는 동작을 수행해 본다.

아래 그림처럼 세팅 되어 있으면 성공이다.

리액트툴

gif

리렌더를 피하는 방법

props에 객체를 넘기는 것을 지양한다.

React는 이전 props와 이후 props가 다른 경우 리렌더링을 수행한다. 그런데 객체로 props를 넘길 때에는 이전과 같은 props라도 다르다고 인식된다.

자바스크립트가 객체를 인식하는 방식 때문에 발생하는 현상이다. 콘솔 창에 {} === {} 를 쳐보면 false가 나오는 것을 볼 수 있을 것이다. 객체의 내용물이 완전히 같더라도, virtualDOM 비교시 두 객체는 다른 객체가 된다. (call by value, call by reference 에 관해서 공부하면 이 현상을 더 자세히 이해할 수 있다. 본 글에서 이에 대한 설명은 생략한다.)

가급적이면 props를 객체로 넘기지 말자.

그런데도 불구하고 props를 객체로 사용해야 할 경우는 생길 수 있다. 이럴 때는 React에서 제공하는 API 사용을 고려해 보자.

memo, useMemo, useCallback

특정 값을 저장해 놓고 다시 연산할 필요 없이 다시 사용하는 기법을 memoization이라 한다. 컴포넌트 자체 혹은 props를 memoization을 해두면 React는 해당 컴포넌트가 바뀌지 않았다고 판단하고 리렌더링을 수행하지 않는다.

리액트의 대표적인 memoization API에는 memo ,useMemo , useCallback 이 있다.

[memo]

memo 로 감싸진 컴포넌트 자체를 기억한다. 부모 컴포넌트가 리렌더 되더라도 memo로 감싸진 컴포넌트는 리렌더 되지 않는다. 특정 조건에 따라서 리렌더 되게 하고 싶으면 두 번째 인자로 dependency 배열을 넣어 주면 된다.

import React, { memo } from "react"

const SoudlNotRererender = memo(() => {
  return <div>부모 컴포넌트가 리렌더 되어도 리렌더 되지 않습니다.</div>
})

[useMemo]

useMemo로 감싸진 특정 값을 기억한다. 객체로 전달할 props에는 useMemo를 씌워 주는 것이 좋다. 특정 조건에 따라서 다시 계산 되게 하고 싶으면 두 번째 인자로 dependency 배열을 넣어 주면 된다.

import React, { useMemo } from "react";

const App () => {
const options = useMemo(() => ({ option : `option` }))

return (
<Children memoizedProps = {options} />
)

}

[useCallback]

useMemo 와 거의 비슷하지만 살짝 다르다. 값 대신에 특정 콜백 그 자체를 기억한다. 콜백 함수를 props로 내려줄 때는 useCallback 을 써주는 것이 좋다. (함수는 원시값이 아니기 때문에 React는 전달된 콜백을 항상 이전과 다르다고 인식할 것이다.) 특정 조건에 따라서 콜백인 다시 계산 되게 하고 싶으면 두 번째 인자로 dependency 배열을 넣어 주면 된다.

import React, { useCallback } from "react";

const App () => {
const callbackToPass = useCallback((parameter) => {console.log(`${parameter} is passed`)})

return (
<Children memoizedCallback = {callbackToPass} />
)

}

더 좋은 방법이 있을 수 있지만 지금까지 내가 겪은 문제들은 이 정도로 해결할 수 있었던 것 같다.

어느 분야든 마찬가지지만 개발은 특히 그냥 하기는 쉽지만 잘하려면 이것저것 생각해야 할 게 많은 듯 하다.

갈 길이 멀어 보여도 꾸준히 하다 보면 결국 다다를 수 있다고 믿으면서 한 걸음 한 걸음 가는 수밖에는 없어 보인다.