Index

성능최적화 - react developer tools
memoization(useCallback,useMemo)
map,memo(key-uuid?)
CRP(Critical Rendering Path)
Reflow, Repaint
prefetch, preload
이미지 (input.target.files, readAsDataURL, onload)
Promise, Promise.all, Promise.all() - map
LazyLoad vs PreLoad ( Prefetch)
Google PageSpeed Insights
이미지 Webp 확장자
이미지 라이브러리(React-lazy-load, React-dropzone, react-avatar-editor, antd, React-beautiful-dnd)
Optimistic-UI

Intro

자꾸 새로 만들지 말고 메모해 놓는게 어때~?
Async-await를 for 문에서 쓰면 안돼?
빠르게 할 수 없다면 속여보자!

성능 최적화

// 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를 제외한 모든 값이 다시 그려지고 있다는 것을 의미한다.

presenter 페이지 만들고 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
크롬 웹스토어에서 설치해 보면 개발자 도구에 profiler 생성, 렌더링 될 대상일 때 영역을 표시해주는 설정을 바꿔 주어야 한다.

다시 돌아와 stste카운트 버튼을 눌러보면 container 부분과 presenter 부분이 동시에 렌더링 대상임을 볼 수 있다.

반면에 let카운트 버튼을 눌러보시면 아무일도 일어나지 않는데 이는 해당 버튼을 눌러도 렌더링이 일어나지 않는다는 의미이다.

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

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)

이렇게 메모를 걸어두면 처음의 값이 기억되어 업데이트되지 않지만 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사용시 메모를 걸어놔도 키값이 변경되어 props로 넘어가기 때문에 변경된 부분이 모두 리렌더링 된다
-> 불필요한 리렌더링을 초래한다

CRP(Critical Rendering Path)

브라우저에서 코드를 그려주는 과정 - CRP 개념 등장


1) 화면을 그리는데 필요한 리소스(html, css, js) 다운로드
2) HTML, CSS에서 화면에 렌더해야 할 요소 구분 후 렌더되어야 할 요소를 합쳐 화면에 그려줌
3) 해당 요소들이 어느 위치에 놓일지 먼저 그려주는 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.html을 다운로드 받아올 때 network 탭을 보게 되면,
아래와 같이 index.html을 받아온 후에 board.html 문서도 이어서 받아옴


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


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

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를 이용하시면 이미지를 먼저 받아오기때문에 화면로드 지연을 방지할 수 있다.

이미지 미리보기 (임시 URL 생성)

자바스크립트에서 이미지를 보기 위해서는 이미지 경로(주소)가 필요

이미지 경로(주소)를 상대주소 ‘../../’와 같은 형식으로 사용했었다.

하지만 위와 같은 경로에는 단점이 있다. 다른 사람 컴퓨터에 해당 이미지가 없을 경우에는 오류가 뜨는 것.

해당 이미지가 없는 컴퓨터에서도 이미지를 보기 위해서는 이미지 서버에 이미지를 등록하고 등록된 이미지 주소로 불러와야 어디서든 이미지를 볼 수 있었다.

하지만, 이미지를 올려놓고 정작 게시글 등록을 하지 않으면, 이미지 서버에는 이미지가 올라가 있지만, 사용되는 곳은 없죠. 그래서 쓸데없는 데이터 낭비뿐만 아니라, 서버에도 과부하가 걸리게 됨.

그래서 이미지 주소를 서버에서 가져오는 것이 아니라, 미리보기 용으로 만든 임시 주소를 만드는 방법이 있다.

input.target.files

export default function ImageUploadPreviewPage() {


	const onChangeFile=(event)=> {
		const file = event.target.files?.[0]
		console.log(file);
		}
		
	return (
		<>
			<div>
				<input type="file" onChange={onChangeFile}></input>
				<img src="" />
			</div>
		</>
	);
}

기존 코드에서 onChangeFile라는 함수를 하나 만들어 주고, input에 onChange로 함수를 실행시켜주었다.

그리고 그 결과를 file이라는 변수에 담아주고 console.log(file)로 어떤 파일이 들어오는지 확인했다.

파일을 하나 올려보면


콘솔 창에 이런 결과가 나오는데 이 결과를 가지고 이미지 미리보기를 만들 수 있다.

