[React] 게시물 검색 기능 만들기

박기영·2023년 2월 26일
3

React

목록 보기
27/32

게시판에서 검색 기능을 구현하고자 예시들을 알아보니, filter를 이용해 검색어가 포함된 것들만 남겨놓고, 그 것을 하위 컴포넌트에 map으로 뿌려주는 예시들이 많았다.
필자는 검색어를 입력하고 submit이 진행되었을 때만 필터링된 게시물이 보이게 하고 싶었다.
그래서 두 방법을 모두 구현해보고 기록해놓고자 한다.
프론트엔드 부분만 다루고 싶어서 백엔드 쪽 설명은 추가하지 않았다.

submit 될 때만 검색

방법

필자는 react-router-dom을 활용하여 이 기능을 구현했다.
사용된 메서드는 useLocation, useSearchParams 두 가지이다.

  1. 검색바(bar)에서 useSearchParams를 이용하여 URL에 쿼리 스트링을 전달한다.
  2. 게시물을 fetch하는 컴포넌트에서 useLocation을 사용해 쿼리 스트링을 가져온다.
  3. 쿼리 스트링 값으로 API 통신을 진행하여 filter된 게시글을 fetch한다.

컴포넌트 구조

// LecturePage.tsx

// ... //

function LecturePage() {
  
  // ... //
  
  return (
    <Layout>
      <div className="flex flex-col items-center px-2 mt-4 md:px-4 lg:px-10">
        // 검색어 입력 컴포넌트
        <SearchBar placeholder="강의 제목을 검색해보세요" purpose="lecture" />

        // 검색된 게시물이 없을 경우 보여줄 컴포넌트
        {lectureList?.length === 0 && <EmptyPostAlarm />}

        // 검색된 게시물을 보여주는 컴포넌트
        {lectureList?.length !== 0 && <Table dataList={lectureList} />}
      </div>
    </Layout>
  );
}

export default LecturePage;

검색어 입력 컴포넌트

// SearchBar.tsx

import React, { useState } from "react";
import { BsSearch } from "react-icons/bs";
import { useNavigate, useSearchParams } from "react-router-dom";

interface SearchBarPropsType {
  placeholder: string;
  purpose: string;
}

function SearchBar(props: SearchBarPropsType) {
  const [searchKeyWord, setSearchKeyWord] = useState("");

  // useSearchParams는 URL에 쿼리 스트링을 입력해준다.
  const [searchParams, setSearchParams] = useSearchParams();
  
  const navigate = useNavigate();

  const onChangeHandler = (e: React.ChangeEvent<HTMLInputElement>) => {
    setSearchKeyWord(e.target.value);
  };

  const searchSubmitHandler = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();

    // 검색 키워드가 존재하는 경우에만 setState를 진행한다.
    if (!!searchKeyWord) {
      setSearchParams({
        keyword: searchKeyWord,
      });
    } else {
      // 검색 키워드가 존재하지 않는 경우, 쿼리 스트링이 없는 원래 URL을 보여주도록 navigate 처리한다.
      navigate(`${props.purpose === "lecture" ? "/lecture" : "/qa"}`);
    }
  };

  return (
    <form className="relative my-4" onSubmit={searchSubmitHandler}>
      <input
        placeholder={props.placeholder}
        value={searchKeyWord}
        onChange={onChangeHandler}
        className="pl-2 mb-2 font-semibold w-[250px] h-[40px] border-2 border-[#ffcdd2] rounded focus:border-[#e57373] focus:outline-none sm:w-[400px] md:w-[500px] lg:w-[500px] lg:h-[50px]"
      />

      <button
        type="submit"
        className="absolute top-3 right-3 md:text-lg lg:top-4"
      >
        <BsSearch />
      </button>
    </form>
  );
}

export default SearchBar;

useSearchParams을 활용하면 URL에 쿼리 스트링을 입력할 수 있다.

const [searchParams, setSearchParams] = useSearchParams();

setSearchParams에 객체를 넣어줄 것이다.
key-value의 형태로 입력하며,

setSearchParams({
  keyword: searchKeyWord,
});

위 예시는 URL을 다음과 같이 변경시킨다.
검색바에 hi를 입력해보았다.

setSearchParams 이전
http://localhost:3000/lecture

setSearchParams 이후
http://localhost:3000/lecture?keyword=hi

검색어가 없는 경우

검색바에 아무 입력도 하지않은 채로 submit만 했을 때의 URI는 다음과 같다.

http://localhost:3000/lecture?keyword=

검색어가 없기 때문에 아무 의미도 없는 쿼리 스트링이 들어있는 것을 볼 수 있다.

