리렌더링과 최적화(Memoization)

Lee Jooam·2022년 5월 19일
0

프론트엔드를 최적화하는 방법에는 여러가지가 있다.

크게 둘로 나누면 로드 최적화, 렌더링 최적화부터 세세한 부분까지 들어가면 이미지 스프라이트, 웹 폰트 최적화, 트리 쉐이킹, 레이아웃 스레싱 방지 등 굉장히 복잡하다.

이런 부분은 조금 더 숙련되고 난 뒤 다뤄보도록 하고 리액트에서 중점이되는 memoization을 이용한 최적화를 알아보자.

🛴 재조정

리액트에 DOM의 업데이트 여부를 결정하는 재조정 과정은 두 가지 가정을 기반으로 이루어진다.

  1. 다른 타입의 두 엘리먼트는 서로 다른 트리를 만든다.
  2. 개발자가 key를 통해 여러 자식 엘리먼트 중 어떤 것이 재조정되어야 하는지 알려준다.(리스트와 Key in React 참조)

1번도 세부적으로 들어가면 3가지 경우가 나온다. 두 개의 루트 엘리먼트가 있다고 가정하고 말해보겠다.

  1. 루트 엘리먼트가 서로 다른 두 타입일 때
    => 이전 트리를 버리고 새로운 트리 생성
  2. 루트 엘리먼트가 타입이 같을 때
    => 변경 속성만 반영하고 이후 자식에 대해 재귀적으로 수행
  3. 같은 타입의 컴포넌트일 때

우리가 살펴볼 것은 3번 내용이다. 컴포넌트가 어떤 경우에 리렌더링이 이루어지는지 알아보자.

📖 컴포넌트 리렌더링 조건

컴포넌트가 리렌더링 될 경우는 다음과 같다.

  1. 부모 컴포넌트가 리렌더링될 때
  2. props가 변할 때
  3. state가 변할 때

이 외에 몇 가지가 더 있지만 너무 심화된 내용이라고 생각해 제외했다.

생각해봐야 할 사항은 1번이다. 부모 컴포넌트가 자식 컴포넌트를 렌더링할 경우 부모의 상태가 변경되면 자식도 리렌더링 된다.

하지만 부모가 넘겨준 props가 변함이 없다면 어떨까? 이것은 불필요한 리렌더링이다.

이런 현상을 방지하기 위한 기법을 알아보자.

📖 예시

const Card = ({ user: { id, name } }) => {
  return (
    <div style={{ padding: "1rem", borderBottom: "1px solid gray" }}>
      <div>{`id: ${id}`}</div>
      <div>{`name: ${name}`}</div>
    </div>
  );
};
const App = () => {
  const [cards, setCards] = useState([
    {
      name: "kim",
      id: 1,
    },
    {
      name: "hello",
      id: 2,
    },
    {
      name: "jin",
      id: 3,
    },
  ]);

  const nextId = useRef(4);

  const onSubmit = (e) => {
    e.preventDefault();
    setCards((prev) =>
      prev.concat({ id: nextId.current++, name: e.target.elements.name.value })
    );

    e.target.reset();
  };

  return (
    <div className='App'>
      {cards.map((user) => (
        <Card key={user.id} user={user} />
      ))}

      <form onSubmit={onSubmit}>
        <input name='name' type='text' placeholder='name...' />
        <button type='submit'>등록</button>
      </form>
    </div>
  );
};

다음과 같이 form을 이용해 데이터를 생성, 렌더링하는 컴포넌트가 있다고 해보자. Card 컴포넌트는 부모로부터 props를 넘겨받아 그것을 렌더링해주는 컴포넌트다.

form으로 데이터를 추가할 때 기존에 있던 Card 컴포넌트도 모조리 리렌더링 된다.

부모의 state가 변하고 => 그로 인해 부모 리렌더링이 발생하고 => 부모 리렌더링으로 인해 자식도 리렌더링 되었기 때문이다.

😊 React.memo

이를 해결하는 방법은 간단하다.

const Card = React.memo(({ user: { id, name } }) => {
  return (
    <div style={{ padding: "1rem", borderBottom: "1px solid gray" }}>
      <div>{`id: ${id}`}</div>
      <div>{`name: ${name}`}</div>
    </div>
  );
});

React에 내장된 memo로 감싸면 된다. memo는 고차 컴포넌트(컴포넌트를 가져와 새 컴포넌트를 반환하는 함수)다.

받은 컴포넌트가 동일한 props로 렌더링 된다면, 다시 렌더링하지 않고 마지막으로 렌더링된 값을 재사용한다.

