이미지 업로드, 검색기능까지는 사실상 프론트-엔드를 구현함에 있어 기본기능, 다들 쓰기 때문에 마스터 하는 것이 좋다.

이미지 업로드 핵심정리
write component+ upload component
를이용한다.
write 컴포넌트에서는
1. 최종적인 게시물 등록(onClickSubmit)
2. fileUrls
3. seFileUrls (onChangeFileUrls함수)
가 있어야한다.

업로드 컴포넌트에서는
1. validation,사이즈, 확장자 검증
2. uploadFile, 실제 업로드, 이후 주소(url)받아오기
3. 이후 setstate를 써야썼지만 재사용을 위해 분리해놨으므로
props.onChinageFileUrls로 작성 컴포넌트에서 setFileUrls 하기

fileUrls를 state사용할 때 세 칸을 비워둔 이유는 파일이 세개, 세개를 담을 수 있기 때문

onChnageFileUrls에서도 넣어야될 때 순서에 맞게 넣어야함으로 newFileUrls의 [인덱스 번호]

이후 게시물 작성 부분에서 fileUrls를 불러올때는 map으로 뿌리게됨
사실상 3개를 그려주는셈

각각의 미리보기를 위해서 fileUrl={el}를 넣어서 미리보여줌

그리고 index={index}부분은 몇번째 파일인지 알게하기 위해서 넣음


이후 게시물 작성 컴포넌에서 props로 받아옴
그리고 삼항연산자를 사용해서 파일이 있으면 파일을 보여주고, 없으면 버튼을 보여줌.

파일업로드 버튼은 못생겼기 때문에 onClickUpload를 만들어서 대신클릭해서 연결시켜줌

그럼 onChangeFile이 실행되고 그에 해달하는 파일을 myFile에 저장, 그리고 uploadFile을 통해서 스토리지로 업로드, 이후 url를 result에 저장했음

그 저장된 url을 props.onChangeFileUrls. 에 넘김
보드작성 컴포넌트에 있는걸 불러옴

여기도 스프레드로 인덱스 번호에 맞게 뿌려줌

그럼 fileUrls에 하나씩 들어가고 게시물 등록이 완료됨

화면에 보여지는 부분은 fetchBoard에 그냥 img 추가하면 어렵지 않음 0_0 어려운데요
여기서 이미지 수정까지 해야함

검색 프로세스

검색을 누르고 검색하기를 누르면 refetch가 되서 다시 나오게 되는 것임. 근데 백엔드 구조까지 알아야 소통할 수 있으므로 구조를 알아야한다.
실제로는 프런트에서는 검색 api만 요청하면되지만
백엔드에서는 다르게 돌아감

fetchBoard{search : "오늘"} 이런식으로 백엔드에 보내지면

백엔드에서 db로 mysql방식으로 찾아옴
그럼 데이터베이스에서는 저장소에서 한칸한칸 맞는 값이 있는지 찾아옴 - full scan 이라고 함

작을때야 이런 full scan 해도 문제가 없음
근데 데이터가 너무 커지면 너무 오래걸리니까 데이터 저장을 다르게 할 수 있음

키워드를 중심으로 해당하는 글 번호를 할당해서 저장
이런거를 역인덱스(invuerted index)라고 함
실제로는 만드는게 좀 그러니까 만들어주는 도구들이 있음

이런 방식을 Elastic Search라고 부름
데이터 베이스 종류중엔 디스크기반, 메모리기반 데이터베이스가 따로 있음

디스크는 하드디스크에 저장되는 것을 생각하면 됨, 느리지만, 끈다고 저장이 날라가지 않음

메모리기반(예시로 Redis)은 끄면 날라가지만 빠름
또한 검색 로그 등을 바탕으로(캐싱한다고 함) 디스크 데이터베이스에 가서 가져오지 않고 그냥 가져옴 -> 빠름빠름

프런트엔드에서는 요청하고 받고만 있을 뿐 백엔드에서는 이런 일이 일어나고 있는 것을 알아두자.

그럼 이제 내가 할일인 검색기능을 구현해보자

검색어 결과 표시

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

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

