본문 바로가기

CS/테코톡

리액트 렌더링 최적화

728x90

출처 : 리액트 렌더링 최적화

브라우저 랜더링 과정

먼저 HTML을 파싱해서 DOM을 만들고 CSS를 파싱해서 CSSOM을 만든다.

DOM과 CSSOM을 활용해서 렌더트리를 만든다.

그리고 layout과 repaint 과정을 거쳐 화면에 웹사이트가 렌더링이 된다. 즉 사용자가 웹 사이트를 볼 수 있게 된다.

리액트에서의 렌더링

그렇다면 리액트에서의 렌더링은 뭘까??

리액트에서의 렌더링 === 함수를 호출하는 것

예를들어 APP 컴포넌트가 있을 때 이 컴포넌트가 렌더링 된다는 말의 뜻은 APP 컴포넌트가 호출이 되어서 내부 로직들이 실행이 되고 return 문을 통해 리액트 element를 반환하는 것이다.

예시 코드

위의 코드를 예시로 들어 렌더링을 알아보자

먼저 Parent 컴포넌트 내부에 FirstChild 컴포넌트와 SecondChild 컴포넌트가 있다

Parent 컴포넌트 내부에서 valueForFirstChild라는 state를 선언해주고 이 state를 FirstChild 컴포넌트에 props로 전달해주고 SecondChild 컴포넌트에는 handleClick이라는 함수만을 넘겨주고 있다.

그리고 SecondChild 컴포넌트에는 약 1000개의 GrandChild 컴포넌트들을 자식으로 가지고 있고 이 GrandChild 컴포넌트는 단순히 자기가 secondChild 컴포넌트를 기준으로 몇 번째 자식 컴포넌트인지를 콘솔에 출력해주는 기능을 가지고 있다.

위 코드의 전체적인 동작과정

Parent 컴포넌트가 렌더링 되고 난 후 useEffect 내부의 로직들이 실행이 된다.

setTimeout을 통해서 state에 변화를 주었고 state에 변화가 생겼으니 Parent 컴포넌트가 리렌더링 됩니다.

앞에서 말했듯 렌더링은 함수를 호출하는 것이기 때문에 Parent 컴포넌트가 호출이 된디.

Parent 컴포넌트 return 문을 실행하면서 FirstChild와 SecondChild 컴포넌트도 리렌더링이 된다.

여기서 효율을 생각하는 개발자라면 신경쓰이는 부분이 있을 것이다.

Parent 컴포넌트와 FirstChild 컴포넌트 입장에서는 본인들이 사용하는 값(state와 props)에 변경이 생겨서 변경이 된 값을 보여줘야 하므로 리렌더링이 되는 것이 당연하지만,

SecondChild입장에서는 변경된 값(state와 props)도 없는데 똑같은 정보를 보여주기 위해 리렌더링되는게 불필요해 보인다.

뿐만아니라 SecondChild 컴포넌트가 리렌더링되면 약 1000개의 GrandChild 컴포넌트들도 특정한 상황이 아닌 이상 리렌더링 될 것이다.

Profiler devtools를 통해 실제로 그렇게 렌더링 되는지 확인해보면 된다.

Profiler devtools

  • 웹 사이트의 컴포넌트들이 어떻게 렌더링되고 있는지를 보여주는 도구
  • Profiler를 통해 렌더링과 관련된 정보를 수집할 수 있음 특히 어디 부분에서 렌더링이 오래걸리고 불필요한 렌더링이 걸리는지 확인해야할때 유용함

그렇다면 SecondChild 컴포넌트가 불필요하게 렌더링 되는 것을 막아주는 방법은?

현재 리렌더링 되는 이유

매번 Parent 컴포넌트가 리렌더링될 때마다 handleClick이라는 함수가 재생성이 되고 이전에 handle 클릭과 현재 handle 클릭 함수는 서로 다른 참조 값을 가지게 되고 따라서 이 둘은 서로 다른 함수가 된다.

  • 이는 함수가 참조 타입의 데이터이기 때문

