티스토리 뷰

공부를 시작하며...

이직하면서 짧은 기간동안 빠르게 리엑트를 공부하고 프로젝트에 투입되었던 경험이 있었습니다.

리엑트 첫 프로젝트이며, 혼자서 초기 구축부터 진행을 해야하는 상황이였습니다.

그렇다보니 리엑트 친화적인 개발과 성능 최적화를 염두하며 진행하지 못하였습니다.

그때의 저의 시작이 최선이였다면, 발전을 위해서 리엑트 성능 최적화를 목적에 두고 공부를 진행하려 합니다.

 

이번 글에서는 React의 함수형 컴포넌트를 통한 성능 최적화에 대한 내용을 다루고 있습니다.

 

React의 유용한 경고

리엑트는 기본적으로 개발하기 편한 유용한 경고가 많이 포함되어 있습니다.

하지만 이 경고들은 리엑트가 더 무겁고 느리게 만들기 때문에 앱을 배포할 때에는

프로덕션 버전을 사용해야합니다.

 

기본적으로 create-react-app 은 프로덕션 모드를 통해서 앱을 최적화 해주는데,

빌드 프로세스가 잘 설정되어 있는지 확인하기 위해서는

크롬의 익스텐션인 React Devloper Tools를 통해서 확인하면 됩니다.

 

프로덕션 모드는 1번의 사진과 같이 표시가 되며, 개발 모드일때에는 2번 사진과 같이 표시하게 됩니다.

앱을 개발할 때에는 개발모드, 배포할 때에는 개발 모드를 사용해야합니다.

 

1. 프로덕션 빌드 사용 (React 공식 문서 사진)
2. 빌드 버전 사용 (React 공식 문서 사진)

 

React 빌드 및 단일 파일 빌드

프로젝트가 Create React App 이라면, 아래 명령어를 통해서 빌드를 시작할 수 있습니다.

npm run bulid

 

빌드를 하게 되면 React 와 React DOM의 프로덕션 준비 버전을 단일 파일로 제공합니다.

~~~.production.min.js 로 끝나는 React 파일이 프로덕션 환경에 적합하다고 합니다.

<script src="https://unpkg.com/react@17/umd/react.production.min.js"></script>
<script src="https://unpkg.com/react-dom@17/umd/react-dom.production.min.js"></script>

 

* 여러 프로덕션 빌드 방법에 대해서는 다음을 확인해주세요

- Brunch

- Browerify

- Rollup

 

디버깅 여담

console.log를 통해서 디버깅을 하는 과정에서도 속도가 줄어든다고 합니다.

- console.log를 호출하는 것이 빈 함수를 호출하는 것보다 약 10,000배 정도 느립니다.

위 정보는 여러 레퍼런스를 통해서 확인할 수 있지만, 여기 여기를 통해서 참고하였습니다.

 

console.log 를 프로덕션 모드에서 제거(log가 콘솔에 찍히지 않는 등을 말함)

하기 위해서는 여러 방법론이 존재합니다.

그 중 몇가지를 소개합니다.

 

1. boolean 값으로 제어

디버깅(log를 찍기 위한) 모드를 boolean (true/false)값으로 정의하고 컨트롤 하는 방법입니다.

const DEBUG = true; // or false

// code
DEBUG && console.log('log를 확인하는 부분')

 

2. console.log를 임의의 빈 함수로 재 정의

스크립트의 시작 부분에 console.log를 빈 함수로 대체하여 console.log()가

호출되는 부분이  빈 함수가 호출되게 하는 방법입니다. 

위에서 설명 된 " console.log 가 빈 함수보다 속도가 느리다 "라는 점을 이용한 방법입니다.

// script 시작 부분
console.log = function(){};


// DEBUG boolean과 함께 사용하면
const DEBUG = true; // or false
if(!DEBUG){
	console.log = function(){};
}

