[코드캠프]31일차_TIL_Memoization

윤성해·2023년 4월 25일
0

프론트엔드_TIL

목록 보기
26/27
post-thumbnail

목차

  1. 성능최적화
  2. 메모이제이션(Memoization)
  3. map, memo의 관계
  4. CRP(Critical Rendering Path)

오늘의 TIL

성능최적화

// memoization 폴더 _ container 파일
const containerPage = ()=>{
	console.log("컨테이너가 렌더링 됩니다.")

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

	const onClickCountLet = ()=>{
		console.log(countLet+1)
		countLet += 1
	}

	const onClickCountState = ()=>{
		console.log(countState+1)
		setCountState(countState+1)
	}
	return(
		<div> 
			<div>================<div> 
			<h1>이것은 컨테이너 입니다.</h1>

			<div> 카운트(let): {countLet} </div>
			<button onClick={onClickCountLet}> 카운트(let) +1 올리기! </button>

			<div> 카운트(state): {countLet} </div>
			<button onClick={onClickCountState}> 카운트(state) +1 올리기! </button>

			<div>================<div>

		<MemoizationPresenterPage/>
		</div>
	)
}

export default containerPage

이렇게 만들어 놓은 후 let과 state의 버튼을 눌러보도록 하겠습니다.
콘솔을 보면 let은 버튼을 누르면 콘솔값은 올라가지만 리렌더가 일어나지 않아서 화면이 계속 0이고, 렌더링 된다는 메세지가 콘솔에 찍히지 않습니다.

하지만 state는 버튼을 누름과 동시에 리렌더링되며 우리가 이전에 올려두었던 countLet이 0으로 초기화됩니다. useState를 제외한 모든값이 다시 그려지고 있는것을 확인할 수 있습니다!!!

폴더안에 폴더가 두개일 때 주소찾는 법 참고!!!
우리가 폴더명을 입력해서 엔터를 치면, next.js에서는 가장먼저 해당 폴더 안의 index.js(tsx)파일을 찾습니다. 하지만 우리가 주소창에 폴더명을 적고 이후에 파일명을 적는다면 next.js는 해당 파일을 찾게됩니다.

프리젠터 페이지를 만든 후 위 container 페이지에 import 해보겠습니다.

// memoization 폴더 _ presenter 파일
const MemoizationPresenterPage = ()=>{
	console.log("프리젠터가 렌더링 됩니다.")


	return(
		<div>  
			<div>================<div>
			<h1>이것은 프리젠터 입니다.</h1>
			<div>================<div>
		</div>
	)
}

export default MemoizationPresenterPage

이렇게 import 한 후 새로고침을하시면 콘솔에 “컨테이너가 렌더링 됩니다.”“프리젠터가 렌더링 됩니다.” 가 찍혀 있는것 을 볼 수 있습니다. 문제는!! 부모의 state를 바꿨는데 자식도 다시 렌더링이 됩니다. 굉장히 비효율적이죠!

react developer tools 설치

크롬 웹스토어에 react developer tools 설치하면 개발자 도구에 proflier가 생성됩니다.

설정에서 이부분 체크해줍니다!! 페이지에 돌아와서 state 카운트 버튼을 눌러보면 container, presenter 부분이 동시에 렌더링 대상임을 볼수가 있습니다. 반면 let은 버튼을 눌러도 아무일이 일어나지 않습니다. 해당버튼을 눌러도 렌더링이 일어나지 않는 다는 말이겠죠!!

메모이제이션(Memoization)

우리는 지금까지 리렌더가 얼마나 일어나는지 신경쓰지 않고 서비스를 만들었습니다. 그렇지만 불필요한 리렌더링이 많아질수록 서비스의 성능이 저하됩니다.
성능이 저하된다는 것은 사용자의 이탈율을 높일 수 있기 때문에 매출과 관련하여 긴밀한 연관이 있습니다.

따라서 우리는 리렌더링을 줄여주실 필요가 있습니다. 리렌더링을 막아주는 방법을 알아봅시다.

useCallback(), useMemo()

