렌더링 속도를 올리기 위한 성능 최적화

kangdari·2020년 8월 23일
2

렌더링 속도를 올리기 위한 성능 최적화

리액트는 데이터(속성 값 + 상태 값)과 컴포넌트 함수로 화면을 그린다. 속성 값이나 상태 값이 변경되면 리액트가 자동으로 컴포넌트 함수를 이용해서 화면을 다시 그린다. 이것이 리액트의 역할이다.

리액트에서 최초 렌더링 이후에는 데이터 변경 시 렌더링을 하는데 다음 단계를 거친다.

  1. 이전 렌더링 결과를 재사용할지 판단.
  2. 컴포넌트 함수를 호출.
  3. 가상 돔끼리 비교해서 변경된 부분만 실제 돔에 반영.

첫 번째 단계에서는 속성 값이나 상태 값의 이전 이후 값을 비교 한다. 클래스형 컴포넌트에서는 shouldComponentUpdate, 함수형 컴포넌트에서는 React.memo를 이용해서 구현할 수 있다.

첫 번째 단계에서 렌더링이 필요하다고 판단하면 컴포넌트 함수를 호출한다. 호출 뒤 새로운 가상돔을 만들고 이전에 만들었던 가상 돔과 비교하여 변경점을 찾고 변경된 부분은 실제 돔에 반영한다.

React.memo로 렌더링 결과 재사용하기

컴포넌트 속성 값이나 상태 값이 변경되면 리액트는 그 컴포넌트를 다시 그린다. React.memo 함수로 감싼 컴포넌트는 이전 이후 속성값을 비교하여 참 또는 거짓을 반환한다. 참을 반환하면 렌더링을 멈추고, 거짓을 반환하면 컴포넌트 함수를 실행해서 가상 돔을 업데이트한 후 변경된 부분을 실제 돔에 반영한다.

React.memo로 함수로 감싸지 않으면 항상 거짓을 반환합니다. 이때는 속성 값이 변경되지 않아도 부모 컴포넌트가 렌더링될 때마다 자식 컴포넌트도 렌더링됩니다.

속성 값과 상태 값을 불변 변수로 관리하는 방법

함수의 값이 변경하지 않도록 관리하기

function Parent() {
  const [selectedFruit, setSelectedFruit] = useState('apple');
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>{count}</p>
      <button onClick={() => setCount(count + 1)}>count up</button>
      <SelectFruit 
        selected={selectedFruit}
        onChange={fruit => setSelectedFruit(fruit)}
      />
    </div>
  );
}

버튼을 누를 경우 count 상태 값이 변경되어 Parent 컴포넌트의 렌더링이 시작된다. 이때 SelectFruit 컴포넌트로 전달되는 속성 값은 변경되지 않았으므로 SelectFruit 컴포넌트에서 React.memo를 사용했다면 SelectFruit 컴포넌트는 호출되지 않을 것이라고 생각할 수 있다. 하지만 count 상태가 변경되면 SelectFruit 컴포넌트도 호출된다.
바로 SelectFruit 컴포넌트에 호출되는 onChange 속성 값이 변하기 때문입니다. onChange 속성 값은 부모 컴포넌트가 렌더링될 때마다 새로운 함수를 만든다.

useState, useReducer의 상태 값 변경 함수는 변하지 않는다는 점을 이용하면 쉽게 해결이 가능하다.

SelectFruit 컴포넌트의 onChange의 속성 값으로 useState 상태 업데이트 함수를 전달해준다. 상태 업데이트 함수는 변하지 않으므로 onChange 속성 값에는 항상 같은 값이 입력된다.

      <SelectFruit 
        selected={selectedFruit}
        onChange={setSelectedFruit}
      />

상태 값 변경 이외 다른 처리가 필요하다면 useCallback을 사용할 수 있다.

  const onChangeFruit = useCallback(() => {
    setSelectedFruit(fruit);
    // 추가 작업
  }, []);

  <SelectFruit selected={selectedFruit} onChange={onChangeFruit} />;

useCallback의 의존성 배열을 비워두면 이 함수는 항상 고정된 값을 가진다.
SelectFruit 컴포넌트의 onChange 속성 값으로는 useCallback이 반환하는 함수가 전달된다.

객체 값이 변경하지 않도록 관리하기
함수와 마찬가지로 컴포넌트 내부에서 객체를 정의해서 자식 컴포넌트의 속성 값으로 입력하면, 자식 컴포넌트는 객체의 내용이 변경되지 않았는데도 속성 값이 변경되었다고 인식한다.

function SelectFruit({ SelectFruit, onChange }) {
  return (
    <div>
      <Select
        options={[
          { name: 'apple', price: '1200' },
          { name: 'banana', price: '1500' },
          { name: 'lemon', price: '1200' },
        ]}
        selected={SelectFruit}
        onChange={onChange}
      />
    </div>
  );
}

SelectFruit 컴포넌트가 렌더링될 때마다 options 속성 값으로 새로운 객체가 입력된다.
과일 목록 객체는 항상 고정된 값을 가지므로 컴포넌트 밖에서 상수 변수로 관리할 수 있다.

