Lighthouse와 React Profiler를 활용한 최적화

sham·2024년 10월 28일
0

SkyScope 개발일지

목록 보기
7/12
post-thumbnail

다음 토이프로젝트의 개발 기록이다.

리액트 컴포넌트를 잘 구현하는 것도 중요하겠지만, 얼마나 최적화가 잘 되었는지도 중요한 사항이다. Lighthouse, profiler 등을 사용해서 프로젝트의 성능을 분석해보고 최적화를 진행한 후 얼마나 개선되었는지 비교해보는 과정을 진행해보자.

개요

Lighthouse, React Profiler(react devtools)는 Chrome 확장 프로그램이 제공하는 웹사이트 성능 측정 도구이다. 설치 후 개발자 도구(F12)를 열어서 화면을 녹화해 검사를 진행할 수 있다.

Lighthouse

Lighthouse는 자체적으로 페이지를 로드하고 분석을 해 웹사이트의 성능을 분석할 수 있다.

사이트 성능을 평가하는 기준은 다음과 같다.

  • First Contentful Paint - 페이지의 첫번째 요소가 보이게 되는 시간, DOM에서 가장 처음 렌더링되는 요소를 기준으로 평가한다.
  • Largest Contentful Paint - 페이지의 가장 큰 요소(주요 컨텐츠)가 보이게 되는 시간. 가장 큰 요소를 기준으로 평가한다.
  • Total Blocking Time - 사용자가 입력을 한 후 결과가 올 때까지 페이지가 동작하지 않는 시간을 기준으로 평가한다.
  • Cumulative Layout Shift - 페이지 로딩 중 예상치 못한 레이아웃 이동의 빈도와 크기를 측정한다. 페이지의 각 프레임에서 발생하는 레이아웃 이동의 크기를 측정하고, 이를 합산하여 점수를 계산한다.
  • Speed Index - SI는 페이지 로딩 시 사용자 화면에 콘텐츠가 얼마나 빨리 표시되는지를 시각적으로 측정한다. 전체 페이지 로딩 속도를 평가하는 기준이 된다.

Profiler - react devtools

React Profiler는 React 애플리케이션의 성능을 분석하고 최적화할 수 있는 도구다.

컴포넌트의 렌더링 시간, 재렌더링 빈도, 그리고 렌더링이 발생한 이유 등을 추적하여 성능 병목 현상을 식별할 수 있다.

LightHouse와는 달리 렌더링 시간 측정, 재렌더링 빈도 추적, 렌더링 원인 분석 기능을 제공해서 React 내부의 성능을 최적화하는데 도움을 준다.

결론

Lighthouse와 Profiler 둘 다 웹페이지의 성능을 분석할 수 있는 개발자 도구이고, 각자의 특성을 가지고 있다.

Lighthouse는 전체 웹페이지가 얼마나 빠르게 렌더링 되는지, 사용자의 경험을 중점으로 분석을 한다면 Profiler는 react 내부의 성능을 중점으로 분석해주는 차이점이 있다.

최적화 전 분석 결과

Lighthouse

성능이 50점을 웃돌며 좋지 못한 것으로 평가받았다.

FCP, LCP, TBT, Speed Index를 개선해야 한다.

Profiler

  • 불필요한 렌더링이 많이 되는 것으로 분석되었다.
  • 하나의 컴포넌트의 props를 수정하면 부모를 타고 올라가 부모의 모든 요소를 렌더링하고 있었다.

본격 최적화

Lighthouse 최적화

React.lazy

번들 파일의 코드를 분할하여, 모든 코드를 한 번에 불러오지 않고 사용자가 필요로 할 때에 필요한 코드만 불러오는 개념인 코드 분할을 dynamic import를 활용해 구현할 수 있다.

React.lazy() 는 import() 구문을 반환하는 콜백함수를 인자로 받는다. 동적 불러오기로 불러와지는 모듈은 ReactComponent를 포함하며 default export를 가진 모듈이어야 한다. 그리고 불러온 컴포넌트를 반환한다.

React.lazy로 불러온 컴포넌트는 단독으로 쓰일 수 없고, React.Suspense 컴포넌트로 하위에서 렌더링되어야 한다.

import React, { Suspense, lazy } from 'react';
import { createBrowserRouter } from 'react-router-dom';
import { LoadingState } from '@src/Component';

// import { MainPage, MapPage, ErrorPage } from '@src/Page';
import { Layout } from '@src/Component';

const MainPage = lazy(() => import('@src/Page/MainPage'));
const MapPage = lazy(() => import('@src/Page/MapPage'));
const ErrorPage = lazy(() => import('@src/Page/ErrorPage'));

