본문 바로가기

리액트 심화 스터디

🏄‍♀️ 리심스 6주차 - 리액트 성능 최적화

성능 최적화 방법 몇 가지를 소개해보겠다.

1. React Profiler

<Profiler>는 프로그래밍 방식으로 React 트리의 렌더링 성능을 측정할 수 있다. 

<App>
  <Profiler id="Sidebar" onRender={onRender}>
    <Sidebar />
  </Profiler>
  <PageContent />
</App>

Props

  • id: 성능을 측정하는 UI 컴포넌트를 식별하기 위한 문자열
  • onRender: 프로파일링된 트리 내의 컴포넌트가 업데이트될 때마다 React가 호출하는 onRender 콜백으로, 렌더링된 내용과 소요된 시간에 대한 정보를 받고 있다.

 

onRender 콜백

  • render 함수의 파라미터들의 의미는 각각 다음과 같다.
function onRenderCallback(
  id, // 방금 커밋 한 프로파일 러 트리의 "id"소품
  phase, // "mount"(트리가 방금 마운트 된 경우) 또는 "update"(다시 렌더링 된 경우)
  actualDuration, // 커밋 된 업데이트를 렌더링하는 데 소요 된 시간
  baseDuration, // 메모없이 전체 하위 트리를 렌더링하는 데 걸리는 예상 시간
  startTime, // React가 이 업데이트를 렌더링하기 시작했을 때
  commitTime, // React가 이 업데이트를 커밋했을 때
  interactions //이 업데이트에 속하는 상호 작용 집합
)
  • actualDuration: memo, shouldComponentUpdate, useMemo 등과 같이 사용 중인 모든 최적화를 포함하여 구성 요소가 렌더링하는 데 걸린 실제 측정 시간
  • baseDuration: baseDuration은 성능 개선 사항(메모이제이션)이 제거된 구성 요소를 렌더링하는 데 걸린 시간

 

사용법

  • React 트리를 <Profiler> 컴포넌트로 감싸서 렌더링 성능을 측정한다.
  return (
    <>
      <Profiler
        id="App"
        onRender={(id, phase, actualTime, baseTime, startTime, commitTime, interactions) =>
          console.table({ id, phase, actualTime, baseTime, startTime, commitTime, interactions })
        }>
        <FoodDetail />
      </Profiler>
    </>
  );

export default FoodDetailProfiler;
  • 콘솔에 찍힌 결과를 보고, actualDuration과 baseDuration 을 비교해 메모이제이션으로 얼마만큼의 시간을 절약하는지 알 수 있다.
  • <Profiler> 컴포넌트를 여러 개 사용해서 부분 별로 측정하거나, 중첩해서 사용할 수 있다.
  • 단, <Profiler>는 가벼운 컴포넌트이긴 해도 사용할 때마다 앱에 약간의 cpu 및 메모리 오버헤드가 추가되기 때문에 필요할 때만 사용해야 한다.

 

개발자 도구의 Profiler Tab

  • 개별 컴포넌트의 성능을 개발자 도구의 profiler tab에서 한 눈에 볼 수 있다.
  • 주로 사용 탭
    • Flamegraph Tab: 각 막대는 리액트 컴포넌트를 나타내고, 막대의 길이를 통해 컴포넌트와 해당 자식을 렌더링하는데 걸리는 시간을 볼 수 있다.
    • Ranked Tab: 컴포넌트 자체에 소요된 시간을 볼 수 있음, 세부 컴포넌트 별 렌더링 시간을 볼때 유용하다.

프로파일러 탭 Ranked Tab을 보면, 다음과 같이 컴포넌트 별 렌더링 시간이 한눈에 보임

  • 노란색 막대 : 상대적으로 더 많은 시간이 걸림
  • 파란색 막대 : 상대적으로 더 적은 시간이 걸림
  • 회색 막대 : 현재 commit에서 렌더링하지 않음

사용법

  • 프로파일링 시작 버튼을 클릭하면, 프로파일러는 각 컴포넌트들이 렌더링된 시간들을 기록
  • 기록이 시작되면, 컴포넌트들은 각 커밋을 생성
  • React.memo와 useCallback 등으로 최적화를 진행한 후 렌더링 시간을 측정하여 비교하면 좋다

 

2. React.lazy

리액트와 같은 SPA로 개발된 프로젝트는 빌드를 통해서 배포하는데 빌드된 내용을 살펴보면 하나의 JS 파일로 번들링되는 것을 확인할 수 있다. 이렇게 하나의 JS 파일로 번들링된 웹페이지에 진입하면 사용자는 최초 진입 시 모든 페이지에 대한 정보를 불러오게 되는데, 이는 초기 로딩을 느리게 만든다.

 

코드 스플리팅

  • 하나로 크게 뭉쳐진 코드를 조각 조각으로 나누는 작업으로, 나누어진 부분들 중 해당 부분이 필요한 시점에 동적으로 로딩하는 기술이다.
  • 즉, 사용자가 필요한 코드만 비동기적으로 로딩하는 것

코드 스플리팅 방법 중에서도 React.lazy에 대해 알아보겠다.

 

React.lazy()란?

