[코드캠프]20일차_TIL_

윤성해·2023년 4월 10일
0

프론트엔드_TIL

목록 보기
19/27
post-thumbnail

🏷️ 수업 목표

  1. 이미지등록 리뷰
  2. 검색 프로세스 이해
  3. 검색버튼 없이 검색
  4. 비다운싱 & 쓰로틀링
  5. Lodash 디바운싱 구현

이미지등록 리뷰하기


예시 이미지와 같이 사진 첨부 버튼을 총 3개 만들어봅시다!

// BoardWrite.container.tsx

const [fileUrls, setFileUrls] = useState(["", "", ""]);
// BoardWrite.presenter.tsx

<S.ImageWrapper>
  <S.Label>사진첨부</S.Label>
  {props.fileUrls.map((el, index) => (
    <Uploads01
      key={uuidv4()}
      index={index}
      fileUrl={el}
      onChangeFileUrls={props.onChangeFileUrls}
    />
  ))}
</S.ImageWrapper>

3개의 이미지 첨부 버튼이 화면에 노출되도록 하려면, map을 실행하는 배열의 길이가 일정하게 3이어야 합니다. fileUrls라는 state의 초기값에 빈 문자열이 3개 들어가 있는 배열을 넣어줬습니다.
이제 화면에는 Uploads01이라는 컴포넌트가 배열의 길이 수 만큼 그려질 것입니다.
또한, Uploads01 컴포넌트의 key에는 지난 시간에 배운 uuid를 이용하여 고유한 id 값을 넣어주었습니다.

복습!
→ uuid란 무작위로 고유한 값을 지닌 id를 생성해주는 라이브러리로, map의 key 등에 고유한 값을 넣어줄 때 사용할 수 있습니다.

uuid Docs : https://www.npmjs.com/package/uuid

Uploads 컴포넌트 - useRef

Uploads 컴포넌트는 다음과 같은 구조로 만들어줍니다.

파일 업로드 기능을 가지고 있는 file 타입의 input 태그디자인 커스텀이 불가능합니다.
그렇기 때문에 파일 첨부를 실행할 file input을 만든 뒤,
display: none 스타일을 지정해서 사용자 눈에 보이지 않도록 숨겨줍니다.
그리고 useRef를 이용해서, 따로 만든 파일 업로드 버튼 요소와 file input을 연결해줍니다.
이렇게 하면, 따로 만든 파일 업로드 버튼을 클릭했을 때 file input에도 이벤트가 발생하도록 만들어 줄 수 있습니다.

const fileRef = useRef<HTMLInputElement>(null);
const onClickUpload = () => {
  fileRef.current?.click();
};

Uploads 컴포넌트 - 이미지 업로드 기능

Uploads 컴포넌트의 container 파일은 다음과 같이 구성됩니다.

export default function Uploads01(props: IUploads01Props) {
  const fileRef = useRef<HTMLInputElement>(null);
  const [uploadFile] = useMutation(UPLOAD_FILE);

	// onClickUpload를 실행하면 fileRef가 click 된다
  const onClickUpload = () => {
    fileRef.current?.click();
  };

	// input에 파일이 첨부될 경우 작동하는 함수
  const onChangeFile = async (event: ChangeEvent<HTMLInputElement>) => {
		// 이미지 적합성 체크 (Uploads01.validation.ts에서 함수 import)
    const file = checkValidationImage(event.target.files?.[0]);
    if (!file) return;

    try {
			// 첨부된 파일을 구글 스토리지에 업로드 후 url를 반환받는다.
      const result = await uploadFile({ variables: { file } });
			// BoardWrite에서 props로 받아온 onChangeFileUrls 함수에 스토리지에 업로드가 완료된 file의 url을 넘겨준다.
      props.onChangeFileUrls(result.data.uploadFile.url, props.index);
    } catch (error) {
      Modal.error({ content: error.message });
    }
  };

  return (
    <Uploads01UI
      fileRef={fileRef}
      fileUrl={props.fileUrl}
      defaultFileUrl={props.defaultFileUrl}
      onClickUpload={onClickUpload}
      onChangeFile={onChangeFile}
    />
  );
}

