Learn How To Optimize Performance

Junghan Lee·2023년 5월 5일
0

Learnd in Camp

목록 보기
40/48

Index

  1. memoization(useCallback,useMemo)
  2. Promise, Promise.all, Promise.all() - map
  3. LazyLoad vs PreLoad ( Prefetch)
  4. Optimistic-UI

Intro

자꾸 새로 만들지 말고 메모해 놓는게 어때~? memo
빠르게 할 수 없다면 속여보자! Optimistic UI

Memoization(메모이제이션)

불필요한 리렌더링이 많아질 수록 서비스의 성능이 저하되게 되고
성능이 저하된다는 것은 사용자의 이탈율을 높일 수 있기 때문에 매출과 관련하여 긴밀한 연관이 있다.

따라서 리렌더링을 줄여주실 필요가 있으며 리렌더링을 막아주는 방법을 알아보도록 하자

useCallback(),useMemo()

자식컴포넌트는 memo를 사용해 불필요한 리렌더가 더이상 일어나지 않도록 막아주었지만, 부모 컴포넌트는 지속적으로 렌더링이 일어나는 상태

하지만 부모컴포넌트에서도 부분적으로 렌더링이 일어나지 않아도 되는 부분이 있다.

예를들어 stateCout를 변경했을때 letCout의 값이 지속적으로 다시 만들어지고 있는 상황. (초기화라고하 하지만 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으로 함수를 감싸주게 되면 해당 함수를 다시 불러오지 않고 이전에 불러왔던 함수를 실행시키게 되는데, 이를 눈으로 확인하기 위해 state를 담은 함수에도 useCallback을 감싸주었다.

그리고 카운트 버튼을 클릭하니까

카운트가 고정되어 있었는데 이것이 바로 useCallback의 결과

즉, 이전에 불러왔던 값을 유지시키는 것

이렇게 함수는 다시불러오지 않지만 값은 올려주고 싶을 때 사용할 수 있는 방법이 하나 있다. prev를 이용하는 것!

setCountState((prev)=>prev+1) 이렇게 setCoutState함수를 작성해주면 onClickCountState 함수를 새로 그리지 않지만, state는 올려줄 수 있다.

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

알아두면 유용한 개발자 도구(구글 웹스토어 설치)
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
react에서 제공

// 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의 일종임을 알 수 있다.

Promise & Promise.all()

// Promise
// Promise에서 resolve가 실행이 되면 종료, reject가 실행되면 오류

	const onClickPromise = async () => {
		const result1 = await new Promise((resolve, reject) => {
			setTimeout((resolve("3초 후 실행됩니다.")) => {}, 3000)
		})
		const result2 = await new Promise((resolve, reject) => {
			setTimeout((resolve("2초 후 실행됩니다.")) => {}, 2000)			
		})
		const result3 = await new Promise((resolve, reject) => {
			setTimeout((resolve("1초 후 실행됩니다.")) => {}, 1000)
		})
	};


// Promise.all()

	const onClickPromiseAll = async () => {
		const result = await Promise.all([
			setTimeout((resolve("3초 후 실행됩니다.")) => {}, 3000)
			setTimeout((resolve("2초 후 실행됩니다.")) => {}, 2000)
			setTimeout((resolve("1초 후 실행됩니다.")) => {}, 1000)
		])
	};

promise
위 함수는 result1이 실행되고 난 후 result2 실행, 2 실행 후 3 실행 -> onClickPromise 함수의 경우 약 6초 소요

const startPromise = async () => {
        console.time("=== 개별 Promise 각각 ===");
        const result1 = await new Promise((resolve, reject) => {
          setTimeout(() => {
            resolve("성공");
          }, 2000);
        });
        const result2 = await new Promise((resolve, reject) => {
          setTimeout(() => {
            resolve("성공");
          }, 3000);
        });
        const result3 = await new Promise((resolve, reject) => {
          setTimeout(() => {
            resolve("성공");
          }, 1000);
        });
        console.timeEnd("=== 개별 Promise 각각 ===");
      };

Promise.all()
Promise.all()에 포함되어 있는 함수들을 동시에 실행(3초)

	const startPromiseAll = async () => {
       // await Promise.all([promise, promise, promise])

       console.time("=== 한방 Promise.all ===");
       const result = await Promise.all([
         new Promise((resolve, reject) => {
           setTimeout(() => {
             resolve("성공");
           }, 2000);
         }),
         new Promise((resolve, reject) => {
           setTimeout(() => {
             resolve("성공");
           }, 3000);
         }),
         new Promise((resolve, reject) => {
           setTimeout(() => {
             resolve("성공");
           }, 1000);
         }),
       ]);
       console.log(result);
       console.timeEnd("=== 한방 Promise.all ===");
     };

결과값은 같은데 시간을 단축함 !

LazyLoad vs PreLoad

LazyLoad?
페이지를 읽어주는 시점에 중요하지 않은 리소스 로딩을 추 후에 하는 기술로 스크롤이 내려가면서 필요한 때가 되면 로드되어야 한다.
예를 들어, 이미지가 10장이 넘는 페이지가 있다고 가정할 때 이미지를 모두 다 로드가 될 때까지 기다리게 된다면, 페이지의 로딩을 길어지게 될 것이다. 하지만, 맨 위의 화면에 보이는 이미지만 로드를 한 후에, 스크롤을 내리면서 이미지가 보여져야 할 때마다 이미지를 로드한다면, 데이터의 낭비를 막을 수 있다!

PreLoad?
페이지를 읽어줄 때 미리 리소스를 받아놓는 기술이다. 예를 들어 이미지가 10장이 넘는 페이지가 있다고 가정하면, LazyLoad의 경우에는 필요할 때마다 데이터를 로드하는 방법이고, PreLoad의 경우에는 모든 데이터들을 미리 로드해놓고 대기하는 방식이라 볼 수 있다!

아래는 각각을 적용한 예시(이미지 업로드)

// 프리로드된 이미지를 넣어둘 배열
const PRELOADED_IMAGES = []

export default function ImagePreloadPage () {
	const router = useRouter()
	const divRef = useRef(null)
	
	useEffect(() => {
		const preloadImage = ()=>{
			// image 태그를 생성해줍니다.
			const img = new Image()
			// img 태그의 src에 주소를 넣어줍니다.
			img.src = PRELOAD_IMAGES
			// img 태그가 onload 되었을 때
			img.onload = () => {
			// 프리로드 된 이미지들을 PRELOADED_IMAGES 배열에 넣어줍니다.
			PRELOADED_IMAGES.push(img)
			}
		}
		preloadImage()
	}, [])

const onClickPreload = () => {
	if(imgTag) divRef.current?.appendChild(imgTag)
}


return (
	<div>
		<div ref={divRef}></div>
			<button onClick={onClickPreload}> 이미지 프리로드 </button>
	</div>
)
}

프리로드 함수는 여기저기서 사용할 수 있으므로 공통으로 빼두는게 좋다(common)

Prefetch
있던 것에서 함수 추가하며 진행

import { useQuery, gql, useApolloClient } from "@apollo/client";
import { useRouter } from "next/router";
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
    }
  }