위에서 번들 파일이 만들어지면 모든 페이지에 대한 정보를 불러온다고 언급했는데, 페이지가 나눠져 있으면 각 페이지에 진입할 때는 해당 페이지에 대한 코드만 로딩하도록 하면 로딩 속도를 훨씬 단축시킬 것이다.

React.lazy()는 애플리케이션을 이루는 코드들 중 특정 코드를 필요할 때만 로딩하는 것이다. (하나의 번들 파일을 손쉽게 개별 청크로 분리)

 

사용법

const 컴포넌트명 = React.lazy(() => import('./컴포넌트경로'));
  • lazy함수는 실행될 때, 동적으로 import하는 해당 함수를 인자로 받는다.
  • 동적으로 불러오는 컴포넌트의 필수 요건 🔽
    • default export를 가진 컴포넌트를 포함해야 할 것
  • Suspense 컴포넌트로 감싸기
    • 필요한 컴포넌트만 로드하고 사용자가 해당 컴포넌트를 요청할 때까지 다른 컴포넌트를 로드하지 않도록 하여, 코드 스플리팅과 함께 사용하여 번들 크기를 줄이고 초기 로딩 시간을 최적화할 수 있다.
    • 비동기 작업을 수행하거나 데이터를 가져올 때 화면에 로딩 상태를 표시하여 사용자 경험을 개선할 수 있다.
return (
   <Router>
    <Suspense fallback={<div>Loading...</div>}>
      <Routes>
        <Route path="/" element=<About/>} />
      </Routes>
    </Suspense>
  </Router>
);
  • 공식문서에 따르면 React.lazy()는 Router 바로 아래에 Suspense를 위치시키고, Route로 보여줄 컴포넌트를 React.lazy로 불러올 것을 권장하고 있다.

 

3. Debounce & throttle

스크롤이나 드래그를 통해 기능을 구현할 때, 과도하게 많은 횟수의 이벤트가 발생하여 버벅임이 발생하는 경우가 있다. debounce와 throttle은 이벤트 핸들러의 과도한 횟수가 발생하는 경우에 대해 제약을 걸어 함수를 몇번이나 실행할 지를 제어하는 기술이다.

 

Debounce

  • 연속적으로 발생한 이벤트를 하나로 처리하는 방식이다.
  • 주로 처음이나 마지막으로 실행된 함수만을 실행한다.
  • 모든 함수를 실행하면 성능적으로 문제가 생길 수 있는 경우에 사용한다.

Debounce 구현 예시

 

아래 코드는 input 태그에 타이핑 될때마다 console 이 찍히는 것을 확인할 수 있다.

function typingInput() {
    const name = nameElem.value;
    console.log(`입력된 이름: ${name}`)
}

const nameElem = document.getElementById('inputName')

nameElem.addEventListener("input", typingInput)

 

 

아래는 setTimeout으로 특정 시간동안 딱한번만 함수가 실행되도록 디바운스를 적용한 코드이다. 마지막 함수 실행 후 1초 뒤에 console 이 찍힌다.

let alertTimer
function alertWhenTypingStops() {
  // 앞선 타이머를 리셋
  // 따라서 마지막 함수가 실행 (타이핑을 멈추고선 함수실행) 
  if (alertTimer) {
    clearTimeout(alertTimer)
  }
  
  const name = nameElem.value
  // 타이머 시작
  alertTimer = setTimeout(() => console.log(`입력된 이름: ${name}`), 1000)
}

const nameElem = document.getElementById('inputName')

nameElem.addEventListener("input", alertWhenTypingStops)

 

대표적 사용예시

  • 키워드 검색 혹은 자동완성 기능에서 api 함수 호출 횟수를 최대한 줄이고 싶을때
  • 사용자가 창크기 조정을 멈출때까지 기다렸다가 resizing Event 를 반영하고 싶을때

 

Throttling

  •  이벤트를 일정주기마다 발생하도록 하는 기술, 마지막 함수가 호출된 후 일정 시간이 지나기 전에 다시 호출되지 않도록 한다.
  • 100ms 를 준다면 이벤트는 100ms 동안 최대 한번만 발생하게 됨-> 즉 일정 시간동안 딱 한 번만 발생
  • 연이어 발생한 이벤트에 대해, 일정한 delay를 포함시켜 연속적으로 발생한 이벤트는 무시하는 방식을 뜻한다. 즉, delay 시간동안 호출된 함수는 무시한다.
let isInThrottle
function increaseScoreDuringTyping() {
  if (isInThrottle) {
    return
  }
  
  isInThrottle = true
  
  // 타이머 세팅
  setTimeout(() => {
    const score = document.querySelector('#score')
    const newScore = parseInt(score.innerText) + 1
    score.innerText = newScore
    
    isInThrottle = false
  }, 500)
}

const nameElem = document.querySelector('#inputName')

nameElem.addEventListener("input", increaseScoreDuringTyping)
  • 쓰로틀러를 500ms동안 작동시키고, 만약 쓰로틀러가 이벤트를 조이고 있는 경우, 해당 이벤트는 무시된다. 결과적으로 500ms 동안 최대 1번의 이벤트만이 발생한다.
  • 주로 스크롤 이벤트에서 많이 사용