자식컴포넌트는 memo를 사용해 불필요한 리렌더가 더이상 일어나지 않도록 막아주었습니다.

import { memo } from "react";

function MemoizationWithChildPage(): JSX.Element {
  console.log("자식이 렌더링 됩니다.");

  return (
    <>
      <div>=======================</div>
      <h1>저는 자식 컴포넌트 입니다!</h1>
      <div>=======================</div>
    </>
  );
}

export default memo(MemoizationWithChildPage);

요렇게! 그렇지만 부모 컴포넌트는 지속적으로 렌더링이 일어나는 상태입니다. 부모컴포넌트에서도 부분적으로 렌더링이 일어나지 않아도 되는 부분이 있습니다. 예를들어 stateCount를 변경했을때 letCount의 값이 지속적으로 다시 만들어지고 있는 상황입니다. (초기화라고 하지만 0이라는 값이 계속 만들어지고 있는 것입니다.) 이런 불필요한 값들이 지속적으로 다시 만들어지지 않도록 유지시켜주는 hooks가 바로 useMemo, useCallback 입니다.

useMemo() 사용방법 _변수기억

// memoization 폴더 _ presenter 파일
import {useMemo} from 'react'

const containerPage = ()=>{
	console.log("컨테이너가 렌더링 됩니다.")

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

// 1. useMemo로 변수 기억
	const memo = useMemo( () => {
		const aaa = Math.random()
	}, [])
	console.log(`${memo}는 더이상 안 만들어`)

	const onClickCountLet = ()=>{
		console.log(countLet+1)
		countLet += 1
	}

	const onClickCountState = ()=>{
		console.log(countState+1)
		setCountState(countState+1)
	}
	return(
		<div> 
			<div>================<div> 
			<h1>이것은 컨테이너 입니다.</h1>

			<div> 카운트(let): {countLet} </div>
			<button onClick={onClickCountLet}> 카운트(let) +1 올리기! </button>

			<div> 카운트(state): {countLet} </div>
			<button onClick={onClickCountState}> 카운트(state) +1 올리기! </button>

			<div>================<div>

		<MemoizationPresenterPage/>
		</div>
	)
}

export default containerPage

일반적으로 useMemo의 사용은 굉장히 복잡한 계산 이외에는 그렇게 흔하지는 않습니다.

useCallback() 사용법 _ 함수기억

// memoization 폴더 _ presenter 파일
import {useCallback} from 'react'

const containerPage = ()=>{
	console.log("컨테이너가 렌더링 됩니다.")

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

// 2. useCallback으로 함수 기억하기	
// useCallback을 사용하게 되면 함수를 다시 만들지 않습니다.
	const onClickCountLet = useCallback(()=>{
		console.log(countLet+1)
		countLet += 1
		},[])

// 3. usecallback의 잘못된 사용사례 _ state를 기억하기 때문에 아무리 count를 올려도 1만 찍히게 됩니다.
	const onClickCountState = useCallback(()=>{
		console.log(countState+1)
		setCountState(countState+1)
		},[])

// 4. 3번의 잘못된 사용사례 보완
	const onClickCountState = useCallback(()=>{
		setCountState((prev)=>prev+1)
		},[])

	return(
		<div> 
			<div>================<div> 
			<h1>이것은 컨테이너 입니다.</h1>

			<div> 카운트(let): {countLet} </div>
			<button onClick={onClickCountLet}> 카운트(let) +1 올리기! </button>

			<div> 카운트(state): {countLet} </div>
			<button onClick={onClickCountState}> 카운트(state) +1 올리기! </button>

			<div>================<div>

		<MemoizationPresenterPage/>
		</div>
	)
}

export default containerPage

useCallback으로 함수를 감싸주게 되면 해당 함수를 다시 불러오지 않고 이전에 불러왔던 함수를 실행시키게 됩니다. 2번의 결과는 계속 1이 찍혔습니다. 즉 카운트가 고정되어있었습니다. 이것이 바로 useCallback의 결과, 이전에 불러왔던 값을 유지시키는 것 입니다.
그렇다면 함수는 다시볼러오지 않고 값은 올려주고 싶을 때 어떻게 하면 좋을까요??
setCountState((prev)=>prev+1) 이렇게 setCoutState함수를 작성해주시면 onClickCountState 함수를 새로 그리지 않지만, state는 올려줄 수 있습니다.

