본문 바로가기

바이브코딩

16. 제품 선택부터 이메일 발송까지 - React Hook Form 문의 폼 구현

React Hook Form 문의 폼 구현 - 16편. 제품 선택부터 이메일 발송까지

B2B 문의 폼을 구현하여 리드를 수집합니다.
이 글에서는 폼 검증, 제품 연결, CAPTCHA, DB 저장, 이메일 발송을 다룹니다.


📝 문의 폼의 중요성

B2B 서비스에서 문의 폼은 생명줄입니다.

여기서 잠재 고객이 들어옵니다.

Claude에게 이야기했습니다.

문의 폼을 만들어줘.
제품 상세에서 "이 제품 문의하기"를 누르면
그 제품이 자동으로 선택되게.

특히 신경 쓴 부분들이 있었습니다.

  1. 입력이 쉬워야 함
  2. 에러가 명확해야 함
  3. 스팸을 막아야 함
  4. 접수 확인 이메일이 가야 함

🗄️ 문의 데이터 구조

create table inquiries (
  id uuid primary key default gen_random_uuid(),
  user_id uuid references users(id),
  product_id uuid references products(id),

  -- 비회원도 문의 가능
  name text not null,
  email text not null,
  phone text,
  company text,

  content text not null,
  status text default 'pending',
  -- pending, processing, completed, cancelled

  assignee_id uuid references users(id),
  -- 담당자

  created_at timestamptz default now(),
  updated_at timestamptz default now()
);

user_id가 null이면 비회원 문의입니다. 회원이 아니어도 문의할 수 있게 했습니다.


📋 문의 폼 스키마

Zod로 검증 스키마를 만들었습니다.

// lib/validations/inquiry.ts
import { z } from 'zod'

export const inquirySchema = z.object({
  productId: z.string().optional(),
  name: z
    .string()
    .min(1, '이름을 입력하세요')
    .max(50, '50자 이내로 입력하세요'),
  email: z
    .string()
    .min(1, '이메일을 입력하세요')
    .email('올바른 이메일 형식이 아닙니다'),
  phone: z
    .string()
    .regex(/^[0-9-]+$/, '올바른 전화번호를 입력하세요')
    .optional()
    .or(z.literal('')),
  company: z
    .string()
    .max(100, '100자 이내로 입력하세요')
    .optional(),
  content: z
    .string()
    .min(10, '최소 10자 이상 입력하세요')
    .max(2000, '최대 2000자까지 입력 가능합니다'),
  turnstileToken: z.string().min(1, '보안 검증을 완료해주세요'),
})

export type InquiryFormData = z.infer<typeof inquirySchema>

🎨 문의 폼 컴포넌트

// components/forms/InquiryForm.tsx
'use client'

import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { inquirySchema, InquiryFormData } from '@/lib/validations/inquiry'
import { submitInquiry } from '@/app/actions/inquiry'
import { useSearchParams, useRouter } from 'next/navigation'
import { useState } from 'react'
import { TurnstileWidget } from './TurnstileWidget'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Button } from '@/components/ui/button'
import { useTranslations } from 'next-intl'

interface Props {
  products: Array<{ id: string; name_ko: string; name_ja?: string }>
  locale: string
}

