React 최적화

룸잉·2023년 12월 15일
1

🧶 컴포넌트 수준의 최적화

🔗 React.memo

  • 함수 컴포넌트의 결과를 Memoization하는 메소드
    • Memoization
      • 비용이 많이 드는 함수 호출의 결과를 저장
      • 동일한 입력 발생 시, 재사용 → 성능 향상에 사용됨.
  • props가 변경되지 않은 경우에만 재사용하는 고차 컴포넌트
// 최적화 전
function My(props) {
    return (
        <div>
            {props.data}
        </div>
    )
}
function App() {
    const [state, setState] = useState(0)
    return (
        <>
            <button onClick={()=> setState(0)}>Click</button>
            <My data={state} />
        </>
    )
}
  • 버튼을 계속 누를 경우, state 값이 동일함에도 불구하고 My 컴포넌트는 계속해서 리렌더링 됨.
    • 만약 App과 My 컴포넌트 하위로 수천개의 컴포넌트들이 존재한다면 …? → 엄청난 성능이슈 발생 ; ;
  • 리렌더링을 줄이기 위해 React.memo(My 컴포넌트를 memoization된 버전으로 리턴)를 활용해보자.

// React.memo()를 활용한 최적화 코드
function My(props) {
    return (
        <div>
            {props.data}
        </div>
    )
}

// React.memo() 활용
const MemoedMy = React.memo(My)
function App() {
    const [state, setState] = useState(0)
    return (
        <>
            <button onClick={()=> setState(0)}>Click</button>
            <MemeodMy data={state} />
        </>
    )
}
  • 버튼을 연속적으로 클릭하더라도 My 컴포넌트는 한 번만 렌더링된 후, 다시 렌더링 ❌!
    • React.memo()가 props 값을 memoization한 후, 캐싱된 결과를 리턴하기 때문에 동일한 입력 값에 대해 My 컴포넌트를 실행하지 않음.


🔗 useMemo

  • React에서 CPU 소모가 심한 함수들을 캐싱하기 위해 사용됨.
  • 계산 비용이 많이 드는 작업에 유용함.
// 최적화 전
function App() {
    const [count, setCount] = useState(0)

    const expFunc = (count)=> {
        waitSync(3000);
        return count * 90;
    }
    const resCount = expFunc(count)
    return (
        <>
            Count: {resCount}
            <input type="text" onChange={(e)=> setCount(e.target.value)} placeholder="Set Count" />
        </>
    )
}
  • 어떠한 입력값이 들어올 때마다 컴포넌트가 다시 렌더링되어 expFunc이 호출됨.
    • 대규모 성능 병목 현상 유발
      • 병목 현상: 전체 시스템의 성능이나 용량이 하나의 구성요소로 인해 제한받는 현상

// useMemo()를 활용한 최적화 코드
function App() {
    const [count, setCount] = useState(0)
    
    const expFunc = (count)=> {
        waitSync(3000);
        return count * 90;
    }

    // useMemo() 활용
    const resCount = useMemo(()=> {
        return expFunc(count)
    }, [count])
    return (
        <>
            Count: {resCount}
            <input type="text" onChange={(e)=> setCount(e.target.value)} placeholder="Set Count" />
        </>
    )
}
  • expFunc은 입력값 캐싱
    • 동일한 입력 발생 시, useMemo는 expFunc를 호출하지 않고 입력된 값에 대해 캐싱된 결과 값을 리턴함!

🔗 useCallback

  • useMemo와 비슷하지만, 함수 선언 자체를 memoization하는데 사용된다는 차이가 있음.
function App() {
    const check = 90
    const [count, setCount] = useState(0)

    // useCallback() 활용
    const clickHndlr = useCallback(()=> { setCount(check) }, [check]);
    return (
        <>
            <button onClick={()=> setCount(count + 1)}>Set Count</button>
            <TestComp func={clickHndlr} />
        </>
    )
}
  • clickHndlr는 dependenccy인 check의 값이 변하지 않는 한, App 컴포넌트가 리렌더링 되어도 새로 생성되지 않음.
    • 즉, setCount 버튼을 반복해서 클릭하더라도 TestComp는 리렌더링 되지 않음.
  • useCallbacck이 check 값을 확인
    • 이전 값과 다른 경우: 새로운 함수를 리턴 + 새로운 참조가 되었으므로 리렌더링됨.
    • 이전 값과 동일한 경우: 아무 것도 리턴 ❌ + 함수 참조가 이전과 같다고 판단하여 리렌더링 ❌

