본문 바로가기
Frontend/React

React의 최적화 전략

by 그냥하는거지뭐~ 2024. 4. 11.
목차
- 프롤로그
- 렌더링 최적화 기법
- 상태 관리 및 업데이트 최적화
- 로딩 성능 최적화

프롤로그

 지난번에는 리액트를 왜 쓰는지에 대해 브라우저의 렌더링 원리부터 리액트의 특징까지 알아보았다. 이렇게나 잘 만든 라이브러리인데 장점을 최대한 살려 코드를 짜야하지 않을까? 이번에는 최적화를 어떻게 하는지 알아보자! 

 

 

프로젝트 5개 하고 돌아보는 React를 쓰는 이유

목차 - 프롤로그 - SPA - 브라우저의 렌더링 원리 - React의 렌더링 과정 - React의 특징 3가지 - React+TypeScript 프롤로그 나의 2023년은 프로젝트로 가득했다. 7~8개월 동안 vanilla JS, React, React Native를 이용

hwanheejung.tistory.com


렌더링 최적화 기법

#불필요한리렌더링 #React.memo #useMemo #useCallback

 

 지난 시간에 리액트는 virtual DOM을 통해 상태가 변경된 부분만 diffing 알고리즘을 통해 찾아내서 실제 DOM에 업데이트한다고 배웠다. 그런데 코드를 짜다 보면 props나 state가 변하지 않았음에도 리렌더링이 불필요하게 일어나는 경우가 있다. 언제 그러는지 알아보고 최적화해보자. 

 

1. props의 불필요한 변경 => useMemo로 해결해보자!

부모 컴포넌트가 리렌더링 될 때, 자식 컴포넌트도 불필요하게 리렌더링될 수 있다. 특히 객체나 배열과 같은 참조 타입의 props가 매번 새로운 참조로 전달될 때 자주 발생하는데, 예시를 통해 이해해보자. 

function ParentComponent() {
  const [count, setCount] = useState(0);

  const userInfo = {
    name: '그냥하는거지뭐~',
    age: 23
  };

  return (
    <div>
      <button onClick={() => setCount(count + 1)}>클릭!</button>
      <ChildComponent user={userInfo} />
    </div>
  );
}
function ChildComponent({ user }) {
  console.log('자식컴포넌트 렌더링..');
  return <div>{user.name}</div>;
}

 

위 예시에서 ParentComponentuserInfo 객체를 ChildComponent에 prop으로 전달한다. 여기서 문제는 ParentComponent가 리렌더링될 때마다 userInfo 객체가 새로 생성되어 새로운 참조가 ChildComponent에 전달된다는 점이다. 이는 React.memo를 사용하더라도, prop의 참조가 변경되므로 ChildComponent가 불필요하게 리렌더링 된다. 

그러면 어떻게 최적화를 할 수 있을까!!!

 

useMemo hook을 써서 메모이징하면 된다

function ParentComponent() {
  const [count, setCount] = useState(0);

  const userInfo = useMemo(() => ({
    name: '그냥하는거지뭐~',
    age: 23
  }), []);

  return (
    <div>
      <button onClick={() => setCount(count + 1)}>클릭!</button>
      <ChildComponent user={userInfo} />
    </div>
  );
}

이렇게 하면 userInfo 객체는 ParentComponent가 처음 mount 될 때 한 번만 생성되고, 이후 리렌더링에서는 동일한 참조를 유지한다. 

 

 

2. 상태 업데이트의 과도한 호출 => setState함수에 callback 함수를 전달하자! 

실제로 UI에 변화가 없음에도 상태 업데이트 함수가 과도하게 호출되면 리렌더링이 발생할 수 있다. 예시를 보자. 

import React, { useState } from 'react';

function Counter() {
  const [count, setCount] = useState(0);

  const handleIncrease = () => {
    // 상태 업데이트 함수가 과도하게 호출됨
    setCount(count + 1);
    setCount(count + 1);
  };

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={handleIncrease}>Increase</button>
    </div>
  );
}

