본문 바로가기

바이브코딩

17. 검색과 SEO 구조화 데이터 - shadcn/ui FAQ 아코디언 구현

shadcn/ui FAQ 아코디언 구현 - 17편. 검색과 SEO 구조화 데이터

FAQ 페이지를 아코디언 UI로 구현합니다.
이 글에서는 DB 설계, 아코디언 컴포넌트, 카테고리 필터, 검색, SEO 구조화 데이터를 다룹니다.


❓ FAQ가 필요한 이유

같은 질문이 계속 들어왔습니다.

"배송은 얼마나 걸려요?"
"견적은 어떻게 받아요?"
"결제는 어떻게 해요?"

매번 답하기 귀찮았습니다.

FAQ 페이지를 만들면 고객 문의도 줄고, 신뢰도도 올라갑니다.

Claude에게 물었습니다.

FAQ 페이지를 만들어줘.
아코디언 형태로.
카테고리별로 필터도 되게.

🗄️ FAQ 데이터 구조

create table faqs (
  id uuid primary key default gen_random_uuid(),
  question_ko text not null,
  question_ja text,
  answer_ko text not null,
  answer_ja text,
  category text,
  display_order integer default 0,
  is_active boolean default true,
  created_at timestamptz default now(),
  updated_at timestamptz default now()
);

-- 정렬용 인덱스
create index idx_faqs_order on faqs(display_order);

질문과 답변 모두 다국어 컬럼으로 만들었습니다.

샘플 데이터

insert into faqs (question_ko, answer_ko, category, display_order) values
('배송은 얼마나 걸리나요?', '주문 확정 후 영업일 기준 3-5일 내 배송됩니다.', 'delivery', 1),
('견적은 어떻게 받나요?', '문의하기 페이지에서 제품을 선택하고 문의를 남겨주시면 담당자가 견적서를 보내드립니다.', 'order', 2),
('결제 방법은 무엇이 있나요?', '계좌이체, 카드결제가 가능합니다. 세금계산서 발행도 가능합니다.', 'payment', 3);

📋 FAQ 조회 함수

// lib/queries/faqs.ts
import { createClient } from '@/lib/supabase/server'

export async function getFaqs(category?: string) {
  const supabase = await createClient()

  let query = supabase
    .from('faqs')
    .select('*')
    .eq('is_active', true)
    .order('display_order')

  if (category) {
    query = query.eq('category', category)
  }

  const { data, error } = await query

  if (error) throw error
  return data
}

export async function getFaqCategories() {
  const supabase = await createClient()

  const { data, error } = await supabase
    .from('faqs')
    .select('category')
    .eq('is_active', true)
    .not('category', 'is', null)

  if (error) throw error

  // 중복 제거
  const categories = [...new Set(data.map((d) => d.category))]
  return categories.filter(Boolean) as string[]
}

🎯 아코디언 컴포넌트

shadcn/ui의 Accordion을 사용했습니다.

설치

npx shadcn@latest add accordion

FAQ 아코디언

// components/faq/FaqAccordion.tsx
'use client'

import {
  Accordion,
  AccordionContent,
  AccordionItem,
  AccordionTrigger,
} from '@/components/ui/accordion'
import { FAQ } from '@/lib/types'

interface Props {
  faqs: FAQ[]
  locale: string
}

export function FaqAccordion({ faqs, locale }: Props) {
  return (
    <Accordion type="single" collapsible className="w-full">
      {faqs.map((faq, index) => {
        const question = locale === 'ja' && faq.question_ja
          ? faq.question_ja
          : faq.question_ko
        const answer = locale === 'ja' && faq.answer_ja
          ? faq.answer_ja
          : faq.answer_ko

        return (
          <AccordionItem key={faq.id} value={`item-${index}`}>
            <AccordionTrigger className="text-left">
              <span className="flex items-center gap-3">
                <span className="text-primary font-bold">Q.</span>
                {question}
              </span>
            </AccordionTrigger>
            <AccordionContent>
              <div className="flex gap-3 pt-2">
                <span className="text-green-600 font-bold">A.</span>
                <p className="text-gray-700 whitespace-pre-line">
                  {answer}
                </p>
              </div>
            </AccordionContent>
          </AccordionItem>
        )
      })}
    </Accordion>
  )
}

Q, A 표시로 질문과 답변을 구분했습니다.


📄 FAQ 페이지

// app/[locale]/(public)/faq/page.tsx
import { getFaqs, getFaqCategories } from '@/lib/queries/faqs'
import { getTranslations, getLocale } from 'next-intl/server'
import { FaqAccordion } from '@/components/faq/FaqAccordion'
import { FaqCategoryFilter } from '@/components/faq/FaqCategoryFilter'

interface Props {
  searchParams: { category?: string }
}

export default async function FaqPage({ searchParams }: Props) {
  const t = await getTranslations('faq')
  const locale = await getLocale()

  const [faqs, categories] = await Promise.all([
    getFaqs(searchParams.category),
    getFaqCategories(),
  ])

  return (
    <div className="container mx-auto px-4 py-8">
      <h1 className="text-3xl font-bold mb-2">{t('title')}</h1>
      <p className="text-gray-600 mb-8">{t('description')}</p>

      {/* 카테고리 필터 */}
      {categories.length > 0 && (
        <FaqCategoryFilter
          categories={categories}
          currentCategory={searchParams.category}
        />
      )}

      {/* FAQ 목록 */}
      {faqs.length > 0 ? (
        <FaqAccordion faqs={faqs} locale={locale} />
      ) : (
        <p className="text-center text-gray-500 py-12">
          {t('noFaqs')}
        </p>
      )}
    </div>
  )
}