그리고 input에 첨부된 파일의 적합성을 검사하기 위한 코드는 별개의 함수로 만들어서 파일을 분리해줍니다. Uploads01.Validation.ts 라는 이름의 파일로 만들어주었습니다.

import { Modal } from "antd";

export function checkValidationImage(file: File | undefined) {
  if (!file?.size) {
    Modal.error({ content: "파일이 없습니다." });
    return false;
  }
  if (file.size > 5 * 1024 * 1024) {
    Modal.error({ content: "파일이 너무 큽니다.(제한: 5MB)" });
    return false;
  }
  if (!file.type.includes("png") && !file.type.includes("jpeg")) {
    Modal.error({
      content: "파일 확장자가 올바르지 않습니다.(png, jpeg만 가능)",
    });
    return false;
  }
  return file;
}

이렇게 하면 BoardWrite에서 버튼을 클릭했을 때, 해당 버튼의 index를 props로 함께 넘겨줍니다.
그리고 onChangeFileUrls 함수는, 배열로 이루어진 fileUrls의 몇 번째 index를 교체할 것 인지를 매개변수로 받아오게 됩니다.

// BoardWrite.containter.tsx의 onChangeFileUrls 함수

const onChangeFileUrls = (fileUrl: string, index: number) => {
  const newFileUrls = [...fileUrls];
  newFileUrls[index] = fileUrl;
  setFileUrls(newFileUrls);
};

이렇게 하면, 파일 업로드 시 FileUrls라는 state의 특정 index 값구글 스토리지에 올라간 이미지 파일의 url로 바뀌게 됩니다. 그리고 변경 완료된 fileUrls를 useMutation 요청 시 함께 보내면 게시글 등록 시 첨부된 이미지의 url도 함께 DB에 저장됩니다.

상세 페이지 이미지 출력

fetchBoard할 때, 이미지 url도 함께 받아와서 상세 페이지에 뿌려볼까요?

export const FETCH_BOARD = gql`
  query fetchBoard($boardId: ID!) {
    fetchBoard(boardId: $boardId) {
      writer
      title
      contents
      youtubeUrl
      likeCount
      dislikeCount
      boardAddress {
        zipcode
        address
        addressDetail
      }
      images
      createdAt
    }
  }
`; 

그리고, 받아온 images 배열 데이터를 filter와 map을 이용하여 페이지에 출력합니다. 이 때, el이 빈 값이 아닌 경우에만 이미지가 그려지도록 filter 조건 설정을 해줍니다.

<S.ImageWrapper>
  {props.data?.fetchBoard.images
    ?.filter((el: string) => el !== "")
    .map((el: string) => (
      <S.Image
        key={el}
        src={`https://storage.googleapis.com/${el}`}
      />
    ))}
</S.ImageWrapper>

위 코드의 filter문은 다음과 같이 생략하여 쓰는 것도 가능합니다.

<S.ImageWrapper>
  {props.data?.fetchBoard.images
    ?.filter((el: string) => el)
    .map((el: string) => (
      <S.Image
        key={el}
        src={`https://storage.googleapis.com/${el}`}
      />
    ))}
</S.ImageWrapper>

리뷰 실습
미리보기 어떤방식으로 나오는건지 봅시다!!(컴포넌트로)

el은 컨테이너에서 가져온거. 빈문자열 3개 , index는 0번째,1번째,2번째!

업로드01 컨테이너 보니, 이런것들이 프롭스에 있다.



이거 이미지 저장할 state왜 없을까?

이거 보고 onChangeFileUrls찾아가보자. 가보면 컨테이너에 가게됨.

이렇게 드릴링이 된다. index는 0,1,2 !
-> map을 통해서 3개를 그렸다. fileUrls.map()

네모박스 하나당 컨테이너, 프레젠터 하나씩 있다. 0번째 + 버튼을 클릭했으면, 빈문자열과 인덱스 0 이 실행된다. 표시된 곳에 인덱스 0 이 들어가면서 온체인지 파일이 실행되서, 그주소에는 강아지 들어가고, 인덱스는 0 들어간다.


