Next 프로젝트 컴포넌트 렌더링 최적화(useCallback, useMemo, react.memo)

우디·2024년 3월 4일
0
post-thumbnail

안녕하세요:) 개발자 우디입니다! 아래 내용 관련하여 작업 중이신 분들께 도움이되길 바라며 글을 공유하니 참고 부탁드립니다😊
(이번에 벨로그로 이사오면서 예전 글을 옮겨적었습니다. 이 점 양해 부탁드립니다!)

작업 시점: 2022년 2월

배경

  • 불필요한 컴포넌트 렌더링이 지속적으로 발생하는 것 확인함 ex) 공유 버튼을 누르면 이와 상관 없는 video list 컴포넌트가 계속 렌더링 되는 경우
  • 불필요한 렌더링을 제거하기 위해 최적화 작업 진행하는 과정을 정리하고자 함

렌더링 최적화 관련 개념

  1. useMemo

    • 이 함수는 React Hook 중 하나로서 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은 3분후 실행되는 비싼 함수임. 이 함수는 count를 입력받아 3분을 기다린 후 90을 곱하여 리턴함.
      • resCount는 useState hook에서 count 변수를 받아 expFunc을 실행하는 함수. 여기서 count는 입력할 때마다 값이 변경되어야 함.
      • 사용자가 input 창에 아무거나 입력할 때마다 앱 컴포넌트가 다시 렌더링 되어 expFunc 함수가 호출 됨.
        • 계속 입력을 하면 대규모 성능 병목 현상을 유발하는 기능이 실행됨. 각각의 입력마다 렌더링하는 데 최소 3분이 소요.
        • 만약 3을 입력하게 되면 expFunc은 3분동안 실행되고 다시 3을 입력하면 또 3분간 실행됨.
        • 하지만 이전 입력과 같다면, 두번째 입력에서는 다시 실행되지 않고 결과를 어딘가 저장한 후 값을 리턴하는 것이 좋을 것.
    • 위와 같은 문제는 useMemo를 통해 expFunc을 최적화 함으로써 해결할 수 있음.
      • useMemo 구조
        useMemo(()=> func, [input_dependency])
      • func은 캐시하고 싶은 함수이고, input_dependency는 useMemo가 캐시할 func에 대한 입력의 배열로서 해당 값들이 변경되면 func이 호출됨.
      • useMemo를 이용하여 위의 코드를 최적화 해보자면:
        function App() {
            const [count, setCount] = useState(0)
        
            const expFunc = (count)=> {
                waitSync(3000);
                return count * 90;
            }
            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을 호출하지 않고 입력에 대해 캐시된 결과값을 리턴.
    • 이것은 앱 컴포넌트를 최적화 시키는 방법 중 하나임. useMemo 캐싱 기술을 이용해 성능을 향상시키고, 함수형 컴포넌트에서는 prop 값을 캐싱하는 것 또한 도움이 됨.
  2. useCallback

    • 이 작업은 useMemo와 비슷하지만 차이점은 '함수 선언'을 memoize 하는데 사용된다는 것.
    • 예시 코드
      function TestComp(props) {
         l('rendering TestComp')
         return (
           <>
            TestComp
            <button onClick={props.func}>Set Count in 'TestComp'</button>
           </>
         )
       }
       
      TestComp = React.memo(TestComp)
      
      function App() {
          const [count, setCount] = useState(0)
          return (
              <>
                  <button onClick={()=> setCount(count + 1)}>Set Count</button>
                  <TestComp func={()=> setCount(count + 1)} />
              </>)
      }
      • App 컴포넌트는 useState를 이용하여 count 값을 관리하고 있음.
      • 언제든 setCount를 실행시키면 App 컴포넌트는 리렌더링 됨.
      • App 컴포넌트는 하위로 button 과 TestComp 컴포넌트를 가지고 있음.
      • 만약 Set Count 버튼을 클릭하면 App 컴포넌트는 자식 트리를 포함하여 리렌더링 됨.
      • 여기서 TestComp는 불필요한 렌더링을 막기 위해 React.memo를 이용하여 memoize 되어 있음. (React.memo는 현재와 다음 props를 비교하여 이전 props와 같다면 컴포넌트를 리렌더링 하지 않음)
        • TestComp는 func props를 함수로 받고 있는데 언제든 App이 리렌더링 될 때 TestComp에게 전달되는 func props가 동일한지 체크한 후 동일하다면 리렌더링 되지 않을 것임.
    • 하지만 문제는, TestComp는 새로운 인스턴스의 함수를 전달받는 것임!
      ...
          return (
              <>
                  ...
                  <TestComp func={()=> setCount(count + 1)} />
              </>)
      ...
      • props로 화살표 함수 선언이 전달되므로 App 컴포넌트가 리랜더링 할때마다 항상 새 참조로 새로운 함수 선언이 전달됨(메모리 주소 포인터)
      • 따라서 얕은 비교를 하는 React.memo는 다른 결과가 들어왔다고 이해하고 리렌더링을 하도록 실행할 것임.
    • 여기서 이 문제를 어떻게 해결할 수 있을까? 함수 선언을 컴포넌트 밖에서 해야 할까? 이렇게 된다면 좋겠지만 그럴 경우 setCount 함수를 참조할 수 없게 됨 → 여기서 useCallback이 필요한 것임!
      • useCallback으로 함수와 변경될 기준 값을 같이 전달하면 useCallback은 memoize된 함수를 리턴하고 이 함수 값을 TestComp에 전달하면 되는 것임.
        function App() {
            const check = 90
            const [count, setCount] = useState(0)
            const clickHndlr = useCallback(()=> { setCount(check) }, [check]);
            return (
                <>
                    <button onClick={()=> setCount(count + 1)}>Set Count</button>
                    <TestComp func={clickHndlr} />
                </>)
        }
      • 여기서 clickHndlr는 dependency 값인 check가 변경되지 않는 한 App 컴포넌트가 리 렌더링 되어도 새로 생성되지 않으므로 Set Count 버튼을 반복해서 클릭해도 TestComp는 다시 리렌더링 되지 않음.
      • useCallback이 check 변수값을 확인하여 이전 값과 변경되었다면 새로운 함수를 리턴하고 TestComp와 React.memo는 새로운 참조가 되었으므로 리렌더링 됨.
      • 만약 check 값이 이전과 동일하다면 useCallback은 아무것도 리턴하지 않고 React.memo는 함수 참조가 이전과 같다고 판단하여 TestComp를 리렌더링 하지 않음.
  3. react.memo

    • useMemo와 React.PureComponent와 같이 React.memo()는 함수 컴포넌트를 캐시하는데 사용됨.
    • 예시 코드
      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} />
              </>)
      }
      • App 컴포넌트는 state를 data라는 props로 My 컴포넌트에 넘겨줌.
      • 버튼 엘리먼트의 onClick을 클릭할 때 마다 state 값을 0으로 변환해주는 작업.
      • 만약 버튼을 계속 누른다면, state 값이 동일함에도 불구하고 My 컴포넌트는 계속 리렌더링이 될 것임. -> 이는 App 과 My 컴포넌트 하위로 수천개의 컴포넌트들이 존재한다면 엄청난 성능 이슈가 될 것임.
    • 리렌더링 되는 것을 줄이기 위해서, My 컴포넌트를 memoized 버전으로 리턴하는 React.memo를 이용하여 한번 감싼 후 App에 포함 시킴.
      function My(props) {
          return (
              <div>
                  {props.data}
              </div>)
      }
      
      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 값을 memoize 한 후 캐싱된 결과를 리턴하기 때문에 동일한 입력에 대해서는 My 컴포넌트를 실행하지 않기 때문임.
      • React.PureComponent가 class component를 위한 거라면 React.memo는 함수형 component를 위한 캐싱 방법임.

