블로그에 있는 많은 포스트중에 원하는 포스팅을 찾기 위한 필터를 어떻게 만들면 좋을까요?
필터링 조건을 상태로 관리하기?
가장 간단한 방법은 state
와 핸들러를 사용해 현재 찾고자 하는 포스트에 대한 검색어나 태그, 시리즈 등을 상태로 관리해 걸러내는 방식이죠. 구현하기도 쉽고 구조도 단순해 유지 보수하기도 편합니다.
하지만 이러한 구현에는 몇 가지 단점이 있습니다.
클라이언트 상태의 단점
- 이전의 필터 상태를 기억하지 못한다.
사용자가 필터를 통해 특정 태그가 포함된 포스트를 모아서 읽기로 했다고 치겠습니다. 만약 필터로 걸러낸 포스트 중 하나를 읽고 다시 되돌아왔을 때, 해당 페이지는 이전의 필터 값을 기억하지 못합니다.
이를 해결하기 위해서 상태를 전역으로 관리한다고 해도 새로고침이 발생하면 다시 초기화 되기 마련이죠.
- 서버 컴포넌트로 상태를 공유하지 못한다.
Next.js의 서버 컴포넌트는 핸들러와 상태를 사용할 수 없습니다. 서버에서 컴포넌트를 미리 만들어 클라이언트로 직렬화 된 컴포넌트 데이터만을 넘겨주기 때문에 핸들러가 동작할 여지가 없기 때문이죠.
컴포넌트 구조와 해결 방안
그럼 이제 제 블로그 목록 페이지 구조를 한번 보도록 하겠습니다.
<section>
<CoverImage coverData={coverData} />
<Inner className="flex flex-col lg:flex-row gap-5 z-20">
<Filter tags={allTags} series={allSeries} />
<Seperator className="w-full border-b my-2 lg:hidden" />
<PostLists posts={filteredPosts} />
</Inner>
</section>
최상단에 블로그 포스트 목록 페이지가 있고 하위 자식 컴포넌트로 Filter
와 PostLists
컴포넌트가 함께 있습니다.
Filter
에서 선택한 조건을 부모 요소에서 state
로 받아 데이터를 걸러내 PostLists
로 넘기면 될까요?
물론 두 컴포넌트를 포함하는 클라이언트 컨테이너를 하나 만들어서 구성할 수는 있습니다. 하지만 위의 1번 단점 항목은 해결 할 수 없죠.
저는 이 부분을 URL의 쿼리를 활용하기로 하였습니다.
쿼리 파라미터 핸들러 만들기
Filter
의 필터 조건에는 시간순 정렬, 시리즈 선택, 키워드 검색, 태그 다중 선택 이 있습니다.
먼저 각각 선택된 필터 조건을 독립적으로 쿼리에 담기 위해 params
를 다음과 같이 추출합니다.
const searchParams = useSearchParams();
const params = new URLSearchParams(searchParams.toString());
URLSearchParams 전역 클래스를 통해 만들어진 params
객체는 Map
자료구조와 비슷하게 동작합니다. 이를 활용하여 필요한 쿼리 파라미터를 추가하거나 제거하는 등의 조작을 할 수 있습니다.
sortHandler
먼저 시간순 정렬 핸들러인 sortHandler
를 만들어 보겠습니다.
const sortHandler = (value: string) => {
params.set("sort", value);
router.push(`?${params.toString()}`);
};
<Select onValueChange={sortHandler}>
<SelectTrigger>
<SelectValue placeholder="정렬" />
</SelectTrigger>
<SelectContent>
<SelectItem value="latest">최신순</SelectItem>
<SelectItem value="oldest">오래된순</SelectItem>
</SelectContent>
</Select>;
구조가 굉장히 간단하죠? set
메서드를 사용하면 sort라는 쿼리 키에 선택한 value
를 담을 수 있습니다. 최신순을 골랐다면 latest를, 오래된순을 골랐다면 oldest를 추가하게 되죠. 최종적으로 useRouter
훅의 push
로 해당 쿼리 파라미터의 url로 이동하도록 합니다.
정렬 조건을 URL에 전달
seriesHandler
그럼 다음으로 시리즈 핸들러인 seriesHandler
를 만들어 보겠습니다.
const seriesHandler = (value: string) => {
if (value === "시리즈 전체") {
params.delete("series");
} else {
params.set("series", value);
}
router.push(`?${params.toString()}`);
};
<Select onValueChange={seriesHandler} value={params.get("series") || undefined}>
<SelectTrigger>
<SelectValue placeholder="시리즈" />
</SelectTrigger>
<SelectContent>
{["시리즈 전체", ...series].map((s) => (
<SelectItem key={s} value={s}>
{s}
</SelectItem>
))}
</SelectContent>
</Select>;
위의 sortHandler
와 대부분 비슷하지만 시리즈 전체에 해당하는 셀렉터를 선택하면 쿼리 파라미터에서 series
라는 쿼리 키를 제거하도록 했습니다.
시리즈 조건을 URL에 전달
tagHandler
다음은 태그를 선택할 수 있는 tagHandler
를 만들어 보겠습니다.
const tagHandler = (tag: string) => {
const currentTags = params.getAll("tag");
if (currentTags.includes(tag)) {
const newTags = currentTags.filter((currentTag) => currentTag !== tag);
params.delete("tag");
newTags.forEach((newTag) => params.append("tag", newTag));
} else params.append("tag", tag);
router.push(`?${params.toString()}`);
};
<div className="flex gap-1 flex-wrap">
{tags.map((tag) => (
<TagSelector
key={tag}
tagName={tag}
className={params.getAll("tag").includes(tag) ? "bg-primary/30" : ""}
tagEvent={() => tagHandler(tag)}
/>
))}
</div>;
이전보다 훨씬 복잡하죠? 하지만 잘 들여다 보면 간단합니다.
먼저 TagSelector
컴포넌트는 각각의 태그를 선택/해제 할 수 있는 토글 형태의 요소입니다. 즉, 해당 태그가 쿼리에 포함되어 있으면 제거하고, 포함되어있지 않으면 추가하도록 하면 되겠죠.
또한 여러개의 태그를 선택할 수 있도록 하기 위해 선택된 모든 태그를 tag
라는 이름의 동일한 쿼리 키로 관리하기로 하였습니다.
먼저 getAll
메서드로 tag
쿼리 키를 가진 모든 쿼리를 배열로 불러옵니다. 조건에 해당하는 요소를 모두 불러올 때 사용하는 querySelectorAll
과 유사하다고 생각하면 쉽습니다.
if (currentTags.includes(tag)) {
const newTags = currentTags.filter((currentTag) => currentTag !== tag);
params.delete("tag");
newTags.forEach((newTag) => params.append("tag", newTag));
} else params.append("tag", tag);
그리고 이 로직을 하나하나 살펴볼까요?
방금 불러온 모든 currentTags
중에 핸들러 이벤트로 동작시킨 현재의 tag가 포함이 되어있다면 currentTags
배열에서 해당 tag를 제거한 새 배열을 만듭니다.
그리고 기존 쿼리 파라미터에서 모든 tag
들을 지우고 새 배열을 순회하며 append
메서드를 사용해 tag
들을 쿼리 파라미터에 추가합니다.
만약 currentTags
에 현재 tag가 포함되어있지 않다면 append
로 추가합니다.
태그 조건을 URL에 전달
searchHandler
마지막으로 검색 핸들러인 searchHandler
를 만들어 보겠습니다.
const [search, setSearch] = useState("");
const { debouncedSearch } = useDebounce(search, 250);
useEffect(() => {
if (debouncedSearch) {
params.set("search", debouncedSearch);
} else {
params.delete("search");
}
router.push(`?${params.toString()}`);
}, [debouncedSearch]);
const searchHandler = (e: ChangeEvent<HTMLInputElement>) => {
setSearch(e.target.value);
};
<Input
type="text"
value={search}
onChange={searchHandler}
placeholder="키워드 검색"
/>;
쿼리 파라미터를 수정하는 부분은 seriesHandler
와 별반 다르지 않습니다. 하지만 useDebounce
라는 특이한 훅이 적용되어 있죠. 이건 뭘까요?
debounce란?
input에 텍스트를 입력하게 되면 모든 텍스트의 변화마다 상태값이 변화하게 됩니다. 즉, 쿼리 파라미터의 값이 매 순간 변화하게 되죠. 매 타이핑 마다 url이 변화한다는 것은 페이지가 계속해서 변하고 있고 매번 페이지를 렌더링 한다는 뜻입니다. 성능에 정말 좋지 못하겠죠.
이를 해결하고자 검색어 입력이 일정 시간동안 멈추면 그 때까지 입력된 내용을 한번에 반영하도록 하는 디바운스를 적용한 커스텀 훅을 만들어 적용하였습니다.
import { useEffect, useState } from "react";
const useDebounce = (search: string, delay: number) => {
const [debouncedSearch, setDebouncedSearch] = useState(search);
useEffect(() => {
const id = setTimeout(() => {
setDebouncedSearch(search);
}, delay);
return () => {
clearTimeout(id);
};
}, [search]);
return { debouncedSearch };
};
export default useDebounce;
코드는 간단합니다. search
의 값이 변화할 때마다 useEffect
의 콜백 함수가 새로 실행되면서 클린 업 함수로 이전 콜백 함수의 타임아웃을 초기화 시킵니다. delay
만큼의 시간이 흐를 만큼 search
의 값이 변화하지 않으면, 그제서야 search
의 값을 debouncedSearch
로 넘겨주게 되죠.
이걸 활용하면 타이핑을 250ms 만큼 멈추게 되면 그 전까지 입력한 내용이 쿼리 파라미터로 전달되도록 할 수 있습니다.
검색어 입력을 통한 필터 조건 전달
데이터 필터링하기
그럼 이렇게 Filter
에서 선택한 조건을 URL의 쿼리에 넘겨주었다면, 이제 포스트 목록에서 이 조건을 반영해 데이터를 필터링해야겠죠?
const Page = async ({ searchParams }: Props) => {
const { series, tag: tags, search, sort } = searchParams;
const filteredPosts = await filterPosts({ series, tags, search, sort });
<PostLists posts={filteredPosts} />;
};
먼저 페이지에서 쿼리 파라미터를 추출합니다. 위에서 만든 핸들러들을 통해 필터 조건들을 쿼리 파라미터에 담았기 때문에 이걸 사용하기 위함이죠.
이제 이 조건들을 처리하기 위한 filterPosts
라는 서버 함수를 만듭니다.
"use server";
export const filterPosts = async ({ series, tags, search, sort }: Props) => {
const originalPosts = getAllPosts();
let posts = originalPosts;
// Note: 태그 필터링
if (tags) {
posts = posts.filter((post) => {
if (typeof tags === "string") {
return post.frontMatter.tags.includes(tags);
} else {
return tags.some((tag) => post.frontMatter.tags.includes(tag));
}
});
}
// Note: 시리즈 필터링
if (series) {
posts = posts.filter((post) => post.frontMatter.series.includes(series));
}
// Note: 검색 필터링
if (search) {
const searchLower = search.toLowerCase();
posts = posts.filter(
(post) =>
post.frontMatter.title.toLowerCase().includes(searchLower) ||
post.frontMatter.description.toLowerCase().includes(searchLower)
);
}
// Note: 정렬
posts.sort((a, b) => {
if (sort === "oldest") {
return new Date(a.frontMatter.date) > new Date(b.frontMatter.date)
? 1
: -1;
}
return new Date(a.frontMatter.date) > new Date(b.frontMatter.date) ? -1 : 1;
});
return posts;
};
먼저 모든 블로그 포스트 데이터를 불러오고, 그 데이터들을 각 필터 조건의 유무에 따라서 알맞게 조정합니다.
그렇게 조정된 데이터는 PostLists
컴포넌트로 넘겨져 필터링 된 포스트 리스트를 보여주게 됩니다.
필터 조건이 모두 URL에 담기게 된다
마무리
이 과정을 통해 이 글의 초반에 이야기 한 두 가지 문제점 을 모두 보완한 형태의 필터를 제작했습니다. 특히, 필터 상태의 기억은 해당 필터를 다시 참조할 수 있을 뿐 아니라 링크 형태로 공유하는데에 있어서도 아주 유용한데요.
위와 같은 링크 공유를 통해서 같은 필터 조건을 가진 페이지를 모두가 볼 수 있다는 장점이 있습니다. 마치 원하는 색상, 사이즈 등을 선택한 옷이 담긴 페이지를 공유하듯이 말이죠.
다음 포스트에서는 목차 컴포넌트인 TOC 제작을 다뤄보도록 하겠습니다. 생각보다 까다로웠던 부분이라 많은 분들이 TOC를 제작하는데 도움이 될 수 있도록 세세하게 적어볼 생각입니다.