즉 이전 값을 memoizing하도록 만들어준다.

😊 React.useCallback

하지만 다음과 같이 이벤트 핸들러를 추가할 때 여전히 같은 문제가 발생한다.

  const onClick = (target) => {
    console.log(target);
  };

  return (
    <div className='App'>
      {cards.map((user) => (
        <Card key={user.id} user={user} onClick={onClick} />
      ))}

      <form onSubmit={onSubmit}>
        <input name='name' type='text' placeholder='name...' />
        <button type='submit'>등록</button>
      </form>
    </div>
  );

부모의 state가 변경되고 함수 컴포넌트가 다시 실행된다. 이때 onClick의 선언도 재실행되는데, 새로운 함수가 생기는 것과 같다.

이전과 다른 참조인 onClick이 들어왔기 때문에 Card 컴포넌트들이 리렌더링되는 것이다.

물론 이런 현상도 해결책이 존재한다.

  const onClick = React.useCallback((target) => {
    console.log(target);
  }, []);

리액트에서 제공하는 useCallback으로 함수를 감싸면 된다. 이것도 memo와 마찬가지로 고차 함수처럼 작동한다.

두 번째 인자인 의존성 배열로 callback에 의존성을 주입하고 해당 배열에 들어간 요소가 바뀌면 그때 새로운 함수가 선언된다.

😊 React.useMemo

const Card = React.memo(({ user: { id, name }, onClick }) => {
  const [myState, setMyState] = useState(0);
  const calculate = () => {
    console.log("calc");
    return id > 5 ? "lower" : "bigger";
  };

  return (
    <div
      style={{ padding: "1rem", borderBottom: "1px solid gray" }}
      onClick={() => {
        setMyState((prev) => prev + 1);
        onClick(name);
      }}
    >
      <div>{`id: ${id}`}</div>
      <div>{`name: ${name}`}</div>
      <div>{`myState: ${myState}`}</div>
      <div>{calculate()}</div>
    </div>
  );
});

만약 Card 컴포넌트의 구성이 다음과 같이 바뀐다면 어떨까?

여기서 봐야할 것은 calculate 함수의 실행 여부이다. onClick 이벤트에 자신의 state를 업데이트 하는 로직을 추가했다.

자신의 state가 업데이트 되었기 때문에 리렌더링이 발생한다.

그리고 calculate 함수 호출로 계산된 결과도 다시 구해진다.

지금의 예시에서는 이렇게 중복된 값을 계산해도 문제 없다. 하지만 네트워크 요청이나 localStorage에 접근하는 등 I/O 요청이라면 문제가 있다.

역시 리액트에서는 이런 상황의 해결책도 존재한다.

const Card = React.memo(({ user: { id, name }, onClick }) => {
  const [myState, setMyState] = useState(0);
  const calculate = React.useMemo(() => {
    console.log("calc");
    return id > 5 ? "lower" : "bigger";
  }, [id]);

  return (
    <div
      style={{ padding: "1rem", borderBottom: "1px solid gray" }}
      onClick={() => {
        setMyState((prev) => prev + 1);
        onClick(name);
      }}
    >
      <div>{`id: ${id}`}</div>
      <div>{`name: ${name}`}</div>
      <div>{`myState: ${myState}`}</div>
      <div>{calculate}</div>
    </div>
  );
});

React.useMemo로 계산된 값을 저장할 수 있다. 이때 조금 주의사항이 있는데 calculate는 더이상 함수가 아닌 값이 저장되어 있기 때문에 호출이 아닌 값 그 자체로 넣어야한다.

  const callbackFunc = React.useMemo(() => {
    return () => {
      console.log('memo and callback');
    };
  }, []);

React.useMemo를 위와 같이 함수를 반환하는 형태로 사용하면 useCallback과 같은 효과를 낼 수 있다.

하지만 Memo와 Callback이 각기 다른 함수로 존재하니 구별해서 사용하는 게 훨씬 좋을 것 같다.

후기

아주 기가막힌 기능이다. 모든 계산 값과 callback, 그리고 컴포넌트를 모두 메모이제이션하면 엄청난 성능 개선을 이룰 것 같다.

이런 생각은 좋지 못하다. 한때 궁금증이 생겨 현직자 분에게 의견을 물어봤는데, 메모이제이션 또한 비용이기 때문에 꼭 필요한 경우가 아니라면 사용하지 않는게 나을 수 있다는 말을 들었다.

profile
프론트엔드 개발자로 걸어가는 중입니다.

0개의 댓글