작은 서비스라면 한번 더 렌더링 되는게 문제될 건 없지마, 서비스가 점점 커지고 기능도 많아진다면 이런 작은 부분 하나하나가 속도를 늦춥니다.

💡tip : 알아두면 유용한 개발자 도구

설치는 구글 웹스토어에서 진행하면 됩니다.
1. Apollo Client Devtools

→ 설치후 app.tsx에서 client 설정 부분에 connectToDevTools : true로 설정해주셔야 합니다.
2.wappalyzer
→특정 사이트에 들어가시면 해당 사이트가 사용한 스택을 분석해줍니다.

useMemo로 나만의 useCallback 만들어보기

const onclickCountState = useMemo(()=>{
	console.log(countState+1)
setCountState((prev)=>prev+1)
}, [])

❗️ useCallback을 쓰지 말아야 할 때
의존성 배열의 인자가 1~2개보다 많아질 때는 차라리 리렌더를 하는것이 유지 보수에는 더 좋은방법 입니다.성능이 조금이나마 좋아지는 것 보다는 유지보수가 편리한 편이 훨씬 좋습니다.
따라서 의존성 배열의 인자가 2개를 초과할때는 그냥 리렌더를 해주는게 좋습니다.

memo

다음으로는 memo 라는 기능을 이용해보겠습니다.
memo라는 기능은 리액트에서 제공하고 있습니다.

// memoization 폴더 _ presenter 파일
import {memo} from "react"

const MemoizationPresenterPage = ()=>{
	console.log("프리젠터가 렌더링 됩니다.")


	return(
		<div>  
			<div>================<div>
			<h1>이것은 프리젠터 입니다.</h1>
			<div>================<div>
		</div>
	)
}

export default memo(MemoizationPresenterPage)

이렇게 memo를 사용해주시고 state카운트를 클릭해보시면 프리젠터는 렌더링이 되지않아 콘솔도 찍히지 않을 뿐더러 리액트 툴에도 보이지 않습니다. memo를 호출하는 부분을 잘 보면 HOC의 일종임을 알 수 있습니다.

map, memo의 관계

import { useState } from "react"
import Word from "./02-child"

export default function MemoizationParentPage(){
	const [data,setData] = useState("철수는 오늘 점심을 맛있게 먹었습니다.")

	const onClickChange = ()=>{
		setData("영희는 오늘 저녁을 맛없게 먹었습니다.")
	}
	return(
		<>
			{data.split("").map((el)=>(
				<Word key={index} el={el}/>
			))}
			<button>체인지</button>
		</>
	)
}

import { memo } from "react"

export default function Word(props: any){
	console.log("자식이 렌더링 됩니다!",props.el)
	return <span>{props.el}</span>
}

export default memo(Word)

이렇게 memo를 걸어두면 우리가 위에서 배웠던 것 과 같이 처음의 값이 기억되어 업데이트 되지 않습니다.
그러나 key값에 index가 아니라 uuid를사용하게 되면 상황이 달라집니다.

key값을 uuid로 설정시 문제점

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

export default function MemoizationParentPage(){
	const [data,setData] = useState("철수는 오늘 점심을 맛있게 먹었습니다.")

	const onClickChange = ()=>{
		setData("영희는 오늘 저녁을 맛없게 먹었습니다.")
	}
	return(
		<>
			{data.split("").map((el)=>(
				<Word key={uuidv4} el={el}/>
			))}
			<button>체인지</button>
		</>
	)
}

import { memo } from "react"

export default function Word(props: any){
	console.log("자식이 렌더링 됩니다!",props.el)
	return <span>{props.el}</span>
}

export default memo(Word)

