Zenithium 제작기

블로그 Filter 만들기

서버 컴포넌트에서는 데이터를 어떻게 넘겨받으면 좋을까?

2024-10-14 작성

평균 15분 소요

178회 조회

#Blog#Next.js#RSC
cover

블로그에 있는 많은 포스트중에 원하는 포스팅을 찾기 위한 필터를 어떻게 만들면 좋을까요?

필터링 조건을 상태로 관리하기?

가장 간단한 방법은 state와 핸들러를 사용해 현재 찾고자 하는 포스트에 대한 검색어나 태그, 시리즈 등을 상태로 관리해 걸러내는 방식이죠. 구현하기도 쉽고 구조도 단순해 유지 보수하기도 편합니다.

하지만 이러한 구현에는 몇 가지 단점이 있습니다.

클라이언트 상태의 단점

  1. 이전의 필터 상태를 기억하지 못한다.

사용자가 필터를 통해 특정 태그가 포함된 포스트를 모아서 읽기로 했다고 치겠습니다. 만약 필터로 걸러낸 포스트 중 하나를 읽고 다시 되돌아왔을 때, 해당 페이지는 이전의 필터 값을 기억하지 못합니다.

이를 해결하기 위해서 상태를 전역으로 관리한다고 해도 새로고침이 발생하면 다시 초기화 되기 마련이죠.

  1. 서버 컴포넌트로 상태를 공유하지 못한다.

Next.js의 서버 컴포넌트는 핸들러와 상태를 사용할 수 없습니다. 서버에서 컴포넌트를 미리 만들어 클라이언트로 직렬화 된 컴포넌트 데이터만을 넘겨주기 때문에 핸들러가 동작할 여지가 없기 때문이죠.

컴포넌트 구조와 해결 방안

그럼 이제 제 블로그 목록 페이지 구조를 한번 보도록 하겠습니다.

/blog/page.tsx
<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>

최상단에 블로그 포스트 목록 페이지가 있고 하위 자식 컴포넌트로 FilterPostLists 컴포넌트가 함께 있습니다.

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>;
시간순 정렬 핸들러와 Selector

구조가 굉장히 간단하죠? 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>;
URL의 변화를 감지해 selector의 value를 변화시키기 위해 value prop을 추가했다.

위의 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들을 쿼리 파라미터에 추가합니다.

append는 쿼리 파라미터에 쿼리 키에 해당하는 데이터를 추가하는 메서드로, set과는 다르게 중복된 키에 대해서 데이터를 덮어씌우는 대신 동일한 키의 다른 데이터를 계속해서 추가합니다.

만약 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="키워드 검색"
/>;
과도하게 많은 데이터 변경을 방지하기 위해 debounce 적용

쿼리 파라미터를 수정하는 부분은 seriesHandler와 별반 다르지 않습니다. 하지만 useDebounce라는 특이한 훅이 적용되어 있죠. 이건 뭘까요?

debounce란?

input에 텍스트를 입력하게 되면 모든 텍스트의 변화마다 상태값이 변화하게 됩니다. 즉, 쿼리 파라미터의 값이 매 순간 변화하게 되죠. 매 타이핑 마다 url이 변화한다는 것은 페이지가 계속해서 변하고 있고 매번 페이지를 렌더링 한다는 뜻입니다. 성능에 정말 좋지 못하겠죠.

이를 해결하고자 검색어 입력이 일정 시간동안 멈추면 그 때까지 입력된 내용을 한번에 반영하도록 하는 디바운스를 적용한 커스텀 훅을 만들어 적용하였습니다.

useDebounce.ts
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의 쿼리에 넘겨주었다면, 이제 포스트 목록에서 이 조건을 반영해 데이터를 필터링해야겠죠?

/blog/page.tsx
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에 담기게 된다

마무리

이 과정을 통해 이 글의 초반에 이야기 한 두 가지 문제점 을 모두 보완한 형태의 필터를 제작했습니다. 특히, 필터 상태의 기억은 해당 필터를 다시 참조할 수 있을 뿐 아니라 링크 형태로 공유하는데에 있어서도 아주 유용한데요.

https://www.zenithium.info/blog?tag=Blog&sort=oldest

위와 같은 링크 공유를 통해서 같은 필터 조건을 가진 페이지를 모두가 볼 수 있다는 장점이 있습니다. 마치 원하는 색상, 사이즈 등을 선택한 옷이 담긴 페이지를 공유하듯이 말이죠.

다음 포스트에서는 목차 컴포넌트인 TOC 제작을 다뤄보도록 하겠습니다. 생각보다 까다로웠던 부분이라 많은 분들이 TOC를 제작하는데 도움이 될 수 있도록 세세하게 적어볼 생각입니다.