function SelectFruit({ SelectFruit, onChange }) {
  return (
    <div>
      <Select options={FRUITS} selected={SelectFruit} onChange={onChange} />
    </div>
  );
}

const FRUITS = [
  { name: 'apple', price: '1200' },
  { name: 'banana', price: '1500' },
  { name: 'lemon', price: '1200' },
];

이제 options 속성값은 컴포넌트 렌더링과 무관하게 항상 같은 값을 가진다.
하지만 다른 상태 값이나 속성 값을 이용해서 계산되는 값은 상수 변수로 관리할 수 없다.

function SelectFruit({ SelectFruit, onChange }) {
  const [maxPrice, setMaxPrice] = useState(1000);
  return (
    <div>
      <Select
        options={FRUITS.filter((item) => item.price <= maxPrice)}
        // ...
      />
    </div>
  );
}
// ...

useMemo 훅을 사용하면 필요한 경우에만 속성 값 option 값이 변하도록 만들 수 있다.

function SelectFruit({ SelectFruit, onChange }) {
  const [maxPrice, setMaxPrice] = useState(1000);
  const fruits = useMemo(() => FRUITS.filter((item) => item.price <= maxPrice), [maxPrice]);

  return (
    <div>
      <Select options={fruits} selected={SelectFruit} onChange={onChange} />
    </div>
  );
  // ... 

maxPrice 값이 같으면 fruits 값은 변경되지 않는다. 이처럼 useMemo 훅은 꼭 필요할 때만 반환되는 값이 변경되도록 한다.

가상 돔에서의 성능 최적화

요소의 타입 또는 속성을 변경하는 경우

function App() {
  // ...
  if (flag) {
    return (
      <div>
        <Counter />
        <p>사과</p>
      </div>
    );
  } else {
    return (
      <span>
        <Counter />
        <p>사과</p>
      </span>
    );
  }
}

최상위 요소의 타입이 flag의 값에 따라 div 또는 span 으로 변경된다. 리액트는 부모의 요소 타입이 변경되면 모든 자식 요소를 삭제하고 다시 추가한다.
즉, 부모 요소의 타입이 변경되면 자식 컴포넌트도 삭제 후 다식 추가되고, 일반 요소인 경우에는 실제 돔에서 제거하고 다시 추가한다. 따라서 자식 요소가 많은 부모 요소의 타입을 변경하면 화면이 끊기는 느낌이 들 수있다.

요소의 속성 값만 변경하면 해당하는 속성만 실제 돔에 반영된다.

function App() {
  // ...
  return (
    <div
      className={flag ? 'yes' : 'no'}
      style={{ color: 'black', background: flag ? 'green' : 'red' }}
    >
      <Counter />
      <p>사과</p>
    </div>
  );
}

style 속성은 color 속성은 그대로 두고 background 속성만 실제 돔에 반영한다.

요소를 추가하거나 삭제하는 경우
마지막에 새로운 요소를 추가하거나 삭제하면 해당 요소만 실제 돔에 추가 또는 삭제하고 기존 요소는 건드리지 않는다.

function App() {
  // ...
  if (flag) {
    return (
      <div>
        <p>사과</p>
        <p>바나나</p>
      </div>
    );
  } else {
    return (
      <span>
        <p>사과</p>
        <p>바나나</p>
        <p>파인애플</p>
      </span>
    );
  }
}

마지막에 파인애플 추가하거나 삭제하면 리액트는 가상 돔 비교를 통해 앞의 두 요소가 변경되지 않았다는 것을 안다. 따라서 실제 돔에는 파인애플만 삭제하거나 추가한다.

하지만 중간에 요소를 추가하거나 삭제하는 경우에는 다르다.

  // ...
  if (flag) {
    return (
      <div>
        <p>사과</p>
        <p>바나나</p>
      </div>
    );
  } else {
    return (
      <span>
        <p>사과</p>
        <p>파인애플</p>
        <p>바나나</p>
      </span>
    );
  }

이 경우 리액트는 순서 정보를 사용한다. 두번째 요소는 바나나였고 이후 파인애플로 변경됐으므로 두번째 요소에서 변경됐으므로 두 번째 요소에서 변경된 부분만 실제 돔에 반영한다. 세번째 요소는 없다가 생겼으므로 실제 돔에 추가한다.

이 경우 key 속성을 이용하면 파인애플만 실제 돔에 추가할 수 있다.
key 속성은 리액트가 렌더링을 효율적으로 할 수 있도록 우리가 제공하는 추가 정보로 key 속성 값을 입력하면 리액트는 같은 키를 가지는 요소끼리만 비교한다.

  if (flag) {
    return (
      <div>
        <p key='apple'>사과</p>
        <p key='bannana'>바나나</p>
      </div>
    );
  } else {
    return (
      <span>
        <p key="pineaplle">파인애플</p>
        <p key='apple'>사과</p>
        <p key='bannana'>바나나</p>
      </span>
    );
  }
}

pineaplle key가 새로 입력됐으므로 파인애플만 실제 돔에 반영한다.

0개의 댓글