uuid를 사용하면 memo를 걸어놔도 key값이 변경되어 props로 넘어가기 때문에, 변경된 부분이 모두 리렌더링 됩니다. uuid는 불필요한 리렌더링을 초래하므로 사용할 때 필요한 상황에서만 사용하기!!!!

CRP(Critical Rendering Path)

우리는 작성한 코드를 브라우저를 통해 보게됩니다. 그렇다면 브라우저에서 우리의 코드를 그려주는 과정이 어떻게될까요?
중요한 개념인 Critical Rendering Path 를 봅시다.

브라우저에서 렌더링을 해주는 과정은 아래와 같습니다.

먼저 화면을 그려주는데 필요한 리소스(html,css,js)를 다운로드 해옵니다.

그리고 HTML과 CSS에서 화면에 렌더해야 할 요소들을 구분 후 렌더되어야 할 HTML,CSS 요소를 합쳐 화면에 그려주게 됩니다.

화면에 그려줄때 해당 요소들이 어느 위치에 놓일지 먼저 그려주는 Layout Reflow와 해당 요소들을 색칠하는 Paint Repaint과정이 발생합니다.

⚠️ 렌더트리
렌더트리는 최종적으로 브라우저에 표기될 요소들이라고 생각시면 됩니다.

위에서 렌더링 시 화면에 렌더해야 하는 요소를 구분하는 작업을 한다고 했습니다.
이때 HTML의 요소를 구분할 수 있도록 도와주는 것이 DOM(Document Object Model),
CSS요소를 구분할 수 있도록 도와주는 것이 CSSOM(CSS Object Model)입니다.

이렇게 DOM과 CSSOM이 합쳐진것이 바로 렌더트리 입니다.
즉 2번과정과 3번과정이 합쳐진 4번과정의 결과물이 렌더트리라고 보시면 됩니다.

Reflow와 Repaint

reflow란 렌더링 되어야 할 요소들을 화면상 위치를 그려주는 과정입니다.
그리고 repaint는 위치를 잡고 난 이후 색칠을 해주는 과정입니다.

<!Doctype html>
<html lang="ko">
<head>
	<title>리플로우/리페인트</title>
	<style>
		.qqq{
			width : 100px;
			height : 100px;
		}
		.qqq:hover{
			width : 110px;
			height : 110px;
			background-color : pink;
		}
		.aaa{
			width : 100px;
			height : 100px;
			background-color : green;
		}
		.aaa:hover{
			background-color : red;
		}
	</style>
<head/>
<body>
	<div class="qqq"> 리플로우 연습! </div>
	<div class="aaa"> 리페인트 연습! </div>
</body>
</html>

위의 코드처럼 박스를 그려두고, 마우스를 hover하면 qqq박스에는 리플ㄹ우가 aaa 박스에는 리페인트가 일어나도록 하였습니다.

reflow & repaint with Board

우리가 목록을 조회한다고 가정했을 때, 데이터가 비어있다가 나중에 들어오면 UI 상으로 데이터를 뿌려주는 부분의 크기가 달라지는것을 볼 수 있었습니다.
이렇게되면 reflow가 일어나기때문에 높이를 고정시켜보겠습니다.

//index.tsx
import { useQuery, gql } from "@apollo/client";
import { MouseEvent } from "react";
import {
  IQuery,
  IQueryFetchBoardsArgs,
} from "../../src/commons/types/generated/types";

const FETCH_BOARDS = gql`
  query fetchBoards($page: Int) {
    fetchBoards(page: $page) {
      _id
      writer
      title
      contents
    }
  }
`;

