Turnstile을 모달에서 실행하면 폼 제출과 토큰 발급 사이에 시간이 생깁니다. 이때 getValues()를 다시 호출하면 사용자가 그사이 수정한 값이 섞일 수 있습니다. handleSubmit이 넘긴 스냅샷을 ref에 보관한 뒤 토큰 콜백에서 꺼내면 값의 흐름이 분명해집니다.
이 예제에서 useState는 모달 표시 여부를 관리합니다. 제출 스냅샷과 제출 ID는 화면을 다시 그릴 이유가 없으므로 ref에 보관합니다. 전송에 실패하면 스냅샷과 제출 ID를 유지하고, 재시도할 때는 새 Turnstile 토큰과 새 검증 ID를 만듭니다. 토큰은 300초 뒤 만료되며 한 번만 사용할 수 있기 때문입니다.
이 예제를 폼에 적용할 때는 각 필드 오류를 해당 입력과 연결합니다. 폼 전체 오류는 role="alert"로 읽히게 하고 성공 문구는 aria-live="polite" 영역에 둡니다. 모달을 닫을 때는 전송 버튼으로 포커스를 돌려야 키보드 사용자도 현재 위치를 잃지 않습니다.
next-safe-action은 입력 파싱과 타입 추론, 결과 상태를 정리해 줍니다. 여기서는 v7의 검증 오류를 flattened 형태로 맞추고 사용자에게 보여도 되는 오류만 ActionError로 통과시킵니다. CSRF 방어는 배포 환경의 Next.js 설정, Origin 정책, 쿠키 정책을 함께 검토할 항목입니다.
import { createSafeActionClient, DEFAULT_SERVER_ERROR_MESSAGE} from 'next-safe-action';export class ActionError extends Error {}export const actionClient = createSafeActionClient({ defaultValidationErrorsShape: 'flattened', handleServerError(error) { if (error instanceof ActionError) return error.message; return DEFAULT_SERVER_ERROR_MESSAGE; }});
환경 변수와 외부 API 응답도 런타임에서 검사합니다. 타입 단언으로 JSON을 믿으면 Cloudflare가 오류 페이지를 돌려주거나 응답 형식이 달라졌을 때 실패 지점이 뒤로 밀립니다.
import 'server-only';import { z } from 'zod';import { ActionError } from '@/lib/safe-action';const TurnstileEnvSchema = z.object({ TURNSTILE_SECRET_KEY: z.string().min(1), TURNSTILE_EXPECTED_HOSTNAME: z.string().min(1)});const TurnstileResponseSchema = z.object({ success: z.boolean(), hostname: z.string().optional(), action: z.string().optional(), 'error-codes': z.array(z.string()).optional()});type VerifyTurnstileInput = { token: string; validationId: string;};export async function verifyTurnstile({ token, validationId}: VerifyTurnstileInput): Promise<void> { const env = TurnstileEnvSchema.safeParse(process.env); if (!env.success) throw new Error('Turnstile 서버 설정이 누락되었습니다.'); const body = new FormData(); body.set('secret', env.data.TURNSTILE_SECRET_KEY); body.set('response', token); body.set('idempotency_key', validationId); try { const request = await fetch( 'https://challenges.cloudflare.com/turnstile/v0/siteverify', { method: 'POST', body, cache: 'no-store', signal: AbortSignal.timeout(5000) } ); if (!request.ok) { throw new ActionError('로봇 확인 서버가 응답하지 않습니다.'); } const payload: unknown = await request.json(); const parsed = TurnstileResponseSchema.safeParse(payload); if (!parsed.success) { throw new ActionError('로봇 확인 응답을 처리하지 못했습니다.'); } const isExpectedChallenge = parsed.data.success && parsed.data.hostname === env.data.TURNSTILE_EXPECTED_HOSTNAME && parsed.data.action === 'contact'; if (!isExpectedChallenge) { throw new ActionError('로봇 확인에 실패했습니다. 다시 시도해 주세요.'); } } catch (error: unknown) { if (error instanceof ActionError) throw error; throw new ActionError('로봇 확인이 지연되고 있습니다. 다시 시도해 주세요.'); }}
request.ok 확인은 HTTP 실패와 정상 JSON 응답을 구분합니다. 5초 제한은 외부 서비스가 느릴 때 Server Action이 끝없이 대기하지 않도록 합니다. hostname은 허용한 배포 도메인과, action은 위젯에 지정한 contact와 일치해야 합니다. remoteip는 선택값입니다. 신뢰할 수 있는 프록시 구성이 없다면 x-forwarded-for의 첫 값을 그대로 보내지 않습니다.
이하 코드는 현재 메일 전송 흐름에 속도 제한과 멱등성 처리를 더한 예시입니다. 속도 제한을 Siteverify보다 앞에 두면 공격자가 검증 API 호출 자체를 쏟아붓는 상황도 줄일 수 있습니다.
'use server';import { Resend } from 'resend';import { z } from 'zod';import { actionClient, ActionError } from '@/lib/safe-action';import { ContactActionSchema } from '@/lib/contact-contract';import { ContactEmail } from '@/components/emails/contact-email';import { enforceContactRateLimit } from '@/lib/contact-rate-limit';import { verifyTurnstile } from '@/lib/turnstile';const EmailEnvSchema = z.object({ RESEND_API_KEY: z.string().min(1), EMAIL_FROM: z.string().min(1), EMAIL_TO: z.string().email()});export const contactSubmit = actionClient .schema(ContactActionSchema) .action(async ({ parsedInput }) => { const env = EmailEnvSchema.safeParse(process.env); if (!env.success) throw new Error('이메일 서버 설정이 누락되었습니다.'); await enforceContactRateLimit(); await verifyTurnstile({ token: parsedInput.turnstileToken, validationId: parsedInput.turnstileValidationId }); const resend = new Resend(env.data.RESEND_API_KEY); const { error } = await resend.emails.send( { from: env.data.EMAIL_FROM, to: env.data.EMAIL_TO, replyTo: parsedInput.email, subject: '포트폴리오 문의', react: ContactEmail({ name: parsedInput.name, email: parsedInput.email, message: parsedInput.message }) }, { idempotencyKey: `contact/${parsedInput.submissionId}` } ); if (error) { throw new ActionError( '메일을 보내지 못했습니다. 잠시 뒤 다시 시도해 주세요.' ); } return { sent: true } as const; });
enforceContactRateLimit은 저장소나 게이트웨이를 이용해 짧은 구간의 반복 요청을 막는 별도 모듈입니다. 키를 IP로 잡는다면 신뢰할 수 있는 프록시에서 전달한 주소인지 확인해야 합니다. 로그인 없는 공개 폼에서는 IP, 세션 쿠키, 시간 구간을 조합하고 개인정보 보관 기간도 함께 정하는 편이 낫습니다.
멱등성과 속도 제한은 서로 다른 문제를 풉니다. 속도 제한은 너무 많은 시도를 거절하고, 멱등성 키는 같은 논리 요청을 재시도했을 때 메일이 두 번 나가는 일을 막습니다. Cloudflare의 idempotency_key는 같은 토큰의 Siteverify 재시도를 위한 검증 ID입니다. Resend의 idempotencyKey는 이메일 재전송을 위한 제출 ID입니다. 새 토큰을 받으면 검증 ID도 바꾸지만 제출 ID는 그대로 둡니다. Resend는 같은 키를 24시간 보관하므로 제출 ID를 성공할 때까지 유지합니다.
이름, 이메일, 본문 형식 오류는 필드 바로 아래에 표시하고 해당 입력으로 포커스를 옮깁니다.
만료된 토큰, 잘못된 hostname이나 action, 속도 제한은 폼 전체 오류로 안내합니다. 내부 오류 코드나 환경 변수 이름은 노출하지 않습니다.
Siteverify 시간 초과와 Resend 실패에는 재시도 경로를 남깁니다. Turnstile 토큰과 검증 ID는 다시 발급하되 동일한 제출 ID는 유지합니다.
환경 변수 누락과 예상하지 못한 응답 형식은 서버 로그와 모니터링 대상으로 남깁니다. 사용자 화면에는 일반화한 문구만 보냅니다.
서버에서 반환된 validationErrors도 무시하면 안 됩니다. 정상 UI에서는 공유 스키마가 먼저 잡아내지만 오래 열린 탭, 변조된 요청, 배포 버전 차이 때문에 서버 검증만 실패할 수 있습니다. flattened 결과의 fieldErrors는 해당 필드에 연결하거나 폼 상단 오류 요약에 표시하고 첫 오류로 포커스를 옮깁니다. serverError는 필드와 무관한 실패에 씁니다.