export function InquiryForm({ products, locale }: Props) {
  const t = useTranslations('inquiry.form')
  const router = useRouter()
  const searchParams = useSearchParams()

  // URL에서 제품 ID 가져오기
  const preSelectedProduct = searchParams.get('product')

  const form = useForm<InquiryFormData>({
    resolver: zodResolver(inquirySchema),
    defaultValues: {
      productId: preSelectedProduct || '',
      name: '',
      email: '',
      phone: '',
      company: '',
      content: '',
      turnstileToken: '',
    },
  })

  const [turnstileKey, setTurnstileKey] = useState(0)

  const onSubmit = async (data: InquiryFormData) => {
    const formData = new FormData()
    Object.entries(data).forEach(([key, value]) => {
      if (value) formData.append(key, value)
    })

    const result = await submitInquiry(formData)

    if (result.error) {
      // Turnstile 리셋
      setTurnstileKey((prev) => prev + 1)
      form.setValue('turnstileToken', '')

      alert(result.error)
      return
    }

    // 성공!
    router.push('/inquiry/complete')
  }

  return (
    <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
      {/* 제품 선택 */}
      <div>
        <label className="block text-sm font-medium mb-2">
          {t('product')}
        </label>
        <select
          {...form.register('productId')}
          className="w-full rounded-lg border p-3"
        >
          <option value="">{t('selectProduct')}</option>
          {products.map((product) => (
            <option key={product.id} value={product.id}>
              {locale === 'ja' && product.name_ja
                ? product.name_ja
                : product.name_ko}
            </option>
          ))}
        </select>
      </div>

      {/* 이름 */}
      <div>
        <label className="block text-sm font-medium mb-2">
          {t('name')} <span className="text-red-500">*</span>
        </label>
        <Input
          {...form.register('name')}
          placeholder={t('namePlaceholder')}
        />
        {form.formState.errors.name && (
          <p className="text-red-500 text-sm mt-1">
            {form.formState.errors.name.message}
          </p>
        )}
      </div>

      {/* 이메일 */}
      <div>
        <label className="block text-sm font-medium mb-2">
          {t('email')} <span className="text-red-500">*</span>
        </label>
        <Input
          type="email"
          {...form.register('email')}
          placeholder={t('emailPlaceholder')}
        />
        {form.formState.errors.email && (
          <p className="text-red-500 text-sm mt-1">
            {form.formState.errors.email.message}
          </p>
        )}
      </div>

      {/* 문의 내용 */}
      <div>
        <label className="block text-sm font-medium mb-2">
          {t('content')} <span className="text-red-500">*</span>
        </label>
        <Textarea
          {...form.register('content')}
          placeholder={t('contentPlaceholder')}
          rows={6}
        />
        {form.formState.errors.content && (
          <p className="text-red-500 text-sm mt-1">
            {form.formState.errors.content.message}
          </p>
        )}
        <p className="text-gray-500 text-xs mt-1">
          {form.watch('content')?.length || 0} / 2000
        </p>
      </div>

      {/* Turnstile */}
      <div>
        <TurnstileWidget
          key={turnstileKey}
          onVerify={(token) => form.setValue('turnstileToken', token)}
        />
      </div>

      {/* 제출 버튼 */}
      <Button
        type="submit"
        size="lg"
        className="w-full"
        disabled={form.formState.isSubmitting}
      >
        {form.formState.isSubmitting ? t('submitting') : t('submit')}
      </Button>
    </form>
  )
}

searchParams.get('product')로 URL의 제품 ID를 읽어서 자동 선택합니다.


🔧 Server Action

// app/actions/inquiry.ts
'use server'

import { createClient } from '@/lib/supabase/server'
import { verifyTurnstile } from '@/lib/turnstile'
import { inquirySchema } from '@/lib/validations/inquiry'
import { sendInquiryReceivedEmail } from '@/lib/email/send'

export async function submitInquiry(formData: FormData) {
  // 1. 데이터 추출
  const rawData = {
    productId: formData.get('productId') as string,
    name: formData.get('name') as string,
    email: formData.get('email') as string,
    phone: formData.get('phone') as string,
    company: formData.get('company') as string,
    content: formData.get('content') as string,
    turnstileToken: formData.get('turnstileToken') as string,
  }

  // 2. 유효성 검사
  const result = inquirySchema.safeParse(rawData)
  if (!result.success) {
    return { error: result.error.errors[0].message }
  }

  // 3. Turnstile 검증
  const isValidTurnstile = await verifyTurnstile(rawData.turnstileToken)
  if (!isValidTurnstile) {
    return { error: '보안 검증에 실패했습니다. 다시 시도해주세요.' }
  }

  // 4. DB 저장
  const supabase = await createClient()

  // 로그인한 사용자인지 확인
  const { data: { user } } = await supabase.auth.getUser()

  const { data: inquiry, error } = await supabase
    .from('inquiries')
    .insert({
      user_id: user?.id || null,
      product_id: rawData.productId || null,
      name: rawData.name,
      email: rawData.email,
      phone: rawData.phone || null,
      company: rawData.company || null,
      content: rawData.content,
      status: 'pending',
    })
    .select()
    .single()

  if (error) {
    console.error('Inquiry insert error:', error)
    return { error: '문의 접수에 실패했습니다. 다시 시도해주세요.' }
  }

  // 5. 이메일 발송 (비동기로 처리)
  try {
    await sendInquiryReceivedEmail({
      to: rawData.email,
      name: rawData.name,
      inquiryId: inquiry.id,
    })
  } catch (emailError) {
    console.error('Email send error:', emailError)
    // 이메일 실패해도 문의 접수는 성공
  }

  return { success: true, inquiryId: inquiry.id }
}

