Next.js 15 App Router의 Server Component 철학에 충실하면서도, 백엔드 서버와 안정적으로 통신하고,
검색 확장성도 우수한 설계입니다.
소스 : https://github.com/braverokmc79/nextjs-review
⭕ 전체 구조 개요
[✅ 사용자가 검색어 입력] ↓ ✅ src/ReviewsSearchBox.tsx → 클라이언트 컴포넌트 → fetch("/api/reviews/search")로 POST 요청 (searchBy, searchTerm 포함) ↓ ✅ src/app/api/reviews/search/route.ts → API 라우트 (Next.js 서버 컴포넌트에서 동작) → getSearchableReviews 호출 ↓ ✅ src/lib/reviews/reviews.ts → 서버 전용(server-only) 모듈 → Strapi 백엔드 CMS로 fetch 요청 (쿼리 파라미터 구성: qs.stringify 등) ↓ ✅ Strapi CMS (혹은 Spring Boot API) → 조건 필터링 후 리뷰 데이터 응답 ↓ ✅ lib/reviews/reviews.ts → 데이터 변환 ↓ ✅ route.ts → 클라이언트에 JSON 응답 ↓ ✅ ReviewsSearchBox.tsx → 결과 렌더링
디렉토리 구조 요약
src/ ├── app/ │ ├── api/ │ │ └── reviews/ │ │ └── search/route.ts # 검색 API (Next.js 15 API Route) │ └── reviews/ │ └── page.tsx # 검색 결과 페이지 ├── components/ │ └── ReviewsSearchBox.tsx # 클라이언트 자동완성 검색 컴포넌트 ├── lib/ │ └── reviews/ │ └── reviews.ts # 백엔드 서버 전용 리뷰 데이터 처리 모듈 server-only 보안적용 └── types/ └── reviewType.ts # 타입 정의
???? 검색 흐름 요약
사용자가 ReviewsSearchBox에 키워드 입력
POST /api/reviews/search API 호출 (자동완성 요청)
route.ts → getSearchableReviews() 실행 → Strapi CMS에서 리뷰 검색
결과 리뷰 데이터 중 필요한 필드(title, subtitle, slug)를 추출해 자동완성 목록 표시
항목 클릭 또는 Enter 시 → /reviews?searchBy=xxx&search=yyy로 이동
✅ 핵심 포인트 요약
1)src/lib/reviews/reviews.ts
// src/lib/reviews/reviews.ts import 'server-only'; import { marked } from "marked"; import { notFound } from "next/navigation"; import qs from "qs"; import { GetReviewData, Review, Reviews, ReviewSearchByEnum } from '@/types/reviewType'; const CMS_URL = process.env.BACKEND_URL || ""; export const CACHE_TAG_REVIEWS = "reviews"; /** * 백엔드에서 받아온 리뷰 데이터 객체를 우리가 사용할 형태로 변환 */ function toReview(item: GetReviewData) { const { id } = item; const { slug, title, subtitle, publishedAt } = item; const image = item === undefined ? undefined : new URL(item?.image[0].url, CMS_URL).href; //new URL('/uploads/1.jpg', 'https://example.com').href // "https://example.com/uploads/1.jpg" const date = publishedAt.slice(0, "yyyy-mm-dd".length); return { id, slug, title, subtitle, date, image }; } /** * 리뷰 데이터를 백엔드에서 요청해 가져오는 함수 * - 필드, 정렬, 필터, 페이지네이션 등 파라미터를 전달 가능 */ export async function fetchReviews(parameters: object) { const url = `${CMS_URL}/api/reviews?` + qs.stringify(parameters, { encodeValuesOnly: true }); const response = await fetch(url, { next: { tags: [CACHE_TAG_REVIEWS], } }); if (!response.ok) { throw new Error(`Backend returned ${response.status} for ${url}`); } return await response.json(); } /** * 슬러그(slug)를 기준으로 특정 리뷰 상세 데이터 가져오기 * - 본문은 markdown → HTML로 변환됨 * - 리뷰가 없으면 notFound() 호출 */ export async function getReview(slug: string): Promise<Review | null> { try { const { data } = await fetchReviews({ filters: { slug: { $eq: slug } }, fields: ["slug", "title", "subtitle", "publishedAt", "body"], populate: { image: { fields: ["url"] } }, pagination: { pageSize: 1, withCount: false }, }); if (data.length === 0) { return null; } const item = data[0]; return { ...toReview(item), body: await marked(item.body), } } catch (error) { console.error("Error fetching review:", error); notFound(); } } /** * 리뷰 리스트 데이터를 불러오는 함수 * - 페이지네이션, 검색 기준, 검색어 등 포함 가능 * - UI에서 목록 출력 시 사용 */ export async function getReviews( pageSize: number = 6, page: number = 1, searchBy?: ReviewSearchByEnum, searchTerm?: string ): Promise<Reviews> { let filters = undefined; console.log("⭕ getReviews :" ,pageSize, page,searchBy,searchTerm); if (searchTerm && searchBy) { if (searchBy === 'all') { filters = { $or: [ { title: { $containsi: searchTerm } }, { subtitle: { $containsi: searchTerm } }, { slug: { $containsi: searchTerm } }, ], } } else { filters = { [searchBy]: { $containsi: searchTerm, }, } } } const { data, meta } = await fetchReviews({ fields: ['slug', 'title', 'subtitle', 'publishedAt'], populate: { image: { fields: ['url'] } }, sort: ['publishedAt:desc'], pagination: { pageSize, page }, filters: filters&&filters }) return { total: meta.pagination.total, pageCount: meta.pagination.pageCount, reviews: data.map((item: GetReviewData) => toReview(item)), } } /** * 자동완성 검색 기능에 사용되는 리뷰 제목/부제목/슬러그 간략 정보만 불러오기 * - 클라이언트 자동완성 입력 필드에서 호출됨 */ export async function getSearchableReviews( pageSize: number = 6, page: number = 1, searchBy?: ReviewSearchByEnum, searchTerm?: string ) { let filters = undefined; if (searchTerm && searchBy) { if (searchBy === 'all') { filters = { $or: [ { title: { $containsi: searchTerm } }, { subtitle: { $containsi: searchTerm } }, { slug: { $containsi: searchTerm } }, ], } } else { filters = { [searchBy]: { $containsi: searchTerm, }, } } } const { data, meta } = await fetchReviews({ fields: ['slug', 'title', 'subtitle'], sort: [searchBy === 'all' ? 'title:asc' : `${searchBy}:asc`], pagination: { pageSize, page }, filters, }) return { total: meta.pagination.total, pageCount: meta.pagination.pageCount, reviews: data.map((item: GetReviewData) => { return { slug: item.slug, title: item.title, subtitle: item.subtitle, } }), } } /** * 전체 리뷰들의 슬러그 목록을 가져오는 함수 * - 정적 페이지 생성 등에 활용됨 */ export async function getSlugs() { const { data } = await fetchReviews({ fields: ["slug"], sort: ["publishedAt:desc"], pagination: { pageSize: 1000 }, }); return data.map((item: GetReviewData) => item.slug); } //slug 가 이미 등록되었는지 확인 export async function getReviewSlugsExists(slug: string) { const { data } = await fetchReviews({ filters: { slug: { $eq: slug } }, pagination: { pageSize: 1 }, // 단일 항목만 가져오도록 제한 fields: ["slug"], }); return data.length > 0 ? true : false } /** * 최신 리뷰 하나를 가져오는 함수 * - 홈 화면이나 추천 콘텐츠 등에 사용 */ export async function getFeaturedReview(): Promise<Review> { const { reviews } = await getReviews(); return reviews[0]; }
⭕서버 전용 모듈로의 분리
src/lib/reviews/reviews.ts는 import 'server-only' 선언을 통해 Server Component에서만 사용 가능하게 제한
보안이 필요한 백엔드 서버 요청, markdown 파싱 등을 서버 환경에서만 처리함으로써 클라이언트 노출 방지
✅ fetchReviews() 세부 설명
Strapi CMS의 /api/reviews 엔드포인트에 요청
qs.stringify()로 파라미터를 URL에 직렬화
next: { tags: ["reviews"] }를 통해 Next.js의 캐시 태깅 적용
const url = `${CMS_URL}/api/reviews?` + qs.stringify(parameters, { encodeValuesOnly: true });
2)/src/app/reviews/page.tsx
import { ChevronLeftIcon, ChevronRightIcon } from "@heroicons/react/16/solid"; import Link from "next/link"; import React from "react"; interface PaginationBarProps { href: string; page: string | number; defaultPage?: number; pageSize: string | number; defaultPageSize?: number; pageCount: number; searchBy?: string; search?: string; } const PaginationBar: React.FC<PaginationBarProps> = ({href, page, defaultPage=1, pageSize, defaultPageSize=6 , pageCount , searchBy, search}) => { const safePage = parseNumberCheck(page, defaultPage) ; const safePageSize = parseNumberCheck(pageSize, defaultPageSize) ; const prevHref = buildQueryString(href, { page: safePage - 1, pageSize: safePageSize, searchBy, search, }); const nextHref = buildQueryString(href, { page: safePage + 1, pageSize: safePageSize, searchBy, search, }); return ( <div className="flex gap-2 pb-3 relative"> <PaginationLink href={prevHref} enabled={safePage > 1}> <ChevronLeftIcon className="h-5 w-5" aria-hidden="true" /> <span className="sr-only">Previous Page</span> </PaginationLink> <span> 페이지 {safePage} / {pageCount} </span> <PaginationLink href={nextHref} enabled={safePage < pageCount}> <ChevronRightIcon className="h-5 w-5" aria-hidden="true" /> <span className="sr-only">Next Page</span> </PaginationLink> </div> ); }; export default PaginationBar; function PaginationLink({children, enabled,href} :{children: React.ReactNode,enabled: boolean, href: string}) { if (!enabled) { return ( <span className="h-7 w-7 items-center justify-center flex border rounded text-slate-500 text-sm hover:bg-orange-100 hover:text-slate-700 "> {children} </span> ); } return( <Link href={href} className=" h-7 w-7 items-center justify-center flex border rounded text-slate-500 text-sm hover:bg-orange-100 hover:text-slate-700 " > {children} </Link> ); } export function parseNumberCheck(paramValue: string | number, defaultValue: number): number { if (paramValue) { let page=1; if(typeof paramValue === 'string') { page = parseInt(paramValue, 10); }else{ page = paramValue as number; } if (isFinite(page) && page > 0) { return page; } } return defaultValue; } const buildQueryString = (base: string, params: Record<string, any>) => { const query = new URLSearchParams(); for (const key in params) { const value = params[key]; if (value !== undefined && value !== null && value !== "") { query.append(key, String(value)); } } return `${base}?${query.toString()}`; };
3)/src/components/ReviewsSearchBox.tsx
'use client' // /src/components/ReviewsSearchBox.tsx import React, { useEffect, useState, useRef } from 'react' import { Input } from '@/components/ui/input' import { Button } from '@/components/ui/button' import { Search } from 'lucide-react' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select' import { useRouter } from 'next/navigation' import { Review, ReviewSearchByEnum } from '@/types/reviewType' interface ReviewsSearchBoxProps { searchBy?: string; searchTerm?: string; } const ReviewsSearchBox: React.FC<ReviewsSearchBoxProps> = ({ searchBy = 'title', searchTerm = '' }) => { const router = useRouter() const [by, setBy] = useState<ReviewSearchByEnum>(searchBy as ReviewSearchByEnum); const [term, setTerm] = useState(searchTerm); const [suggestions, setSuggestions] = useState<string[]>([]) const [showSuggestions, setShowSuggestions] = useState(false) const [highlightedIndex, setHighlightedIndex] = useState<number>(-1) const inputRef = useRef<HTMLInputElement>(null) const selectedRef = useRef<HTMLLIElement>(null) //키보드 탐색 시 리스트가 길어지면 현재 선택된 항목이 보이지 않을 경우 useEffect(() => { if (selectedRef.current) { selectedRef.current.scrollIntoView({ block: 'nearest' }) } }, [highlightedIndex]) useEffect(() => { const delayDebounce = setTimeout(async () => { if (by && term.length > 1) { const response = await fetch('/api/reviews/search', { method: 'POST', body: JSON.stringify({ searchBy: by, searchTerm: term }), }); const data = await response.json(); if(!data.success){ return null; } setSuggestions(data.reviews.map((review: Review) => by === 'all' ? review.title : review[by])) setShowSuggestions(true) setHighlightedIndex(-1) } else { setSuggestions([]) setShowSuggestions(false) } }, 300) return () => clearTimeout(delayDebounce) }, [term, by]); const handleSearch = async (value?: string) => { const keyword = value ?? term const params = new URLSearchParams() params.set('searchBy', by) params.set('search', keyword) router.push(`/reviews?${params.toString()}`) setTerm(keyword) router.refresh() setShowSuggestions(false) } const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { if (e.key === 'ArrowDown' && suggestions.length > 0) { e.preventDefault() setHighlightedIndex((prev) => (prev + 1) % suggestions.length) } if (e.key === 'ArrowUp' && suggestions.length > 0) { e.preventDefault() setHighlightedIndex((prev) => (prev - 1 + suggestions.length) % suggestions.length) } if (e.key === 'Enter') { e.preventDefault() if (highlightedIndex >= 0 && suggestions[highlightedIndex]) { handleSearch(suggestions[highlightedIndex]) } else { handleSearch() } setShowSuggestions(false) // 자동완성 닫기 setHighlightedIndex(-1) } if (e.key === 'Escape') { setShowSuggestions(false) setHighlightedIndex(-1) } } return ( <div className="flex items-center gap-2 w-full max-w-xl mx-auto p-4"> <Select value={by} onValueChange={(val) => setBy(val as ReviewSearchByEnum)}> <SelectTrigger className="w-[120px]"> <SelectValue placeholder="검색 기준" /> </SelectTrigger> <SelectContent> <SelectItem value={ReviewSearchByEnum.all}>전체</SelectItem> <SelectItem value={ReviewSearchByEnum.slug}>Slug</SelectItem> <SelectItem value={ReviewSearchByEnum.title}>Title</SelectItem> <SelectItem value={ReviewSearchByEnum.subtitle}>Subtitle</SelectItem> </SelectContent> </Select> <div className="relative w-full"> <Input ref={inputRef} type="search" placeholder={`${by === 'all' ? '전체' : by}로 검색`} value={term} onChange={(e) => setTerm(e.target.value)} onFocus={() => setShowSuggestions(true)} onKeyDown={handleKeyDown} className="w-full" /> {showSuggestions && suggestions.length > 0 && ( <ul className="absolute z-10 mt-1 w-full bg-white border border-gray-300 rounded-md shadow-md max-h-60 overflow-auto"> {suggestions.map((s, i) => ( <li ref={i === highlightedIndex ? selectedRef : null} key={i} className={`px-4 py-2 cursor-pointer text-sm ${ i === highlightedIndex ? 'bg-gray-200 font-semibold ' : 'hover:bg-gray-100' }`} onMouseEnter={() => setHighlightedIndex(i)} onMouseDown={() => handleSearch(s)} > {s} </li> ))} </ul> )} </div> <Button variant="default" size="icon" onClick={() => handleSearch()}> <Search className="h-4 w-4" /> </Button> </div> ) } export default ReviewsSearchBox;
✅ ReviewsSearchBox 클라이언트 컴포넌트 주요 동작
항목설명
검색 기준Select UI로 title, slug, subtitle, all 중 선택 가능
검색어 입력Input 필드 입력 시 debounce로 300ms 후 검색 API 호출
자동완성 리스트입력 키워드 기준으로 백엔드 응답 받아 자동완성 표시
키보드 탐색↑ ↓ 키로 자동완성 항목 탐색, Enter로 선택 가능
검색 실행버튼 클릭 또는 키보드 입력으로 /reviews 페이지 이동
✅ 기술 요소
useRouter → 검색 결과 페이지로 이동 및 새로고침 처리
fetch('/api/reviews/search') → POST 방식으로 자동완성 요청
useEffect → 입력 감지, debounce 적용, 백엔드 요청
useState → 입력어, 선택 기준, 자동완성 상태 등 저장
scrollIntoView() → 선택된 자동완성 항목이 항상 보이도록 조정
4)src/app/api/reviews/search
// src/app/api/reviews/search import { NextRequest, NextResponse } from "next/server"; import { getSearchableReviews } from "@/lib/reviews/reviews"; export async function POST(request: NextRequest) { const body = await request.json(); const { searchBy, searchTerm } = body; try { const { reviews } = await getSearchableReviews(6, 1, searchBy, searchTerm); return NextResponse.json({ success: true, reviews, }); } catch (error:unknown) { console.error('검색 중 오류:', error); return NextResponse.json({ success: false, message: '검색 중 오류 발생', }, { status: 500 }); } }
✅ route.ts API 핸들러의 흐름
클라이언트에서 보낸 JSON body를 파싱
getSearchableReviews() 호출
JSON 형식으로 클라이언트에 검색 결과 응답
댓글 ( 0)
댓글 남기기