렌더링 최적화 작업

  • 문제상황 1)

    • 문제 상황 확인
      • 공유 버튼을 누르면 이와 상관 없는 video list 컴포넌트가 계속 렌더링 됨
      • 이외에도 불필요한 컴포넌트 렌더링이 지속적으로 발생하는 것 확인.
    • 시도
      • VideoList 컴포넌트에 react.memo 적용해보기
        const VideoList = ({
        			  ...
        }) => {
        ...
        export default React.memo(VideoList);
    • 결과
      • 해당 컴포넌트에서 총 22번 렌더링 되던 것이 10번으로 줄었음.
  • 문제상황 2)

    • 문제 상황 확인
      • 하지만 또 다른 video list 컴포넌트에서 불필요하게 렌더링 되는 문제 발견.
      • 확인해보니 불필요하게 onClick 으로 함수가 전달되어 있어서 그런 것이었음.
    • 시도
      • 굳이 넘겨주지 않아도 되는 onClick 부분을 지우고 next/link 활용하여 이동 부분 구현.
        <Link
          ...
        >
          <S.RecommendVideo onClick={tag.clickCategoryRecommendationCard}>
            <VideoList
              ...
              // onClick={() => setVodId(list.vodId)} -> 삭제한 부분
            />
          </S.RecommendVideo>
        </Link>
    • 결과
      • onClick 삭제하니까 렌더링 한 번만 발생.
  • 문제상황 3)

    • 문제 상황 확인

      • 코멘트 미리보기 부분이 계속 렌더링 됨
    • 시도

      • memoization을 위해 따로 빼서 관리하면 되지 않을까 ⇒ 따로 함수로 만들고, return 값 받는 부분에서 memo 해주기.

         const getCommentPreviewInfo = () => {
          let commentPreviewDisplayName, commentPreviewBody;
          ...
          return [commentPreviewDisplayName, commentPreviewBody];
        };
        
        const [commentPreviewDisplayNameData, commentPreviewBodyData] = useMemo(getCommentPreviewInfo, [
          commentList.results[0],
        ]);
    • 결과

      • 코멘트 미리보기 정보가 동일할 경우 리랜더링 되지 않음.

      • 이전에는 동일한 내용이 반복적으로 출력되었는데, 변경 후에는 불필요한 반복이 줄어든 것을 볼 수 있음.

        • 변경 전

        • 변경 후

          • 초반 로드 시간도 기존에 30ms 정도였는데 24.6ms로 줄었음
  • 문제상황 4)
    • 문제 상황 확인
      • 또 다른 video list 컴포넌트에서 렌더링이 불필요하게 두 번씩 발생하는 문제 발견
    • 시도
      • 함수 선언과 관련이 있나?
        • 확인해보니 관련 없음.
      • 그렇다면 video list 컴포넌트 자체에 문제?
        • 내용들을 지워보니 한 번만 렌더링 됨!
          • video list 내부의 useEffect 안에 있는 함수에 setState가 있어서 두 번씩 렌더링되었던 것.
    • 결과
      • setState 하지 않도록 작동 방식을 수정하여 불필요한 렌더링 제거.
        • 수정 전
          const [overlayTimeStatus, setOverlayTimeStatus] = useState('00:00');
          ...
          const getOverlayTimeStatus = () => {
            ...
            setOverlayTimeStatus(h === 0 ? `${m}:${s}` : `${h}:${m}:${s}`);
          };
          ...
          <OverlayTime>{overlayTimeStatus}</OverlayTime>
        • 수정 후
          const getOverlayTimeStatus = () => {
            ...
            return (overlayTimeStatus = h === 0 ? `${m}:${s}` : `${h}:${m}:${s}`);
          };
          ...
          <OverlayTime>{getOverlayTimeStatus()}</OverlayTime>

배우고 느낀 점

  • 렌더링 최적화 작업이 쉽지는 않았다.
    • 테스트도 많이 필요했고, 다양한 경우의 수를 고려해야 했기 때문.
  • 하지만 프로그램의 성능, 유지보수의 편의성, 협업 등 많은 것들을 위해 최적화 작업은 앞으로도 계속 해결해나가야 할 숙제임
    • 앞으로도 최적화 관련 공부 및 고민은 끊임없이 해야겠다.
  • 불필요한 렌더링 수를 줄이는 과정에서 뿌듯했다.
  • 요즘 성능 모니터링 도구가 많이 나오는 것 같던데, 다음에는 이런 것도 찾아봐서 활용해 봐야겠다.
profile
넓고 깊은 지식을 보유한 개발자를 꿈꾸고 있습니다:) 기억 혹은 공유하고 싶은 내용들을 기록하는 공간입니다

0개의 댓글