//--------------------------//
// React app 일 때
if(process.env.React_APP_PRODUCTION){
	console.log = function no_console(){}
}

 

긴 목록 가상화

앱에서 목록을 렌더링하게 되면 "windowing"이라는 기법을 사용하는것이 좋다고합니다.

아래와 같이 긴 목록이 있고 화면에서 보이지 않는 목록을 렌더링 하지 않는 기법을 말합니다.

렌더링 하는 데 걸리는 시간과 생성되는 DOM 노드의 수를 크게 줄일 수 있습니다.

 

목록 설명 참고 사진

 

리엑트 공식 문서에서는 두 windowing 라이브러리를 소개해주고 있습니다.

- react-window

- react-vitualized

 

 

useMemo()

useMemo는 리엑트의 hook이며, *메모제이션된 값을 반환합니다.

* 메모제이션이란?

- 컴퓨터 프로그램이 동일한 계산을 반복해야 할 때, 이전에 계산한 값을 메모리에 저장함으로써

동일한 계산의 반복 수행을 제거하여 프로그램 실행 속도를 빠르게 하는 기술입니다.

import {useMemo} from 'react';

// 여기서 a, b는 상태입니다.
// computeExpensiveValue는 고비용의 계산이 이루어지는 함수입니다.
const memoizedValue = useMemo(()=> computeExpensizeValue(a,b), [a, b]);

 

사용되는 패턴은 생성 함수 및 의존성 값의 배열을 전달합니다.

useMemo는 전달한 의존성이 변경되었을 때에만 메모이제이션 된 값을 다시 계산 합니다.

이것은 렌더링 되는 과정에서 불필요하고 고비용 계산을 방지해줘 성능의 이점을 가져가게 됩니다.

 

* useMemo로 전달된 함수는 렌더링 중에 실행됩니다.

* 렌더링중에 하지 않는것을 사용하지 않는것을 추천합니다.

- 예를 들면 사이드 이펙트(이벤트 핸들러 등록, 코드블록, API 통신 등 부수적인 효과)는

useMemo말고 useEffect에서 동작되게 해주세요.

* 전달하는 의존성 값의 배열이 없다면 렌더링 때마다 새 값을 계산하게 됩니다.

 

export default function ReactUseMemoPage() {
  const [inputValue, setInputValue] = useState(1);

  useEffect(() => {
    // 2초마다 반복적으로 1~3의 난수를 생성해줘 inputValue의 상태를 변경합니다.
    setInterval(() => {
      const randomValue = getRandomIntInclusive(1, 3);
      setInputValue(randomValue);
    }, 2000);
  }, []);

  const expensiveFunction = (value) => {
    return value * 100;
  };

  const calcValue = expensiveFunction(inputValue);

  return (
    <>
      <p>result : {calcValue}</p>
    </>
  );
}

위와 같이 2초마다 반복적으로 1 ~ 3 사이의 난수를 생성해주는 로직과

그 값에 100을 곱해서 렌더링 해주는 함수가 있습니다.

 

위의 경우에서는 단편적으로 큰 비용의 계산이 이루어지지 않지만,

큰 앱의 경우에서는 위와 같은 상황과 계산식이 복잡해지면 복잡할수록 비용이 커지게 될겁니다.

 

그리고 1 ~ 3 사이의 난수라면 1이 나오고 2초후에 또 1이 나오게 되면 값은 같지만

1에 100을 곱하고 리턴하여 불필요한 계산이 이루어지게 됩니다.

 

그렇기에 이전과 같은 값이면 어딘가에 값이 저장되어 있고, expensiveFunction 이 실행되지 않고

저장되어 있는 값이 리턴되게 해야합니다.

 

// before useMemo
const calcValue = expensiveFunction(inputValue);

// after useMemo
const calcValue = useMemo(() => expensiveFunction(inputValue), [inputValue]);

위와 같이 코드가 작성되게 되면 의존되어 있는(inputValue) 값이 이전과 같다면