이메일 발송이 실패해도 문의 접수 자체는 성공 처리합니다.


✅ 완료 페이지

// app/[locale]/(public)/inquiry/complete/page.tsx
import { CheckCircle } from 'lucide-react'
import Link from 'next/link'
import { Button } from '@/components/ui/button'

export default function InquiryCompletePage() {
  return (
    <div className="container mx-auto px-4 py-16 text-center">
      <div className="w-20 h-20 bg-green-100 rounded-full
                      flex items-center justify-center mx-auto mb-6">
        <CheckCircle className="w-10 h-10 text-green-600" />
      </div>

      <h1 className="text-3xl font-bold mb-4">
        문의가 접수되었습니다
      </h1>

      <p className="text-gray-600 mb-8">
        입력하신 이메일로 확인 메일을 보내드렸습니다.<br />
        담당자가 검토 후 빠른 시일 내에 연락드리겠습니다.
      </p>

      <div className="flex gap-4 justify-center">
        <Button variant="outline" asChild>
          <Link href="/products">제품 더 보기</Link>
        </Button>
        <Button asChild>
          <Link href="/">홈으로</Link>
        </Button>
      </div>
    </div>
  )
}

🔗 제품 상세에서 연결

제품 상세 페이지의 버튼입니다.

<Button asChild>
  <Link href={`/inquiry?product=${product.id}`}>
    이 제품 문의하기
  </Link>
</Button>

URL의 ?product=xxx가 폼에서 자동으로 선택됩니다.

사용자가 다시 제품을 찾을 필요가 없어서 편합니다.


⚠️ 삽질했던 것들

폼 제출이 두 번 됨

버튼을 빠르게 두 번 클릭하면 두 번 제출됐습니다.

<Button disabled={form.formState.isSubmitting}>
  {form.formState.isSubmitting ? '처리 중...' : '문의하기'}
</Button>

제출 중에는 버튼을 비활성화했습니다.

에러 메시지가 안 보임

서버에서 에러가 나도 화면에 표시가 안 됐습니다.

if (result.error) {
  alert(result.error)
}

alert로 표시하게 수정했습니다. 나중에 toast로 바꿀 예정입니다.

한글 이름 검증 실패

정규식이 영어만 허용했습니다.

name: z.string().min(1, '이름을 입력하세요')

정규식을 제거하고 min/max만 체크하도록 바꿨습니다.


💡 UX 개선

Claude가 제안한 것들입니다.

자동 포커스

<Input autoFocus {...form.register('name')} />

페이지 열리면 바로 입력 가능합니다.

실시간 글자 수

<p className="text-xs text-gray-500">
  {form.watch('content')?.length || 0} / 2000
</p>

사용자가 얼마나 썼는지 알 수 있습니다.

로그인 사용자 정보 자동 입력

useEffect(() => {
  if (user) {
    form.setValue('name', user.user_metadata.name || '')
    form.setValue('email', user.email || '')
  }
}, [user])

로그인한 사용자는 정보를 다시 입력할 필요가 없습니다.


📋 이번 편 요약

항목 내용
다룬 기능 문의 폼 구현, 제품 연결, 이메일 발송
핵심 기술 React Hook Form, Zod, Server Action
보안 Turnstile CAPTCHA
UX 자동 완성, 실시간 검증, 글자 수 표시

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

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