🧶 React 내장 성능 기능 활용

🔗 Lazy Loading

  • 부하를 단축하기 위해 자주 사용되는 최적화 기법
  • React에서는 컴포넌트의 Lazy Lodading을 위해 React.lazy()를 사용함.
    • React.lazy()
      • React v16.6에 새로 추가된 기능으로, React.lazy()를 사용하면 동적 import를 사용하여 컴포넌트를 렌더링 할 수 있음.
      • 즉, React.lazy()를 사용하면 동적 가져오기를 통해 구성 요소 수준에서 React 애플리케이션을 쉽게 코드 분할할 수 있음.
  • 사용하는 이유!
    • 일반적으로 규모가 큰 React 애플리케이션은 많은 요소, 라이브러리 등으로 구성됨.

    • 애플리케이션의 다른 부분을 로드하려고 노력하지 않을 경우, 사용자가 첫 페이지를 로드하는 즉시 대규모 단일 Javascript 번들이 사용자에게 전송됨 → 페이지 성능에 상당한 영향을 미침.

    • React.lazy()를 통해 손쉽게 개별 Javascript 청크로 분리해보자!


    1. React.lazy()

      const About = React.lazy(() => import('./About'));
      • import() 구문을 반환하는 콜백 함수를 인자로 받음.
      • 동적으로 불러오는 컴포넌트 파일은 두 가지 규칙을 지켜야 함!
        • React 컴포넌트를 포함해야 함.
        • default export를 가진 컴포넌트여야 함.
    2. <Suspense />

      <Suspense fallback={<div>Loading...</div>}>
        <Routes>
          <Route path="/" element=<About/>} />
        </Routes>
      </Suspense>
    • 컴포넌트가 로드될 때까지 fallback() props에 있는 React 엘리먼트를 띄워줌.
    • 비동기 데이터 가져오기와 코드 분할을 더 간단하게 처리하고, 사용자 경험을 개선하기 위한 목적으로 도입된 기능
    • React v16.6 이상부터 사용 가능
    • 사용 목적
      • 비동기 데이터 가져오기
        Suspense 사용 시, 컴포넌트에서 비동기 작업을 수행하거나 외부 데이터를 가져오는 동안 화면에 로딩 상태를 표시하여 사용자 경험을 향상시킬 수 있음.
      • 코드 분할 Suspense를 사용하면 코드 분할과 함께 앱 번들의 크기를 줄이고 초기 로딩시간을 최적화할 수 있음. 필요한 컴포넌트만 로드하고 사용자가 해당 컴포넌트를 요청할 때까지 다른 컴포넌트를 로드하지 않도록 해야 함.
    1. React Router와 함께 사용

      return (
         <Router>
          <Suspense fallback={<div>Loading...</div>}>
            <Routes>
              <Route path="/" element=<About/>} />
            </Routes>
          </Suspense>
        </Router>
      );
    • React 공식문서에 따르면, Router 바로 아래 Suspense를 위치시키고 React.lazy()는Route로 보여줄 컴포넌트를 불러오는 것을 권장하고 있음.

🧶 이외에도 ..!

🔗 List 가상화

  • React 공식문서를 참고하면, 애플리케이션에서 거대한 List(수백 또는 수천 행)를 렌더링하는 경우 windowing이라는 기법을 사용하는 것을 추천한다고 함.
    • windowing
      • 거대한 List 렌더링 시, 브라우저의 viewport에 보여지는 부분만 렌더링하고 나머지는 스크롤할 때 보여지도록 하는 기법
      • 이를 활용하면 컴포넌트를 리렌더링하는데 걸리는 시간과 생성된 DOM 노드의 수를 크게 줄일 수 있음.
      • 대표적인 windowing 라이브러리: react-window, react-virtualized

