리액트의 성능 최적화 방법

Bitnara Lee·2021년 12월 13일
1

✨리액트의 성능 최적화 방법

리액트는 내가 코딩을 시작하고 프론트엔드를 지망하면서 가장 많이 사용한 프레임 워크이지만, 아직 어떻게 하면 더 효율적으로 코드를 최적화할 수 있을까에 대한 고민을 많이 해보지 못한 것 같다.
이 기회에 그 방법들에 대해 알아보았다.

들어가기 전에 리액트에 대해, 그리고 몇가지 알아야할 점들을 짚고 넘어가겠다.

React

자바스크립트 라이브러리의 하나로서 사용자 인터페이스(UI)를 만들기 위해 사용된다. 페이스북과 개별 개발자 및 기업들 공동체에 의해 유지보수된다. 리액트는 싱글 페이지 애플리케이션(SPA)이나 모바일 애플리케이션 개발에 사용될 수 있다. - 위키백과 -

  • 자바스크립트에 HTML을 포함하는 JSX(JavaScript XML)이라는 문법
  • 단방향 데이터 바인딩(One-way Data Binding)
  • 가상 돔(Virtual DOM)이라는 개념을 사용하여 웹 애플리케이션의 퍼포먼스를 최적화

가상 돔(Virtual DOM)

일종의 DOM 메타데이터, 뷰에 변화가 있다면, 그 변화가 실제 DOM에 적용되기 전에 Virtual DOM에 적용시키고 최종 결과만 한번 실제 DOM에 전달하므로써 렌더링 비용을 줄인다.

(렌더링에 대해 : 이전 글 - 브라우저의 렌더링 과정 참고)

성능 최적화가 중요한 이유

유저들은 반응이 빠른 UI를 선호한다. 100ms 미만의 UI 응답 지연은 유저들이 즉시 느낄 수 있고, 그 이상 지연될 수록 유저들은 상당한 지연을 느낀다.

보여지는 부분에 영향을 미치지 않는 변경사항때문에 리렌더링이 발생하게 된다면(불필요한 렌더링), 이 때문에 UI 성능의 손실이 일어나게 된다.

-> 이를 방지위해 개발자는 컴포넌트가 최소한으로 렌더링 되기위한 최적화 작업을 하는 것이 중요하다.

🧐 위에서 최종 결과만 DOM에 전달함으로써 렌더링 비용을 줄일 수 있다고 했는데?

  • 리액트 컴포넌트는 기본적으로 부모컴포넌트가 리렌더링되면 바뀐 내용이 없다 할더라도 자식 컴포넌트 또한 리렌더링이 된다.
    위에서 언급했듯이 실제 DOM 에 변화가 반영되는 것은 바뀐 내용이 있는 컴포넌트에만 해당하지만, Virtual DOM 에는 모든걸 다 렌더링하고 있다.

    -> 따라서 컴포넌트를 최적화 하는 과정에서 기존의 내용을 그대로 사용하면서 Virtual DOM에 렌더링 하는 리소스를 아낄 수도 있다.

memorization
기존에 수행한 연산의 결과값을 어딘가에 저장해두고 동일한 입력이 들어오면 재활용하는 프로그래밍 기법
리액트에서는 props을 입력값으로 받는 컴포넌트(-React.memo), 함수(-useMemo()) 혹은 일반 계산된 값을 memorize 할 수 있다.

이를 바탕으로 가장 대표적인 최적화 방법 몇가지와 나머지 방법들을 정리해보았다.

🌟 1. React.memo()

HOC(High-Order-Components), 컴포넌트를 인자로 받아서 새로운 컴포넌트를 반환하는 구조의 함수

