React.memo

김동현·2022년 1월 24일
0

React

목록 보기
17/27
post-thumbnail

리액트 렌더링 과정

리액트는 state가 변경되었을 때 리렌더링을 발생시킵니다. state가 변경되고 최종적으로 브라우저상의 UI에 반영되기까지 각 컴포넌트는 크게 4단계를 거치게 됩니다.

  1. 기존 컴포넌트를 재사용할 지 확인합니다.

  2. 함수 컴포넌트의 경우 함수를 호출합니다.

  3. 2과정에서 반환된 결과를 통해 새로운 VirtualDOM을 생성합니다.

  4. 이전 VirturalDOM과 새로운 VirtualDOM을 서로 비교해서 실제 변경된 부분만 실제 DOM에 반영합니다.

브라우저는 근본적으로 화면을 보여주기 위해서 HTML, CSS, JavaScript를 로드하고 처리해서 화면에 픽셀 형태로 페인트처리를 합니다. 이 과정을 CRP(Critical Rendering Path)라고 부릅니다.


CRP(Critical Rendering Path) 과정은 아래와 같습니다.

  1. HTML을 파싱하여 DOM을 생성합니다.

  2. CSS를 파싱하여 CSSOM을 생성합니다.

  3. DOM과 CSSOM을 결합하여 Render Tree를 생성합니다.

  4. Render Tree와 Viewport의 width를 통해 각 요소의 위치와 크기를 계산합니다(Layout).

  5. 지금까지 계산된 정보를 통해 Render Tree상 요소들을 실제 픽셀로 그리게 됩니다(Paint).

이후 DOM 또는 CSSOM이 수정될 때마다 위 과정을 반복합니다. 따라서 이 과정을 최적화하는 것이 퍼포먼스상 중요한 포인트가 됩니다. 특히 Layout와 Paint 과정의 경우 많은 계산이 필요로하는 부분입니다. 따라서 리액트는 이 CRP가 수행되는 횟수를 최적화하기 위해서 VirtualDOM을 사용합니다.

UI를 변화시키기 위해서는 많은 DOM 조작이 필요합니다. 하나하나의 DOM 조작마다 CRP가 수행될 것이고 이는 곧 퍼포먼스 측면에 악영향을 제공하게 됩니다.


리액트에서는 UI의 변화가 발생하면 필요한 DOM 조작을 매번 실제 DOM에 적용하지 않고 VirtualDOM이라는 DOM과 유사한 객체를 생성합니다. 그리고 이전 VirtualDOM과 새로운 VirtualDOM을 비교해서 실제로 변화가 필요한 DOM 요소를 찾아내고 한 번에 DOM요소를 조작합니다.

VirtualDOM을 통해 브라우저에서 사용되는 CRP의 빈도를 줄일 수 있게 됩니다. 즉, 4. 이전 VirturalDOM과 새로운 VirtualDOM을 서로 비교해서 실제 변경된 부분만 실제 DOM에 반영합니다.의 과정은 리액트 내부적으로 처리되는 과정이므로 이 부분은 개발자가 따로 최적화를 수행할 여지가 없습니다.

개발자들이 할 수 있는 최적화는 1. 기존 컴포넌트를 재사용할 지 확인합니다.3. 2과정에서 반환된 결과를 통해 새로운 VirtualDOM을 생성합니다. 부분을 최적화할 수 있습니다.

1. 기존 컴포넌트를 재사용할 지 확인합니다. 경우에는 리렌더링될 컴포넌트의 UI가 이전 UI와 동일하다고 판단되는 경우 이전 결과값을 그대로 사용하도록 최적화할 수 있습니다.

3. 2과정에서 반환된 결과를 통해 새로운 VirtualDOM을 생성합니다. 과정에서는 새롭게 생성되는 VirtualDOM이 이전 VirtualDOM과 차이나는 부분을 적은 형태로 생성되도록 하는 것입니다. 예를 들어, div 태그를 갖는 요소를 section 태그를 갖도록 변경하는 것보다 div 태그를 갖도록 유지한 채로 스타일이나 어트리뷰트를 변경하는 것이 더 나은 퍼포먼스를 갖도록 해줍니다.

