게시판에서 검색 기능을 구현하고자 예시들을 알아보니,
filter
를 이용해 검색어가 포함된 것들만 남겨놓고, 그 것을 하위 컴포넌트에map
으로 뿌려주는 예시들이 많았다.
필자는 검색어를 입력하고submit
이 진행되었을 때만 필터링된 게시물이 보이게 하고 싶었다.
그래서 두 방법을 모두 구현해보고 기록해놓고자 한다.
프론트엔드 부분만 다루고 싶어서 백엔드 쪽 설명은 추가하지 않았다.
필자는 react-router-dom
을 활용하여 이 기능을 구현했다.
사용된 메서드는 useLocation
, useSearchParams
두 가지이다.
useSearchParams
를 이용하여 URL에 쿼리 스트링을 전달한다.fetch
하는 컴포넌트에서 useLocation
을 사용해 쿼리 스트링을 가져온다.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
를 진행해보자.
// 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
라는 것이 보인다. 이게 뭘까?
이 작업을 해주는 이유는 검색어로 한국어를 입력해보면 알 수 있다.
다음은 "한글"이라는 검색어를 입력했을 때의 useLocation
이 보여주는 값이다.
쿼리 스트링이 깨지는 현상이 발생한다.
이는 인코딩 과정에서 한글을 고려하지 않은 방법이 사용되기 때문인데, 자세한 설명은 넘어가겠다.
(OS 별 인코딩 방식 차이에서 발생하는 문제로 알고 있다. 필자는 Mac OS이다.)
아무튼, 이를 해결하기 위해서 decodeURI
를 사용한다.
decodeURI
에는 string
타입만 입력이 가능하기 때문에, location.search
를 넣어준다.
디코딩한 결과는 다음과 같다.
짠! 한글이 깨지지 않고 잘 나오는 것을 확인 할 수 있다.
이제 쿼리 스트링을 온전하게 얻어낼 수 있으니, 이를 API 경로에 넣어서 전달해주면 된다.
API 통신 후 받아온 데이터는 게시물 state
에 setState
해주면 끝이다.
검색된 게시물이 없어서 게시물 state
가 빈 배열인 경우에는
이를 유저에게 알려줄 컴포넌트(필자의 코드에서는 EmptyPostAlarm
)를 보여주도록 하자.
아래는 사용 예시이다.
filter
메서드를 사용하여 구현한다.
fetch
한다.state1
과 필터링 된 데이터를 담을 state2
에 1에서 얻은 데이터를 저장한다.filter
함수에 전달한다.state1
을 filter
하여 state2
에 저장한다.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;
여기서 핵심은 세 가지로 볼 수 있겠다.
state
state
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님 블로그