const LazyComponent = ({ children }: { children: React.ReactNode }) => {
  return <Suspense fallback={<LoadingState />}>{children}</Suspense>;
};
const BrowserRouter = createBrowserRouter([
  {
    path: '/',
    element: <Layout />,

    children: [
      {
        path: '/',
        element: (
          <LazyComponent>
            <MainPage />
          </LazyComponent>
        ),
      },
      {
        path: '/live',
        element: (
          <LazyComponent>
            <MapPage />
          </LazyComponent>
        ),
      },
      {
        path: '/error',
        element: (
          <LazyComponent>
            <ErrorPage />
          </LazyComponent>
        ),
      },
    ],
  },
]);

export default BrowserRouter;

dynamic import와 React.lazy

Profiler 최적화

불필요한 렌더링을 줄여 최적화를 진행해보자.

React 컴포넌트는 언제 리렌더링이 되는가?

  • 부모로부터 받는 Props가 변경되었을 때
  • 자신의 state가 변경되었을 때
  • 부모 컴포넌트가 리렌더링되었을 때

그러나 자기 자신의 props는 아무런 변화도 없는데 부모 컴포넌트가 변했다고 자식도 렌더링 되는 것은 불필요하다.

최적화에 사용되는 Memoization

Memoization은 이전에 계산한 값을 메모리에 저장해 동일한 계산이 들어왔을 때 새로 계산하는 것이 아닌 저장한 값을 전달해 반복을 줄여 성능을 향상하는 기술이다.

React에서는 react.memo(), usememo, usecallback을 사용해 memoization을 구현할 수 있다.

react.memo()

react.memo는 고차 컴포넌트(HOC) 방식으로 컴포넌트를 검사하여 변경된 props이 없다면 렌더링을 하게 될 때 최근에 렌더링 된 값을 기억해서 재사용하게 된다.

단, 내부에서 useState 같은 훅을 사용하면 상태 변경 시 리렌더링된다.

props로 콜백함수가 전달되는 경우는 계속 동일한 값이 전달되더라도 함수의 인스턴스는 렌더링마다 매번 달라지므로 Memoization이 적절하게 수행되지 않는다. 그래서 useCallback(콜백함수, [])를 통해 해당 콜백함수 인스턴스를 보존해줘야 한다.

최적화를 위한 방법이지만, 이전 값을 비교하는데도 연산이 들어가기에 남발하는 것은 좋지 않다.

// example
const MyComponent = React.memo((props) => {
	return (/*컴포넌트 렌더링 코드*/)}
);
// other way
const MyComponent = () => {
return ()
);

export default React.memo(MyComponent);

useMemo, useCallback

컴포넌트는 자신의 state가 변경되거나 부모로부터 물려받는 props가 변경될 때 리렌더링된다.

상위 컴포넌트에서 자식 컴포넌트로 함수를 props로 넘겨줄 때, 상위 컴포넌트가 리렌더링 될 때마다 상위 컴포넌트 안에 선언된 함수를 새로 생성하기 때문에 그때마다 새 참조 함수를 자식 컴포넌트로 넘겨주게 된다.

함수는 객체이고, 함수를 새로 생성하면 기존과는 다른 참조 값을 가지기 때문에 부모가 리렌더링 될 때마다 해당 함수를 props으로 받는 자식도 props이 변경되었기에 리렌더링을 하게 된다.

최적화를 해주지 않으면 props가 변경되지 않더라도 부모 컴포넌트가 리렌더링되면 자식도 리렌더링되며, 컴포넌트가 리렌더링될 때 내부에 있는 변수나 함수 등의 표현식들은 전부 다시 선언되고 할당되게 되는 것이다.

그러나 함수 선언단에서 useCallback을 감싸주면, useCallback의 종속 변수들이 변하지 않는 이상 이전에 있던 참조 변수들을 하위 컴포넌트로 전달하기 때문에 불필요한 렌더링을 방지하게 된다.

이 때, 변수나 함수가 다시 생성되어 할당되는 것을 방지하기 위한 훅이 바로 useMemo와 useCallback이다.

값을 memoize한다는 점은 똑같지만 useMemo는 값을 반환하고 useCallback은 함수를 반환한다.

그래서 특정 함수를 계산한 결과값을 받아 변수에 할당하는 방식이면 useMemo를,

특정 함수 자체를 사용하는 방식(명시적인 return값 없이 계산만 수행)이면 useCallback을 사용해주면 된다.