그러면 컨테이너 온체인지파일 유알엘에 이렇게 들어온다.
얕응ㄴ 복사 통해서 새로운 파일유알엘즈 만든다. 이렇게되면 스테이트에 강아지가 0번째로 들어가고 스테이트가 바뀌었으니 리랜더가 될것이다!
다시 돌아와서 맵쪽으로 오니까

인덱스값이 0,1,2 에서 강아지사진, 1,2 로 바뀐다.

삼항연산자 작성쪽 와서 있니? 있으면 해당주소 보여줘(사진보여줘)
없으면 업로드버튼 보여줘. 되는것임!!

이미지 수정할때는 어떻게 할 것인가?

이게 defaultvalue인데, 이걸 어떻게 줄 수 있을까?
이미지 태그에는 defaultvalue 자체가 없다.
컨테이너가 등록, 수정 같이 쓰니까

이거를 활용하자. 이거를 다시 수정하기에서도 map으로 뿌려줄테니까.

다운로드 보냈을때 이미 저기 주소에는 빈문자열로 만들어지기때문에 초기값에 데이터값 넣는다고 해서 바뀌지가 않는다.
-> useEffect 사용해서 모두 함수가 다 위에서부터 아래까지 실행되고 실행시켜줘 ! 이걸 이용한다.

이미지 비었는지 확인하고, 안비었으면 초기값 바뀌게. 처음에는 "" 였다가 data 받아오는 순간 미리보기 이미지 변경이 될 것임! 리랜더가 한번 더 일어난다. 그래도 코드 줄이 짧다면 이게 더 효율적이다.


검색 처리 과정


실제 데이터는 오른쪽이지만 검색전용 테이블을 왼쪽처럼 하나 만들어놓고, 검색할 때는 왼쪽에서 찾는다 (구글 검색엔진 초창기 방법!!)
이것을 역색인 => inverted index 라고 부른다.

토큰화 했다를 토크나이징 이라고 한다.

위 방법은 엘라스틱서치 (다시찾아보기)

  • 임시저장 데이터베이스 -> Redis
    엘라스틱서치로 가져온 데이터들을 레디스에 저장해놓는다. 메모리에 저장해놓는 임시저장 느낌이라서 컴퓨터가 꺼지면 없어질 수 있지만, 빠르게 데이터를 제공할 수 있다.
    ex) 1분동안만, 5분동안만 저장(TTL:time to live. 살아있는 시간)하면 그동안 점심이라는 키워드를 검색하는 사람들은 레디스에 있는 데이터들을 받게된다. 엘라스틱서치까지 가지 않아도 되서 속도가 빠르다! ➡️ 캐싱! 캐싱은 임시저장을 의미합니다.
    💡 위 방법은 매시-어사이드-패턴 이라고 합니다.
    💡 글이 많이 올라올 것 같으면 TTL을 짧게 해주고, 일주일에 한번정도 새로운 글이 올라올 것 같다 싶으면 TTL을 길게 가져가도 된다.


캐싱이 되어있다면 Redis , 캐싱되어 있지 않은 기록은 Elastic Search 방식이 사용되는 것!
여기까지는 백엔드이고, 우리는 이과정에서 결과를 얻어온다. 그래도 잘 알아놓자!

검색하기


베리어블즈는, 기본적으로 쿼리자체에 저장이 되어있다. 이상태에서 삼페이지 클릭하면, 서치 철수에 페이지 삼! 으로 해서 검색이 자동으로 된다.

onClick 함수는 서치라는 키워드가 없지만 온클릭서치에 있는 서치가 자동으로 빨려들어가는 것!

검색버튼 없이 검색 시 발생하는 문제

검색버튼을 누르지 않고 검색기능이 활성화 될때, 생기는 문제가 있습니다.
바로, page를 변경하며 refetch될 때, state로 관리하는 검색 input키워드 값이 검색을 누르지 않아도 검색되는 문제입니다. 이러한 문제는 검색을 눌렀을때 들어가있는 키워드 값을 따로 저장시켜주는 state를 분리시켜주어야 합니다.

refetch될때 mySearch로 검색되는 부분을 myKeyword로 바꿔 해결합니다.

