# 이메일을 보내는 Next.js 문의 폼

> Server Action으로 Next.js 폼을 이메일로 보내는 두 가지 방법: Resend나 Nodemailer로 직접 보내기와 폼 백엔드로 전달하기를 솔직하게 비교합니다.

2026-10-25 게시 · https://ikiform.com/ko/guides/send-form-to-email/nextjs

App Router에서는 Server Action이 서버에서 폼의 `FormData`를 받으므로 이메일 API 키를 안전하게 둘 수 있습니다. Resend나 Nodemailer로 이메일을 직접 보내거나, 스팸을 거르고 저장한 뒤 이메일을 보내 주는 폼 백엔드로 POST를 전달할 수 있습니다. 직접 보내면 이메일을 완전히 제어할 수 있고, 전달하면 직접 만들어야 했을 스팸 필터링, 레이트 리밋, 저장을 아낄 수 있습니다.

## 이 경우가 다른 점

Next.js에는 이미 서버가 있으므로 폼 백엔드를 쓰는 흔한 이유인 "사이트에서 코드를 실행할 수 없다"는 해당하지 않습니다. 문제는 그 코드가 무엇을 해야 하느냐입니다. 이메일을 보내는 건 쉬운 부분입니다. 시간이 드는 건 그 주변의 모든 일입니다.

다이어그램: Server Action이 이메일을 직접 보낼 때와 POST를 전달할 때 하는 일

1. 직접 보내기: 액션이 필드를 검증하고, 허니팟을 확인하고, API 키로 이메일 제공업체를 호출합니다. 메시지는 그 이메일로만 존재합니다.
2. 전달하기: 액션이 시크릿 키로 필드를 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`만 바뀝니다.

`app/inquiry/state.ts`:

```ts
export interface InquiryState {
  sent: boolean;
  errors: Partial<Record<"name" | "email" | "company" | "budget" | "message" | "form", string>>;
}

export const initialInquiry: InquiryState = { sent: false, errors: {} };
```

`app/inquiry/inquiry-form.tsx`:

```tsx
"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>
  );
}
```

**Resend** (`app/inquiry/actions.ts`)

```ts
"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: {} };
}
```

**Ikiform** (`app/inquiry/actions.ts`)

```ts
"use server";

import { headers } from "next/headers";
import type { InquiryState } from "./state";