useMemo는 expensiveFunction을 호출하지 않고 이전에 캐시되어 있는 결과 값을 리턴하게 됩니다.

 

+ 그렇다면 의존되어 있는 [inputValue] 를 빈 값으로 두게되면 어떻게 되나요?

const calcValue = useMemo(() => expensiveFunction(inputValue), []);

위와 같이 두게되면 초기 값만 한번 실행되고 변경되는 값에 대해서 렌더링이 일어나지 않게 됩니다.

 

이 방식은 useEffect에서 의존성을 넣어주는 것과 비슷하게 동작하는데요.

마찬가지로 비어있는 값([] -> 빈 배열조차 넣지 않게되면)을 넣으면

const calcValue = useMemo(() => expensiveFunction(inputValue));

useEffect에서 의존성을 넣지 않은것과 같이 메모이제이션이 되지 않고 inputValue가 바뀔때마다

expensiveFunction 함수가 실행되고 계산되며 렌더링이 일어나게 됩니다.

 

 

useMemo의 설명을 끝으로

useMemo는 캐싱 기술을 이용해서 성능을 향상시키며, 함수형 컴포넌트에서는

props로 넘기는 값들을 useMemo를 통해서 비용 절감하는 것에 도움이 됩니다.

 

React Lazy

React Lazy를 사용하면 동적 import를 사용해서 컴포넌트를 렌더링 할 수 있으며,

번들의 크기를 줄이고, 초기 렌더링에서 사용되지 않는 컴포넌트를 불러오는

작업을 지연시킬 수 있습니다.

 

// before use lazy
import OtherComponent from './OtherComponent';

// after use lazy
const OtherComponent = React.lazy(() => import('./OtherComponent'));

 

React Lazy는 import 호출로 해당 파일의 Promise를 반환하게 되는데,

webpack이 코드를 컴파일하고 번들링하는 과정에서 React Lazy를 사용해서 

import 한 모듈을 발견하게 되면 별도의 번들을 만들게 됩니다.

 

React Suspense, Lazy

React Lazy는 React Suspense와 같이 사용합니다.

Suspense와 lazy를 같이 사용해서 페이지를 받아오는 동안 로딩을 표시해 줄 수 있는데요

이를 로딩 지시기(Loading indicator)를 나타낸다고 표현합니다.

* 동적 import 와 React.lazy를 사용하려면 javascript 환경이 Promise를 지원해야 합니다.

 

다음 코드는 App.js에서 react-dom-router을 사용해서 페이지 별 lazy 로딩을 적용한 예제입니다.

import 된 ReactLazyPage이 코드스플릿팅을 통해서 나눠진 번들 파일이 해당 컴포넌트가 보여질 때 받게되며

받을 동안엔 로딩 지시기를 보여주게 됩니다.

// react-lazy.page.js 파일
export default function ReactLazyPage() {
  return <>Lazy page 화면</>;
}

// App.js 파일
import React from "react";
import { Routes, Route } from "react-router-dom";

import { NavBar } from "./common/components/navbar";

const ReactLazyPage = React.lazy(() =>
  import("./page/react-optimize/react-lazy.page")
);

function App() {
  return (
    <>
      <NavBar />
      {/* 아래의 Suspense를 통해서 ReactLazyPage의 번들 파일을 받아오는 동안
      fallback 컴포넌트를 보여주게 됩니다.*/}
      <React.Suspense fallback={<div>Loading...</div>}>
        <Routes>
          {/*... pages*/}
          <Route path="/react-lazy" element={<ReactLazyPage />} />
        </Routes>
      </React.Suspense>
    </>
  );
}

export default App;

 

Named Exports

React Lazy 는 아직 default exports만을 지원하고 있습니다.

아래와 같은 named export 로는 lazy import를 사용할 수 없습니다.

// can not use lazy import
export const ReactLazyComponent = ()=> {
	return <></>
}

