폼을 이메일로 보내기4분 분량
이메일을 보내는 Next.js 문의 폼
Server Action으로 Next.js 폼을 이메일로 보내는 두 가지 방법: Resend나 Nodemailer로 직접 보내기와 폼 백엔드로 전달하기를 솔직하게 비교합니다.
App Router에서는 Server Action이 서버에서 폼의 FormData를 받으므로 이메일 API 키를 안전하게 둘 수 있습니다. Resend나 Nodemailer로 이메일을 직접 보내거나, 스팸을 거르고 저장한 뒤 이메일을 보내 주는 폼 백엔드로 POST를 전달할 수 있습니다. 직접 보내면 이메일을 완전히 제어할 수 있고, 전달하면 직접 만들어야 했을 스팸 필터링, 레이트 리밋, 저장을 아낄 수 있습니다.
이 경우가 다른 점
Next.js에는 이미 서버가 있으므로 폼 백엔드를 쓰는 흔한 이유인 "사이트에서 코드를 실행할 수 없다"는 해당하지 않습니다. 문제는 그 코드가 무엇을 해야 하느냐입니다. 이메일을 보내는 건 쉬운 부분입니다. 시간이 드는 건 그 주변의 모든 일입니다.
- 직접 보내기: 액션이 필드를 검증하고, 허니팟을 확인하고, API 키로 이메일 제공업체를 호출합니다. 메시지는 그 이메일로만 존재합니다.
- 전달하기: 액션이 시크릿 키로 필드를 Ikiform에 POST합니다. Ikiform은 폼 정의에 맞춰 검증하고, 스팸 점수를 매기고, 제출을 저장한 뒤 수신자에게 이메일을 보냅니다.
| 작업 | Server Action과 Resend 또는 Nodemailer | Server Action과 Ikiform |
|---|---|---|
| 검증 | 액션 안의 Zod | Ikiform 쪽에서 적용되는 폼의 필드 정의 |
| 스팸 | 직접 만든 허니팟. 그 이상은 직접 추가 | 허니팟, 스팸 점수, 선택적 사람 확인 |
| 레이트 리밋 | Redis 같은 저장소를 추가하기 전까지 없음 | 방문자별, 폼별로 기본 제공 |
| 발신 도메인 | SPF와 DKIM 레코드로 직접 인증 | 필요 없음: 메일은 notifications@ikiform.com에서 발송 |
| 제목과 디자인 | 원하는 대로 | 고정: New submission: <form name> |
| 방문자에게 보내는 확인 메일 | send 호출 한 번 더 | 지원하지 않음. 이메일 도구에서 보내세요 |
| 모든 메시지의 사본 | 데이터베이스를 추가한 경우에만 | 대시보드, CSV 내보내기, API |
스팸과 저장을 직접 책임질 생각이 있고 브랜딩된 이메일이나 방문자용 확인 메일이 필요하다면 직접 만드는 버전을 고르세요. 한번 동작하면 폼에 대해 더 신경 쓰고 싶지 않다면 전달을 고르세요.
두 버전
이 폼은 프로젝트 문의 폼입니다. 이름, 회사 이메일, 회사, 예산 범위, 설명을 받습니다. 상태 타입과 클라이언트 컴포넌트는 공유하고 actions.ts만 바뀝니다.
export interface InquiryState {
sent: boolean;
errors: Partial<Record<"name" | "email" | "company" | "budget" | "message" | "form", string>>;
}
export const initialInquiry: InquiryState = { sent: false, errors: {} };"use client";
import { useActionState } from "react";
import { useFormStatus } from "react-dom";
import { sendInquiry } from "./actions";
import { initialInquiry } from "./state";
function SubmitButton() {
const { pending } = useFormStatus();
return <button disabled={pending}>{pending ? "Sending" : "Send inquiry"}</button>;
}
export function InquiryForm() {
const [state, action] = useActionState(sendInquiry, initialInquiry);
if (state.sent) return <p role="status">Thanks. We reply to every inquiry within two working days.</p>;
return (
<form action={action}>
<label htmlFor="name">Name</label>
<input id="name" name="name" autoComplete="name" required />
{state.errors.name ? <p role="alert">{state.errors.name}</p> : null}
<label htmlFor="email">Work email</label>
<input id="email" name="email" type="email" autoComplete="email" required />
{state.errors.email ? <p role="alert">{state.errors.email}</p> : null}
<label htmlFor="company">Company</label>
<input id="company" name="company" autoComplete="organization" />
<label htmlFor="budget">Budget</label>
<select id="budget" name="budget" required defaultValue="">
<option value="" disabled>Choose a range</option>
<option value="under-5k">Under $5k</option>
<option value="5k-20k">$5k to $20k</option>
<option value="over-20k">Over $20k</option>
</select>
{state.errors.budget ? <p role="alert">{state.errors.budget}</p> : null}
<label htmlFor="message">About the project</label>
<textarea id="message" name="message" rows={6} required minLength={20} />
{state.errors.message ? <p role="alert">{state.errors.message}</p> : null}
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{state.errors.form ? <p role="alert">{state.errors.form}</p> : null}
<SubmitButton />
</form>
);
}"use server";
import { Resend } from "resend";
import { z } from "zod";
import type { InquiryState } from "./state";
const resend = new Resend(process.env.RESEND_API_KEY);
const schema = z.object({
name: z.string().trim().min(1, "Tell us your name.").max(120),
email: z.email("Enter an address we can reply to."),
company: z.string().trim().max(120).optional(),
budget: z.enum(["under-5k", "5k-20k", "over-20k"], "Pick a budget range."),
message: z.string().trim().min(20, "A few sentences help us reply properly.").max(5000),
});
export async function sendInquiry(_previous: InquiryState, formData: FormData): Promise<InquiryState> {
if (formData.get("_gotcha")) return { sent: true, errors: {} };
const parsed = schema.safeParse(Object.fromEntries(formData));
if (!parsed.success) {
const fields = z.flattenError(parsed.error).fieldErrors;
return {
sent: false,
errors: Object.fromEntries(Object.entries(fields).map(([key, messages]) => [key, messages?.[0]])),
};
}
const inquiry = parsed.data;
const { error } = await resend.emails.send({
from: "Website <inquiries@yourdomain.com>",
to: ["you@yourdomain.com"],
replyTo: inquiry.email,
subject: `Project inquiry from ${inquiry.name}`,
text: [
`Name: ${inquiry.name}`,
`Email: ${inquiry.email}`,
`Company: ${inquiry.company ?? "-"}`,
`Budget: ${inquiry.budget}`,
"",
inquiry.message,
].join("\n"),
});
if (error) return { sent: false, errors: { form: "Couldn't send your inquiry. Try again." } };
return { sent: true, errors: {} };
}"use server"파일은 async 함수만 내보낼 수 있으므로 상태 타입은 별도 파일에 둡니다.useFormStatus는 자신이 들어 있는 폼의 대기 상태를 읽으므로 버튼은 별도 컴포넌트에 둡니다.- Resend 버전에서는 허니팟이 채워져 있으면 보내지 않고 성공을 반환하므로 봇은 아무것도 알 수 없습니다. Ikiform도 POST를 스팸으로 저장할 때 똑같이 합니다.
- Resend 버전의
replyTo와 Ikiform 버전의email필드 모두 Reply가 방문자에게 답장하게 합니다. - Nodemailer도 Resend와 같은 방식입니다. SMTP 정보로 transport를 만들고
from,to,replyTo,subject,text로sendMail을 호출하세요. - Ikiform 버전에서는 폼의 Fields 탭에서
budget을select로 하여 다섯 필드를 정의하세요. 그러면 잘못된 값은 필드별 메시지와 함께422 validation_failed를 받습니다.
IKIFORM_SECRET_KEY는 서버 전용 환경 변수에 두세요. 서버 자체의 fetch는 이 헤더를 보내지 않고 Ikiform은 무료 localhost 테스트 제출을 이 헤더로 집계하므로, 각 서버 예제는 브라우저의 Origin 헤더가 있으면 그대로 전달합니다.세 번째 방법도 있습니다. 액션을 건너뛰고 브라우저가 Ikiform으로 바로 POST하게 하는 것입니다. 시크릿 키가 없으므로 Standard 레이트 리밋과 Allowed domains가 방문자별로 적용됩니다. 클라이언트 컴포넌트는 Next.js 문서 가이드에서 보여 줍니다.
동작 확인하기
- 예산을 비워 두고 제출하세요. 오류가 select 아래에 표시되고 다른 입력은 사라지지 않습니다.
- 실제 문의를 제출하세요. 성공 문구가 폼을 대신합니다.
- 이메일을 열고 Reply를 누르세요. To 줄에 입력한 주소가 있습니다.
- 브라우저 개발자 도구로 숨겨진 허니팟을 채우고 제출하세요. 성공이 표시되지만 이메일은 오지 않습니다.
- 대시보드에서 Submissions를 여세요. 보낸 모든 필드가 담긴 새 제출이 있습니다. 이 단계는 Ikiform 버전에만 해당합니다.
주의할 점
- HTML 이메일의 이스케이프. Resend 버전은
text를 보냅니다.html로 바꾼다면 모든 값을 먼저 이스케이프하세요. 그러지 않으면 방문자가 마크업과 링크를 받은편지함에 넣을 수 있습니다. - 헤더 인젝션. 폼 값을 가공하지 않은 채
from이나to에 넣지 마세요. Zod가 이미 이메일인지 확인했으므로 Resend의replyTo는 안전합니다. - 환경 변수.
RESEND_API_KEY와IKIFORM_SECRET_KEY는NEXT_PUBLIC_으로 시작하면 안 됩니다. 그러면 Next.js가 브라우저로 보냅니다. - 중복 발송. 더블 클릭하면 액션이 두 번 실행될 수 있습니다. 대기 상태가 버튼을 비활성화하지만 느린 네트워크에서는 여전히 경합이 생길 수 있습니다. Resend의
idempotencyKey옵션이나 Ikiform의Idempotency-Key헤더를 쓰면 반복 요청은 아무 일도 하지 않습니다. - Server Action은 공개 엔드포인트입니다. 누구나 직접 호출할 수 있으므로 검증과 스팸 검사는 폼에서만이 아니라 액션 안에서 실행되어야 합니다.
문제가 생기면
| 문제 | 해결 방법 |
|---|---|
| Resend가 도메인이 인증되지 않았다고 응답함 | from에 있는 도메인에 Resend의 DNS 레코드를 추가하거나, 개발하는 동안에는 Resend의 테스트 주소에서 보내세요. |
429 rate_limited | 한 방문자가 폼의 레이트 리밋보다 많이 보냈습니다. Standard는 방문자당 분당 5회입니다. Retry-After만큼 기다리거나, 공유 IP 하나에서 POST하는 폼에는 Relaxed나 Custom을 고르세요. |
422 validation_failed | POST가 폼의 필드 정의와 맞지 않습니다. error.fields에 각 필드와 이유가 있습니다. 입력의 name을 필드 ID와 맞추세요. |
| 액션이 로컬에서는 동작하지만 프로덕션에서는 안 됨 | 배포 환경 변수에 키가 없습니다. 프로덕션 환경에 키를 추가하고 다시 배포하세요. |
질문
네, Server Action이 기본으로 쓰는 Node.js 런타임에서 동작합니다. SMTP 연결을 열기 때문에 Edge 런타임에서는 실행되지 않으며, 일부 호스팅은 외부로 나가는 SMTP를 제한합니다. Resend 같은 HTTP 이메일 API를 쓰면 두 문제를 모두 피할 수 있습니다.
서버에서 실행되는 Server Action으로 폼을 처리하세요. 거기서 Resend나 Nodemailer 같은 이메일 API를 호출하거나, 이메일을 보내 주는 폼 백엔드로 필드를 전달하세요.
네, 이름이 NEXT_PUBLIC_으로 시작하지 않는 한 API 키는 서버에 머뭅니다. Server Action은 직접 호출될 수 있으므로 모든 필드를 액션 안에서 검증하세요.
이메일을 보내는 데는 필요 없습니다. 이메일이 사라져도 남는 메시지 기록을 원한다면 필요합니다. 폼 백엔드는 그 기록을 대신 보관합니다.
아니요. Ikiform은 여러분과 팀에게만 이메일을 보냅니다. 확인 메일은 Server Action이나 웹훅에서 여러분의 이메일 제공업체로 보내세요.
막히는 단계가 있거나 내 상황에 맞는 가이드가 필요하다면 이메일을 보내세요: hello@ikiform.com.