Front-End(Web)/React - 프레임워크(React, Next)

[React] 컴포넌트 렌더링 최적화

ttaeng_99 2022. 4. 13. 04:40
반응형

 

React를 사용하면 렌더링 성능에 대해 고민할 수 밖에 없다. 최적화를 위해선, React 컴포넌트의 리렌더링 조건을 먼저 복기하자!

  • 본인의 state가 변경될 때
  • 부모 컴포넌트로부터 받아오는 props가 변경될 때
  • 부모 컴포넌트가 리렌더링 될 떄
  • shouldComponentUpdate(), forceUpdate() 등 강제 업데이트 메서드 호출

 

지금까지 나도 state, props를 단순히 데이터를 다루기 위해 적용하다보니, 데이터의 할당이나 위치가 부적절했을때 불필요한 리렌더링이 유발되었을 거라고 생각한다.

 

리렌더링을 하면 컴포넌트를 reflow & repaint 를 함에 따른 브라우저 성능 저하가 발생할 뿐만 아니라, 다른 이슈도 동반된다.

함수형 컴포넌트의 경우, 리렌더링을 할 경우 내부 로직들이 재호출되기 때문에 그만큼 불필요한 컴포넌트들의 호출을 최소화해야 한다.

 

그렇기에, React에서 렌더링을 최적화하는 좀 더 효율적인 설계와 기능들에 대해 한 번 정리해보고자 한다.

 


 

💙 렌더링 최적화하는 8가지 기법

 

1. 적절한 위치에 state 선언

const DataPage = () => {
  const [list, setList] = useState(LIST)
  return (
    <div>
      <h1>페이지 컴포넌트</h1>
      <DataList listProps={list} />
    </div>
  )
}

const DataList = ({ listProps }) => {
  const [list, setList] = useState(LIST)
  return (
    <div>
      <h2>리스트 컴포넌트</h2>
      // 1) 최상위 컴포넌트의 props로 맵핑
      {listProps.map(e => <DataItem data={e} />)}
      // 2) 본인 컴포넌트의 state로 맵핑
      {list.map(e => <DataItem data={e} />)}
    </div>
  )
}

const DataItem = ({ data }) => {
  return (
    <div>
      <h3>아이템 컴포넌트</h3>
      <h4>{data.title}</h4>
      <img src={data.img} />
      <p>{data.description}</p>
    </div>
  )
}

위처럼, 페이지, 리스트, 아이템 컴포넌트가 부모 - 자식관계를 이루고 있는 예시를 살펴보자.

LIST라는 static data를 가져와 state로 관리하고 이를 리스트 컴포넌트에서 렌더링하려고 할 때, 방법은 2가지가 있을 것이다.

* 1) 페이지 컴포넌트에서 props로 리스트 컴포넌트에 전달, 2) 리스트 컴포넌트가 state로 저장해서 사용

 

어떤 방법을 사용할지는 여건에 따라 달라질 수 있다. 만약, 페이지 컴포넌트에서 리스트 데이터를 참조한다면 여기서 state로 저장하는 게 나을 것이다.

 

하지만, 그런 경우가 아니라면 오히려 이 리스트 데이터가 갱신됬을 때, 페이지 컴포넌트 내 리스트 외 다른 컴포넌트들까지 리렌더링을 유발할 수 있다.

그렇기 때문에, 리스트 컴포넌트가 직접 state에 저장하고 이를 아이템 컴포넌트로 맵핑하는 2)번 방법이 최적화 측면에서 유리할 것이다.

 

 

2. 객체 타입의 state 변형하지 않기

// 생성자 함수
<Component prop={new Obj('x')} />

// 객체 리터럴
<Component prop={{ key: 'x' }} />

이처럼, 새로운 객체를 생성해서 props로 자식 컴포넌트에 전달하면 문제가 발생할 수 있다.