이때엔 default로 재정의한 중간다리 역할을 하는 모듈을 생성하면 됩니다.

이렇게 하면 *트리 쉐이킹(tree shaking)이 계속 동작하며 사용하지 않는 컴포넌트는 가져오지 않게됩니다.

* 트리 쉐이킹이란? 사용하지 않는 코드를 제거하는 방식을 말합니다.

// react-lazy-named-exports.page.js
export const ReactLazyNamedExportsPage = () => {
  return <>Lazy Named Exports page 화면</>;
};

// react-lazy-middle-module.js
export { ReactLazyNamedExportsPage as default } from "./react-lazy-named-exports.page";

// App.js imports
const ReactLazyNamedExportsPage = React.lazy(() =>
  import("./page/react-optimize/react-lazy/react-lazy-middle-module")
);

 

React.memo()

고차 컴포넌트

React.memo는 고차 컴포넌트(HOC, Higher Order Component)라고 불립니다.

*고차 컴포넌트란 컴포넌트를 가져와서 새 컴포넌트를 반환하는 함수를 말합니다. 

 

const MemoizedComponent = React.memo(function MyComponent(props) {
  /* props를 사용하여 렌더링 */
});

 

메모이징

React.memo로 래핑한 컴포넌트가 동일한 props로 동일한 결과를 렌더링하게 되면,

React.memo를 호출하게되고 결과를 메모이징(Memoizing) 하여 컴포넌트를 재 렌더링 하지 않고,

마지막으로 렌더링된 결과물을 렌더링하게 되어 성능을 향상 시킬 수 있습니다.

 

Props의 영향

React.memo가 성능의 이점을 가져가기 위해서는 전달된 props의 변화에 집중을 해야합니다.

상단의 컴포넌트에서 전달한 props의 변화를 감지해서 memo는 메모이징이 제대로 동작하게 됩니다.

 

React.memo로 감싸진 함수 컴포넌트에 useState, useReducer, useContext ( 상태가 변경되는 여지가 있는 hook들 )을

사용하게 된다면 state나 context가 변하게 되면 다시 렌더링 됩니다.

이전 props의 비교는 props가 가지는 객체를 얕은 비교를 통해서 동작이 됩니다.

 

예제

그럼 예제를 통해서 memo를 사용하는 것을 보도록 하겠습니다.

 

다음 예제는 간단하게 Input에 값을 입력하고 추가를 누르면 리스트에 값이 추가되는 페이지입니다.

// Input에 값을 입력하고 추가를 누르면 리스트에 값이 추가되는 페이지
import { useState } from "react";

import InputWithButton from "../../../common/components/input-with-button";
import ReactMemoPropsComponent from "./components/react-memo-props-component";

function ReactMemoPage() {
  const [inputValue, setInputValue] = useState("");
  const [list, setList] = useState([]);

  const handleChangeInput = (e) => {
    setInputValue(e.target.value);
  };

  const handleClickAddButton = () => {
    if (inputValue === "") return;
    setList((prevList) => [...prevList, inputValue]);
    setInputValue("");
  };

  return (
    <>
      <InputWithButton
        inputValue={inputValue}
        onChangeInput={handleChangeInput}
        onClickButton={handleClickAddButton}
      />
      <ReactMemoPropsComponent list={list} />
    </>
  );
}

export default ReactMemoPage;

 

예제의 퍼블리싱 된 화면

정말 간단하게 입력 폼과 추가 버튼이 존재합니다.

 

그리고 리스트가 붙여지는 컴포넌트에서는 console.log에 1을 찍어줍니다.

그렇게 되면 컴포넌트가 리 렌더링이 되는지 확실히 볼 수 있게됩니다.

(*리엑트 개발자도구의 오류때문인지 Highlight updates when components render 옵션으로는 확인하기 어려웠습니다.)

아직까지는 React.memo를 사용하지 않은 상태입니다.

