React

 

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               # 타입 정의

 

 

???? 검색 흐름 요약

  1. 사용자가 ReviewsSearchBox에 키워드 입력

  2. POST /api/reviews/search API 호출 (자동완성 요청)

  3. route.ts → getSearchableReviews() 실행 → Strapi CMS에서 리뷰 검색

  4. 결과 리뷰 데이터 중 필요한 필드(title, subtitle, slug)를 추출해 자동완성 목록 표시

  5. 항목 클릭 또는 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 형식으로 클라이언트에 검색 결과 응답

 


 

 

about author

PHRASE

Level 60  라이트

부자가 그 부를 자랑하더라도 그 부를 어떻게 쓰는가를 알기 전에는 그를 칭찬해서는 안 된다. -소크라테스

댓글 ( 0)

댓글 남기기

작성