부모 컴포넌트가 다른 요인으로 리렌더링되면, 해당 props 객체는 값은 같지만 참조주소가 다른 새로운 객체로 인식되기 때문에 이 역시 자식 컴포넌트의 리렌더링을 유발한다.

 

그렇기에 데이터의 변형을 상위에서 하는 것보단, state 그대로 컴포넌트로 전달에서 하위에서 이를 가공하는 것이 유리하다.

 

// 안좋은 예

function UserList() {
{...}

 const getResult = useCallback((score) => {
    if (score <= 70) {
      return { grade: "D" };
    } else if (score <= 80) {
      return { grade: "C" };
    } else if (score <= 90) {
      return { grade: "B" };
    } else {
      return { grade: "A" };
    }
  }, []);

return(
 <div>
 {users.map((user) => {
    return (
      <Item key={user.id} user={user} result={getResult(user.score)} />
        );
      })}
 </div> 
  
)
export default memo(UserList);


function Item({ user, result }) {
  console.log("Item component render");

  return (
    <div className="item">
      <div>이름: {user.name}</div>
      <div>나이: {user.age}</div>
      <div>점수: {user.score}</div>
      <div>등급: {result.grade}</div>
    </div>
  );
}

export default Item;

result라는 가공된 객체를 별도의 props로 전달하기 때문에, <UserList> 컴포넌트가 다른 요인으로 리렌더링 될 때마다 이 result 객체가 얽인 <Item> 컴포넌트들도 같이 리렌더링되는 비효율성이 생긴다.

 

// 좋은 예

function UserList() {
{...}

return(
 <div>
 {users.map((user) => {
    return (
      <Item key={user.id} user={user} />
        );
      })}
 </div> 
  
)
export default memo(UserList);


function Item({ user }) {
  console.log("Item component render");

  const getResult = useCallback((score) => {
    if (score <= 70) {
      return { grade: "D" };
    }
    if (score <= 80) {
      return { grade: "C" };
    }
    if (score <= 90) {
      return { grade: "B" };
    } else {
      return { grade: "A" };
    }
  }, []);

  const { grade } = getResult(user.score);

  return (
    <div className="item">
      <div>이름: {user.name}</div>
      <div>나이: {user.age}</div>
      <div>점수: {user.score}</div>
      <div>등급: {grade}</div>
    </div>
  );
}

export default memo(Item);

이처럼, <UserList> 컴포넌트는 각 user 데이터 그대로만 props로 내려주고, 데이터 가공을 <Item> 에서 진행한다.

 

 

3. 컴포넌트 맵핑 시 key 값의 중요성

React에서는 컴포넌트들을 맵핑할 때, 고유의 key 값을 부여할 것을 강제하고 있다. 이 때, key값에 index를 사용하는것을 지양해야 한다.

 

리스트 데이터에 아이템이 추가/삭제 되었을 때를 생각하면 그 이유를 알 수 있다.

아이템이 중간에서 추가/삭제되면, 그 이후의 아이템 컴포넌트들의 index가 바뀌므로 key값이 바뀜에 따라 리렌더링이 발생하게 된다.

또한, 이러한 반복적인 데이터 조작으로 index가 순간적으로 꼬이면서 오류를 유발할 수 있다.

 

가급적이면 데이터의 id 등 고유값을 key에 넣기를 권장하나, 아래와 같은 경우엔 index를 써도 무방하다.

  • 배열과 각 요소가 수정, 삭제, 추가 등의 기능이 없는 단순 렌더링만 담당하는 경우
  • id로 쓸만한 unique 값이 없을 경우
  • 정렬 혹은 필터 요소가 없어야 함

 

 

4. shouldComponentUpdate(), React.PureComponent - 클래스형 컴포넌트

class ReactComponent extends Component {
  constructor() {
    this.state = { data: null }
  }
  shouldComponentUpdate(nextProps, nextState) {
    return nextState.data !== this.state.data
  }
  render() {
    return <p>{this.state.data}</p>
  }
}

shouldComponentUpdate()클래스형 컴포넌트에서 리렌더링의 조건을 거는 메서드이다.

부모 컴포넌트의 리렌더에 따라 자식 컴포넌트의 리렌더도 불가피할 때, 여기에 조건을 부여하여 부합하지 않으면 false를 반환해서 리렌더링을 방지할 수 있다.

* shouldComponentUpdate() 가 false를 반환하면 render() 메서드가 호출되지 않는다.

 

class Pure extends React.PureComponent<PureProps, PureStates> {
    constructor(props: AuthProps) {
        super(props);
        this.state = {};
    }


    render() {
        return (
        	<div> React.PureComponent </div>
        );
    }
}

이와 비슷하게, 클래스형 컴포넌트는 PureComponent 컴포넌트를 제공하며, 이는 불필요한 리렌더링을 자동적으로 방지한다.

Pure는 이미 shouldComponentUpdate() 가 적용되어 있어, props와 state를 얕은 비교하여 변경된 부분이 있을때만 true를 반환한다고 한다.

 

 

5. React.memo()

React.memo() 는 컴포넌트를 랩핑하여 메모이제이션 하고, props가 바뀌지 않으면 리렌더링을 방지하는 함수이다.

const FunctionalComponent = React.memo(({...props}) => {
  return (
    //html tag
  )
}, (prevProps, nextProps) => {
  if('리 렌더링 해야하는 조건') {
    return false;
  }
  return true;
})

기본적인 문법은 위와 같다. 컴포넌트를 React.memo() 로 감싸며, 첫 번째 인자는 컴포넌트, 두 번째 인자는 콜백함수(리렌더 조건)을 받는다.

 

다음은 사용 예시이다. <UserList> 의 리스트 state가 수정되어도, 이미 렌더링된 <UserItem> 은 리렌더되지 않도록 React.memo()로 방지한 모습이다.

// UserList.jsx

import { useState } from "react";

import UserItem from "components/section/examples/example5/UserItem";
import Button from "components/atom/Button";

function UserList() {
  console.log("UserList component render");

  const [users, setUsers] = useState([
    {
      id: 0,
      name: "Kim",
      age: 27,
      score: 80,
    },
    {
      id: 1,
      name: "Jo",
      age: 25,
      score: 70,
    },
  ]);

  const addUser = () => {
    setUsers([
      ...users,
      {
        id: 2,
        name: "Jung",
        age: 30,
        score: 90,
      },
    ]);
  };

  return (
    <div>
      <Button
        value="새 유저 생성"
        disabled={users.length >= 3}
        onClick={addUser}
      />
      {users.map(user => {
        return <UserItem key={user.id} user={user} />;
      })}
    </div>
  );
}

export default UserList;
// UserItem.jsx

import React from "react";

function UserItem({ user }) {
  console.log(`UserItem (id: ${user.id}) component render`);

  return (
    <div className="user-item">
      <div>이름: {user.name}</div>
      <div>나이: {user.age}</div>
      <div>점수: {user.score}</div>
    </div>
  );
}

export default React.memo(UserItem);

 

React.memo() 는 Hooks가 아닌 일종의 HOC(고차 컴포넌트)이다. 

그렇기에 클래스형 & 함수형 컴포넌트 모두 적용 가능하며, 함수형 컴포넌트에서 shouldComponentUpdate() 메서드의 대안으로 제시되는 솔루션이다.

 

 

6. useMemo()

컴포넌트 내 어떤 함수가 값을 리턴하는데 많은 시간이 소요된다면, 이 컴포넌트가 리렌더링 될 때마다 함수호출에 많은 시간이 소요될 것이다.

또, 그 함수의 리턴값을 자식 컴포넌트가 참조한다면, 해당값이 변경될 때마다 리렌더링이 발생될 것이다.

 

useMemo() 는 이런 경우 사용되는 Hooks로, CPU 소모가 심한 함수들을 캐싱하기 위해 사용된다.

 

// UserList.jsx

import { useState } from "react";

import Average from "components/section/examples/example8/Average";
import UserItem from "components/section/examples/example8/UserItem";
import Button from "components/atom/Button";

function UserList() {
  console.log("UserList component render");

  const [text, setText] = useState("");
  const [users, setUsers] = useState([
    {
      id: 0,
      name: "Kim",
      age: 27,
      score: 80,
    },
    {
      id: 1,
      name: "Jo",
      age: 25,
      score: 70,
    },
  ]);

  const average = (function () {
    console.log("calculate average. It takes long time !!");

    return users.reduce((result, user) => {
      return result + user.score / users.length;
    }, 0);
  })();

  const addUser = () => {
    setUsers([
      {
        id: 2,
        name: "Jung",
        age: 30,
        score: 90,
      },
      ...users,
    ]);
  };

  return (
    <div>
      <div>
        <input
          type="text"
          value={text}
          placeholder="아무 내용이나 입력하세요."
          onChange={event => setText(event.target.value)}
        />
      </div>
      <Button
        value="새 유저 생성"
        disabled={users.length >= 3}
        onClick={addUser}
      />
      <Average average={average} />
      {users.map(user => {
        return <UserItem key={user.id} user={user} />;
      })}
    </div>
  );
}

export default UserList;

위 예제의 average 함수를 보자. users 유저들의 점수 데이터의 평균을 반환하는 일종의 computed state(계산된 상태값) 이다.

이는 유저수가 많아질수록 연산에 많은 리소스가 소요되기에, 컴포넌트가 리렌더링 될 때마다 호출된다는 비효율성이 발생한다.

 

useMemo(()=> func, [input_dependency])

이를 최적화하기 위해 useMemo() Hooks를 사용한다.

첫 번째 인자는 캐싱하는 함수이며, 두 번째 인자는 [dependency array] 로 여기에 포함된 값이 바뀌어야 해당 함수를 재호출한다. 

통상, dependency 에는 캐싱함수와 연관된 props, state 등을 넣어준다.

 

  const average = useMemo(() => {
    console.log("calculate average. It takes long time !!");
    return users.reduce((result, user) => {
      return result + user.score / users.length;
    }, 0);
  }, [users]);

위는, average 함수를 useMemo() Hooks를 통해 캐싱한 예제이다.

 

 

7. useCallback()

useCallback() 역시 useMemo()와 같은 매커니즘으로 렌더링 최적화에 활용된다.

useMemo() 가 특정 리턴값을 메모이징했다면, useCallback() 은 props로 넘겨주는 함수 자체를 메모이징한다.

 

보통, 자식 컴포넌트의 조작을 통해 부모 컴포넌트의 state를 조작할 때 이벤트 핸들러로 사용될 함수를 props로 넘기는 경우가 많다.

이 때, 상위 컴포넌트의 state가 갱신되고, 이에 따라 이벤트 핸들러 함수 역시 재호출이 되기 때문에 이를 props로 받는 자식 컴포넌트 역시 불필요한 리렌더링이 발생한다.

 

const Root = () => { 
  const [isClicked, setIsClicked] = useState(false); 
  const _onClick = useCallback(() => { 
      setIsClicked(true); }, 
  []); 
  // dependency가 없으므로 Root component가 렌더링 되는 최초에 한번만 생성되며 이후에는 동일한 참조 값을 사용한다. 
    
  return ( 
      <> 
          <Child onClick={_onClick}/> 
          <Child onClick={_onClick}/> 
          ... 
          <Child onClick={_onClick}/>            
      </> 
  ); 
}; 
// Root와 Child가 여러번 렌더링 되더라도 onClick props으로 전달되는 _onClick 함수는 한번만 생성되므로 계속해서 동일 참조 값을 가진다. 


const Child = ({onClick}) => { 
  return <button onClick={onClick}>Click Me!</button> 
};

위 예시를 보면, <Child> 컴포넌트의 클릭 이벤트 핸들러 함수인 _onClick을 useCallback() 으로 메모이제이션 한 모습이다.

 

본래는, _onClick() 메서드로 인해 isClicked가 토글링되면 <Root>가 리렌더링 되고, 이에 따라 _onClick() 메서드 역시 재호출되어야 하지만,

useCallback() Hooks로 메모이징했고 dependency가 없기 때문에 최초 렌더링 이외에는 재호출이 일어나지 않는다.

 

* useCallback() 은 이벤트 핸들러 함수들에 기본적으로 적용되어도 유용할 것 같다고 생각된다.

 

 

8. useState의 함수형 업데이트

// 예시) 삭제 함수 
const onRemove = useCallback(
  id => {
    setTodos(todos.filter(todo => todo.id !== id));
  },
  [todos],
);

// 예시) 함수형 업데이트 후
const onRemove = useCallback(id => {
  setTodos(todos => todos.filter(todo => todo.id !== id));
}, []);

위는 todos 리스트를 삭제하는 onRemove() 메서드의 예시이다. 본래, useCallback으로 선언했고, todos가 dependency로 들어가있다.

 

하지만, setState() 함수새로운 값이 아닌, 상태 업데이트를 정의하는 함수를 넣어주면 dependency가 필요없게 되므로 onRemove() 함수의 불필요한 재호출을 최소화할 수 있다.

 


 

이번 포스팅을 작성하면서 생각보다 React와 함수형 컴포넌트의 렌더링에 대해 더 깊은 이해가 된 것 같다.

컴포넌트가 리렌더링 될 때마다 내부의 프로퍼티와 메서드가 재선언/재할당 되기 때문에, 불필요한 부분을 최소화해야 함이 이해되었다.

 

우선적으로, 화면구성에 필요한 최소한의 값만 state, props로 다뤄야 하며, 참조값들은 useRef()를 사용하거나 메모이제이션 Hooks들을 적용해야한다고 생각했다. 

 

또한, 가장 큰 이슈는 아무래도 부모 컴포넌트의 리렌더에 따라 자식 컴포넌트들도 리렌더링된다는 점일 것이다.

그렇기에, 조건부로 리렌더링하는 shouldComponentUpdate()나 React.memo() 와 같은 함수들을 자식 컴포넌트에 적용하거나,

부모 컴포넌트가 props로 넘겨주는 객체(혹은 함수)들을 적절하게 전달해서 데이터 갱신 외 리렌더링을 방지해야 한다.

 

무엇보다, state를 적절한 위치에 두어 하위 컴포넌트의 리렌더링을 최소화하거나, state가 변형되는 지점을 적절히 선정하는 등 컴포넌트 트리의 설계 역시 중요함을 절실히 느낀 계기였다.

 

 

📎 출처

- [렌더링 이해 및 최적화] MinuKang 님의 블로그 : https://medium.com/vingle-tech-blog/react-%EB%A0%8C%EB%8D%94%EB%A7%81-%EC%9D%B4%ED%95%B4%ED%95%98%EA%B8%B0-f255d6569849

- [렌더링 최적화 기법] cocoder16 님의 블로그 : https://cocoder16.tistory.com/36  

- [렌더링 최적화 기법] shin6403 님의 블로그 : https://velog.io/@shin6403/React-%EB%A0%8C%EB%8D%94%EB%A7%81-%EC%84%B1%EB%8A%A5-%EC%B5%9C%EC%A0%81%ED%99%94%ED%95%98%EB%8A%94-7%EA%B0%80%EC%A7%80-%EB%B0%A9%EB%B2%95-Hooks-%EA%B8%B0%EC%A4%80  

- [shouldComponentUpdate, React.PureComponent] dolarge 님의 블로그 : https://velog.io/@dolarge/Pure-Component%EB%9E%80

반응형