🔍 카테고리 필터

// components/faq/FaqCategoryFilter.tsx
'use client'

import { useRouter } from 'next/navigation'
import { cn } from '@/lib/utils'

const categoryLabels: Record<string, string> = {
  order: '주문/견적',
  delivery: '배송',
  payment: '결제',
  product: '제품',
  etc: '기타',
}

interface Props {
  categories: string[]
  currentCategory?: string
}

export function FaqCategoryFilter({ categories, currentCategory }: Props) {
  const router = useRouter()

  const handleClick = (category: string | null) => {
    if (category) {
      router.push(`/faq?category=${category}`)
    } else {
      router.push('/faq')
    }
  }

  return (
    <div className="flex flex-wrap gap-2 mb-8">
      <button
        onClick={() => handleClick(null)}
        className={cn(
          'px-4 py-2 rounded-full text-sm font-medium transition',
          !currentCategory
            ? 'bg-primary text-white'
            : 'bg-gray-100 text-gray-700 hover:bg-gray-200'
        )}
      >
        전체
      </button>

      {categories.map((category) => (
        <button
          key={category}
          onClick={() => handleClick(category)}
          className={cn(
            'px-4 py-2 rounded-full text-sm font-medium transition',
            currentCategory === category
              ? 'bg-primary text-white'
              : 'bg-gray-100 text-gray-700 hover:bg-gray-200'
          )}
        >
          {categoryLabels[category] || category}
        </button>
      ))}
    </div>
  )
}

🔎 검색 기능

FAQ가 많아지면 검색이 필요합니다.

// components/faq/FaqSearch.tsx
'use client'

import { useState } from 'react'
import { Search } from 'lucide-react'
import { Input } from '@/components/ui/input'
import { FAQ } from '@/lib/types'

interface Props {
  faqs: FAQ[]
  locale: string
  onFilter: (filtered: FAQ[]) => void
}

export function FaqSearch({ faqs, locale, onFilter }: Props) {
  const [query, setQuery] = useState('')

  const handleSearch = (value: string) => {
    setQuery(value)

    if (!value.trim()) {
      onFilter(faqs)
      return
    }

    const searchTerm = value.toLowerCase()

    const filtered = faqs.filter((faq) => {
      const question = (
        locale === 'ja' ? faq.question_ja : faq.question_ko
      )?.toLowerCase()
      const answer = (
        locale === 'ja' ? faq.answer_ja : faq.answer_ko
      )?.toLowerCase()

      return question?.includes(searchTerm) || answer?.includes(searchTerm)
    })

    onFilter(filtered)
  }

  return (
    <div className="relative mb-6">
      <Search className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 w-5 h-5" />
      <Input
        type="text"
        placeholder="질문 검색..."
        value={query}
        onChange={(e) => handleSearch(e.target.value)}
        className="pl-10"
      />
    </div>
  )
}

질문과 답변 모두에서 검색합니다.


📊 SEO: 구조화된 데이터

FAQ를 검색 엔진에 알려주면 검색 결과에 직접 표시됩니다.

구글 검색에서 "자주 묻는 질문"이 펼쳐지는 형태를 본 적 있을 겁니다.

// app/[locale]/(public)/faq/page.tsx
import { Metadata } from 'next'

export const metadata: Metadata = {
  title: '자주 묻는 질문',
  description: '서비스 이용에 관한 자주 묻는 질문과 답변입니다.',
}

// 구조화된 데이터 추가
function FaqJsonLd({ faqs }: { faqs: FAQ[] }) {
  const jsonLd = {
    '@context': 'https://schema.org',
    '@type': 'FAQPage',
    mainEntity: faqs.map((faq) => ({
      '@type': 'Question',
      name: faq.question_ko,
      acceptedAnswer: {
        '@type': 'Answer',
        text: faq.answer_ko,
      },
    })),
  }

  return (
    <script
      type="application/ld+json"
      dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
    />
  )
}

이걸 페이지에 추가하면 SEO에 도움이 됩니다.


⚠️ 삽질했던 것들

아코디언이 안 열림

AccordionItemvalue가 중복됐습니다.

<AccordionItem value={faq.id}>

고유한 값을 사용하도록 수정했습니다.

검색 결과가 이상함

대소문자 구분 때문이었습니다.

const searchTerm = value.toLowerCase()

toLowerCase()를 적용했습니다.

카테고리가 안 나옴

null 카테고리가 포함되고 있었습니다.

.not('category', 'is', null)

null을 필터링했습니다.


💡 FAQ 관리 팁

Claude가 알려준 것들입니다.

순서 관리

display_order 필드로 관리자가 순서를 조정할 수 있습니다.

자주 묻는 질문을 위로 올리면 됩니다.

조회수 추적

alter table faqs add column view_count integer default 0;

어떤 질문이 많이 조회되는지 알 수 있습니다.

"도움이 됐어요" 버튼

alter table faqs add column helpful_count integer default 0;
alter table faqs add column not_helpful_count integer default 0;

답변이 도움이 됐는지 피드백을 받을 수 있습니다.


📋 이번 편 요약

항목 내용
다룬 기능 FAQ 페이지, 아코디언, 검색, 필터
핵심 기술 shadcn/ui Accordion
다국어 question_ko/ja, answer_ko/ja
SEO FAQPage 구조화 데이터

다음 편에서는 마이페이지를 만듭니다.

💬 FAQ 관련 질문 있으면 댓글로 남겨주세요!