export default function SearchPage() {
  const [mySearch, setMySearch] = useState("");
  const { data } = useQuery<Pick<IQuery, "fetchBoards">, IQueryFetchBoardsArgs>(
    FETCH_BOARDS
  );

  function onChangeSearch(event: ChangeEvent<HTMLInputElement>) {
    setMySearch(event.target.value);
  }

  function onClickSearch() {
    // mySearch키워드로 fetchBoard 요청하기
  }

  return (
    <>
      <h1>검색 페이지!</h1>
      검색어 입력: <input type="text" onChange={onChangeSearch} />
      <button onClick={onClickSearch}>검색</button>
      {data?.fetchBoards.map((el) => (
        <div key={el._id}>
          <span style={{ paddingRight: "50px" }}>{el.writer}</span>
          <span style={{ paddingRight: "50px" }}>{el.title}</span>
          <span style={{ paddingRight: "50px" }}>{el.createdAt}</span>
        </div>
      ))}
    </>
  );
}

왜 h1태그를 씀?
나중에 검색 봇들이 검색할 때 검색 키워드 인식을 중요한 태그들을 중점으로 하기 때문에(h1같은) 가급적이면 사용해 주는 것이 좋다.

여기까지 하고 refecth 를 하면 됨

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

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

export default function SearchPage() {
  const [mySearch, setMySearch] = useState("");
  const { data, refetch } = useQuery<
    Pick<IQuery, "fetchBoards">,
    IQueryFetchBoardsArgs
  >(FETCH_BOARDS);

  function onChangeSearch(event: ChangeEvent<HTMLInputElement>) {
    setMySearch(event.target.value);
  }

  function onClickSearch() {
    refetch({ search: mySearch });
  }

  return (
    <>
      <h1>검색 페이지!</h1>
      검색어 입력: <input type="text" onChange={onChangeSearch} />
      <button onClick={onClickSearch}>검색</button>
      {data?.fetchBoards.map((el) => (
        <div key={el._id}>
          <span style={{ paddingRight: "50px" }}>{el.writer}</span>
          <span style={{ paddingRight: "50px" }}>{el.title}</span>
          <span style={{ paddingRight: "50px" }}>{el.createdAt}</span>
        </div>
      ))}
    </>
  );
}

const {data}부분에 refetch를 추가하고
검색 버튼 기능에 refetch, search:mySearch를 넣으면 ㅅ끗
신기신기