필자는 이런 상황을 원치 않았고, 검색어가 없다면 전체 게시물을 보여주도록 해줬다.

navigate(`${props.purpose === "lecture" ? "/lecture" : "/qa"}`);

useNavigate를 활용하여 쿼리 스트링이 없는 URL로 이동시키는 것으로 이를 구현했다.
쿼리 스트링이 없는 URL로 이동되는 것으로 인해 useEffect가 작동하고,
전체 게시글을 fetch하는 함수가 실행되는 것이다.

여기서, 왜 굳이 navigate를 해줘야하는지 의문을 품을 수 있다.
"그냥 if문까지만 작성하고 else 부분은 없애도 큰 문제 없지않나?" 하고 말이다.
만약, 검색어가 존재하는 경우에 대해서만 처리를 하면,
검색어 입력 후 filter된 게시물에서 다시 원래 게시물(전체 게시물)을 볼 방법이 없기 때문이다.

아래 영상을 보자. else 부분의 분기 처리를 삭제한 경우에 대한 예시이다.

"안녕" -> "aa" -> ""(아무런 검색어도 없는 상황)

위 순서로 검색어를 입력했다.

참고 동영상

빈 검색어를 아무리 submit을 해도 전체 게시물이 보이지 않는다.
당연하게도 빈 문자열을 가지고 API 통신을 하니, filter되는 게시물이 없는 것이다.

이 문제를 해결하기 위해서 검색어가 있는 경우와 없는 경우를 분기 처리하고,
없는 경우에는 navigate를 통해 페이지 이동 후, 전체 게시물 fetch를 진행하는 것이다.

검색 기능, 데이터 fetch를 어떤 방식으로 구현하느냐에 따라 사람마다 전혀 다른 방식이 될 수 있으므로,
필자는 이렇게 했구나~ 정도로 봐주시면 감사하겠습니다!
데이터 fetch에 대한 코드는 바로 다음에 살펴볼 부모 컴포넌트에 작성되어 있습니다.

이제 검색어를 쿼리 스트링으로 URL에 전달하는 방법을 알았으니, 이를 활용하여 fetch를 진행해보자.

게시물을 fetch하는 컴포넌트(부모 컴포넌트)

// LecturePage.tsx

import React, { useContext, useEffect, useState } from "react";
import { Link, useLocation } from "react-router-dom";

import Layout from "../../layout/Layout";
import Table from "../../components/Table";
import { useHttpClient } from "../../hoc/http-hook";
import { AuthContext } from "../../context/auth-context";
import SearchBar from "../../components/SearchBar";
import FetchLoadingSpinner from "../../shared/FetchLoadingSpinner";
import EmptyPostAlarm from "../../components/post/EmptyPostAlarm";

interface lectureListType {
  _id: string | undefined;
  title: string | undefined;
  date: string | undefined;
  like: number | undefined;
  see: number | undefined;
  comments: Array<any> | undefined;
}

function LecturePage() {
  const auth = useContext(AuthContext);
  const { isLoading, sendRequest } = useHttpClient();

  const [lectureList, setLectureList] = useState<lectureListType[]>();

  // useLocation을 활용하여 쿼리 스트링 값을 가져온다.
  const location = useLocation();

  useEffect(() => {
    // search 속성에 접근하면 쿼리 스트링 값을 얻을 수 있다.
    const keyWord = decodeURI(location.search);

    if (!!keyWord) {
      // 검색어가 존재하는 경우에 API 경로에 쿼리 스트링으로 전달하여 fetch한다.
      const fetchLectures = async () => {
        try {
          const responseData = await sendRequest(
            `${process.env.REACT_APP_BASE_URL}/lecture/search/input${keyWord}`
          );

          setLectureList(responseData.searchedLectures.reverse());
        } catch (err) {}
      };

      fetchLectures();
    } else {
      // 검색어가 없는 경우 전체 게시물을 fetch한다.
      const fetchLectures = async () => {
        try {
          const responseData = await sendRequest(
            `${process.env.REACT_APP_BASE_URL}/lecture`
          );

          setLectureList(responseData.lectures.reverse());
        } catch (err) {}
      };

      fetchLectures();
    }
  }, [location]);

  return (
    <Layout>
      {isLoading && <FetchLoadingSpinner />}

      <div className="flex flex-col items-center px-2 mt-4 md:px-4 lg:px-10">
        <div className="w-full border-b-2 mb-2 pb-1 border-[#ffa4a2] flex justify-between items-center">
          <h1 className="text-xl font-bold sm:text-2xl md:text-3xl">강의</h1>

          {auth.manager ? (
            <Link
              to="/lecture/write"
              className="px-2 rounded border-2 border-[#ffcdd2] hover:bg-[#ffcdd2] hover:text-white hover:font-semibold hover:cursor-pointer"
            >
              강의 올리기
            </Link>
          ) : null}
        </div>

        <SearchBar placeholder="강의 제목을 검색해보세요" purpose="lecture" />

        {isLoading && <div>강의 불러오는 중</div>}

        {lectureList?.length === 0 && <EmptyPostAlarm />}

        {lectureList?.length !== 0 && <Table dataList={lectureList} />}
      </div>
    </Layout>
  );
}

