React 최적화(memo, useCallback, lazy loading, debounce)

YeongWoooo·2021년 5월 18일
8

어찌어찌 돌아가는 리액트 웹을 구현했습니다! 모든 기능이 정상작동하고, 빠진 기능 하나 없네요. 근데 뭔가 이상합니다. 왠지모르게 렌더링이 느린 것 같고, 섹시하지 않은 것 같은 건 기분탓일까요? 어디서부터가 잘못되었을까요? 어떻게 해결할 수 있을까요? React의 여러가지 최적화에 대한 문제와 해결방법을 간단하게 소개받고 알아보는 시간을 가져보겠습니다.


Memoization

메모이제이션(memoization)은 컴퓨터 프로그램이 동일한 계산을 반복해야 할 때, 이전에 계산한 값을 메모리에 저장함으로써 동일한 계산의 반복 수행을 제거하여 프로그램 실행 속도를 빠르게 하는 기술이다. 동적 계획법의 핵심이 되는 기술이다. - 위키백과

알고리즘을 공부해보셨다면 Dynamic Programming에 대해서 들어보셨을거에요. 반복적인 계산 수행을 어딘가에 저장해놓고 그 값을 필요할 때마다 꺼내 사용함으로써 시간복잡도를 크게 줄일 수 있는 방법입니다. 이 또한 메모이제이션의 활용방안이라고 생각할 수 있습니다. 이 방법은 React에서도 최적화를 위해 사용하게 됩니다. 어떻게 사용될까요?


React의 리렌더링 과정

// 상위 컴포넌트
...
const ParentComponent = ({props}) => {
  const [state, setState] = useState(initial)

  return (
    <>
      <ChildComponent props={props} />
    </>
  )
}

export default ParentComponent
// 하위 컴포넌트
...
const childComponent = ({props}) => {
  return (
    <div>{JSON.stringify(props)}</div>
  )
}

export default childComponent

React에서 리렌더링이 일어나는 경우는 크게 다섯가지가 있습니다.

  1. State 변경이 있을 때
  2. 부모 컴포넌트가 렌더링 될 때
  3. 새로운 props가 들어올 때
  4. shouldComponentUpdate 생명주기 단계에서 true가 반환될 때
  5. forceUpdate가 실행될 때

그리고 React에서 컴포넌트의 업데이트는 2가지 단계로 나뉩니다.

  • 렌더단계: 실제 DOM에 반영하기 전, 변경사항을 파악하는 단계
  • 커밋단계: 렌더단계에서 파악된 변경사항을 실제 DOM에 반영하는 단계

리렌더링이 일어나는 경우에는 이 외에도 여러가지 상황이 있지만 1, 2번의 경우 리액트는 얕은비교(참조 타입들을 실제 내부 값까지 비교하지 않고 동일한 메모리 값만 사용하는지 비교하는 것)를 통해 새로운 값인 경우 리렌더링을 하게 됩니다. 여기서 렌더단계에서 Virtual DOM을 다시 만들게 되고, 이를 이전에 만들어진 Virtual DOM과 비교합니다. 새로운 props가 들어왔다고 생각해서 렌더단계를 거쳤지만, 사실은 변하지 않았기때문에 커밋단계에서는 하는 일이 없게 됩니다.

예를 들어서, 상위 컴포넌트의 state가 변하게 되면, 상위 컴포넌트가 리렌더링되며 하위 컴포넌트에게 전달하는 props가 새로 생성되고, props에 참조 타입(객체, 배열, 함수...)이 있다면 하위 컴포넌트는 얕은 비교를 통해 새로운 값으로 인식해 리렌더링을 일으키는 것입니다.

이렇게 보면 하위 컴포넌트 입장에서 실제 DOM에서 변경사항이 없기 때문에 성능에 큰 문제는 없어 보이지만, 만약 하위 컴포넌트가 굉장히 복잡한 컴포넌트라서 Virtual DOM을 생성하고 이전에 생성된 Virtual DOM과 비교하는 작업이 비용이 많이 요구될 수 있습니다.

만약 이런상황이 꼬리에 꼬리를 물고 4중, 5중 리렌더링이 일어나게 된다면... 이런 상황을 방지하기 위해 React는 useCallback, React.memo라는 해결책을 제시합니다.


React.memo()

정의

React.memo는 고차 컴포넌트입니다. 당신의 컴포넌트가 동일한 props로 동일한 결과를 렌더링해낸다면, React.memo를 호출하고 결과를 메모이징(Memoizing)하도록 래핑하여 경우에 따라 성능 향상을 누릴 수 있습니다. 즉, React는 컴포넌트를 렌더링하지 않고 마지막으로 렌더링된 결과를 재사용합니다. - React 공식 문서