🔗 불변성의 중요성

  • 불변성을 지킬 수 있는 가장 간단한 방법은 props와 state로 사용 중인 값을 변경하지 않는 것
  • concat, spread 문법(shallow copy) 등 활용하기
    • concat()
      handleClick() {
        this.setState(state => ({
          words: state.words.concat(['marklar'])
        }));
      }
    • spread syntax
      handleClick() {
        this.setState(state => ({
          words: [...state.words, 'marklar'],
        }));
      };
    • Object.assign(), object spread properties
      // 불변성을 지키지 않은 코드
      function updateColorMap(colormap) {
        colormap.right = 'blue';
      }
      • 이런 코드를 작성하고 싶다면, 위와 같이 객체 원본을 변경시키지 말고 Object.assign()이나 object spread properties를 활용해보자 !

        // Object.assign() 활용 코드
        function updateColorMap(colormap) {
          return Object.assign({}, colormap, {right: 'blue'});
        }
        // object spread properties 활용 코드
        function updateColorMap(colormap) {
          return {...colormap, right: 'blue'};
        }
  • 깊게 중첩된 객체를 처리할 때, 불변성을 지키는 방식(얕은 복사)으로 객체를 업데이트하다 보면 굉장히 복잡하고 헷갈린다고 느낄 수 있음.
  • 이런 경우, immer를 활용하면 불변성이 가져다주는 이득을 잃지 않고 가독성있는 코드를 작성할 수 있음.
    • immer: 상태를 업데이트할 때, 불변성을 관리해주는 라이브러리

🧶 마무리

  • 앞서 정리한 방법 외에도 다양한 최적화 방법이 있음.
  • 기능구현 단계에서부터 최적화를 미리 수행하기 보다, 프로젝트를 모두 개발한 후 필요한 곳에 최적화를 적용시키는 것이 중요함 !

4개의 댓글

comment-user-thumbnail
2023년 12월 16일

여러가지 최적화 방법에 대해서 정리해서 알 수 있어서 너무나도 좋았습니다!

마지막에 소개된 immer에 대해 좀 더 공부해 보고 싶어 조사를 해봤습니다.

immer를 왜 사용해야 할까요?
아티클에 적혀있듯이 리액트에서 배열이나 객체를 업데이트 해야 할 때 직접 수정하면 안되고 불변성을 지켜주어야 합니다. 이에 대한 추가 설명은 위에 잘 적혀있으므로 생략하도록 하겠습니다.
그렇다면 아래와 같은 객체가 있다고 하면 어떨까요?

const state = {
  posts: [
    {
      id: 1,
      title: '제목입니다.',
      body: '내용입니다.',
      comments: [
        {
          id: 1,
          text: '와 정말 잘 읽었습니다.'
        }
      ]
    },
    {
      id: 2,
      title: '제목입니다.',
      body: '내용입니다.',
      comments: [
        {
          id: 2,
          text: '또 다른 댓글 어쩌고 저쩌고'
        }
      ]
    }
  ],
  selectedId: 1
};

여기서 만약 id 1인 post의 comments를 추가하고 싶다면,

const nextState = {
  ...state,
  posts: state.posts.map(post =>
    post.id === 1
      ? {
          ...post,
          comments: post.comments.concat({
            id: 3,
            text: '새로운 댓글'
          })
        }
      : post
  )
};

상태를 업데이트 하기 위해선 위와 같이 다소 읽기 복잡한 코드가 필요하게 됩니다.

이와 같은 상황에서 immer의 유용성이 발휘됩니다.

immer를 사용하게 된다면

const nextState = produce(state, draft => {
  const post = draft.posts.find(post => post.id === 1);
  post.comments.push({
    id: 3,
    text: '와 정말 쉽다!'
  });
});

다음과 같이 깔끔하게 구현이 가능해집니다.

그렇다면 어떻게 사용하는 걸까요?

일단

yarn add immer

라이브러리를 다운로드 받은 뒤,

import produce from 'immer';

immer를 import 해줍니다. 보통은 produce라는 이름으로 import 한다고 합니다.
produce는 함수로 첫 번째 파라미터는 수정하고 싶은 상태, 두 번째 파라미터는 어떻게 업데이트 하고 싶은지 정의하는 함수를 넣어줍니다. 이때 불변성을 신경쓰지 않고 그냥 업데이트 해주면 됩니다.
아래는 그 예시 입니다.

const state = {
  number: 1,
  dontChangeMe: 2
};

const nextState = produce(state, draft => {
  draft.number += 1;
});

여기까지 immer의 가장 기본적인 내용이었고, 추가적인 내용이 궁금하시다면 아래의 참고 자료를 확인하시면 될 거 같습니다!

참고자료
https://react.vlpt.us/basic/23-immer.html