임시 URL 받아오기
2가지 방법이 있다.
1) 가짜 URL 생성 - 내 브라우저에서만 사진에 접근 가능

export default function ImageUploadPreviewPage() {

	const [iamgeUrl, setImageUrl] = useState("")

	const onChangeFile=(event: ChangeEvent<HTMLInputElement>)=> {
		const file = event.target.files?.[0]
		console.log(file);
			// 파일이 없으면 함수를 종료합니다.
			if (!file) {
				alert("파일이 없습니다.");
				return
			}
		
	// 1. 임시 URL 생성 -> 가짜 URL생성, 내 브라우저에서만 접근 가능
	const result = URL.createObjectURL(file)
	console.log(result)
	setImageUrl(result)
		
}
	return (
		<>
			<div>
				<input type="file" onChange={onChangeFile}></input>
				<img src={iamgeUrl}/>
			</div>
		</>
	);
}

2) 진짜 - 다른 브라우저에서도 가능(new FileReader(); 파일 객체를 이용해 내용을 읽고 사용자 컴퓨터에 저장하는 것을 가능하게 해줌 이용)

export default function ImageUploadPreviewPage() {

	const [iamgeUrl, setImageUrl] = useState("")

	const onChangeFile=(event: ChangeEvent<HTMLInputElement>)=> {
		const file = event.target.files?.[0]
		console.log(file);
			if (!file) {
				alert("파일이 없습니다.");
				return
			}
		
		// 2. 임시 URL생성 -> 진짜 URL생성, 다른 브라우저에서도 접근 가능	
		const fileReader = new FileReader()
		fileReader.readAsDataURL(file);
		fileReader.onload = (data) => {
			// 파일리더의 결과값이 string이 아닐수도 있으니 string일때만 실행되도록 
			if(typeof data.target?.result === "string"){
				console.log(data.target?.result);
				setImageUrl(data.target?.result)
			}	
		}
}
	return (
		<>
			<div>
				<input type="file" onChange={onChangeFile}></input>
				<img src={iamgeUrl}/>
			</div>
		</>
	);
}

new FileReader() 기능을 const fileReader에 담아주었습니다. 그러면 fileReader는 new FileReader()의 기능을 사용할 수 있다

readAsDataURL
readAsDataURL()을 사용하면 Data URL을 얻을 수 있게 된다.

onload()

파일 읽기 성공시 실행
onload에서는 파일을 읽고 생성된 Data URL이 target.result에 담기게 된다.
해당 결과를 img태그의 src에 값으로 넣어주면 됨!

Blob(BinaryLargeObject) 이란?
Blob 객체는 파일류의 불변하는 미가공 데이터를 나타냅니다. 텍스트와 이진 데이터의 형태로 읽을 수 있으며, ReadableStream으로 변환한 후 스트림 메서드를 사용해 데이터를 처리할 수도 있습니다.

그럼 어떤걸 사용해야 하지? 상황에 따라 다르다!
new FileReader() 방법은 예전에 나온 기능이라 안정성에 있어서 괜찮지만, Blob 방법은 나온 지 얼마 되지 않은 기능이라 지원하지 않는 브라우저가 있을 수 있습니다.
하지만, Blob 방법은 new FileReader() 방법에 비해 간편하다는 이점이 있기 때문에 브라우저를 따져보고 지원한다면, 사용하셔도 좋습니다.
⚠️ 다만, 두 방법 모두 스토리지에 업로드는 되지 않기때문에 찌거기가 생기지 않는다는 점에서 유용하다는 것만 숙지해주시면 됩니다.

임시 url로 API 요청
안정성을 위해 2번방법으로 사용
이렇게 받은 임시 url은 미리보기 파일이므로 올바른 형태의 file타입이 아니다. 따라서 이 이미지로 api를 요청을 해야한다면 어떻게 해야하는지 보자

import { gql, useMutation } from "@apollo/client";
import { Modal } from "antd";
import { ChangeEvent, MouseEvent, useState } from "react";
import {
  IMutation,
  IMutationUploadFileArgs,
} from "../../src/commons/types/generated/types";

const CREATE_BOARD = gql`
  mutation createBoard($createBoardInput: CreateBoardInput!) {
    createBoard(createBoardInput: $createBoardInput) {
      _id
    }
  }
`;

const UPLOAD_FILE = gql`
  mutation uploadFile($file: Upload!) {
    uploadFile(file: $file) {
      url
    }
  }
`;