setState는 비동기로 처리된다. 즉, setCount를 연속으로 두 번 해도 count는 1만 증가한다. 하지만 컴포넌트는 두 번 리렌더링 된다. 'JavaScript가 비동기를 다루는 방법' 글에서 비동기 처리를 하는 첫 번째 방법이 callback 함수였다. 기억이 안 난다면 다시 읽고 오자

https://hwanheejung.tistory.com/19

 

즉, handleIncrease 함수 내에서 setCount를 다음과 같이 사용하면 문제는 해결된다. 

const handleIncrease = () => {
  // 첫 번째 상태 업데이트에서 이전 상태 값을 기반으로 다음 상태를 계산
  setCount(prevCount => prevCount + 1);
  // 두 번째 상태 업데이트도 마찬가지로 이전 상태 값을 기반으로 계산
  setCount(prevCount => prevCount + 1);
};

 이 코드는 setCount 호출 시마다 항상 최신의 count 값을 참조하여 상태를 업데이트하므로, 의도한 대로 카운터 값이 두 번 증가한다. 

 

 

3. 컴포넌트 내부에서 생성된 함수 또는 객체 => React.memo, useCallback, useMemo으로 해결

 컴포넌트 내부에서 생성된 함수나 객체를 자식 컴포넌트에 props로 전달할 경우, 부모 컴포넌트가 리렌더링 될 때마다 새로운 참조가 생성된다. 즉, 자식 컴포넌트까지 리렌더링 된다. 마찬가지로 예시를 보자. 

function ParentComponent() {
  const [count, setCount] = useState(0);

  const incrementCount = () => {
    setCount(count + 1);
  };

  const someObject = { value: count };

  return (
    <div>
      <ChildComponent action={incrementCount} data={someObject} />
      <button onClick={incrementCount}>Increment</button>
    </div>
  );
}

ChildComponentReact.memo로 감싸져 있어, props가 변경되지 않으면 리렌더링 되지 않도록 최적화되어 있다.

function ChildComponent({ action, data }) {
  console.log('자식컴포넌트 렌더링...');
  return (
    <div>
      <p>{data.value}</p>
      <button onClick={action}>Increment in Child</button>
    </div>
  );
}

export default React.memo(ChildComponent);

문제는 ParentComponent가 리렌더링 될 때마다 incrementCount 함수와 someObject 객체가 새로 생성되어 새로운 참조로 ChildComponent에 전달된다는 것이다. 이로 인해 React.memo에도 불구하고 ChildComponent가 불필요하게 리렌더링 된다. 

 

useCallback을 이용하여 함수 참조를 유지하고, useMemo를 사용하여 객체 참조를 유지함으로써 최적화를 했다. count가 변경될 때마다 새로운 참조가 생성된다.

function ParentComponent() {
  const [count, setCount] = useState(0);

  // useCallback을 사용하여 함수 참조 유지
  const incrementCount = useCallback(() => {
    setCount(count + 1);
  }, [count]);

  // useMemo를 사용하여 객체 참조 유지
  const someObject = useMemo(() => ({ value: count }), [count]);

  return (
    <div>
      <ChildComponent action={incrementCount} data={someObject} />
      <button onClick={incrementCount}>Increment</button>
    </div>
  );
}

 

4. 컴포넌트를 분할하자

컴포넌트를 더 작은 단위로 분할함으로써, 상태가 변경되었을 때 변경된 부분만 리렌더링 되도록 할 수 있다. 

 


상태 관리 및 업데이트 최적화 

#useState #useReducer #props_drilling #immutable_data_patterns

 

1. 복잡한 상태 로직을 다룰 땐 useState 말고 useReducer를 사용하자 

간단한 state를 관리할 땐 useState로도 충분하다. 하지만 가독성이 떨어질 만큼 복잡한 state를 다룬다면 어떤 문제가 생길 수 있을까? 