const getDebounce = _.debounce((data) => {
    refetch({ search: data, page: 1 });
    setMyKeyword(data);
  }, 200);

디바운싱 & 쓰로틀링


디바운싱이란, 연이어 발생한 이벤트를 하나의 그룹으로 묶어 처리하는 방식으로 주로 그룹에서 마지막, 혹은 처음에 처리된 함수를 처리하는 방식으로 사용됩니다. 마지막 호출이 발생한 후 일정 시간이 지날때까지 추가적 입력이 없을때 실행이 됩니다. 디바운싱이 사용되는 대표적 예제로는 검색기능이 있습니다.

반면 쓰로틀링이란, 연이어 발생한 이벤트에 대해 일정한 delay를 포함 시켜, 연속적으로 발생하는 이벤트는 무시하는 방식으로 사용됩니다. 즉, 지정한 delay동안 호출된 함수는 무시합니다. 쓰로틀링이 사용되는 대표적 예제는 스크롤 기능이 있습니다

Lodash 디바운싱 구현

Lodash는 자바스크립트의 유틸리티 라이브러리입니다. 내장되어 있는 유용한 함수가 많기 때문에 자주사용 됩니다. Lodash의 많은 기능 중 디바운싱 내용에 대해 알아보겠습니다.

Lodash 설치하기!
yarn add lodash
yarn add -D @types/lodash

Debounce

Debounce는 반복적인 동작을 강제적으로 대기하는 것을 말합니다.
예를들어, 우리가 input에 onChange를 이용해 console.log()를 확인해 보면 우리가 하나하나 입력할 때마다 아래의 예제와 같이 onChange가 실행됩니다.

export default function Test2() {

	const handleOnChange = debounce((e) => {
		console.log(e.target.value);
  });
  
	return (
		<>
			<input onChange={handleOnChange}></input>
		</>
	);
}


그럴 경우 중간 과정을 없애고 결과만 한 번에 실행해주는 것이 Debounce입니다.

사용하고 싶은 컴포넌트 상단에 Debounce를 불러봅시다.

import { debounce } from 'lodash';

그리고 우리가 원하는 기능을 debounce로 감싸주면 됩니다.

import { debounce } from 'lodash';
export default function Test2() {

	const handleOnChange = debounce((e) => {
		console.log(e.target.value);
	}, 500);

	return (
		<>
			<input onChange={handleOnChange}></input>
		</>
	);
}

위에서 굵게 표시된 부분이 Debounce 입니다.
Debounce는 setTimeout과 사용방법이 똑같습니다. debounce(콜백함수, 시간)
첫 번째 인자로는 실행시키고 싶은 함수가 들어가고, 두 번째 인자로는 시간이 들어갑니다.
debounce는 우리가 두 번째 인자로 넣어준 시간 동안 아무 일도 하지 않았을 때 콜백함수를 실행시킵니다.

즉, 우리가 무언가를 계속 입력하고 있으면 함수를 실행시키지 않고, 우리가 입력을 끝내고 가만히 있으면 그때 함수 결과를 보여줍니다.

처음 결과와는 다르게 Debounce를 사용하니 console.log()에 한 번만 찍힌 것을 볼 수 있습니다.

Debounce 활용하는 곳

그럼 우리는 Debounce를 어떻게 활용할 수 있을까요?
주로 검색을 할 때 사용할 수 있습니다. 우리가 검색을 할 때 엔터를 치지 않더라도, 사용자가 입력을 멈추고 일정 시간이 지나면 자동으로 함수를 실행시켜 검색 결과를 보여주는 것입니다.

검색했던 키워드를 빨간색으로 표시해주기