사용하기

...
// Functional
const ParentComponent = () => {
  ...
}
export default React.memo(ParentComponent)

// Class
class ParentComponent extends React.PureComponent {
  ...
}
export default ParentComponent

위 코드와 같이 함수형 컴포넌트에서는 React.memo를 사용하거나 클래스형 컴포넌트에서 React.PureComponent를 상속받게되면 해당 컴포넌트의 props가 변경되지 않았다면 더 이상 렌더링을 진행하지 않습니다. 이 외에 useStateuseReducer, useContext 훅을 사용한다면 여전히 statecontext가 변할 때 다시 렌더링 됩니다. React.memo는 복잡한 객체에 얕은 비교만을 수행하는 것이 기본 동작입니다! 만약 커스텀한 동작을 원한다면, 두 번째 인자로 별도의 비교 함수를 넘겨주면 됩니다.

여기서 더 배워야할 한 가지가 있습니다!

오직 성능 최적화만을 위해서 사용됩니다. 렌더링을 '방지'하기 위해서 사용하면 안됩니다!


useCallback()

정의

메모이제이션된 콜백을 반환합니다. - React 공식 문서

...
// Functional
const ParentComponent = () => {
  const handleChangeValue = () => { ... }
	
  return ( ... )
}

// Class
class ParentComponent extends React.Component {
  handleChangeValue = () => { ... }
  
  render () { ... }
}

Class 컴포넌트는 초기 생성시 인스턴스 형태로 생성되기 때문에 리렌더링 되는 경우에도 class 내부의 render함수만 다시 호출됩니다. 즉 멤버함수인 handleChangeValue는 리렌더링시에도 다시 할당되지 않습니다. 하지만 Functional 컴포넌트는 컴포넌트 자체를 호출하기 때문에 매번 handleChangeValue가 매번 재할당됩니다. 만약 handleChangeValueprops 인자로 받는 하위 컴포넌트가 있다면 불필요한 렌더단계를 거치게 되는 것입니다. 이런 경우를 방지하기 위해 React에서는 useCallback Hooks를 제공합니다

사용하기

import React, {useCallback} from 'react';

const ParentComponent = () => {
	const handleChangeValue = useCallback(() => { ... }, []);
	
	return ( ... )
}

useCallback은 2번째 인자로 넣어준 배열안의 데이터가 변경되지 않았다면 함수를 새로 생성하지 않고 이전에 있던 값을 재사용합니다. 만약 어떤 state가 변경되어도 해당 useCallback함수의 인자로 들어있지 않다면 해당 함수는 리렌더링되지 않습니다. 물론 해당 함수를 props로 받는 하위 컴포넌트 또한 리렌더링이 일어나지 않겠죠!

useCallback(fn, deps)useMemo(() ⇒ fn, defs)와 같습니다.
useCallback은 메모이제이션된 콜백을 반환하고,
useMemo는 메모이제이션된 값을 반환합니다.

React에서는 useMemo를 사용하지 않고 최적화 할 수 있도록 권장하고있습니다.


React.lazy (lazy loading)

정의

React.lazy 함수를 사용하면 동적 import를 사용해서 컴포넌트를 렌더링 할 수 있습니다. - React 공식 문서

// Before
import OtherComponent from './OtherComponent';

// After
import OtherComponent = React.lazy(() => import ('./OtherComponent'));

앱이 커지면 번들도 커지게되기 마련입니다. 번들이 커지게되면 로드시간이 길어지게 되고, 사용자 이용 측면에서 부정적인 영향을 가져올 수 있습니다. 번들이 거대해 지는 것을 방지하는 방법은 번들을 쪼개는 것입니다. 코드를 분할함으로써 사용자에게 보여지는 부분부터 렌더하게 된다면 총 로드시간이 길더라도 비교적 쾌적하게 사용할 수 있겠죠?

코드 분할은 앱을 "지연 로딩"하게 도와주고 앱 사용자에게 획기적인 성능 향상을 제공합니다. 앱 코드 다이어트를 하지 않더라도, 필요하지 않은 부분을 잘라내 불러오지 않게 하여 초기화 로딩에 필요한 비용을 줄여줍니다. 동적 import()React.lazy 중 후자를 살펴보도록 하겠습니다.

사용하기

  • 코드 구조
