# [Next.js + TS 실전] 8. 목록 검색과 정렬

[Next.js + TS 실전] 8. 목록 검색과 정렬

메모가 늘어나면 목록만으로는 찾기 어렵습니다.
이번 편은 제목 검색정렬을 URL 쿼리로 붙입니다.
목표: ?q= · ?sort= · 서버에서 Prisma 필터.


이 편에서 할 일

  1. 왜 검색 상태를 URL에 두는지 이해하기
  2. getMemos에 검색·정렬 옵션 추가하기
  3. /memos 페이지에서 searchParams 읽기
  4. 검색 폼 + 정렬 링크 만들기

Client에서 배열을 filter해도 되지만,
이번엔 서버 컴포넌트 + DB 쿼리로 갑니다. 데이터가 커져도 같은 패턴입니다.


왜 URL에 두나

예:

/memos?q=Next&sort=oldest

장점:

  • 새로고침해도 검색어 유지
  • 링크를 공유할 수 있음
  • 뒤로 가기와 자연스럽게 맞음
  • 서버 컴포넌트가 searchParams로 바로 읽음

useState만 쓰면 위 이점이 약해집니다.
목록 필터의 기본은 쿼리스트링이 무난합니다.


1) 데이터 함수에 옵션 추가

lib/memos.tsgetMemos를 확장합니다.

type GetMemosOptions = {
  q?: string;
  sort?: "newest" | "oldest";
};

export async function getMemos(
  userId: string,
  options: GetMemosOptions = {}
): Promise<Memo[]> {
  const q = options.q?.trim();
  const sort = options.sort ?? "newest";

  const rows = await prisma.memo.findMany({
    where: {
      userId,
      ...(q
        ? {
            title: {
              contains: q,
              // Postgres면 대소문자 무시에 mode: "insensitive" 가능
              // SQLite는 contains만으로 시작
            },
          }
        : {}),
    },
    orderBy: {
      createdAt: sort === "oldest" ? "asc" : "desc",
    },
  });

  return rows.map(toMemo);
}

포인트:

  • userId는 그대로 (내 메모만)
  • q가 있으면 제목 contains
  • sortasc / desc

Neon(Postgres)을 쓰면 이렇게 대소문자 무시가 됩니다.

title: { contains: q, mode: "insensitive" }

SQLite에서는 mode: "insensitive"가 지원되지 않을 수 있습니다.

Clerk를 아직 안 붙였다면 userId 인자는 프로젝트 상태에 맞게 조정하세요.


2) 목록 페이지에서 searchParams 읽기

app/memos/page.tsx:

import Link from "next/link";
import { auth } from "@clerk/nextjs/server";
import { redirect } from "next/navigation";
import MemoCard from "@/components/MemoCard";
import { getMemos } from "@/lib/memos";

type MemosPageProps = {
  searchParams: Promise<{ q?: string; sort?: string }>;
};

export default async function MemosPage({ searchParams }: MemosPageProps) {
  const { userId } = await auth();
  if (!userId) redirect("/");

  const params = await searchParams;
  const q = params.q ?? "";
  const sort = params.sort === "oldest" ? "oldest" : "newest";

  const memos = await getMemos(userId, { q, sort });

  return (
    <main>
      <h1>메모 목록</h1>

      <p>
        <Link href="/memos/new">새 메모 작성</Link>
      </p>

      <form>
        <label>
          제목 검색
          <br />
          <input type="search" name="q" defaultValue={q} placeholder="검색어" />
        </label>
        {" "}
        <button type="submit">검색</button>
        {q && (
          <>
            {" "}
            <Link href={`/memos?sort=${sort}`}>초기화</Link>
          </>
        )}
      </form>

      <p>
        정렬:{" "}
        <Link href={`/memos?q=${encodeURIComponent(q)}&sort=newest`}>최신순</Link>
        {" | "}
        <Link href={`/memos?q=${encodeURIComponent(q)}&sort=oldest`}>오래된순</Link>
      </p>

      <section>
        {memos.length === 0 ? (
          <p>조건에 맞는 메모가 없습니다.</p>
        ) : (
          memos.map((memo) => <MemoCard key={memo.id} memo={memo} />)
        )}
      </section>
    </main>
  );
}

포인트:

코드 의미
searchParams URL ?q=&sort=
<form>에 action 없음 같은 /memos로 GET 제출
name="q" 쿼리 키 이름
defaultValue={q} 검색 후에도 입력값 유지
encodeURIComponent 한글 검색어 링크 안전

auth / redirect는 Clerk 기준입니다.
미사용이면 기존 getMemos() 호출 방식에 옵션만 추가하면 됩니다.


동작 흐름

/memos?q=Next&sort=oldest
        ↓
page가 searchParams 읽음
        ↓
getMemos(userId, { q: "Next", sort: "oldest" })
        ↓
Prisma where + orderBy
        ↓
필터된 목록 렌더

useEffect로 클라이언트 fetch를 돌릴 필요가 없습니다.


검색 폼이 GET인 이유

<form>
  <input name="q" />
  <button type="submit">검색</button>
</form>
  • 기본 method는 GET
  • 제출하면 /memos?q=입력값
  • Server Action이 아님 (데이터 변경이 아니라 조회)

쓰기(추가/수정/삭제)는 Action,
읽기 필터는 쿼리 — 역할이 갈립니다.


(선택) 디바운스 실시간 검색

입력할 때마다 바로 필터하려면 Client 컴포넌트가 필요합니다.

  • useState + useRouter().push(?q=...)
  • 또는 클라이언트에서 이미 받은 목록을 filter

메모가 적을 때는 편하지만,
이번 편 목표인 “서버+URL”과 결이 다릅니다.
필요해지면 그때 추가하세요.


자주 하는 실수

1. searchParams를 await 안 함
→ 최근 Next에서는 Promise인 경우가 많음

2. 정렬 링크에서 q를 빠뜨림
→ 정렬 바꾸면 검색어가 사라짐. 링크에 q 유지

3. contains에 빈 문자열
trim() 후 없을 때는 where에 넣지 않기

4. 목록을 Client로 통째로 올리고 fetch
→ 이 편 패턴이면 서버 getMemos로 충분

5. Postgres mode: "insensitive"를 SQLite에 그대로
→ 에러 날 수 있음. DB에 맞게


이 편에서 가져갈 것

  • 목록 필터 상태는 URL 쿼리가 기본이다
  • 검색은 GET 폼, 쓰기는 Server Action
  • Prisma where / orderBy로 서버에서 거른다
  • 검색어와 정렬을 링크에 같이 실어 상태를 유지한다

다음으로 이어가면 좋은 것

  • 입문 + 실전 FAQ / 시리즈 총정리 — 지금까지 막혔던 포인트 모음

검색·정렬까지 오면 메모 앱의 “일상 UX”는 한 바퀴 돈 셈입니다.


시리즈 목차 (실전)

  1. 메모 수정과 삭제
  2. Prisma + SQLite로 DB 붙이기
  3. Vercel에 배포하기
  4. 폼 검증하기 (zod)
  5. 로그인 붙이기 (Clerk)
  6. Neon Postgres 연결하기
  7. 삭제 확인 UI
  8. 목록 검색과 정렬 ← 지금