const someValue = useMemo(() => reusedCallback(a, b), [a, b]);
const someFunction = useCallback(() => reusedCallback(a, b), [a, b]);
  • 첫 번째 인자로 들어가는 로직을 Memoizing해서 다시 수행되는 것을 방지한다.
    • 이 때, 첫 번째 인자로 들어가는 함수가 다시 계산되는 경우는 두 번째 인자의 배열에 들어간 값들이 변경되는 경우이다.
  • 두 번째 인자로 들어가는 값들이 변경되지 않는 한, 해당 함수 혹은 값은 다시 계산되지 않는다.
    • 영영 재계산할 필요가 없다면 두 번째 인자의 배열을 비워주면 된다.
💡 하위 컴포넌트가 React.memo로 최적화되어 있을 때, 해당 함수의 props로 내려가는 콜백함수를 useCallback으로 최적화해주는 경우가 많다.

useMemo

useMemo는 계산해서 나온(함수 실행의 리턴) 결과값을 memoize하는데 사용된다.

import React, { useRef, useState, useMemo, useCallback } from 'react';
function countActiveUsers(users) {
  console.log('활성 사용자 수를 세는중..');
  return users.filter(user => user.active).length;
}
const count = useMemo(() => countActiveUsers(users), [users]);

useCallback

useCallback은 함수의 구조를 memoize하는데 사용된다.

import React, { useRef, useState, useMemo, useCallback } from 'react';
const onChange = useCallback(
  e => {
    const { name, value } = e.target;
    setInputs({
      ...inputs,
      [name]: value,
    });
  },
  [inputs],
);

const onRemove = useCallback(
  id => {
    setUsers(users.filter(user => user.id !== id));
  },
  [users],
);

const onToggle = useCallback(
  id => {
    setUsers(users.map(user => (user.id === id ? { ...user, active: !user.active } : user)));
  },
  [users],
);

useCallback 유의사항 - 함수 내부에 memoize 된 값은 변수의 값도 포함한다

다음 함수는 map이 생성되어도 존재하지 않는다며 계속 오류가 발생하는데, 그 이유는 단순했다.

useCallback은 함수를 memoize하면서 참조하는 변수의 값마저 memoize한 시점에 고정된다. 즉, map이 null인 시점에 memoize를 하게 되면 map 변수가 변경되어도 변경된 값을 추적하지 못하고 초기 시점만 저장하게 되는 것이다.

이를 방지하려면 함수 내부에서 변경된 값을 사용하려는 변수들을 의존성 배열 안에 배치해야 한다.

잘못된 코드

  const onClickMarkerFooter = useCallback(
    async (marker: KakaoMapMarkerType) => {
      console.log('onClickMarkerFooter');
      console.log('map : ', map);
      if (!map) return;
      const newMarker = {} as MarkerType;
      newMarker.originalPosition = marker.position;
      newMarker.content = marker.content;
      const result = await transLocaleToCoord(marker.position);

      if (!result) {
        return;
      }

      const { nx, ny, province, city, code } = result;
      if (currentMarkers) {
        if (isSwapMarker(marker.content) !== 0) return;
      }

      const prasedPosition = { lat: ny, lng: nx };
      Object.assign(newMarker, { province, city, code, position: prasedPosition, isBookmarked: false });
      setCurrentMarkers([newMarker, ...currentMarkers]);

      const image = { src: '/icons/search.svg', size: { width: 36, height: 36 } };
      changeOnMapMarker({ image, position: marker.position, content: marker.content, status: 'search' });
    },
    [currentMarkers],
  );

수정 코드

  const onClickMarkerFooter = useCallback(
    async (marker: KakaoMapMarkerType) => {
      console.log('onClickMarkerFooter');
      console.log('map : ', map);
      if (!map) return;
      const newMarker = {} as MarkerType;
      newMarker.originalPosition = marker.position;
      newMarker.content = marker.content;
      const result = await transLocaleToCoord(marker.position);

      if (!result) {
        return;
      }

      const { nx, ny, province, city, code } = result;
      if (currentMarkers) {
        if (isSwapMarker(marker.content) !== 0) return;
      }

      const prasedPosition = { lat: ny, lng: nx };
      Object.assign(newMarker, { province, city, code, position: prasedPosition, isBookmarked: false });
      setCurrentMarkers([newMarker, ...currentMarkers]);

      const image = { src: '/icons/search.svg', size: { width: 36, height: 36 } };
      changeOnMapMarker({ image, position: marker.position, content: marker.content, status: 'search' });
    },
    [currentMarkers, map], // map 추가
  );

레퍼런스

Lighthouse로 Next.js 성능 44% 개선하기

📆 23.03.18 - 성능 개선 #1. LightHouse로 성능 파악해보기

성능 개선 #3. React Profiler로 컴포넌트 해부하기

[React] 렌더링 성능 최적화하는 7가지 방법 (Hooks 기준)

light house 지표에 따른 리액트 최적화

profile
씨앗 개발자

0개의 댓글

관련 채용 정보