문의 폼 한 건이 메일로 가기까지 글 표지
CloudflareTurnstileResendReact Hook FormZodNext Safe ActionTypeScriptServer Action

문의 폼 한 건이 메일로 가기까지

포트폴리오 문의 폼에 Turnstile과 Resend를 붙인 과정과, 실제 운영 전에 보완해야 할 점을 정리했습니다.

이 포트폴리오의 문의 폼은 React Hook Form과 Zod로 입력을 확인하고, Turnstile 검증을 통과한 요청을 Resend로 보냅니다.

현재 문의 폼의 전송 흐름

이름과 이메일, 메시지 입력 아래에 오류가 표시된 문의 폼

현재 구현은 입력 검증부터 메일 전송까지 이어지는 기본 흐름입니다.

  1. React Hook Form이 입력 상태를 관리하고 Zod 스키마로 이름, 이메일, 메시지를 검사합니다.
  2. Turnstile 위젯에서 확인을 마치면 발급된 토큰과 폼 값을 서버로 보냅니다.
  3. 서버에서 입력과 Turnstile 토큰을 검증합니다.
  4. 검증에 성공한 요청을 Resend로 전달합니다.
문의 폼 위에 열린 Cloudflare Turnstile 확인 창

제출과 재시도 흐름을 보강한다면

아래 예제는 설치된 next-safe-action v7 문법을 기준으로 실제 코드를 운영 환경에 맞게 다시 다듬은 보강안입니다. 제출값 스냅샷, hostname·action 확인, 속도 제한과 멱등성 처리는 아직 현재 구현에 들어가 있지 않습니다.

클라이언트와 서버가 공유하는 입력 스키마

클라이언트 스키마와 서버 스키마를 따로 만들면 길이 제한이나 오류 문구가 어긋나기 쉽습니다. 사람이 작성하는 필드와 서버 전송용 필드는 구분하되 앞부분은 같은 스키마를 재사용합니다.

import { z } from 'zod';
 
export const ContactFieldsSchema = z.object({
  name: z
    .string()
    .trim()
    .min(1, '이름을 입력해 주세요.')
    .max(80, '이름은 80자 이하로 입력해 주세요.'),
  email: z
    .string()
    .trim()
    .email('이메일 주소를 확인해 주세요.')
    .max(254, '이메일 주소가 너무 깁니다.'),
  message: z
    .string()
    .trim()
    .min(10, '문의 내용은 10자 이상 입력해 주세요.')
    .max(4000, '문의 내용은 4,000자 이하로 입력해 주세요.')
});
 
export const ContactActionSchema = ContactFieldsSchema.extend({
  turnstileToken: z.string().min(1).max(2048),
  turnstileValidationId: z.string().uuid(),
  submissionId: z.string().uuid()
});
 
export type ContactFields = z.infer<typeof ContactFieldsSchema>;

ContactFieldsSchema로 브라우저에서 입력 오류를 바로 안내하고, 서버 전송용 필드를 더한 ContactActionSchema로 요청을 다시 검사합니다. 요청 본문은 변조될 수 있으므로 서버에서도 검증합니다.

제출한 값과 Turnstile 토큰 묶기

Turnstile을 모달에서 실행하면 폼 제출과 토큰 발급 사이에 시간이 생깁니다. 이때 getValues()를 다시 호출하면 사용자가 그사이 수정한 값이 섞일 수 있습니다. handleSubmit이 넘긴 스냅샷을 ref에 보관한 뒤 토큰 콜백에서 꺼내면 값의 흐름이 분명해집니다.

'use client';
 
import { useRef, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useAction } from 'next-safe-action/hooks';
import { useForm, type SubmitHandler } from 'react-hook-form';
import { contactSubmit } from '@/app/actions';
import { TurnstileDialog } from '@/components/contact/turnstile-dialog';
import {
  ContactFieldsSchema,
  type ContactFields
} from '@/lib/contact-contract';
 
type PendingSubmission = {
  fields: ContactFields;
  submissionId: string;
};
 