아티클 잘 읽었습니다. ☺️ 아티클 작성하시느라 고생 많으셨습니다!

답글 달기
comment-user-thumbnail
2023년 12월 17일

React를 여러번 다뤄봤지만, 항상 최적화적인 부분에선 우선순위가 밀려 뒤쳐졌던 부분이 있었던 거 같은데요, 그래서 인지 이번 글이 굉장히 흥미롭고 도움이 많이 됐던 거 같습니다.

마지막 부분에 "기능구현 단계에서부터 최적화를 미리 수행하기 보다, 프로젝트를 모두 개발한 후 필요한 곳에 최적화를 적용시키는 것이 중요함 !" 글에 공감했는데요, 개발자의 성장은 코드 리펙토링과 디벨롭 그리고 최적화를 얼마나 많이 했냐에 따라서도 변화하는거 같습니다 !!!

윗 내용에 있는 최적화를 꼭 활용하여 리펙토링 해봐야겠다는 생각이 들었습니다!
좋은 글 감사합니다!

답글 달기
comment-user-thumbnail
2023년 12월 17일

최적화에 대해 고민해볼 수 있는 의미있는 글을 작성해주셔서 감사합니다. 저는 리스트가상화에 대해 처음 접해서 더 알아보았는데요. 리스트 가상화는 "뷰포트만 보이는 것만 렌더링 해준다." 라는 말이 이해가 안가서 찾아보니 스크롤 한 이후에 아이템은 렌더링해주지 않는 "눈에 보이는 것만 렌더링 해준다" 였습니다. 구현을 위해서는 element의 위치가 화면 내부에 있는지 가늠하고 Intersection Observer로 교차를 감지하는 방법으로 구현을 생각해볼 수 있겠네요. 그 후 스크롤 이벤트를 감지해서 추가적으로 데이터를 렌더링 하는 방법으로 구현한다고 합니다.
구체적인 구현방법을 찾아보니 리스트에에 position relative와 innerHeight 를 설정해주고 각 항목은 position: absolute를 주면서 렌더링한다고 합니다!
좋은 글 감사합니다 아름님! 처음으로 접한 리스트 가상화 등을 통해 더 넓게 보는 시야를 갖게된 것 같습니다~!

답글 달기
comment-user-thumbnail
2023년 12월 18일

저는 lazy에 대해서 알아봤는데요, lazy 부분의 리액트 공식 문서에 parameter를 이렇게 설명하고 있습니다.
load: A function that returns a Promise or another thenable (a Promise-like object with a then method). React will not call load until the first time you attempt to render the returned component. After React first calls load, it will wait for it to resolve, and then render the resolved value as a React component. Both the returned Promise and the Promise’s resolved value will be cached, so React will not call load more than once. If the Promise rejects, React will throw the rejection reason for the nearest Error Boundary to handle.
여기서, thenable이나 Promise를 반환하는 함수를 매개변수로 가진다고 하는데, 여기서 thenable이 무엇인지에 대해서 잠깐 알아보았습니다.

thenable : then 메서드를 가진 promise와 유사한 객체로, {then : () => {} } 또는 Promise.resolve() 와 같은 형식이 thenable이라고 합니다!

따라서, 매개변수에는 비동기가 오는 게 일반적(꼭 와야하는 건 아니므로)이지만, 그렇지 않은 객체가 들어와도 됩니다! 또 다시한번 보고 넘어가면! 반환된 컴포넌트하려고 처음 렌더링하려고 시도할 때까지는 lazy안에 있는 함수를 절대 실행시키지 않으며 렌더링 필요 시점에 import를 진행하는게 lazy의 역할이고, 한 번 load되면 두 번 이상 호출하지 않는다고 합니다.

그리고 저는 memo 관련해서도 알아보았는데용!
제가 알아본 거는 모든 곳에 memo를 추가하는 것이 좋을까? 였습니다.
일단 일반적으로 인터렉션이 투박한 경우 (페이지 교체 등만 있는 경우) 일반적인 메모화는 불필요하다고 합니다! 반면 편집기나 인터렉션이 세분화되어 있다?? -> 메모화

그리고 memo는 컴포넌트에 전달되는 prop이 항상 다른 경우 무용지물이 될 수 있기 때문에 useMemo, useCallback을 필요로 한다고 합니다!!

답글 달기