결과물을 보자면 위이미지처럼!
그런데 문제는, 철수 라는것을 검색했을 때 펄수가 점심을 먹어다 라는 문장이 나오면 철수만 빨간색으로 바뀌어야 한다. 그런데 이것을 어떻게 만들 수 있을까?
1. <span>철수</span> <span>가 점심을 먹었다.</span> 이렇게 쪼개야한다.
2. split("") 이렇게 사용하면 공백으로 끊기니까 사용하면 "철수가" 이렇게 가 까지 포함이 될 것이다.


  1. 그렇다면 키워드를 만들어서 철수 앞쪽에 붙여보자.
    ex) "@#$철수@#$가 점심을 먹었다".split("@#$")
  2. 위처럼 암호문자를 넣어주고 잘라준다. 이런거는 사람들이 몰라야하기때문에 시크릿코드 라고 한다!
    위 이미지처럼 간단한거는 유저가 실수로 작성할 수 있으니, 길고 어려운걸로 해야한다.
    일단 간단하게 더 예를 보자
    "찰수가 점심을 먹었다".replace("철수","@#$철수@#$").split("@#$")

이렇게 자르기! 그리고 반환되는 값을 map으로 돌려주고 el을 span태그 안에 넣어주기!!

전체코드

import { useQuery, gql } from "@apollo/client";
import { ChangeEvent, MouseEvent, useState } from "react";
import type {
IQuery,
IQueryFetchBoardsArgs,
} from "../../../src/commons/types/generated/types";
import _ from "lodash";
import { v4 as uuidv4 } from "uuid";

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

export default function StaticRoutingPage(): JSX.Element {
const [keyword, setKeyword] = useState("");

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

console.log(data);

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

const getDebounce = _.debounce((value) => {
  void refetch({ search: value, page: 1 });
  setKeyword(value); // 철수가 키워드에 저장됨 이제 아래로 가서 쪼개자
}, 500);

const onChangeSearch = (event: ChangeEvent<HTMLInputElement>): void => {
  getDebounce(event.currentTarget.value);
};

return (
  <div>
    검색어입력: <input type="text" onChange={onChangeSearch} />
    {data?.fetchBoards.map((el) => (
      <div key={el._id}>
        <span style={{ margin: "10px" }}>
          {el.title
            .replaceAll(keyword, `@#$${keyword}@#$`)
            .split("@#$")
            .map((el) => (
              <span
                key={uuidv4()}
                style={{ color: el === keyword ? "red" : "black" }}

                {el}
              </span>
            ))}
        </span>
        <span style={{ margin: "10px" }}>{el.writer}</span>
      </div>
    ))}
    {new Array(10).fill("철수").map((_, index) => (
      <span key={index + 1} id={String(index + 1)} onClick={onClickPage}>
        {index + 1}
      </span>
    ))}
  </div>
);
}

슬라이도 질문


만약에 페이지가 없으면, 백엔드 개발자가 어떻게 짜놨냐에 따라서 다르다.
에러가 날 수도 있고, 백엔드개발자가 정해놓은 페이지수로 갈 수도 있고..

이거는 백엔드쪽에서 10개씩 주는걸로 설정이 되어있어서 백엔드랑 이야기해야한다.
임시로 그 10개를 state에 담아놓고 ?? 페이보드의 리절트를 나만의 스테이트에 담아놓고 꺼내도 되고,

이렇게 인덱스도 설정해놓고, index < 3 && ( 이렇게 해도 된다.


궁금한 것

  • 나 오ㅐ vscode-styled-components 갑자기 안되닝? 이거고쳐보기.. 아무래도 버전문제겄ㅈㅈ쥬.......어려버

배운 것


느낀 점

주말내내 중간평가랑 씨름했다.
리액트이모션 깔았는데 자꾸 모듈을 찾을 수 없다고 해서 버전 바꿔보고 파일 지웠다 깔아보고 다시만들어보고 next.js 다시 깔고 10시부터 오후 3시까지 씨름하다가.. 결국은 실행하던 파일 복사해서 과제시작했다 ㅎ후하하하 .. 난 파일도 깔지못하는 바보인것인가 싶었지만 혼자 처음해보는거니까!!

아무튼 문제는 뭐였냐면

타입스크립트가 "devDependencies" 말고 "dependencies"에 들어가있어서 그랬던 것. 그런디 내가 작업하는 다른 파일들은 이렇게 안되어있는데 왜 잘 돌아가는거지??????????
아무튼 속 시원헙니다.

profile
Slow and steady wins the race.

0개의 댓글