따라서 handle 함수가 매번달리지고 이는 props이므로 SecondChild 컴포넌트가 리렌더링 되는 것임

그렇다면 함수의 참조 값이 바뀌지 않도록 해주면, secondChild 컴포넌트의 불필요한 렌더링을 방지해줄 수 있지 않나?

  • 그렇다면 어떻게 Parent 컴포넌트가 리렌더링 될 때마다. handle 함수의 참조값을 유지할 수 있을까?

해결법

  • 리액트에서 제공하는 useCallback 훅을 사용하면 됨
  • useCallback : 함수를 메모이제이션해주는 훅 메모이제이션 : 기존에 수행한 연산의 결과 값을 어딘가에 저장해두고 필요할 때 재사용하는 기법

useCallback으로 함수를 감싸면 의존성 배열이 변하지 않는 이상 컴포넌트가 리렌더링 되더라도 변수에 같은 함수가 할당된다.

기존의 handle 함수를 useCallback으로 감싸서 Parent 컴포넌트가 리렌더링 되더라도 이전과 동일한 함수(동일한 참조값)을 가지도록 하였음

이제 props가 바뀌는 것을 막았으므로 secondChiled 컴포넌트가 리렌더링되지 않을까?

→ 아직 리렌더링 된다.

위의 코든는 Parent 컴포넌트의 코드를 Babel로 컴파일한 코드이다.

  • Babel 리액트에서 사용하는 jsx는 브라우저에서 이해할 수 없는 문법이므로 Babel이 이를 js 코드로 변환해주어 브라우저가 읽을 수 있게 해줌

코드를 보면 FirstChild 컴포넌트와 SecondChild 컴포넌트에 해당하는 React.createElement가 있음

React.createElement?

  • 새로운 리액트 Element를 생성해서 리턴 해줌

따라서 Parent 컴포넌트가 리렌더링되면 내부의 로직들 역시 다시 실행되면서 SecondChild 컴포넌트에 해당하는 React.createElement 역시 실행되어 리렌더링이 발생한다.

이러한 이유로 useCallback을 사용해 props가 바뀌는 것을 막았더라도 SecondChild 컴포넌트가 리렌더링 되는 것이다.

그럼 useCallback을 사용해도 렌더링 최적화에 효과가 없나? : X

리액트에서의 렌더링 과정

리액트에서의 렌더링 과정은 크게 아래 2가지로 구성된다.

  1. Render Phase
  2. Commit Phase

Render Phase

  • Render Phase에서는 컴포넌트(함수)를 호출하여 React Element를 반환하고 새로운 Virtual DOM을 생성해줌 만약 이번이 처음 렌더링이 아니라면 재조정 과정을 거친 후 Real DOM에 변경이 필요한 목록을 체크함
    • 재조정 이전 Virtual DOM과 현재 Virtual DOM을 비교하는 과정

Commit Phase

  • Render Phase에서 체크해놓았던 변경이 필요한 부분들을 Real DOM에 반영시킴
  • 만약 변경이 필요한 부분이 없다면 Commit Phase는 스킵됨

정리

  • 리액트에서 렌더링이 일어날 때마다 재조정 과정이 포함된 render phase와 commit phase로 구성된 렌더링 프로세스를 거치게 되는 것임

앞선 내용과 이어보면

render phase는 실행되지만 useCallback을 활용해서 props 값을 이전과 같게 유지해주었기 때문에 commit phase는 실행되지 않음

약간 개선되긴 했는데 아직 SecondChild 컴포넌트가 리렌더링 되고 있는데, render phase 조차 막아줄 수는 없나?

React.memo

  • 전달 받은 props가 이전 props와 비교했을 때 서로 같으면 컴포넌트의 리렌더링을 막아주고 마지막으로 렌더링된 결과를 재사용하는 고착 컴포넌트
  • 기본적으로 얕은 비교로 props를 비교
  • 인수로 비교 함수를 넣어주면 해당 함수로 비교시킬 수도 있음

이때 만약 객체를 props로 넘겨주게 된다면?