export default function StaticRoutedPage() {
  const { data, refetch } = useQuery<
    Pick<IQuery, "fetchBoards">,
    IQueryFetchBoardsArgs
  >(FETCH_BOARDS);

  console.log(data?.fetchBoards);

  const onClickPage = (event: MouseEvent<HTMLSpanElement>) => {
    void refetch({ page: Number(event.currentTarget.id) });
  };

  return (
    <>
      {/* 임시 배열 10개를 생성하여, 데이터가 없을 때도 높이 30px을 유지하여 reflow 방지  */}
      {(data?.fetchBoards ?? new Array(10).fill(1)).map((el) => (
        <div key={el._id} style={{ height: "30px" }}>
          <span style={{ margin: "10px" }}>{el.writer}</span>
          <span style={{ margin: "10px" }}>{el.title}</span>
        </div>
      ))}
      {new Array(10).fill(1).map((_, index) => (
        <span key={index + 1} id={String(index + 1)} onClick={onClickPage}>
          {index + 1}
        </span>
      ))}
    </>
  );
}

위와 같이 데이터를 뿌려주는 곳 height를 고정시키면 위치상으로 다시 그려야 하는 일이 없기 때문에 reflow가 일어나는 것을 방지 할 수 있습니다.

프리페치

prefetch 란 다음페이지에서 쓰려고 미리 받는 것 이며, 현재페이지를 모두 받아온 이후 제일 나중에 다운로드 해오게 됩니다.

//index.html
<!DOCTYPE html>
<html lang="ko">
  <head>
    <title>프리페치</title>

    <!-- 프리페치: 다음페이지를 미리 다운로드 받으므로, 버튼 클릭시 페이지이동 빠름 -->
    <link rel="prefetch" href="board.html" />
  </head>
  <body>
    <a href="board.html">게시판으로 이동하기</a>
  </body>
</html>

// board.html
<!DOCTYPE html>
<html lang="ko">
  <head>
    <title>게시판</title>
  </head>
  <body>
    여기는 게시판입니다
  </body>
</html>

index파일에서 board로 넘어오는 파일을 만들었습니다. imdex.html을 다운로드 받아올 때 네트웨크 탭을 보면 아래와 같이 index.html을 받아온 후에 board.html 문서도 이어서 받아옵니다.

index.html을 받아올 때 board.html을 같이 받아오기 때문에 게시판 페이지를 받아올때는 시간이 걸리지 않게 됩니다.

사진을 보면 Size 부분이 prefetch cache라고 적혀있습니다.
이는 이미 이전 페이지에서 prefetch를 이용해 해당 리소스를 다 받아와 캐시에 넣어두었기 때문에 따로 또 받지 않아도 되어 사이즈가 없는 것 입니다.

따라서 prefetch를 이용하시면 페이지가 이동되어도 기다리지 않고 바로 보여주는 것이 가능합니다.

프리로드

preload현재페이지에서 쓸 이미지들을 모두 다운로드 받아놓는 것 입니다.

따라서 prefetch와는 다르게 이미지를 index.html를 받아올때 css와 js보다 먼저 받아오게 됩니다.

<!DOCTYPE html>
<html lang="ko">
  <head>
    <title>프리로드</title>

    <!-- 프리로드: 한 번에 6개씩 받아오므로, body태그의 이미지는 가장 마지막에 다운로드 -->
    <!--         눈에 보이는 이미지를 먼저 다운로드 받아서 보여주고, 클릭하면 실행되는 JS는 나중에 받아오기 -->
    <!--         따라서, DOMContentedLoaded 이후, Load까지 완료되는 최종 로드 시간이 더 짧아짐 -->
    <link rel="preload" as="image" href="./dog.jpeg" />

    <!-- 일반로드 -->
    <link rel="stylesheet" href="./index.css" />
    <script src="index1.js"></script>
    <script src="index2.js"></script>
    <script src="index3.js"></script>
    <script src="index4.js"></script>
    <script src="index5.js"></script>
    <script src="index6.js"></script>
  </head>
  <body>
    <img src="./dog.jpeg" />
  </body>
</html>

위 코드처럼 img 태그를 preload를 걸어주면, 제일 나중에 받아와야 하는 이미지를 상단으로 끌어올려 받아오게 됩니다.

위의 사진에 css와 js보다 먼저 다운로드되는게 보이시나요?
이처럼 preload를 이용하시면 이미지를 먼저 받아오기때문에 화면로드 지연을 방지 할 수 있습니다.

profile
Slow and steady wins the race.

0개의 댓글