// ReactMemoPropsComponent 컴포넌트
import React from "react";
function ReactMemoPropsComponent({ list }) {
  console.log(1);
  return (
    <div>
      {list.map((item, index) => (
        <div key={`${item}-${index}`}>{item}</div>
      ))}
    </div>
  );
}

export default ReactMemoPropsComponent;

 

다음과 같이 input의 값을 변경만 하였는데 list가 렌더링 되는 컴포넌트도 계속해서 렌더링되었습니다.

input의 값만 변경이 되었는데 list 컴포넌트의 console.log의 1도 찍혀서 리렌더링이 되고 있는것을 확인할 수 있다.

 

React.memo를 사용해서 메모이징된 컴포넌트를 내보내줍니다.

// React memo를 사용한 List 컴포넌트
import React from "react";
function ReactMemoPropsComponent({ list }) {
  console.log(1);
  return (
    <div>
      {list.map((item, index) => (
        <div key={`${item}-${index}`}>{item}</div>
      ))}
    </div>
  );
}

// React memo를 사용해서 메모이징된 컴포넌트를 사용합니다.
const MemorizedReactMemoPropsComponent = React.memo(ReactMemoPropsComponent);

export default MemorizedReactMemoPropsComponent;

그렇게 되면 다음과 같이 최초 렌더링 될 때 빼고는 input 폼의 값이 변경될때엔

리스트 컴포넌트가 다시 렌더링 되지 않아 성능을 최적화 할 수 있게됩니다.

input의 값이 변경되어도 list 컴포넌트가 리렌더링 안되는것을 확인할 수 있다.

+ 위의 성능은 React Profiler을 통해서 확실히 확인이 가능합니다.

 

React.memo 두 번째 인자

+ 비교동작을 컨트롤하고 싶다면 React.memo의 두 번째 인자로 비교 함수를 부여하면 됩니다.

// 코드 예시
function MyComponent(props) {
  /* props를 사용하여 렌더링 */
}
function areEqual(prevProps, nextProps) {
  /*
  nextProps가 prevProps와 동일한 값을 가지면 true를 반환하고, 그렇지 않다면 false를 반환
  */
}
export default React.memo(MyComponent, areEqual);

예를 들면 다음과 같이 사용이 가능합니다.

const MemorizedReactMemoPropsComponent = React.memo(
  ReactMemoPropsComponent,
  (prevProps, nextProps) => prevProps.list === nextProps.list
);

 

 

React.memo 는 언제 사용해야할까?

- React memo는 성능 최적화 도구로써 렌더링을 막기(또는 방지)위해서 사용하면 안됩니다.

- 또 컴포넌트가 같은 props로 자주 렌더링이 되며, 비싼 연산과 렌더링 되는 과정이 무겁다면 사용합니다.

 

또한 콜백 함수를 넘기게 되는 경우에서는 메모이제이션이 중단되게 되는 경우가 있으니

useCallback을 사용해서 콜백 인스턴스를 보존시켜야합니다.

 

useCallback

useCallback은 useMemo와 memoization 한다는 개념은 같습니다.

두 hook의 차이점은 useMemo는 결과값을 memoization 할 때 사용하고, 

useCallback은 함수를 재사용할 때 사용합니다.

const memoizedCallback = useCallback(()=>{
  callback(a, b);
},[a, b])

위의 useCallback의 예시에서 deps의 값이 변경이 될 때에만 useCallback내의 콜백 함수가 재 생성됩니다.

 

아래는 실 사용 예제를 사용전, 사용후로 설명하겠습니다.

 

useCallback 사용전

간단하게 count를 1씩 증가시키는 컴포넌트가 있습니다.

UpperCounterButton 컴포넌트는 React.memo를 통해서 memoized된 컴포넌트입니다.

해당 컴포넌트가 리렌더링이 일어나는지 확인하기 위해 console.log를 호출하였습니다.

