[Next.js + TS 실전] 8. 목록 검색과 정렬
메모가 늘어나면 목록만으로는 찾기 어렵습니다.
이번 편은 제목 검색과 정렬을 URL 쿼리로 붙입니다.
목표:?q=·?sort=· 서버에서 Prisma 필터.
이 편에서 할 일
- 왜 검색 상태를 URL에 두는지 이해하기
getMemos에 검색·정렬 옵션 추가하기/memos페이지에서searchParams읽기- 검색 폼 + 정렬 링크 만들기
Client에서 배열을 filter해도 되지만,
이번엔 서버 컴포넌트 + DB 쿼리로 갑니다. 데이터가 커져도 같은 패턴입니다.
왜 URL에 두나
예:
/memos?q=Next&sort=oldest장점:
- 새로고침해도 검색어 유지
- 링크를 공유할 수 있음
- 뒤로 가기와 자연스럽게 맞음
- 서버 컴포넌트가
searchParams로 바로 읽음
useState만 쓰면 위 이점이 약해집니다.
목록 필터의 기본은 쿼리스트링이 무난합니다.
1) 데이터 함수에 옵션 추가
lib/memos.ts의 getMemos를 확장합니다.
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가 있으면 제목containssort로asc/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”는 한 바퀴 돈 셈입니다.
시리즈 목차 (실전)
- 메모 수정과 삭제
- Prisma + SQLite로 DB 붙이기
- Vercel에 배포하기
- 폼 검증하기 (zod)
- 로그인 붙이기 (Clerk)
- Neon Postgres 연결하기
- 삭제 확인 UI
- 목록 검색과 정렬 ← 지금
'frontend > NextJs' 카테고리의 다른 글
| # [Next.js + TS 실전] 9. FAQ · 시리즈 총정리 (1) | 2026.07.31 |
|---|---|
| # [Next.js + TS 실전] 7. 삭제 확인 UI (0) | 2026.07.31 |
| # [Next.js + TS 실전] 6. Neon Postgres 연결하기 (1) | 2026.07.31 |
| # [Next.js + TS 실전] 5. 로그인 붙이기 (Clerk) (0) | 2026.07.30 |
| # [Next.js + TS 실전] 4. 폼 검증하기 (zod) (0) | 2026.07.30 |