- 여러 상태 업데이트가 서로 의존적일 때, 상태를 동기적으로 업데이트하기 힘들다. 
- 상태 업데이트 로직이 컴포넌트에 흩어져 있어, 재사용성과 유지보수성이 떨어진다. 
- 여러 useState 호출로 인해 컴포넌트 로직이 분산되어 가독성이 저하될 수 있다. 

 

애플리케이션의 구조, 유지보수성, 코드의 가독성을 위해 useReducer를 사용해 보자. 사용법은 redux에서의 reducer와 거의 똑같다. 와닿지 않으니 코드로 보자. 

function ShoppingCart() {
  const [items, setItems] = useState([]); // 카트에 담긴 상품 목록
  const [discountCode, setDiscountCode] = useState(''); // 할인 코드
  const [totalPrice, setTotalPrice] = useState(0); // 총 가격

  // 상품 추가
  const addItem = (item) => {
    setItems(currentItems => [...currentItems, item]);
    updateTotalPrice();
  };

  // 상품 제거
  const removeItem = (itemId) => {
    setItems(currentItems => currentItems.filter(item => item.id !== itemId));
    updateTotalPrice();
  };

  // 총 가격 업데이트
  const updateTotalPrice = () => {
    let newTotal = items.reduce((total, item) => total + item.price, 0);
    if (discountCode === 'DISCOUNT50') {
      newTotal *= 0.5;
    }
    setTotalPrice(newTotal);
  };

  // 할인 코드 적용
  const applyDiscount = (code) => {
    setDiscountCode(code);
    updateTotalPrice();
  };

  // ...
}

omg 대충 봐도 너무 복잡하다. 상태 업데이트가 서로 너무 의존적이고, 로직도 복잡해 보인다. 바꿔보자. 

// 초기 상태
const initialState = {
  items: [],
  discountCode: '',
  totalPrice: 0,
};

// 리듀서 함수
function cartReducer(state, action) {
  switch (action.type) {
    case 'ADD_ITEM':
      return {
        ...state,
        items: [...state.items, action.payload],
      };
    case 'REMOVE_ITEM':
      return {
        ...state,
        items: state.items.filter(item => item.id !== action.payload),
      };
    case 'APPLY_DISCOUNT':
      return {
        ...state,
        discountCode: action.payload,
      };
    case 'UPDATE_TOTAL_PRICE':
      let newTotal = state.items.reduce((total, item) => total + item.price, 0);
      if (state.discountCode === 'DISCOUNT50') {
        newTotal *= 0.5;
      }
      return {
        ...state,
        totalPrice: newTotal,
      };
    default:
      throw new Error('Unhandled action type');
  }
}

 

이제 모든 상태 업데이트 로직을 cartReducer 함수 내에서 처리한다. 그리고 ShoppingCart 컴포넌트는 다음과 같이 깔끔하게 바뀌었다. 

function ShoppingCart() {
  const [state, dispatch] = useReducer(cartReducer, initialState);

  // 상품 추가
  const addItem = (item) => {
    dispatch({ type: 'ADD_ITEM', payload: item });
    dispatch({ type: 'UPDATE_TOTAL_PRICE' });
  };

  // 상품 제거
  const removeItem = (itemId) => {
    dispatch({ type: 'REMOVE_ITEM', payload: itemId });
    dispatch({ type: 'UPDATE_TOTAL_PRICE' });
  };

  // 할인 코드 적용
  const applyDiscount = (code) => {
    dispatch({ type: 'APPLY_DISCOUNT', payload: code });
    dispatch({ type: 'UPDATE_TOTAL_PRICE' });
  };

  return (
    <div>
      <p>Total Price: {state.totalPrice}</p>
    </div>
  );
}

 

 

2. props drilling 방지 => 전역 상태관리, Context API