분명 memo를 통해서 memoized를 해주었지만 해당 버튼을 클릭하면서 계속해서 console.log가 호출되는데요,

리렌더링이 일어나고 있다는 증거입니다.

 

ReactUseCallbackPage 컴포넌트에서 setCount를 통해서 count 상태가 변경이 되면 해당 컴포넌트가 리렌더링이 일어나게 되고, handleClick 콜백 함수는 재 생성이 됩니다.

이 과정에서 memoized된 UpperCounterButton 컴포넌트에서 onClick props로 넘긴 콜백 함수가 변경이 되었다고 판단되게 됩니다. 제대로 memoized가 되지 않았습니다.

function UpperCounterButton({ onClick }) {
  console.log("render upperCounterButton");
  return (
    <button
      style={{ padding: "10px", border: "1px solid #000000" }}
      onClick={onClick}
    >
      +1
    </button>
  );
}
const MemoizedUpperCounterButton = React.memo(UpperCounterButton);
export default function ReactUseCallbackPage() {
  const [count, setCount] = useState(1);
  const handleClick = () => {
    setCount((prev) => prev + 1);
  };
  return (
    <div>
      <p>count:{count}</p>
      <MemoizedUpperCounterButton onClick={handleClick} />
    </div>
  );
}

계속해서 console.log 의 메세지가 출력되고 있는것을 확인할 수 있습니다.

useCallback 사용후

useCallback을 사용해서 콜백 함수를 memoized 해줍니다.

export default function ReactUseCallbackPage() {
  const [count, setCount] = useState(1);
  const handleClick = useCallback(() => setCount((prev) => prev + 1), []);
  return (
    <div>
      <p>count:{count}</p>
      <MemoizedUpperCounterButton onClick={handleClick} />
    </div>
  );
}

위와 같이 props로 넘기는 콜백함수를 useCallback을 통해서 넘기면 리렌더링이 일어나지 않는것을 볼 수 있습니다.

카운트가 증가되어도 console.log가 찍히지 않는것을 확인할 수 있다.

Deps 부여

useCallback 의 deps에 count 상태를 넣게되면 handleClick 콜백은 count 상태가 증가함에 따라 재 생성되어서 리렌더링이 이루어지는것을 확인할 수 있습니다. 이렇듯 deps를 잘못 사용하게 되면 원하는 결과를 얻지 못할수도 있으니 유의해야합니다.

const handleClick = useCallback(() => setCount((prev) => prev + 1), [count]);

 

끝으로

간단하게 React의 렌더링 성능 최적화에 대해서 알아보았습니다.

성능 향상을 기대할 수 있지만, 불필요한 곳과 남용하게되면 오히려 성능을 악화시킬수도 있으니

정확하게 사용법을 알고 사용해야할것입니다.

 

추가적으로 공부해야 할 내용

- 긴 목록 가상화 실습

- React.PureComponent

- Caching Functions

- Reselect selectors

- Web worker

- React Profiler

 

레퍼런스

React 공식 사이트

https://ko.reactjs.org/docs/optimizing-performance.html

 

 

javascript — console.log가 JavaScript 실행 성능을 줄입니까?

성능 저하는 최소화되지만 이전 브라우저에서는 사용자 브라우저 콘솔이 열려 있지 않으면 log is not a function of undefined JavaScript 오류가 발생합니다. 이것은 console.log 호출 이후의 모든 JavaScript 코

www.wake-up-neo.com

 

UZILOG

 

uzihoon.com

 

 

React.memo() 현명하게 사용하기

유저들은 반응이 빠른 UI를 선호한다. 100ms 미만의 UI 응답 지연은 유저들이 즉시 느낄 수 있고, 100ms에서 300ms가 지연되면 이미 유저들은 상당한 지연으로 느낀다. UI 성능을 증가시키기 위해, React

ui.toast.com

 

댓글
최근에 올라온 글