.
├── README.md
├── package-lock.json
├── package.json
├── public
│   └── index.html
└── src
    ├── App.js
    ├── components
    │   ├── Img.jsx
    │   ├── ImgItem.jsx
    │   └── ImgWrapper.jsx
    ├── images.js
    ├── index.css
    └── index.js
  • App.js
import ImgWrapper from "./components/ImgWrapper";

function App() {
  return (
    <div>
      <ImgWrapper />
    </div>
  );
}

export default App;
  • ImgWrapper.jsx
import React from "react";
import { images } from "../images";
import ImgItem from "./ImgItem";

const ImgWrapper = () => {
  return (
    <>
      {images.map((image, index) => (
        <ImgItem key={index} src={image.src} />
      ))}
    </>
  );
};

export default ImgWrapper;
  • ImgItem.jsx
import React, { lazy, Suspense } from "react";

// import Image from "./Img";
const Image = lazy(() => import("./Img"));

const ImgItem = ({ src }) => {
  return (
    <div>
      <Suspense fallback={<div>Loading....</div>}>
        <Image src={src} />
      </Suspense>
    </div>
  );
};

export default ImgItem;

lazy로 선언된 컴포넌트는 Suspense 컴포넌트 하위에서 렌더링되어야 합니다! Suspenselazy컴포넌트가 로드될 동안 로딩 화면과 같은 예비 컨텐츠를 fallback prop을 통해 보여줄 수 있습니다. 또한 하나의 Suspense 컴포넌트로 여러 lazy 컴포넌트를 감쌀 수도 있습니다.

  • Img.jsx
import React from "react";

export default function Img({ src }) {
  return <img src={src} alt="" />;
}

실행 결과, Loading... 문구가 뜨며 사진이 순서대로 로딩되는 것을 볼 수 있습니다. 사실 이런 로직은 워낙 인터넷 속도가 빠르기 때문에 체감이 잘 되지 않습니다. 사실 lazy 컴포넌트를 더 잘 사용하는 방법은 라우트 기반에서 사용하는 것이 더 효과적이라고 합니다.

서버 사이드 렌더링(SSR)에서는 Ladable Component를 사용해야 합니다! SSR에서는 React.lazySuspense를 지원하지 않습니다.

Debounce

정의

Debouncing은 함수가 마지막으로 호출된 후 특정 시간까지 실행되지 않도록 해줍니다. 빠르게 발행하는 이벤트(예시 스크롤, 키보드 이벤트)의 응답으로 어떤 비싼 계산을 수행해야 할 때 사용하면 좋습니다. - React 공식 문서

검색할 때, 검색어를 입력하고 잠깐 후에 관련 연관어가 뜨는 것을 경험하신 적이 있으신가요? 만약 ㄱ, 가, 강, 강ㅇ, 강여, ..... 이렇게 value가 하나하나 바뀔 때마다 API를 호출한다면 어떻게 될까요? 분명히 서버와 클라이언트에 굉장한 부담이 됩니다. 이런 이벤트가 발생할 때 과도한 비즈니스 로직이 수행되지 않도록 방지하기 위해 Debounce를 사용합니다.

사용하기

// debounce는 lodash라이브러리에서 사용할 수 있습니다.
import debounce from 'lodash.debounce';

class Searchbox extends React.Component {
  constructor(props) {
    super(props);
    this.handleChange = this.handleChange.bind(this);
		// 250밀리초 이후에 함수 실행
    this.emitChangeDebounced = debounce(this.emitChange, 250);
  }

  componentWillUnmount() {
    this.emitChangeDebounced.cancel();
  }

  render() {
    return (
      <input
        type="text"
        onChange={this.handleChange}
        placeholder="Search..."
        defaultValue={this.props.value}
      />
    );
  }

  handleChange(e) {
    this.emitChangeDebounced(e.target.value);
  }

  emitChange(value) {
    this.props.onChange(value);
  }
}

마치며

서비스가 점점 커지면 앱도 점점 커지게 됩니다. 다양한 최적화를 통해 운영측면에서 굉장한 효율을 기대할 수 있습니다.


참고자료

React 공식 문서

profile
개발 재밌다!

3개의 댓글

comment-user-thumbnail
2021년 7월 28일

코드만 섹시한게 아니였군요... 글 또한.. 멋집니다! 너무 잘 읽었어요!

답글 달기
comment-user-thumbnail
2021년 8월 10일

리엑트.. 이거 참 귀하군요

답글 달기
comment-user-thumbnail
2021년 8월 10일

Vue만 사용하는데 관심을 가져볼게요!!

답글 달기