A 컴포넌트의 의 데이터를 E 컴포넌트에서 사용하고 싶어서 A > B > C > D > E로 props를 전달했다고 해보자. 중간에 위치한 B, C, D 컴포넌트는 단순히 데이터를 전달하는 용도로만 사용되는데, A에서 데이터가 변경되면 모든 컴포넌트가 리렌더링 된다. 지금은 B, C, D 세 개밖에 없어서 다행이지 100개가 있다고 하면 치명적인 성능 저하가 발생할 것이다. 

 

이럴 경우 Redux나 Recoil과 같은 전역 상태관리를 쓰거나 React의 내장 기능인 Context API를 사용하면 된다. 

 

 

3. Immutable Data Patterns의 중요성

Immutable Data, 말 그대로 한 번 생성되면 변경되지 않는 데이터를 의미한다. 

쓰다가 길어져서 분리했다. 

 

React의 원칙: Immutable Data Pattern

목차 - 프롤로그 - JavaScript 복습 - React는 어떻게 state 변화를 감지하는가? - Immutable Data Pattern이란? - TypeScript + Immutable Data Pattern - 정리 프롤로그 React의 핵심에는 컴포넌트 기반 아키텍처가 있다.

hwanheejung.tistory.com

 


로딩 성능 최적화

#code_splitting #React.lazy #Suspense #이미지최적화

 

1. 코드 스플리팅과 React.lazy, Suspense 사용법

React는 SPA로 초기에 모든 HTML, CSS, JS를 가져와서 초기 렌더링 시간이 오래 걸린다. 로딩 성능을 최적화하기 위해 React에서는 코드 스플리팅을 통해 필요한 순간에 특정 코드를 로딩할 수 있다. 

 

React.lazy 함수를 사용하면 동적 import()를 활용하여 컴포넌트를 비동기적으로 로드할 수 있다. 

import React, { Suspense } from 'react';

// React.lazy를 사용하여 동적으로 import합니다.
const LazyComponent = React.lazy(() => import('./LazyComponent'));
const AnotherComponent = React.lazy(() => import('./AnotherComponent'));
const ThirdComponent = React.lazy(() => import('./ThirdComponent'));

function App() {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <LazyComponent />
      <AnotherComponent />
      <ThirdComponent />
    </Suspense>
  );
}

React.lazy로 로드되는 컴포넌트는 <Suspense /> 컴포넌트 내부에서 렌더링 되어야 하는데, fallback이라는 prop을 통해 로드 중에 표시할 컴포넌트를 받는다. 

 

react router dom에 적용하여 페이지 단위로 사용해 보자. 

import React, { Suspense } from "react";
import { Routes, Route, BrowserRouter } from "react-router-dom";

const HomePage = lazy(() => import('./pages/HomePage'));
const AboutPage = lazy(() => import('./pages/AboutPage'));
const ContactPage = lazy(() => import('./pages/ContactPage'));

function App() {
  return (
    <BrowserRouter>
        <Suspense fallback={<div>Loading...</div>}>
          <Routes>
            <Route exact path="/" component={HomePage} />
            <Route path="/about" component={AboutPage} />
       	    <Route path="/contact" component={ContactPage} />
          </Routes>
        </Suspense>
    </BrowserRouter>
  );
}

 

2. 이미지 최적화

 

(번역) 이미지 최적화에 대한 명확한 가이드

이미지 최적화란 좋은 품질의 이미지를 제공하는 동시에 가능한 가장 작은 크기를 유지하는 과정입니다. 즉, 이미지를 최적화하면 최상의 형식, 크기, 해상도로 이미지를 만들고 표시하여 사용

velog.io

 

3. 추가) React Native에서 했던 방법

- 앱을 시작할 때 splash screen이 뜨는 몇 초의 시간 동안 폰트나 이미지와 같은 리소스들을 미리 불러왔다. 

- ScrollView 대신 FlatList를 사용했다. (ScrollView는 모든 자식 컴포넌트를 한꺼번에 렌더링 하는 반면, FlatList는 컴포넌트가 화면에 나타나기 직전에 렌더한다 => lazy render)