React.memo

컴포넌트의 상태가 변경되면 해당 컴포넌트가 다시 실행되어 새로운 가상돔을 생성하여 화면을 리렌더링하게 됩니다.

이때 컴포넌트의 반환값에 작성된 하위 컴포넌트의 상태가 변경되지 않았다 하더라도 상위 컴포넌트가 재평가될 때 하위 컴포넌트 모두 재평가가 되는 것이 기본 동작입니다.

즉, 재평가가 필요하지 않은 하위 컴포넌트까지 모두 재평가되는 것은 불필요하며 react 라이브러리의 memo 함수를 통해 최적화를 할 수 있습니다.

import React from 'react';

const MyComponent = React.memo(function MyComponent(props) {
  /* render using props */
});

React.memo 함수는 HOC(Higher Order Component)입니다. HOC는 인수로 컴포넌트를 전달받아 컴포넌트를 반환하는 컴포넌트 입니다.

불필요한 컴포넌트 재평가

// App.js
import { useState } from 'react';
import DemoOutput from './DemoOuput.js';

function App() {
    console.log('App Running');

    const [showParagraph, setShowParagraph] = useState(false);
    
    const toggleParagraphHandler = () => {
        setShowParagraph(prevShowParagraph => !prevShowParagraph);
    }
    
    return (
        <div>
            <h1>Hi there!</h1>
            <DemoOutput show={false}/>
            <button onClick={toggleParagraphHandler}>Toggle Paragraph!</button>
        </div>
    );
}

export default App
// DemoOuput.js
const DemoOutput = props => {
    console.log('DemoOuput Running');

    return (
        {props.show && <p>DemoOutput</p>}
    );
}

export default DemoOuput;

App 컴포넌트에서는 DemoOuput 컴포넌트를 하위 컴포넌트로 갖고 있습니다. 만약 button 요소를 클릭하면 toggleParagraphHandler가 호출되고 내부에서는 showParagraph 상태값을 변경하는 상태 변경 함수인 setShowParagraph 함수가 호출됩니다.

하지만 사용자에게 보여지는 데이터는 어떤 것도 변경되지 않았습니다. 즉, 실제 DOM에서도 어떠한 변경도 일어나지 않습니다. 이는 리액트가 이전 가상돔과 현재 가상돔을 비교하는데 차이점이 존재하지 않기 때문에 실제 DOM을 조작하지 않았습니다.

문제점은 어떠한 것도 변경되지 않지만 App 컴포넌트에 등록된 상태값이 변경되어 App 컴포넌트가 재평가되고 그에 따라 하위 컴포넌트인 DemoOuput 컴포넌트까지 재평가된다는 것입니다. 즉, 콘솔창에 App Running 문자열이 출력되고 다음 바로 DemoOuput Running이 출력되는 것을 확인할 수 있습니다.

그래서 우리는 DemoOuput 컴포넌트를 "특정 상황에서만 재평가"되도록 해야 합니다. 여기서 특정 상황이란 컴포넌트가 전달받는 props 객체의 프로퍼티 값들이 바뀌었을 때만 재평가되도록 하는 것입니다.

우리는 리액트에게 이러한 명령을 내리기 위해서 특정 상황에만 재평가될 컴포넌트를 export 할 때 인수로 컴포넌트 함수를 전달하면서 React.memo 함수 호출문을 작성 합니다.

즉, 위 예제에서는 DemoOuput 컴포넌트 파일에서 export default DemoOuput;export default React.memo(DemoOutput);으로 작성해줍니다. 이때 인수로는 함수형 컴포넌트만 전달할 수 있습니다.

React.memo의 동작

React.memo 함수로 함수형 컴포넌트를 최적화할 수 있습니다. memo는 인수로 전달한 컴포넌트가 어떤 props를 전달받는지 확인하고, 모든 props 객체의 프로퍼티 값을 확인한 다음 그 값이 "이전 props 객체의 프로퍼티 값과 일치하지 않는 경우에만 컴포넌트를 재평가"하게 됩니다.