컴퍼넌트를 렌더링하고 결과를 메모이징(Memoizing)한다.
그리고 다음 렌더링이 일어날 때 props가 같다면, React는 메모이징(Memoizing)된 내용을 재사용함으로써 불필요한 리렌더링을 피할 수 있다.
-> 가상 DOM에서 달라진 부분을 확인하지 않아 성능상의 이점을 누릴 수 있다.

  • 간단한 예시를 만들어보았다.
    부모컴포넌트 App, 자식컴포넌트 Child가 있고, App에는 클릭시 counter의 상태가 올라가는 버튼, 타이핑을 했을때 input의 상태가 변화하는 input이 있다. 자식인 Child에는 counter의 상태를 props로 전달한다.(위 박스에서 확인 가능)
    Child 컴포넌트가 렌더링 될 때마다 콘솔에 문구가 뜨도록 설정했다.
    (counter의 상태가 변화했을때 외에) Child가 props로 받지 않는 부모의 input 상태가 변경될 때에도 해당 문구가 뜨는 것을 확인할 수 있다.
import React, { memo } from "react";

const Child = (props) => {
   //... 생략
};

export default memo(Child);
/* 
React.memo를 컴포넌트에 래핑시

1. export default React.memo(Child),

2. const MemoChild = React.memo(function Child(props) {
}
형식으로도 사용 가능

*/
  • 위와 같이 Child 컴퍼넌트를 memo()로 래핑해 보니
    더이상 props외 다른 요소나 상태에 의해 consold.log가 뜨지 않는다.(아래 에서 확인 가능)

props 혹은 props의 객체를 비교할 때 얕은(shallow) 비교를 한다.

-> 즉, props로 객체 타입이 넘어왔을 때 reference로 체크하기 때문에 객체 안의 값들이 같더라도 항상 다른 값으로 판단하게 된다.
-> 때문에 React.memo를 사용했더라도 항상 DOM을 다시 그려주게 된다.

얕은(shallow) 비교
객체, 배열, 함수와 같은 참조 타입들을 실제 내부 값까지 비교하지 않고 동일 참조인지(동일한 메모리 값을 사용하는지)를 비교
props에 참조 타입이 있다면 동일한 값이라도 동일 참조 값이 아니므로 얕은 비교를 통해 새로운 값으로 판단하여 리렌더링을 일으킨다.

커스텀 비교 함수를 작성하여 객체의 값이 같은 경우에 true를 리턴하여 재렌더링을 하지 않게끔 할 수 있다.
-> React.memo() 의 두번째 인자로 비교 함수를 직접 만든다.

React.memo(Component, [areEqual(prevProps, nextProps)]);
-------------------------------------------------------------
  
function Component(props) {
  /* props를 사용하여 렌더링 */
}
function areEqual(prevProps, nextProps) {
  /*
  nextProp가 prevProps와 동일한 값을 가지면 true를 반환하고, 그렇지 않다면 false를 반환하는 로직
  */
}
export default React.memo(Component, areEqual);

따라서 React.memo를 쓰면 무조건 성능이 좋아지는 것은 아니다.
-> shallow level에서만 데이터를 비교하므로, 복잡한 구조의 데이터는 비교할 수 없기 때문이다.

사용해야 할 때:

  • (함수형) 컴퍼넌트가 같은 props로 자주 렌더링 될거라 예상될 때
    • ex) 실시간으로 업데이트되는 영화 조회수를 나타내는 부모 컴포넌트에서 조회수 외에 제목,설명 등을 나타내는 자식 컴포넌트도 업데이트된다.
  • props가 복잡한 Object일 때
  • 컴포넌트가 리액트 트리에서 중위-상위 레벨에 해당하는 경우 (ex: Header, Footer, Layout HOC,,,)

사용하지 않아야 할 때:

  • 위에서 언급한 상황과 일치하지 않을 때
  • 클래스 기반의 컴퍼넌트에서 메모이제이션이 필요하다면 PureComponent를 확장하여 사용하거나, shouldComponentUpdate() 메서드를 구현하는 것이 적절하다.
  • props가 자주 변하는 컴퍼넌트
    • 이전 props와 다음 props의 동등 비교를 위해 비교 함수를 수행한다.
      비교 함수는 거의 항상 false를 반환할 것이기 때문에, React는 이전 렌더링 내용과 다음 렌더링 내용을 비교할 것이다.
      -> [비교 함수 실행 + 컴포넌트 재렌더링]의 이중 작업은 성능이 오히려 안좋아질 수 있다.

🌟 2. useCallback()

React의 Hook 중 하나, 컴포넌트 내부 로직에서 쓰이는 함수의 재렌더링을 막는다.

