Published on

성능최적화

Authors
  • avatar
    Name
    길재훈
    Twitter

성능최적화

성능 최적화를 위해 여러가지 방법이 존재한다. 각각의 방법을 살펴본다.

기본적으로 리렌더링 되는것을 알아보는것은 중요하다. 다음을 살펴보자

import { useState } from 'react'

const MemoizationPage = (): JSX.Element => {
  console.log('컴포넌트가 렌더링 되었습니다')

  let countLet = 0
  const [countState, setCountState] = useState(0)

  const onClickCountLet = (): void => {
    console.log(countLet + 1)
    countLet += 1
  }
  const onClickCountState = (): void => {
    console.log(countState + 1)
    setCountState(countState + 1)
  }

  return (
    <>
      <div>카운트[let]: {countLet}</div>
      <button onClick={onClickCountLet}>카운트[let] + 1 올리기</button>
      <div>카운트[state]: {countState}</div>
      <button onClick={onClickCountState}>카운트[state] + 1 올리기</button>
    </>
  )
}

export default MemoizationPage

위의 로직을 살펴보면 매우 명확하게 rerender 이 되는것을 알수있다. 카운트[let] + 1 올리기 를 누르면, 해당 값이 올라가지만 화면상에 나오지는 않는다.

하지만 카운트[state] + 1 올리기 를 누르면 리렌더링 되면서 let의 값이 초기화되는것을 알수 있다. 그렇다면 해당 값을 지속 유지할 수 있는 방법이 없을까?

그때 사용하는 hooks 가 바로 useMemo 이다. useMemo 는 해당 값을 cache 해 놓고, 해당 cache 한 값을 재사용할 수 있다.

useMemo 의 특징으로 한번 연산해 놓고 연산된 값을 리턴하면 그 다음부터는 리턴된 값만을 사용한다. 다음을 보자

const bbb = useMemo(() => {
  let result = 0
  for (let i = 0; i < 900000; i += 1) {
    result += i
  }
  return result // 이후 연산된 result를 저장해서 사용한다.
}, [])

useCallback

useCallback 역시 useMemo 와 비슷하지만, 함수를 저정한다는 것이 다르다 다음을 보자.

const onClickCountLet = useCallback((): void => {
  console.log(countLet + 1)
  countLet += 1
}, []) // useCallback 으로 함수를 저장함

useCallback 사용시 주의해야할 사항이 존재한다. useCallbackstate 를 사용시 주의해야 한다.

다음을 보자

const onClickCountState = useCallback((): void => {
  console.log(countState + 1)
  setCountState(countState + 1) // 이때 countState 값 역시 기억하므로, 값이 변경되지 않는다.
}, [])

위의 setCountState 를 사용하면 제대로 동작하지 않는다. usecallback 시 해당 내용을 기억하고 있기에 변경자체가 되지 않는것이다.

useCallback 사용시 state 를 사용한다면 다음처럼 prev state 를 사용하여 확인해야 한다.

const onClickCountState = useCallback((): void => {
  console.log(countState + 1)
  setCountState((prev) => prev + 1) // 제대로 작동한다
}, [])

이러한 방식으로 useCallback 을 사용할 수 있다

useMemo 를 이용한 useCallback

useMemo 를 사용해 단순한 useCallback 을 만들 수 있다

const onClickCountState = useMemo(() => {
  return (): void => {
    console.log(countState + 1)
    setCountState((perv) => prev + 1)
  }
}, [])

위처럼 함수를 useMemo 해서 return 하면 useCallback 과 똑같이 행동한다. useCallback 처럼 작동하므로 setCountStateperv 를 사용해서 +1 시켰다.

memo

memo 는 컴포넌트 자체를 memo 하여, 부모 컴포넌트가 리렌더링 될때 자식 컴포넌트의 변경사항이 없다면 리렌더링 하지 않는다

이는 성능최적화 작업시 중요한 부분이다. 다음을 살펴보자

const MemoizationPage = (): JSX.Element => {
  console.log('부모 컴포넌트가 렌더링 되었습니다')

  let countLet = 0
  const [countState, setCountState] = useState(0) // 기존의 값을 기억하고 있다.

  const onClickCountState = useCallback((): void => {
    console.log(countState + 1)
    // setCountState(countState + 1); // 이때 countState 값 역시 기억하므로, 값이 변경되지 않는다.
    setCountState((countState: number) => countState + 1)
  }, [])

  return (
    <>
      <div>카운트[state]: {countState}</div>
      <button onClick={onClickCountState}>카운트[state] + 1 올리기</button>
      <MemoizationWithChildPage />
    </>
  )
}