`;

const FETCH_BOARD = gql`
  query fetchBoard($boardId: ID!) {
    fetchBoard(boardId: $boardId) {
      _id
      writer
      title
      contents
    }
  }
`;

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

  console.log(data?.fetchBoards);

  const prefetchBoard = (boardId: string) => async () => {
    await client.query({
      query: FETCH_BOARD,
      variables: { boardId },
    });
  };

  const onClickMove = (boardId: string) => () => {
    void router.push(`/32-08-data-prefetch-moved/${boardId}`);
  };

  return (
    <>
      {data?.fetchBoards.map((el) => (
        <div key={el._id}>
          <span style={{ margin: "10px" }}>{el.writer}</span>
				{/* 마우스를 올렸을 때 data를 받아오도록 */}
          <span
            style={{ margin: "10px" }}
            onMouseOver={prefetchBoard(el._id)}
            onClick={onClickMove(el._id)}
          >
            {el.title}
          </span>
        </div>
      ))}
    </>
  );
}

이렇게 만들면 마우스를 올리게 되면 데이터를 아폴로 캐시에 저장하게 된다.

따라서 클릭했을시에는 캐시에 저장되어있는 데이터를 가지고 오는 것 이라 시간이 걸리지 않게 된다.

Optimistic-UI

한 게시물을 보고 좋아요를 누르게 되면 환경에 따라 좋아요의 수가 올라가는 속도가 다르다, 왜?

좋아요를 누르게 되면 백엔드에 likeBoard라는 api에 요청을 보내게 되고, 백엔드는 DB에 요청을 하게 된다

그럼 DB는 좋아요의 수를 올려두고 올린 좋아요 수를 응답. 해당 응답을 백엔드는 다시 브라우저에 응답!

그런데 느린환경의 컴퓨터라면, 혹은 백엔드 컴퓨터가 굉장히 먼 곳에 있다면 해당과정이 굉장히 지연될 수 있다. 따라서 우리는 옵티미스틱 UI를 사용


옵티미스틱 UI란 요청이 서버에 도달하기도 전에 화면의 값을 바꿔버리는 것.

즉, likeBoard를 요청하기도 전에 화면에 13으로 바꿔버리고 계속해서 요청을 보내는 것이다

그리고 요청이 성공하고 나면 13이 응답으로 돌아올텐데 그때 다시 화면을 업데이트. 하지만 이미 옵티미스틱 UI로 13을 그려줬기때문에 유저가 보기에는 똑같다.

만일 중간에 네트워크 문제나 다른이유로 실패하게 된다면, 이전의 값을 응답으로 보내주고 이전의 값을 화면에 업데이트 해준다.

Optimistic-UI 사용하지 말아야 할 곳
실패확률이 낮고 틀려도 괜찮은 데이터를 보여줄 때 사용한다!
‼️ 데이터가 굉장히 중요하고 안정성이 필요할때는 옵티미스틱을 사용하시면 안됩니다 ‼️(결제 후 잔여 금액과 같은 경우)

import { useMutation,gql,useQuery } from "@apollo/client"

//좋아요 갯수 가지고 오는 api _ 게시글 조회 api에서 좋아요 갯수만 뽑아 옵니다. 
const FETCH_BOARD = gql`
	query fetchBoard($boardId: ID!){
		fetchBoard(boardId: $boardId){
			_id
			likeCount
		}
	}