함수와 종속성 배열(dependency array)을 매개변수로 전달받으면 메모이제이션된 콜백을 반환

위 React.memo()에서 언급했듯이 함수 또한 객체 타입이므로 props로 전달될 때 얕은비교가 된다.

😱 만약 자식 컴포넌트에 전달하는 콜백 함수를 inline 함수로 사용하고 있거나, 컴포넌트 내에서 함수를 생성하고 있다면
-> 새로운 함수 참조 값을 계속해서 만들고 있는 것
-> 메모리가 계속 낭비되고 있다.

inline 함수 : 함수가 쓰인 곳에서 렌더링이 발생할 때마다 새로 생성 (ex: <Child onClick={() => console.log('callback')}/> )
컴포넌트 내의 함수: 그 함수를 가지고 있는 컴포넌트의 렌더링이 발생 할 때마다 새로 생성

const Parents = () => {
  const click = () => {
    console.log('callback');
 };
 
 return (
    <>
      <Child onClick={click}/> // 상단에 생성된
      <Child onClick={click}/> // _onClick 함수를
      <Child onClick={click}/> // 참조하고 있음
	  ...
    </>
 );
};

// Child이 여러 번 생성되더라도 onClick props으로 전달되는 click 함수는 한번만 생성된다.
// inline보다 낫지만 Parents 컴포넌트가 리렌더링 되면 click 함수가 새로 생성된다.
const Child = ({click}) => {
  return <button onClick={click}>Click Me!</button>
};

useCallback을 이용, 종속성 배열이 변경되지 않았다면 동일한 주소값을 가진 함수를 재사용할 수 있다.

종속성 배열이 빈배열([]): 어떤 상태값에도 반응하지 않음
종속성 배열이 없을 때: 모든 상태 변화에 반응

  • 아까의 예시에서 App 컴포넌트에 함수 hiFromChild를 만들고 counter+1하는 콜백을 반환하게 했다, Child 컴포넌트에게 함수를 props로 전달했다.
    그리고 Child 컴포넌트에서 onClick 버튼을 만들었다.
    useCallback이 없으면 App의 input 상태가 바뀌는 것으로도 Child 컴포넌트가 리렌더링되어 console.log가 출력되지만
    함수에 useCallback과 의존성배열(여기서는 counter)를 넣어 counter의 상태가 변할 때만 Child 컴포넌트가 렌더링(함수 재생성)되게 했다

참조의 동일성에 의존적인 최적화된 자식 컴포넌트에 콜백을 전달할 때 유용하다

useCallback만으로는 하위 컴포넌트의 리렌더를 막을 수 없다. 하위 컴포넌트가 props의 값을 비교하여 false인 경우에만 리렌더링을 시킬 때, useCallback이 유용하다.
-> React.memo()로 최적화된 자식 컴포넌트에 props로 콜백 함수를 내려줄 때 유용하다.

🌟 3. useMemo()

React의 Hook 중 하나, 함수형 컴포넌트 내부에서 쓰이는 함수의 값을 메모이징한다.

useCallback과 같이 함수와 종속성 배열(dependency array)을 매개변수로 전달받지만, 콜백이 아닌 함수의 을 메모이징
종속성 배열의 요소가 바뀔 때에만 다시 계산
-> 비싼 계산을 해야하는 잘 바뀌지 않는 값의 리렌더링을 방지할 때 유용하다.

const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);
function App() {
    const [count, setCount] = useState(0)
    
    const expFunc = (count)=> {
        waitSync(3000);
        return count * 90;
    }
    const resCount = expFunc(count)
    
    return (
        <>
            Count: {resCount}
            <input type="text" onChange={(e)=> setCount(e.target.value)} placeholder="Set Count" />
        </>
    )
}
----------------------------------------------------------------------------------------------------

// useMemo를 이용하여 위의 코드를 최적화

function App() {
    const [count, setCount] = useState(0)
    
    const expFunc = (count)=> {
        waitSync(3000);
        return count * 90;
    }
    const resCount = useMemo(()=> {
        return expFunc(count)
    }, [count])
    
    return (
        <>
            Count: {resCount}
            <input type="text" onChange={(e)=> setCount(e.target.value)} placeholder="Set Count" />
        </>
    )
}