export default LecturePage;

우선 useLocation이 어떤 정보를 담고 있는지부터 살펴보자.

참고 이미지

검색바 컴포넌트에서 useSearchParams를 통해 URL에 입력한 쿼리 스트링이
search 속성에 담겨 있는 것을 확인 할 수 있다.

const keyWord = decodeURI(location.search);

그런데 decodeURI라는 것이 보인다. 이게 뭘까?
이 작업을 해주는 이유는 검색어로 한국어를 입력해보면 알 수 있다.

decodeURI

다음은 "한글"이라는 검색어를 입력했을 때의 useLocation이 보여주는 값이다.

참고 이미지

쿼리 스트링이 깨지는 현상이 발생한다.
이는 인코딩 과정에서 한글을 고려하지 않은 방법이 사용되기 때문인데, 자세한 설명은 넘어가겠다.
(OS 별 인코딩 방식 차이에서 발생하는 문제로 알고 있다. 필자는 Mac OS이다.)

아무튼, 이를 해결하기 위해서 decodeURI를 사용한다.
decodeURI에는 string 타입만 입력이 가능하기 때문에, location.search를 넣어준다.

디코딩한 결과는 다음과 같다.

참고 이미지

짠! 한글이 깨지지 않고 잘 나오는 것을 확인 할 수 있다.
이제 쿼리 스트링을 온전하게 얻어낼 수 있으니, 이를 API 경로에 넣어서 전달해주면 된다.

API 통신 후 받아온 데이터는 게시물 statesetState 해주면 끝이다.

검색된 게시물이 없는 경우

검색된 게시물이 없어서 게시물 state가 빈 배열인 경우에는
이를 유저에게 알려줄 컴포넌트(필자의 코드에서는 EmptyPostAlarm)를 보여주도록 하자.

아래는 사용 예시이다.

참고 이미지

검색어 입력과 동시에 검색 진행

방법

filter 메서드를 사용하여 구현한다.

  1. 데이터를 fetch한다.
  2. 원본 데이터를 담을 state1과 필터링 된 데이터를 담을 state2에 1에서 얻은 데이터를 저장한다.
  3. 검색바(bar)에 입력한 값을 filter 함수에 전달한다.
  4. state1filter하여 state2에 저장한다.
  5. state2를 보여준다.

여기서 중요한 점은 원본 데이터를 담고 있는 state1은 변경을 해서는 안된다는 것이다.
이유는 차차 살펴보자.

컴포넌트 구조

// LikeLecturesPage.tsx

// ... //

function LikeLecturesPage() {

  // ... //

  return (
    <Layout>
      <div className="px-2 mt-4 md:px-8 md:pt-8 lg:px-12 lg:pt-12 xl:px-32 xl:pt-20">
        // 검색어 입력 컴포넌트
        <MySearchBar purpose="lecture" filtering={filteringLikedLecture} />

        // 검색된 게시물을 보여주는 컴포넌트
        {filteredLikedLecture.length !== 0 && (
          <MyPostList
            data={filteredLikedLecture}
            deleteHandler={deleteHandler}
            purpose="lecture"
          />
        )}

        // 검색된 게시물이 없을 경우 보여줄 컴포넌트
        {filteredLikedLecture.length === 0 && <EmptyPostAlarm />}
      </div>
    </Layout>
  );
}

export default LikeLecturesPage;

검색어 입력 컴포넌트

// MySearchBar.tsx

import React, { useState } from "react";

interface SearchBarPropsType {
  purpose: string;
  filtering: (keyword: string) => void;
}