export async function sendInquiry(_previous: InquiryState, formData: FormData): Promise<InquiryState> {
  const origin = (await headers()).get("origin");
  const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
    method: "POST",
    headers: {
      Accept: "application/json",
      "Content-Type": "application/json",
      Authorization: `Bearer ${process.env.IKIFORM_SECRET_KEY}`,
      ...(origin ? { Origin: origin } : {}),
    },
    body: JSON.stringify({
      name: formData.get("name"),
      email: formData.get("email"),
      company: formData.get("company"),
      budget: formData.get("budget"),
      message: formData.get("message"),
      _gotcha: formData.get("_gotcha") ?? "",
    }),
  });
  const result = await response.json().catch(() => null);
  if (result?.ok) return { sent: true, errors: {} };

  const errors: InquiryState["errors"] = {};
  for (const [field, messages] of Object.entries<string[]>(result?.error?.fields ?? {})) {
    errors[field as keyof InquiryState["errors"]] = messages[0];
  }
  if (!Object.keys(errors).length) errors.form = result?.error?.message ?? "Couldn't send your inquiry. Try again.";
  return { sent: false, 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`를 받습니다.

> 서버 측 버전은 시크릿 키로 POST하므로 Ikiform이 **Allowed domains**(허용 도메인)와 사람 확인을 건너뜁니다. 그 대신 모든 POST가 서버의 IP에서 오기 때문에, 방문자당 분당 5회인 Standard 한도가 모든 방문자를 합쳐서 집계됩니다. 서버를 거쳐 POST하는 폼에는 Relaxed나 Custom [레이트 리밋](https://ikiform.com/ko/docs/rate-limits.md)을 고르고, `IKIFORM_SECRET_KEY`는 서버 전용 환경 변수에 두세요. 서버 자체의 `fetch`는 이 헤더를 보내지 않고 Ikiform은 무료 `localhost` 테스트 제출을 이 헤더로 집계하므로, 각 서버 예제는 브라우저의 `Origin` 헤더가 있으면 그대로 전달합니다.

세 번째 방법도 있습니다. 액션을 건너뛰고 브라우저가 Ikiform으로 바로 POST하게 하는 것입니다. 시크릿 키가 없으므로 Standard 레이트 리밋과 **Allowed domains**가 방문자별로 적용됩니다. 클라이언트 컴포넌트는 [Next.js 문서 가이드](https://ikiform.com/ko/docs/guides/nextjs.md)에서 보여 줍니다.

## 동작 확인하기

1. 예산을 비워 두고 제출하세요. 오류가 select 아래에 표시되고 다른 입력은 사라지지 않습니다.
2. 실제 문의를 제출하세요. 성공 문구가 폼을 대신합니다.
3. 이메일을 열고 **Reply**를 누르세요. **To** 줄에 입력한 주소가 있습니다.
4. 브라우저 개발자 도구로 숨겨진 허니팟을 채우고 제출하세요. 성공이 표시되지만 이메일은 오지 않습니다.
5. 대시보드에서 **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와 맞추세요. |
| 액션이 로컬에서는 동작하지만 프로덕션에서는 안 됨 | 배포 환경 변수에 키가 없습니다. 프로덕션 환경에 키를 추가하고 다시 배포하세요. |

## 질문

### Nodemailer는 Server Action에서 동작하나요?

네, Server Action이 기본으로 쓰는 Node.js 런타임에서 동작합니다. SMTP 연결을 열기 때문에 Edge 런타임에서는 실행되지 않으며, 일부 호스팅은 외부로 나가는 SMTP를 제한합니다. Resend 같은 HTTP 이메일 API를 쓰면 두 문제를 모두 피할 수 있습니다.

### Next.js 폼에서 이메일을 보내려면 어떻게 하나요?

서버에서 실행되는 Server Action으로 폼을 처리하세요. 거기서 Resend나 Nodemailer 같은 이메일 API를 호출하거나, 이메일을 보내 주는 폼 백엔드로 필드를 전달하세요.

### Server Action에서 Resend를 호출해도 안전한가요?

네, 이름이 NEXT_PUBLIC_으로 시작하지 않는 한 API 키는 서버에 머뭅니다. Server Action은 직접 호출될 수 있으므로 모든 필드를 액션 안에서 검증하세요.

### Next.js 문의 폼에 데이터베이스가 필요한가요?

이메일을 보내는 데는 필요 없습니다. 이메일이 사라져도 남는 메시지 기록을 원한다면 필요합니다. 폼 백엔드는 그 기록을 대신 보관합니다.

### Ikiform이 방문자에게 확인 이메일을 보낼 수 있나요?

아니요. Ikiform은 여러분과 팀에게만 이메일을 보냅니다. 확인 메일은 Server Action이나 웹훅에서 여러분의 이메일 제공업체로 보내세요.

## 가이드 더 보기

- [폼을 이메일로 보내기](https://ikiform.com/ko/guides/send-form-to-email.md): 메일 서버 없이 모든 폼 제출을 받은편지함으로 받으세요. mailto:와 PHP mail()이 실패하는 이유와, 스팸을 거르고 바로 답장할 수 있는 설정을 다룹니다.
- [EmailJS 대안](https://ikiform.com/ko/guides/send-form-to-email/emailjs-alternative.md): 문의 폼을 EmailJS에서 옮기세요. 브라우저 측 이메일 키가 위험한 이유, 코드에서 바뀌는 점, 자동 응답처럼 포기하는 기능을 다룹니다.
- [여러 수신자](https://ikiform.com/ko/guides/send-form-to-email/multiple-recipients.md): 폼이 직접 수신자를 고르게 하지 않고도, 폼 하나를 팀 전체에 이메일로 보내거나 주제별로 메시지를 적절한 사람에게 보내세요.
- [받은편지함에서 답장하기](https://ikiform.com/ko/guides/send-form-to-email/reply-to.md): 폼 알림에서 답장을 누르면 폼을 작성한 사람에게 가도록 하세요. 어떤 필드 이름이 동작하는지, _replyto는 언제 쓰는지, 답장이 엉뚱한 곳으로 가는 이유를 다룹니다.
- [PHP 없이](https://ikiform.com/ko/guides/send-form-to-email/without-php.md): mail()을 호출하는 contact.php 스크립트를 호스팅 엔드포인트로 POST하는 폼으로 바꾸세요. 필드 이름, 감사 페이지, 답장은 그대로 유지됩니다.
- [Gmail로](https://ikiform.com/ko/guides/send-form-to-email/gmail.md): mailto: 링크, 앱 비밀번호, Apps Script 없이 Gmail에서 폼 제출을 받고, 라벨을 붙이고, 스팸함을 피하고, Gmail에서 답장하세요.