export function ContactForm() {
  const form = useForm<ContactFields>({
    resolver: zodResolver(ContactFieldsSchema),
    defaultValues: { name: '', email: '', message: '' },
    shouldFocusError: true
  });
  const pendingSubmission = useRef<PendingSubmission | null>(null);
  const [isChallengeOpen, setChallengeOpen] = useState(false);
  const { executeAsync, result, status } = useAction(contactSubmit);
 
  const onValid: SubmitHandler<ContactFields> = (fields) => {
    pendingSubmission.current = {
      fields,
      submissionId: crypto.randomUUID()
    };
    setChallengeOpen(true);
  };
 
  const onVerify = async (turnstileToken: string) => {
    const pending = pendingSubmission.current;
    setChallengeOpen(false);
 
    if (!pending || !turnstileToken) {
      form.setError('root.server', {
        message: '로봇 확인을 다시 진행해 주세요.'
      });
      return;
    }
 
    try {
      const actionResult = await executeAsync({
        ...pending.fields,
        turnstileToken,
        turnstileValidationId: crypto.randomUUID(),
        submissionId: pending.submissionId
      });
 
      if (actionResult?.data?.sent) {
        pendingSubmission.current = null;
        form.reset();
      }
    } catch {
      form.setError('root.server', {
        message: '네트워크 연결을 확인한 뒤 다시 시도해 주세요.'
      });
    }
  };
 
  const localError = form.formState.errors.root?.server?.message;
  const serverError = result.serverError;
  const isSubmitting = status === 'executing';
 
  return (
    <>
      <form onSubmit={form.handleSubmit(onValid)} noValidate>
        <label htmlFor="contact-name">이름</label>
        <input
          id="contact-name"
          aria-invalid={Boolean(form.formState.errors.name)}
          aria-describedby="contact-name-error"
          {...form.register('name')}
        />
        <p id="contact-name-error" role="alert">
          {form.formState.errors.name?.message}
        </p>
 
        {/* email과 message도 같은 label, aria-describedby 구조를 쓴다. */}
 
        <button type="submit" disabled={isSubmitting}>
          {isSubmitting ? '보내는 중' : '문의 보내기'}
        </button>
 
        <p role="alert" aria-live="polite">
          {localError ?? serverError}
        </p>
        <p aria-live="polite">
          {result.data?.sent ? '문의가 전송되었습니다.' : null}
        </p>
      </form>
 
      <TurnstileDialog
        open={isChallengeOpen}
        action="contact"
        onVerify={onVerify}
        onExpire={() =>
          form.setError('root.server', {
            message: '로봇 확인 시간이 만료되었습니다. 다시 시도해 주세요.'
          })
        }
        onError={() =>
          form.setError('root.server', {
            message: '로봇 확인을 불러오지 못했습니다.'
          })
        }
      />
    </>
  );
}

이 예제에서 useState는 모달 표시 여부를 관리합니다. 제출 스냅샷과 제출 ID는 화면을 다시 그릴 이유가 없으므로 ref에 보관합니다. 전송에 실패하면 스냅샷과 제출 ID를 유지하고, 재시도할 때는 새 Turnstile 토큰과 새 검증 ID를 만듭니다. 토큰은 300초 뒤 만료되며 한 번만 사용할 수 있기 때문입니다.

이 예제를 폼에 적용할 때는 각 필드 오류를 해당 입력과 연결합니다. 폼 전체 오류는 role="alert"로 읽히게 하고 성공 문구는 aria-live="polite" 영역에 둡니다. 모달을 닫을 때는 전송 버튼으로 포커스를 돌려야 키보드 사용자도 현재 위치를 잃지 않습니다.

서버 입력과 Siteverify 응답 검증

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는 필드와 무관한 실패에 씁니다.

메일 전송 이후의 운영

사람이 직접 보내는 광고성 문의나 분산된 저속 공격은 Turnstile 검증만으로 막기 어렵습니다. 속도 제한과 로그, 발신 도메인 설정, 실패 알림을 함께 운영할 필요가 있습니다. 이메일 API가 성공을 반환해도 최종 수신함에 도착했는지는 별도로 확인해야 합니다.

입력 검증, Turnstile 확인, 메일 전송은 연결했지만 반복 요청과 중복 전송 처리는 남아 있습니다. 위 보강안은 제출한 값을 유지하고, 실패한 문의를 다시 보낼 때도 같은 요청으로 다루기 위한 설계입니다.

참고 문서