{new Array(10).fill(1).map((_, index)=> (
        <span key={}>{index + 1}</span>

아이디에 index를 넣기 싫으니 id를 넣어주는 라이브러리가 있다.
uuid

https://www.npmjs.com/package/uuid
설치와 타입을 설치한다.

yarn add uuid
yarn add -D @types/uuid

이후 import { v4 as uuidv4 } from 'uuid';

그다음 key부분에 uuidv4()라고 넣으면 키가 다른 10개의 아이디를 할당할 수 있게됨

<span key={uuidv4()}>{index + 1}</span>

간단

또한 검색결과가 백개든 천개들 검색 결과의 1페이지를 보여줘야함
그래서 page: 1, FETCH_BOARDS gql도 알맞게 수정한다.

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

 function onClickSearch() {
    refetch({ search: mySearch, page: 1});
  }

이러면 1페이지만 볼 수 있으니까 페이지 누르는 것에 따라서 페이지 이동을 시켜야함

이렇게 되면 문제가 있음
검색창에 다른 것을 넣게 되면 키워드가 저장이 안되서 다른 검색결과의 다른 페이지를 가져오게됨
그렇게되면 키워드를 만들어서 검색 키워드를 저장해야함

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

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

export default function SearchPagenationPage() {
  const [mySearch, setMySearch] = useState("");
  const [myKeyword, setmyKeyword] = useState(" ");
  const { data, refetch } = useQuery<
    Pick<IQuery, "fetchBoards">,
    IQueryFetchBoardsArgs
  >(FETCH_BOARDS);

  function onChangeSearch(event: ChangeEvent<HTMLInputElement>) {
    setMySearch(event.target.value);
  }

  function onClickSearch() {
    refetch({ search: mySearch, page: 1 });
    setmyKeyword(mySearch);
  }

  function onClickPage(event: MouseEvent<HTMLSpanElement>) {
    if (event.target instanceof Element)
      refetch({ search: myKeyword, page: Number(event?.target.id) });
  }

  return (
    <>
      <h1>검색 페이지!</h1>
      검색어 입력: <input type="text" onChange={onChangeSearch} />
      <button onClick={onClickSearch}>검색</button>
      {data?.fetchBoards.map((el) => (
        <div key={el._id}>
          <span style={{ paddingRight: "50px" }}>{el.writer}</span>
          <span style={{ paddingRight: "50px" }}>{el.title}</span>
          <span style={{ paddingRight: "50px" }}>{el.createdAt}</span>
        </div>
      ))}
      {new Array(10).fill(1).map((_, index) => (
        <span key={uuidv4()} onClick={onClickPage} id={String(index + 1)}>
          {index + 1}
        </span>
      ))}
    </>
  );
}

디바운싱 쓰로틀링

이제 검색버튼을 눌러서 실행되지 않고 그냥 검색기능이 되게 함

디바운싱

입력을 하다가 잠깐 멈췄을 때 (재입력이 없을 때 시간은 설정 가능)
그때 검색기능을 실행시켜줌

지금까지는 검색버튼을 이용해서 event.target.value로 검색어를 받아왔다.
하지만 디바운싱을 이용하면 알아서 넣어줌

쓰로틀링

먼저 실행하고 일정 시간동안 재 실행이 안됨
쓰로틀링은 스크롤에 많이 쓰임 스크롤을 내렸을때 fetchmore을 실행하는데 만약 그 간격을 지정해주지 않으면 무수히 실행됨 이때 쓰로톨링을 지정해줌

실제로 디바운싱을 코드로 구현하려면 setTimeout을 이용하면 구현할 수 있지만
라이브러리를 참고하는게 좋다.

lodash를 이용해봅시다
https://www.npmjs.com/package/lodash

일단 코드변경부터 함
button과 거기 쓰였던 onClickSearch를 주석처리, 비활성화
그럼 onChangeSearch부분에서 바뀌어야함

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

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

export default function SearchPagenationDebouncePage() {
  const [mySearch, setMySearch] = useState("");
  const [myKeyword, setmyKeyword] = useState(" ");
  const { data, refetch } = useQuery<
    Pick<IQuery, "fetchBoards">,
    IQueryFetchBoardsArgs
  >(FETCH_BOARDS);

  function onChangeSearch(event: ChangeEvent<HTMLInputElement>) {
    refetch({ search: event.target.value });
    // setMySearch(event.target.value);
  }

  // function onClickSearch() {
  //   refetch({ search: mySearch, page: 1 });
  //   setmyKeyword(mySearch);
  // }

  function onClickPage(event: MouseEvent<HTMLSpanElement>) {
    if (event.target instanceof Element)
      refetch({ search: myKeyword, page: Number(event?.target.id) });
  }

  return (
    <>
      <h1>검색 페이지!</h1>
      검색어 입력: <input type="text" onChange={onChangeSearch} />
      {/* <button onClick={onClickSearch}>검색</button> */}
      {data?.fetchBoards.map((el) => (
        <div key={el._id}>
          <span style={{ paddingRight: "50px" }}>{el.writer}</span>
          <span style={{ paddingRight: "50px" }}>{el.title}</span>
          <span style={{ paddingRight: "50px" }}>{el.createdAt}</span>
        </div>
      ))}
      {new Array(10).fill(1).map((_, index) => (
        <span key={uuidv4()} onClick={onClickPage} id={String(index + 1)}>
          {index + 1}
        </span>
      ))}
    </>
  );
}

근데 이렇게하면 수많은 graphql 요청이 들어간 것을 볼 수있다.
여기서 그래서 디바운싱을 적용, lodash를 사용한다. ++타입까지 설치한다

yarn add lodash
yarn add -D @types/lodash

이후

import from "lodash"
사용은
언더바로 많이 사용한다

어디에 적용하느냐가 이제 중요하다.

  const getDebounce = _.debounce(() => {


  }, 500)

그리고 onChangeSearch 안에 디바운스로 값을 보내도록한다.클릭 대신 바뀐것
event.target.value가 mySearch가 되었으니 주석처리한다.

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

import _ from "lodash";

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

export default function SearchPagenationDebouncePage() {
  // const [mySearch, setMySearch] = useState("");
  const [myKeyword, setmyKeyword] = useState(" ");
  const { data, refetch } = useQuery<
    Pick<IQuery, "fetchBoards">,
    IQueryFetchBoardsArgs
  >(FETCH_BOARDS);

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

  function onChangeSearch(event: ChangeEvent<HTMLInputElement>) {
    getDebounce(event.target.value);
  }

  // function onClickSearch() {
  //   refetch({ search: mySearch, page: 1 });
  //   setmyKeyword(mySearch);
  // }

  function onClickPage(event: MouseEvent<HTMLSpanElement>) {
    if (event.target instanceof Element)
      refetch({ search: myKeyword, page: Number(event?.target.id) });
  }

  return (
    <>
      <h1>검색 페이지!</h1>
      검색어 입력: <input type="text" onChange={onChangeSearch} />
      {/* <button onClick={onClickSearch}>검색</button> */}
      {data?.fetchBoards.map((el) => (
        <div key={el._id}>
          <span style={{ paddingRight: "50px" }}>{el.writer}</span>
          <span style={{ paddingRight: "50px" }}>{el.title}</span>
          <span style={{ paddingRight: "50px" }}>{el.createdAt}</span>
        </div>
      ))}
      {new Array(10).fill(1).map((_, index) => (
        <span key={uuidv4()} onClick={onClickPage} id={String(index + 1)}>
          {index + 1}
        </span>
      ))}
    </>
  );
}

이러면 검색창에 입력하다가 손을 떼는 순간 입력된다 오호오

그럼 검색결과 강조기능을 해봅시다

"오늘 점심 맛있어요".split(" ")
빈칸을 기준으로 split을 하면
['오늘', '점심', '맛있어요'] 라고 나온다
이러면 근데 비효율적이니까
"오늘 점심 맛있어요".replaceALL("오늘", "!@#!#!#!") 우리만 아는 키워드로 바꾸면
"!@#!#!#! 점심 맛있어요" 로바뀌게 된다
이 결과로 split 을 하면

"!@#!#!#! 점심 맛있어요".split("!@#!#!#!")
['', '오늘', '점심 맛있어요'] 이렇게 나온다

그럼 이거 두개를 합친다. 바로적용

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

import _ from "lodash";

interface Iprops {
  isMatched: boolean;
}
const MyWord = styled.span`
  color: ${(props: Iprops) => (props.isMatched ? "red" : "black")};
`;

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

export default function SearchKeywordPage() {
  // const [mySearch, setMySearch] = useState("");
  const [myKeyword, setmyKeyword] = useState(" ");
  const { data, refetch } = useQuery<
    Pick<IQuery, "fetchBoards">,
    IQueryFetchBoardsArgs
  >(FETCH_BOARDS);

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

  function onChangeSearch(event: ChangeEvent<HTMLInputElement>) {
    getDebounce(event.target.value);
  }

  // function onClickSearch() {
  //   refetch({ search: mySearch, page: 1 });
  //   setmyKeyword(mySearch);
  // }

  function onClickPage(event: MouseEvent<HTMLSpanElement>) {
    if (event.target instanceof Element)
      refetch({ search: myKeyword, page: Number(event?.target.id) });
  }

  return (
    <>
      <h1>검색 페이지!</h1>
      검색어 입력: <input type="text" onChange={onChangeSearch} />
      {/* <button onClick={onClickSearch}>검색</button> */}
      {data?.fetchBoards.map((el) => (
        <div key={el._id}>
          <span style={{ paddingRight: "50px" }}>{el.writer}</span>
          <span style={{ paddingRight: "50px" }}>
            {el.title
              .replaceAll(myKeyword, `#$%${myKeyword}#$%`)
              .split("#$%")
              .map((el) => (
                <MyWord key={uuidv4()} isMatched={myKeyword === el}>
                  {el}
                </MyWord>
              ))}
          </span>
          <span style={{ paddingRight: "50px" }}>{el.createdAt}</span>
        </div>
      ))}
      {new Array(10).fill(1).map((_, index) => (
        <span key={uuidv4()} onClick={onClickPage} id={String(index + 1)}>
          {index + 1}
        </span>
      ))}
    </>
  );
}

이런식으로 된다 짜잔

오늘 이미지 업로드 기능을 분리하면서 컴포넌트를 분리했고 그 과정에서 어려웠다.
근데 실무에서도 이렇게 쓰니까 분리하는 것을 익히는 것이 좋을 것이다. 😦

이렇게 자유게시판 만들기를 위함 교육은 끝났다.
이후 새로운 게시판기능을 만들면서 로그인이나 결제기능을 구현하면서 배워보자.

profile
개발자 새싹🌱 The only constant is change.

0개의 댓글