export default MemoizationPage

위처럼 부모 컴포넌트가 자식 컴포넌트를 가진 상황이라고 생각해보자. 만약 MemoizationWithChildPagememo 된 상황이 아니라면 MemoizationPagesetCountState가 실행될때마다 MemoizationWithChildPage 는 리렌더링 된다.

굉장히 불필요한 상황이다. MemoizationWithChildPage 는 변경되는 값이 없는데 부모 컴포넌트가 렌더링 되었다는것 하나만으로 지속 리렌더링 되는것이다.

memo 를 사용하면 이러한 불필요한 rerendering 이 발생하지 않는다

import React, { memo } from 'react'

const MemoizationWithChildPage = (): JSX.Element => {
  console.log('자식이 렌더링 됩니다.')
  return (
    <>
      <div>저는 자식 컴포넌트 입니다!!!</div>
    </>
  )
}

export default memo(MemoizationWithChildPage)

이때 memo 사용시 주의해야할 점이 있다 만약 memomap 에 적용해 보도록 해보자.

// MemoizationWithMapParentPage.tsx
const MemoizationWithMapParentPage = (): JSX.Element => {
  const [data, setData] = useState('철수는 오늘 점심을 맛있게 먹었습니다.')

  const onClickChange = (): void => {
    setData('영희는 오늘 저녁을 맛없게 먹었습니다.')
  }

  return (
    <>
      {data.split(' ').map((el, index) => (
        <Word key={index} el={el} />
      ))}
      <button onClick={onClickChange}>클릭</button>
    </>
  )
}

export default MemoizationWithMapParentPage
import React, { memo } from 'react'

interface IWordProps {
  el: string
}

const Word = (props: IWordProps): JSX.Element => {
  console.log('자식이 렌더링 됩니다', props.el)
  return <span>{props.el}</span>
}

export default memo(Word)

위의 로직은 Wrod 컴포넌트 를사용하여 map 을 순회한다. 순회된 mapkey 값으로 고정된 index 를 주므로, change 된 문자만 변경된다.

영희는, 저녁을, 맛없게 만 리렌더링 된다.

이는 우리가 원하는 map 으로써의 기능에 충실한 상황이다. 그런데 key 값을 index 로 주는것은 탐탁치 않다. 그러므로 uuid 를 주도록 하자.


import React, { useState } from "react";
import Word from "./02-child";
import { v4 as uuidv4 } from "uuid";

const MemoizationWithMapParentPage = (): JSX.Element => {
  const [data, setData] = useState("철수는 오늘 점심을 맛있게 먹었습니다.");

  const onClickChange = (): void => {
    setData("영희는 오늘 저녁을 맛없게 먹었습니다.");
  };

  return (
      {data.split(" ").map((el) => (
        <Word key={uuidv4()} el={el} />
      ))}
      <button onClick={onClickChange}>체인지</button>
    </>
  );
};

export default MemoizationWithMapParentPage;

이때 변경되는 문구는 영희는, 오늘, 저녁을, 맛없게, 먹었습니다. 로 전부다 변경되는것을 볼수 있다.

이러한 이유는 memo 는 전달되는 props 가 변경되어도 리렌더링 되기 때문이다. 위의 keyprops 로 넘어가는 uuidv4() 를 사용하여 매번 key 값이 변경된다.

변경된 key 값으로 인해 memo 가 재역할을 못하고 있는 상황으로 매번 리렌더링 된다. 이러한 부분을 상당히 조심히 생각하고 코드를 작성해야 한다.

브라우저의 rendering 과정

                  |--[ HTML ] -- [ DOM ]   |
                  |                        |
[ download ] -->  |                        |-[Attach]--[reflow]--[repaint]--[composition]--[display]
                  |                        |
                  |--[ CSS ] -- [ CSSDOM ] |

실상 repaint 만 작동시 큰 영향을 미치지는 않는다. 여기서 문제를 일으키는 상황은 reflow 이다.

reflowrepaint 까지 같이 진행되므로, 레이아웃 변경이 커질수로 재계산이 많이 일어난다 이러한 모든 상황을 예상하여 코드를 작성하는 것은 한계가 있지만, 관심을 갖고 생각보는것도 좋다.

Layout shift

Layout shiftUX/UI 에서 중요하게 생각하는 것중하나이다.
이미지의 로딩이 느려 화면 레이아웃이 밀리는 현상을 말한다.

frontendUX/UI 에 대해서 중요하게 생각해야 하므로, 이러한 detail 한 부분도 신경써서 만들 필요가 있다