export default function ImageUploadPage() {
	// imageUrl은 미리보기를 위한 주소이므로 해당 Url을 스토리지나, DB에 넣어서는 안됩니다.
  const [imageUrl, setImageUrl] = useState("");
	// DB에 넣기 위한 Url주소
  const [file, setFile] = useState<File>();

  const [ uploadFile ] = useMutation<Pick<IMutation, "uploadFile">,IMutationUploadFileArgs>(UPLOAD_FILE);
	const [ 나의함수 ] = useMutation(CREATE_BOARD);

	// 받아온 주소록 api 날려주기
  const onClickSubmit = async () => {
		// 스토리지에 업로드 해주기
    const resultFile = await uploadFile({ variables: { file } });
    const url = resultFile.data?.uploadFile.url;
		
		// 게시글에 이미지등록해주기
    const result = await 나의함수({
      variables: {
        createBoardInput: {
          writer: "철수",
          password: "1234",
          title: "안녕하세요",
          contents: "반갑습니다",
          images: [url],
        },
      },
    });
    console.log(result);
  };

  const onChangeFile = async (event: ChangeEvent<HTMLInputElement>) => {
    const file = event.target.files?.[0]; // <input type="file" multiple /> 에서 multiple 속성으로 여러개 드래그 가능
    if (!file) return;
    console.log(file);

    // 2. 임시URL 생성 => (진짜URL - 다른 브라우저에서도 접근 가능)
    const fileReader = new FileReader();
    fileReader.readAsDataURL(file);
    fileReader.onload = (event) => {
      if (typeof event.target?.result === "string") {
        console.log(event.target?.result); // 게시판에서 event.target.id 대신 event.currentTarget.id를 썼던 이유: event.target은 태그만을 가르키지 않음
        // 미리보기용
				setImageUrl(event.target?.result);
				// DB에 넣어주기용
        setFile(file);
  };

  return (
    <>
      <input type="file" onChange={onChangeFile} />
      <img src={imageUrl} />
      {/* <img src={`https://storage.googleapis.com/${imageUrl}`} /> */}

      <button onClick={onClickSubmit}>게시글 등록하기</button>
    </>
  );
}

이미지를 업로드할 때 주의해야 할 점은 임시로 받아온 URL주소를 DB에 넣지 않도록 해야한다.

DB에 넣어주는 URL은 input에서 가지고 온 file 주소 : event.target.files?.[0] 을 그대로 넣어주어야 한다.

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 ===");
     };

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

다중 이미지 업로드

Promise.all()사용해 여러 이미지 한번에 올리기

Promise.all() - map

const onClickPromiseAll = async () => {
		const result = await Promise.all(["http://storage.url1.jpg",
		"http://storage.url1.jpg", "http://storage.url1.jpg"].map((el)
			=> new Promise((resolve, reject) => {
					setTimeout(() => {
						resolve(el)
					}, 3000)
			})
		))
	};

promise.all() 사용시 promise 반복해 적는 부분 줄이기

import { gql, useMutation } from "@apollo/client";
import { Modal } from "antd";
import { ChangeEvent, MouseEvent, useState } from "react";
import {
  IMutation,
  IMutationUploadFileArgs,
} from "../../src/commons/types/generated/types";

const CREATE_BOARD = gql`
  mutation createBoard($createBoardInput: CreateBoardInput!) {
    createBoard(createBoardInput: $createBoardInput) {
      _id
    }
  }
`;

const UPLOAD_FILE = gql`
  mutation uploadFile($file: Upload!) {
    uploadFile(file: $file) {
      url
    }
  }
`;

export default function ImageUploadPage() {
  // const [imageUrl, setImageUrl] = useState("");
  // const [file, setFile] = useState<File>();
  const [imageUrls, setImageUrls] = useState(["", "", ""]);
  const [files, setFiles] = useState<File[]>([]);

  const [uploadFile] = useMutation<
    Pick<IMutation, "uploadFile">,
    IMutationUploadFileArgs
  >(UPLOAD_FILE);

  const [나의함수] = useMutation(CREATE_BOARD);

  const onClickSubmit = async () => {
    // Promise.all 썼을 때
     const results = await Promise.all([
       uploadFile({ variables: { file: files[0] } }),
       uploadFile({ variables: { file: files[1] } }),
       uploadFile({ variables: { file: files[2] } }),
     ]);
     console.log(results); // [resultFile0, resultFile1, resultFile2]
     const resultUrls = results.map((el) => (el ? el.data?.uploadFile.url : "")); // [dog1.jpg, dog2.jpg, dog3.jpg]

    const result = await 나의함수({
      variables: {
        createBoardInput: {
          writer: "철수",
          password: "1234",
          title: "안녕하세요",
          contents: "반갑습니다",
          images: resultUrls, // [url0, url1, url2]
        },
      },
    });
    console.log(result);
  };

  const onChangeFile =
    (index: number) => async (event: ChangeEvent<HTMLInputElement>) => {
      const file = event.target.files?.[0]; // <input type="file" multiple /> 에서 multiple 속성으로 여러개 드래그 가능
      if (!file) return;
      console.log(file);

      // 2. 임시URL 생성 => (진짜URL - 다른 브라우저에서도 접근 가능)
      const fileReader = new FileReader();
      fileReader.readAsDataURL(file);
      fileReader.onload = (event) => {
        if (typeof event.target?.result === "string") {
          console.log(event.target?.result); // 게시판에서 event.target.id 대신 event.currentTarget.id를 썼던 이유: event.target은 태그만을 가르키지 않음
          // setImageUrl(event.target?.result);
          // setFile(file);

          const tempUrls = [...imageUrls];
          tempUrls[index] = event.target?.result;
          setImageUrls(tempUrls);

          const tempFiles = [...files];
          tempFiles[index] = file;
          setFiles(tempFiles);
    };

  return (
    <>
      <input type="file" onChange={onChangeFile(0)} />
      <input type="file" onChange={onChangeFile(1)} />
      <input type="file" onChange={onChangeFile(2)} />
      <img src={imageUrls[0]} />
      <img src={imageUrls[1]} />
      <img src={imageUrls[2]} />
      {/* <img src={`https://storage.googleapis.com/${imageUrl}`} /> */}

      <button onClick={onClickSubmit}>게시글 등록하기</button>
    </>
  );
}

uploadFile() 함수가 3번 반복되면서 file의 index를 순차적으로 넣어준다

import { gql, useMutation } from "@apollo/client";
import { Modal } from "antd";
import { ChangeEvent, MouseEvent, useState } from "react";
import {
  IMutation,
  IMutationUploadFileArgs,
} from "../../src/commons/types/generated/types";

const CREATE_BOARD = gql`
  mutation createBoard($createBoardInput: CreateBoardInput!) {
    createBoard(createBoardInput: $createBoardInput) {
      _id
    }
  }
`;

const UPLOAD_FILE = gql`
  mutation uploadFile($file: Upload!) {
    uploadFile(file: $file) {
      url
    }
  }
`;

export default function ImageUploadPage() {
  // const [imageUrl, setImageUrl] = useState("");
  // const [file, setFile] = useState<File>();
  const [imageUrls, setImageUrls] = useState(["", "", ""]);
  const [files, setFiles] = useState<File[]>([]);

  const [uploadFile] = useMutation<
    Pick<IMutation, "uploadFile">,
    IMutationUploadFileArgs
  >(UPLOAD_FILE);

  const [나의함수] = useMutation(CREATE_BOARD);

  const onClickSubmit = async () => {
    // 1. Promise.all 썼을 때
    // const results = await Promise.all([
    //   uploadFile({ variables: { file: files[0] } }),
    //   uploadFile({ variables: { file: files[1] } }),
    //   uploadFile({ variables: { file: files[2] } }),
    // ]);
    // console.log(results); // [resultFile0, resultFile1, resultFile2]
    // const resultUrls = results.map((el) => (el ? el.data?.uploadFile.url : "")); // [dog1.jpg, dog2.jpg, dog3.jpg]

    // 2. Promise.all 썼을 때 - 리팩토링 
    // files - [File0, File1, File2]
    // files.map(el => uploadFile({ variables: { file: el } })) // [uploadFile({ ...: File0 }), uploadFile({ ...: File1 }), uploadFile({ ...: File2 })]
    const results = await Promise.all(
      files.map((el) => el && uploadFile({ variables: { file: el } }))
    );
    console.log(results); // [resultFile0, resultFile1, resultFile2]
    const resultUrls = results.map((el) => (el ? el.data?.uploadFile.url : "")); // [dog1.jpg, dog2.jpg, dog3.jpg]

    const result = await 나의함수({
      variables: {
        createBoardInput: {
          writer: "철수",
          password: "1234",
          title: "안녕하세요",
          contents: "반갑습니다",
          images: resultUrls, // [url0, url1, url2]
        },
      },
    });
    console.log(result);
  };

  const onChangeFile =
    (index: number) => async (event: ChangeEvent<HTMLInputElement>) => {
      const file = event.target.files?.[0]; // <input type="file" multiple /> 에서 multiple 속성으로 여러개 드래그 가능
      if (!file) return;
      console.log(file);

      // 2. 임시URL 생성 => (진짜URL - 다른 브라우저에서도 접근 가능)
      const fileReader = new FileReader();
      fileReader.readAsDataURL(file);
      fileReader.onload = (event) => {
        if (typeof event.target?.result === "string") {
          console.log(event.target?.result); // 게시판에서 event.target.id 대신 event.currentTarget.id를 썼던 이유: event.target은 태그만을 가르키지 않음
          // setImageUrl(event.target?.result);
          // setFile(file);

          const tempUrls = [...imageUrls];
          tempUrls[index] = event.target?.result;
          setImageUrls(tempUrls);

          const tempFiles = [...files];
          tempFiles[index] = file;
          setFiles(tempFiles);
    };

  return (
    <>
      <input type="file" onChange={onChangeFile(0)} />
      <input type="file" onChange={onChangeFile(1)} />
      <input type="file" onChange={onChangeFile(2)} />
      <img src={imageUrls[0]} />
      <img src={imageUrls[1]} />
      <img src={imageUrls[2]} />
      {/* <img src={`https://storage.googleapis.com/${imageUrl}`} /> */}

      <button onClick={onClickSubmit}>게시글 등록하기</button>
    </>
  );
}

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>
      ))}
    </>
  );
}

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

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