function MySearchBar(props: SearchBarPropsType) {
  const [searchKeyWord, setSearchKeyWord] = useState("");

  const onChangeHandler = (e: React.ChangeEvent<HTMLInputElement>) => {
    setSearchKeyWord(e.target.value);

    // 부모 컴포넌트에서 생성한 필터링 함수에 검색어를 전달한다.
    props.filtering(e.target.value);
  };

  return (
    <div className="flex justify-center my-4">
      <input
        placeholder="키워드를 입력해주세요."
        value={searchKeyWord}
        onChange={onChangeHandler}
        className="pl-2 mb-2 font-semibold w-[250px] h-[40px] border-2 border-[#ffcdd2] rounded focus:border-[#e57373] focus:outline-none sm:w-[400px] md:w-[500px] lg:w-[500px] lg:h-[50px]"
      />
    </div>
  );
}

export default MySearchBar;

앞서 구현했었던 submit 발생 시 검색이 되던 때와 다르게, 컴포넌트가 굉장히 간단해졌다.
이유는 fetch로 보여줄 데이터를 가져오는게 아니라,
기존에 있던 데이터에서 filter를 사용해 보여줄 것만 추려내기 때문이다.

앞서 살펴본 컴포넌트 구조 상, 부모 컴포넌트에서 데이터를 받아오고 뿌려주기 때문에
여기서는 부모 컴포넌트에 작성해놓은 필터링 함수에 검색어만 전달하면 된다.

검색된 게시물을 관리하는 컴포넌트(부모 컴포넌트)

// LikeLecturesPage.tsx

import React, { useContext, useEffect, useState } from "react";
import MySearchBar from "../../components/MySearchBar";
import EmptyPostAlarm from "../../components/post/EmptyPostAlarm";

import { AuthContext } from "../../context/auth-context";
import { useHttpClient } from "../../hoc/http-hook";
import Layout from "../../layout/Layout";
import MyPostList from "./MyPostList";

function LikeLecturesPage() {
  const auth = useContext(AuthContext);
  const { sendRequest } = useHttpClient();

  // 원본 데이터를 담을 state
  const [likedLecture, setLikedLecture] = useState<any[]>([]);
  
  // 필터링 함수를 거친 데이터를 담을 state
  const [filteredLikedLecture, setFilteredLikedLecture] = useState<any[]>([]);

  // 첫 렌더링 시 전체 데이터를 가져온다.
  useEffect(() => {
    const loadLikeLecture = async () => {
      try {
        const responseData = await sendRequest(
          `${process.env.REACT_APP_BASE_URL}/users/likeLecture`,
          "GET",
          null,
          {
            Authorization: "Bearer " + auth.token,
          }
        );

        // 가져온 데이터를 원본 데이터 state에 저장한다.
        setLikedLecture(responseData.likedLecture);
        
        // 가져온 데이터를 필터링 데이터 state에 저장한다.
        setFilteredLikedLecture(responseData.likedLecture);
      } catch (err) {}
    };

    loadLikeLecture();
  }, []);

  const deleteHandler = (id: string) => {
    const deletedFromLikedLecture = likedLecture.filter(
      (lecture: any) => lecture.id !== id
    );

    const deletedFromFilteredLikedLecture = filteredLikedLecture.filter(
      (lecture: any) => lecture.id !== id
    );

    setLikedLecture(deletedFromLikedLecture);
    setFilteredLikedLecture(deletedFromFilteredLikedLecture);
  };

  // 필터링 함수. 검색어를 사용하여 보여줄 데이터를 추려낸다.
  const filteringLikedLecture = (keyword: string) => {
    const filterdData = likedLecture.filter((item: any) =>
      item.title.includes(keyword)
    );

    // 필터링된 데이터를 state에 저장한다.
    setFilteredLikedLecture(filterdData);
  };

  return (
    <Layout>
      <div className="px-2 mt-4 md:px-8 md:pt-8 lg:px-12 lg:pt-12 xl:px-32 xl:pt-20">
        <h1 className="font-bold text-xl border-b-2 border-[#ffa4a2] sm:text-2xl md:text-3xl">
          좋아요 표시한 강의
        </h1>

        // 검색어 입력 컴포넌트
        <MySearchBar purpose="lecture" filtering={filteringLikedLecture} />

        // 전체 데이터 혹은 검색 결과를 보여줄 컴포넌트
        {filteredLikedLecture.length !== 0 && (
          <MyPostList
            data={filteredLikedLecture}
            deleteHandler={deleteHandler}
            purpose="lecture"
          />
        )}

        // 검색 결과가 없을 경우 보여줄 컴포넌트
        {filteredLikedLecture.length === 0 && <EmptyPostAlarm />}
      </div>
    </Layout>
  );
}

export default LikeLecturesPage;

여기서 핵심은 세 가지로 볼 수 있겠다.

  1. 원본 데이터를 담는 state
  2. 필터링된 데이터를 담는 state
  3. 필터링 함수