객체가 변경되었더라도 바뀌지 않은 item만을 꺼내서 사용했으므로 리렌더링 되지않을까?

  • 아쉽게도 리렌더링 된다.

왜냐하면 SecondChild 컴포넌트 입장에서는 매번 다른 item을 props로 전달 받기 때문이다.

Parent 컴포넌트가 리렌더링될 때마다 item 객체가 새로 생성되고 객체가 참조 타입의 데이터이기 때문에 매번 다른 참조 값을 가지게 됨

그래서 React.memo로 props의 값을 비교할 때 객체는 값이 같아도 서로 다른 것으로 인식한다.

이때는 useMemo 훅을 사용해야 한다.

useMemo

  • 값에 대한 메모이제이션을 제공해주는 훅 의존성 배열에 들어있는 값이 변경되지 않는 이상 매번 리렌더링 될 때 마다 같은 값을 반환해주게 됨

위와 같이 item 객체에 useMemo를 사용해주면 의존성 배열이 변하지 않는 이상 이전에 사용했던 값을 반환해줍니다.

따라서 메모이제이션이 적용된 값을 SecondChild 컴포넌트에 전달해주면 매번 새로운 item을 전달해주지 않고 이전과 동일한 item 객체를 전달해주기 때문에

정상적으로 React.memo가 동작하면서 불필요한 렌더링을방지해줌

위의 내용에서 useCallback, useMemo, React.memo를 사용해 최적화를 진행했다.

그러면 useCallback, useMemo, React.memo를 모든 곳에서 사용해주면 좋을까?

  • 위의 세가지 방법도 하나의 코드이고 내부적으로 특정 동작을 수행시켜줘야하기 때문에 하나하나가 모두 비용이다.
  • 따라서 리렌더링이 자주되는 컴포넌트라고해서 컴포넌트 내부의 함수를 useCallback으로 감싸거나 props가 바뀔 수 밖에 없는 상황에서 자식 컴포넌트에 React.memo를 전달해준다면 이는 오히려 웹 사이트 성능 저하를 초래할 수 있다,.

최적화 도구를 사용하기 전에 먼저 근본적인 코드를 개선하자

  • 불필요한 렌더링이 발생하지 않도록 처음부터 코드를 작성하자
  • 무작정 최적화 도구를 사용하기보단 기본 코드에서 불필요한 리렌더링이 발생하지 않도록 작성하는 것이 더 좋다.

근본적인 코드를 개선하는 방법?

위의 코드는 버튼을 누르면 자동으로 컴포넌트가 리렌더링되고 Consoler 컴포넌트에는 고정된 값을 전달하고 있지만 부모 컴포넌트가 리렌더링 되므로 Consoler 컴포넌트가 불필요하게 리렌더링되고 있다.

이를 Consoler 컴포넌트가 불필요하게 리렌더링되지 않도록 수정해보자.

이전에는 부모 컴포넌트 내부에서 Consoler 컴포넌트를 사용해 주었다면

지금은 Children을 활용해서 Consoler 컴포넌트를 주입해주었다.

이렇게 컴포넌트를 리팩토링해주면 컴포넌트가 리렌더링되더라도 Consoler 컴포넌트는 리렌더링되지 않는다.

오른쪽은 왼쪽 코드를 컴파일한 코드이다.

코드를 보면 부모 컴포넌트의 리턴문에는 Consoler 컴포넌트에 대한 React.createElement가 존재하지 않아 부모 컴포넌트가 렌더링, 즉 호출되더라도 Consoler 컴포넌트는 호출되지 않는다.

이렇게 최적화 도구를 사용하지 않고 코드를 리팩토링해서도 불필요한 렌더링을 방지할 수 있다.

최적화는 미리 하지말자, 필요할 때 하자

'CS > 테코톡' 카테고리의 다른 글

애니메이션 최적화  (1) 2024.12.06
프론트엔드 성능 측정  (2) 2024.12.06
CSS 프레임워크(Tailwind)  (1) 2024.12.06
쿠키와 웹 스토리지  (0) 2024.12.06
Flux Architecture  (0) 2024.12.06