이미지 성능 관련 사이트

Google PageSpeed Insights
배포를 진행하고 나서, 내가 배포한 페이지의 개선할 점을 찾을때 유용

이미지 Webp 확장자
webp 확장자는 구글에서 만든 이미지 포맷(웹피라고 부름) png, jpeg와 같은 이미지 확장자!

구글에서는 왜 webp 확장자를 만들었을까?

구글은 전세계적으로 사용하고 있는 사이트로 구글에서만 관리하고 있는 이미지 서버만 해도 엄청난 트래픽이 있다.
구글은 이미지 서버의 부담을 줄이고, 서버비를 아낄 수 있는 방안으로 Webp라는 확장자를 만들었다.

Webp의 장점
Webp는 GIF, PNG, JPEG 확장자 모두를 대체 가능한 확장자이며 이미지를 파일을 압축했을 때 기존 PNG, JPEG보다 약 30%정도 용량을 줄일 수 있는 장점이 있습니다.
같은 이미지를 webp으로 받을시 webp은 300kb라면 png는 500kb정도 됩니다.
그리고 GIF는 256색만 표현할 수 있지만, Webp은 파일 크기도 작고 , 색상 수에 제한이 없으므로 GIF보다 훨씬 좋은 성능을 보입니다.
또한 PNG 처럼 알파 채널을 지원합니다.
알파 채널이란 배경이 투명한 것을 이야기 합니다.

Webp 확장자 변환
https://cloudconvert.com/

1)select file

2)선택한 파일 어떤 파일로 변환(webp)

3)convert!

이미지 라이브러리 (React-lazy-load, React-dropzone, react-avator-editor, ant-design)

직접 디자인하지 않고 이미지 등록을 예쁘게 만들어주는 기능

React-lazy-load

https://www.npmjs.com/package/react-lazy-load
React-lazy-load는 스크롤이 내려가면서 해당 이미지가 들어가있는 컴포넌트가 등장할 때 사진을 다운로드.

React-dropzone
https://www.npmjs.com/package/react-dropzone
React-dropzone은 react에서 제공하는 대표적인 이미지 라이브러리.

React-avator-editor
https://www.npmjs.com/package/react-avatar-editor

ant-design

React-beautiful-dnd
https://www.npmjs.com/package/react-beautiful-dnd
dnd : drag and drop

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개의 댓글