`

//좋아요 카운트 올리는 api
const LIKE_BOARD = gql`
	mutation likeBoard($boardId:ID!){
		likeBoard(boardId:$boardId)
	}
`

export default function(){
	const [likeBoard] = useMutation<Pick<IMutation,"likeBoard">,IMutationLikeBoardArgs>(LIKE_BOARD)
	const { data } = useQuery(FETCH_BOARD,
														{variables :{boardId : "게시글 아이디 넣어주세요!"} })
	
	const onClickLike = ()=>{
		//likeBoard 뮤테이션 함수를 실행하겠습니다.
		void likeBoard({
			variables :{
				boardId : "게시글 아이디 넣어주세요!"
			},

		// 응답을 받고난 후 받아온 응답을 다시 fetch 해줍니다. -> 느리고 효율적이지 못합니다.(백엔드에 요청을 한번더 해야하고 받아올때 까지 기다려야 합니다.)
		//refetchQueries: [
		//	{
		//		query: FETCH_BOARD,
		//		variables : {	boardId : "//게시글 아이디 넣어주세요!" }
		//	}
		// ]

		//옵티미스틱 UI -> 캐시를 바꾸고 캐시값을 받아오는걸 기다리지 않고 바로 바꿔줍니다.
		optimisticResponse: {
			likeBoard : (data?.fetchBoard.likeCount || 0)+1
		},
		// apollo 캐시를 직접 수정을 할 수 있었습니다.(백엔드 캐시가 아닙니다.) -> 느리지만 효율적입니다. (백엔드에 요청은 안하지만, 받아올때까지 기다려줘야 합니다.)
			update(cache,{data}){
				// 이전 시간에는 modify를 사용했지만, 오늘은 writeQuery를 사용해보겠습니다.
				cache.writeQuery({
					query : FETCH_BOARD,
					variables : {boardId:'게시글 아이디 넣어주세요!'}
					//어떻게 수정할 것인지는 아래에 적어줍니다.
					data: {
						// 기존값과 똑같이 받아오셔야 합니다.
						fetchBoard: {
							_id : '게시글 아이디 넣어주세요!',
							__typename : "Board"
							likeCount: data?.likeBoard
						}
					}
				})
			}
		})
	}

	return(
		<div>
				<h1>옵티미스틱 UI</h1>
				<div>현재카운트(좋아요):{data.fetchBoard.likeCount}</div>
				<button onClick={onClickOptimisticUI}>좋아요 올리기!!</button>
		</div>
	)
} 

옵티미스틱 UI를 적용하면 컴퓨터 환경에 상관없이 유저 모두가 빠른 서비스를 이용하는 것 같이 속일 수 있다
데이터가 중요하지 않고 실패할 확률이 없다면 옵티미스틱 UI를 사용한다!

profile
Strive for greatness

0개의 댓글