useEffect, useCallback과 마찬가지로 종속성 배열의 여부에 따라 다르게 작용한다(useCallback에서 설명)

( useCallback, useMemo에서 )

사용해야 할 때:

  • 값을 얻어낼 때 마다 복잡한 로직을 사용해야 할 때
    • ex) for loop의 사용, json.parse, regex 사용 등
  • 렌더링 할 때 마다 비용 소모가 클 때

사용하지 않아야 할 때:

  • side effects(useEffect에서 하는 일이다) 등 렌더링 중에 하지 않는 일
  • 값이 단순할 거나 / 비용소모가 크기 않은 함수일때
    • ex) const add = useMemo(()=> 1+1, [])
    • 불필요하게 코드가 길어져 가독성이 떨어지고, 계산 비용이 소모된다.
  • 해당 함수/변수를 component scope 밖으로 옮길 수 있을 때

useCallback, useMemo, React.memo의 비교

React.memouseMemoUseCallback
종류HOC(High-Order-Components)hookhook
사용범위class,function componentfunction componentfunction component
메모이징컴포넌트 자체콜백함수의 값콜백함수 자체

🌟 4 그 외

자세한 설명 참고

  • 가상 List

    거대한 data list를 렌더링 할 때:
    브라우저의 viewport에 보여지는 부분만 렌더링하고 나머지는 스크롤 할때 보여지도록 하는 것을 권장
    ->"windowing"
    많은 React 라이브러리들이 존재한다. (ex :Brian Vaughn이 개발한 react-window, react-virtualized)

  • shouldComponentUpdate()
    (Class Component의 메서드)

    React.memo()에서 커스텀 비교 함수와 비슷한 역활
    이전과 다음 state, props 객체의 메모리 참조를 비교

  • React.PureComponent
    (Class Component의 메서드)

    React.PureComponent는
    React.Component에 shouldComponentUpdate()가 적용된 버전으로
    state와 prop값을 체크하여
    (이전 props와 state 객체들과 변경할 state와 props를 얇게 비교)
    component가 업데이트 되어야 하는지 확인한다.

  • Caching functions

    JSX 컴포넌트 render 메소드 안에서 호출되는 함수이다.
    값이 같다면 캐시 처리하여 같은 값을 리턴하도록 하는 것이다. 따라서 같은 입력이 다시 발생할 경우 함수의 연속 실행이 더 빨라지게 된다.

  • Reselect selectors

    Reselect 라이브러리는 Redux state를 캡슐화하여 component를 확인하고 렌더링 할지 안할지 여부를 알려준다.
    -> reselect는 메모리 참조가 서로 다르지만 변경되었는지 확인하기 위해 이전 및 현재 Redux 상태를 얕게 체크함으로써 시간을 절약하게 한다. 새로운 상태 객체 생성 여부와 상관없이 필드가 변경되었을 때만 React에 리렌더링을 알린다.

  • Web worker

    UI 흐름을 방해하지 않고 메인 쓰레드와 동시에 실행할 수 있는 게이트웨이

    자바스크립트 코드는 싱글 쓰레드에서 동작
    동일한 쓰레드에서 오래걸리는 프로세스를 실행하면 UI 렌더링 코드에도 심각한 영향을 미친다
    -> 프로세스를 다른 쓰레드로 옮기는 것이 최선

  • Lazy Loading

    부하를 단축하기 위해 자주 사용되는 최적화 기법 중 하나
    동적 import를 사용하여 component를 생성하고 렌더링 하는 걸 쉽게 만들어준다.

🌟 유의 사항

정리한 방법들은 성능 개선을 하는데 유용하게 사용된다.
하지만 리렌더링을 막기 위해 오로지 해당 방법들에 의존하면 안된다.
위에 언급했 듯 사용할 때, 사용하지 않아야 할 때를 구분해 적절하게 사용하는 것이 중요하다.


참고
Use React.memo() wisely
Optimizing React performance with memo, useMemo, and useCallback
React 공식문서

profile
Creative Developer

0개의 댓글