1번 데이터는 절대로 변경되어서는 안된다. 왜냐?
검색어를 입력했다가 지우게 되면, 분명히 "어떠한 데이터"를 filter하게 되는데,
만약, 원본 데이터를 담은 1번 데이터를 만들어 두지 않고, 2번 데이터만 활용하게 되면
filter된 데이터에 또 다시 filter를 적용하게 된다.

이게 어떤 상황인지 예를 들어보겠다.
5개의 데이터에서 검색어 입력을 통해 3개의 데이터가 남았다면,
다음 검색어 입력에서는 3개의 데이터에서 filter를 진행하게 되는 것이다!
이는 명백한 기능 오류이다.

따라서, 검색어가 없는 경우(혹은 검색어를 지운 경우)에 대해 보여줄 데이터가 필요하다.
이 역할을 1번 데이터가 담당한다.

2번 데이터는 검색어에 변화에 맞춰 유동적으로 변경되는 데이터이다.
필터링된 데이터를 담는 것이기 때문에, 원본 데이터를 담을 필요가 없어보이지만,
useEffect 내에서 첫 렌더링 시 받아온 데이터를 저장하게끔 설정되어 있다.
이는 첫 렌더링 시, 검색어가 없기 때문에 원본 데이터를 보여주기 위함이다.

만약, 1번 데이터에만 fetch된 데이터를 저장하게 된다면,
받아온 데이터를 보여줄 컴포넌트를 또 하나 생성해야한다.
즉, 위 코드에서 확인 가능한 MyPostList 컴포넌트를 하나 더 만들어둬야한다는 것이다.
왜냐?
필터링이 진행되기 전이므로, 2번 데이터에는 아무 것도 없고,
보여줄 수 있는 데이터가 존재하지 않기 때문이다.
즉, 원본 데이터와 필터링 후 데이터를 보여줄 컴포넌트를 따로 관리하게 되버린다.
이는 비효율적이라고 생각했기 때문에 fetch된 데이터를 2번 데이터에도 저장해줬다.

이제 핵심 중 핵심인 필터링 함수를 보자.

const filteringLikedLecture = (keyword: string) => {
    // 1번 데이터에 대하여 filter를 진행한다. filter는 원본 배열을 변경시키지 않는다.
    const filterdData = likedLecture.filter((item: any) =>
      item.title.includes(keyword)
    );

    // 필터링된 데이터를 state에 저장한다.
    setFilteredLikedLecture(filterdData);
  };

절대로 변경해서는 안되는 1번 데이터에 filter를 적용하여 검색어에 일치하는 데이터만 남기고
이를 2번 데이터에 저장해준다.
2번 데이터는 MyPostList 컴포넌트에서 map을 통해 유저에게 보여지게 된다.

검색어를 전부 지우게 되면 어떻게 될까?
검색어를 전부 지운다는 것은 검색어가 빈 문자열 ""라는 것을 의미한다.
fetch로 받아온 데이터에는 검색어의 비교 대상이 될 문자열이 존재할텐데,
어떠한 문자열이든 빈 문자열("")을 포함하고 있기 때문에
모든 데이터(원본 데이터. 즉, 1번 데이터)가 2번 데이터에 저장되게 된다.
따라서, 첫 렌더링 후, 모든 데이터를 담고 있던 상태로 돌아가게 된다는 것이다.
(정확히는 참조값이 다른 배열일텐데, 담고있는 데이터는 같으니까 돌아간다는 표현을 사용했다.)

아래 동영상은 두 개의 데이터를 가지고 검색 기능을 실험한 영상이다.

참고 동영상

영어 검색어에 대하여

필자가 현재 진행하고 있는 프로젝트는 일본인에게 한국어를 알려주는 교육용 커뮤니티 제작이다.
따라서, 영어를 쓸 일이 없다. 그러나 일반적인 경우라면 영어가 들어가는 게시물이 꽤 있을 것 이다.
참고 자료에 첨부된 다른 분들의 경험에서는
영어로 된 데이터를 검색하기 위해서 toLowerCase(), toUpperCase()를 사용해서
검색어와 데이터의 문자열을 소문자 혹은 대문자로 통일해서 비교를 해줬다.
includes()가 대문자와 소문자를 구분해서 동작하기 때문이다.
만약, 영어가 포함된 검색 기능을 구현해야한다면 이를 고려해서 작업하자.

참고 자료

seoyul0203님 블로그
sso-feeling님 블로그
dev-bomdong님 블로그
sunnyfterrain님 블로그
ki226님 블로그

profile
나를 믿는 사람들을, 실망시키지 않도록

0개의 댓글