
React Hook Form 문의 폼 구현 - 16편. 제품 선택부터 이메일 발송까지
B2B 문의 폼을 구현하여 리드를 수집합니다.
이 글에서는 폼 검증, 제품 연결, CAPTCHA, DB 저장, 이메일 발송을 다룹니다.
📝 문의 폼의 중요성
B2B 서비스에서 문의 폼은 생명줄입니다.
여기서 잠재 고객이 들어옵니다.
Claude에게 이야기했습니다.
문의 폼을 만들어줘.
제품 상세에서 "이 제품 문의하기"를 누르면
그 제품이 자동으로 선택되게.특히 신경 쓴 부분들이 있었습니다.
- 입력이 쉬워야 함
- 에러가 명확해야 함
- 스팸을 막아야 함
- 접수 확인 이메일이 가야 함
🗄️ 문의 데이터 구조
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 페이지를 만듭니다.
💬 문의 폼 관련 질문 있으면 댓글로 남겨주세요!
'바이브코딩' 카테고리의 다른 글
| 18. 문의와 견적 조회 구현 - Next.js 마이페이지 대시보드 (0) | 2026.01.25 |
|---|---|
| 17. 검색과 SEO 구조화 데이터 - shadcn/ui FAQ 아코디언 구현 (0) | 2026.01.24 |
| 15. 목록과 상세 구현 - Next.js 제품 카탈로그 페이지 (0) | 2026.01.22 |
| 14. 스팸 방지 구현 - Cloudflare Turnstile CAPTCHA (0) | 2026.01.21 |
| 13. 고객과 관리자 권한 분리 - Supabase RBAC 역할 관리 (0) | 2026.01.20 |