ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [React] 컴포넌트 렌더링 최적화
    Front-End(Web)/React - 프레임워크(React, Next) 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

    반응형
Designed by Tistory.