즉, 부모 컴포넌트가 재평가되더라도 하위 컴포넌트가 전달받는 props 객체의 프로퍼티 값이 바뀌지 않은 경우, 하위 컴포넌트는 재평가되지 않습니다.

DemoOuput을 아래 코드처럼 수정해보겠습니다.

// DemoOuput.js
import React from 'react';

const DemoOutput = props => {
    console.log('DemoOuput Running');

    return (
        {props.show && <p>DemoOutput</p>}
    );
}

// React.memo를 통해 최적화되도록 수정
export default React.memo(DemoOuput);

이제 버튼을 클릭하더라도 DemoOuput에게 전달되는 props값이 언제나 false로 하드 코딩되어 있으므로 재평가 되지 않습니다.
이는 콘솔창에 버튼을 클릭하면 App Running 문자열만 출력되고, DemoOuput Running은 출력되지 않는 것으로 확인할 수 있습니다.

참고로 만약 DemoOuput 컴포넌트의 return 문에 자식 컴포넌트가 존재하더라도 자식 컴포넌트도 당연히 재평가되지 않습니다.

효과적인 최적화

이렇게 React.memo 메서드를 통해 불필요한 컴포넌트 재평가를 막을 수 있습니다. 여기서 의문점이 존재합니다. 만약 "모든 컴포넌트를 최적화하면 되지 않을까?" 라는 의문점이 생깁니다.

memo를 통해 컴포넌트를 최적화할 때 리액트는 두 가지 일을 하게 됩니다.

  1. "이전 props 객체의 프로퍼티 값을 보관"

  2. "props 객체의 프로퍼티 값들을 비교"

어떤 컴포넌트를 최적화하느냐에 따라 효율이 달라지게 됩니다. "컴포넌트 재평가하는 효율을 props 비교하는 효율과 바꾸는 것"으로 어느 쪽이 더 효율적인지는 상황에 따라 달라지게 됩니다. 이는 사용하는 props 객체의 프로퍼티 개수와 컴포넌트의 복잡성, 컴포넌트의 자식 컴포넌트 수에 달려있습니다.

만약 자식 컴포넌트가 많아 트리가 깊은 경우 React.memo가 유용하게 동작합니다. 컴포넌트 트리의 상위에서 컴포넌트 트리의 불필요한 가지를 재실행하는 것을 방지해주기 때문입니다.

반면 컴포넌트가 전달받는 props 객체의 프로퍼티값이 상위 컴포넌트가 재평가될 때마다 변경된다면 React.memo를 사용하는 것은 좋지 않습니다. 결과적으로 컴포넌트가 다시 실행되면서 props 값을 비교한 내용이 저장되는 비용이 많이 들어가기 때문에 좋지 않습니다.

주의할 점

React.memo는 현재 props를 이전 props를 비교합니다. 객체의 각 프로퍼티 값을 비교할 때 일치 비교 연산자(===)을 사용합니다.

즉, props 객체의 프로퍼티로 객체 타입 값을 전달하는 경우 리액트는 이전 값과 비교했을 때 일치하지 않다고 판단하여 React.memo를 사용하더라도 컴포넌트를 재평가하게 될 것입니다.

참고로 원시 타입 경우 컴포넌트가 재평가된다고 하더라도 무조건 새로운 원시값을 다시 생성하는 것이 아니라, 메모리에 동일한 원시값이 존재하는 경우 그 원시값이 저장되어 있는 메모리 주소값을 사용합니다.
즉, 원시값이 저장된 메모리의 주소값을 "재사용"합니다. 그러므로 리액트가 props 객체의 프로퍼티 값이 동일한 원시값인 경우 동일한 메모리 주소값을 비교하므로 동일한 값으로 판단합니다.

하지만 객체 타입의 값인 경우 컴포넌트가 재평가될 때마다 언제나 새로운 객체를 다시 생성하게 됩니다. 이러한 이유로 인해서 React.memo를 사용하더라도 props로 함수나 다른 객체를 전달하는 경우 매번 새로운 값으로 인식되어 컴포넌트가 재평가됩니다.

함수나 객체의 경우 useCallback 훅이나 useMemo 훅을 사용하여 최적화할 수 있습니다.

profile
Frontend Dev

0개의 댓글