# Netlify Forms 없이 Netlify에 문의 폼 추가하기

> Netlify Forms 없이 Netlify 사이트에서 문의 폼을 운영하세요. 빌드 시점 감지 없이 React에서 JSON을 POST하고, 배포 프리뷰용 폼을 따로 둡니다.

2026-10-25 게시 · https://ikiform.com/ko/guides/contact-form-static-site/netlify

폼에서 `data-netlify`를 빼고 `action`을 Ikiform 엔드포인트로 지정하세요. 그러면 Netlify는 페이지만 제공하고 POST에는 관여하지 않습니다. React와 Vue 폼은 숨겨진 HTML 사본이 필요 없고 JSON을 POST할 수 있습니다. 프리뷰 호스트는 **Allowed domains**에서 사이트와 일치하지 않으므로, `netlify.toml`에서 배포 프리뷰에 다른 폼 ID를 설정하세요.

## Netlify에서 다른 점

Netlify는 주요 정적 호스트 중 유일하게 폼 기능이 내장되어 있습니다. Netlify Forms는 빌드할 때마다 배포된 HTML을 읽어 `data-netlify`나 `netlify`가 표시된 폼을 찾고, 그 폼에 대해서는 여러분의 사이트로 보낸 POST를 받습니다. 그 밖의 정적 페이지 POST는 모두 실패하며, Netlify에서는 `405`가 아니라 `404`로 실패합니다.

`터미널`:

```bash
curl -i -X POST -d "message=hello" https://your-site.netlify.app/contact/
```

`응답`:

```http
HTTP/2 404
server: Netlify
cache-status: "Netlify Edge"; fwd=method; fwd-status=404
content-length: 0
```

이 `404` 때문에 사람들은 사라지지도 않은 페이지를 찾아 헤맵니다. 실제 의미는 감지된 폼 중 POST와 일치하는 것이 없다는 것입니다. Netlify Forms를 쓸지는 별개의 문제이며, [Ikiform과 Netlify Forms 비교](https://ikiform.com/alternatives/netlify-forms)에서 솔직하게 답합니다. 사이트가 언제까지나 Netlify에 있고 폼이 일반 HTML이라면 Netlify Forms가 더 수고가 적습니다. 이 페이지는 그 밖의 경우를 위한 것입니다.

| 여러분의 상황 | Netlify Forms가 걸림돌이 되는 이유 |
| --- | --- |
| 폼을 React, Vue, Svelte로 렌더링함 | 빌드는 정적 HTML을 파싱하므로, 클라이언트에서 렌더링하는 폼은 Netlify가 찾을 수 있게 숨겨진 사본이 필요합니다 |
| `fetch`로 JSON을 POST하고 싶음 | Netlify Forms는 urlencoded와 multipart 본문만 받습니다 |
| 사이트를 다른 호스트로 옮길 수도 있음 | 폼이 여러분의 도메인으로 POST하므로 Netlify가 그 도메인을 제공하는 동안에만 작동합니다 |
| 메시지가 직접 검증하는 웹훅으로 가야 함 | Ikiform은 모든 전달에 Standard Webhooks 헤더로 서명하고 재시도합니다 |

다이어그램: 같은 Netlify 사이트에서 나란히 쓰는 Netlify Forms와 Ikiform

1. Netlify Forms를 쓰면 Netlify가 배포마다 HTML을 스캔해 data-netlify가 표시된 폼을 찾습니다.
2. 찾은 폼은 여러분의 사이트로 POST를 받고, Netlify가 이를 저장합니다.
3. 숨겨진 사본 없이 JavaScript로 렌더링한 폼처럼 찾지 못한 폼은 모든 POST에서 404를 받습니다.
4. Ikiform을 쓰면 빌드 시점에 아무것도 찾을 필요가 없습니다.
5. 폼이 HTML이나 JSON으로 ikiform.com에 바로 POST하고, Ikiform이 저장하고 이메일로 알려 줍니다.

둘은 한 사이트에 함께 있을 수 있습니다. data-netlify가 있는 폼은 Netlify로, action이 Ikiform 엔드포인트인 폼은 Ikiform으로 갑니다.

## Netlify Forms에서 폼 옮기기

- 폼에서 `data-netlify` 또는 `netlify`, `netlify-honeypot`, `data-netlify-recaptcha`를 지우고, 숨겨진 `form-name` 입력도 지우세요. 남겨 두면 `form-name`이 일반 필드로 저장됩니다.
- 아래와 같은 JavaScript 폼의 숨겨진 정적 사본을 지우세요. Netlify가 폼을 감지하는 데만 필요했던 것입니다.
- `action` 또는 `fetch` URL을 Ikiform 엔드포인트로 설정하세요. 기존의 `action="/thanks"`는 폼의 **Redirect after submit** 설정으로 옮깁니다.
- 먼저 Netlify의 **Forms** 페이지에서 지난 제출을 내려받으세요. 자동으로 옮겨지지 않습니다.

`index.html, 이 부분 삭제`:

```html
<form name="contact" netlify hidden>
  <input name="name" />
  <input name="email" />
  <textarea name="message"></textarea>
</form>
```

사이트의 다른 폼이 아직 Netlify Forms를 쓴다면 **form detection**을 켜 두세요. Netlify 문서에 따르면 감지를 끄는 것은 Netlify Forms를 쓰지 않는 사이트만을 위한 것입니다. 더 이상 쓰는 폼이 없다면 끄면 배포할 때마다 스캔을 하나 줄일 수 있습니다.

### Netlify의 스팸 설정을 대신하는 것

Netlify는 제출에 Akismet을 실행하고 `netlify-honeypot`과 reCAPTCHA를 제공합니다. Ikiform에서는 폼의 **Honeypot field**를 `bot-field`처럼 이미 쓰는 이름으로 설정하면 허니팟이 계속 작동합니다. 스팸 점수는 설정 없이 모든 POST에 적용되고, **Spam check**를 **Ikiform**으로 설정하면 키 없이 reCAPTCHA 대신 Cloudflare Turnstile을 씁니다. 전환할 때 reCAPTCHA `div`를 지우세요.

## JSON을 POST하는 React 폼

Netlify Forms가 끼어들지 않으면 클라이언트에서 렌더링하는 폼에 사본이 필요 없고, JSON을 보낼 수 있습니다. JSON은 타입을 유지하고 값을 먼저 다듬을 수 있게 해 줍니다. 이 예제는 어떤 Netlify 컨텍스트가 페이지를 빌드했는지도 기록합니다.

`src/ContactForm.tsx`:

```tsx
import { type FormEvent, useState } from "react";

const ENDPOINT = `https://ikiform.com/f/${import.meta.env.VITE_IKIFORM_FORM_ID}`;

interface Message {
  name: string;
  email: string;
  message: string;
  context: string;
}

export function ContactForm() {
  const [sent, setSent] = useState(false);
  const [error, setError] = useState("");

  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const data = new FormData(event.currentTarget);
    const body: Message & Record<string, string> = {
      name: String(data.get("name") ?? "").trim(),
      email: String(data.get("email") ?? "").trim(),
      message: String(data.get("message") ?? "").trim(),
      context: import.meta.env.VITE_CONTEXT ?? "local",
      _gotcha: String(data.get("_gotcha") ?? ""),
    };
    setError("");
    const response = await fetch(ENDPOINT, {
      method: "POST",
      headers: { "Content-Type": "application/json", Accept: "application/json" },
      body: JSON.stringify(body),
    }).catch(() => null);
    const result = await response?.json().catch(() => null);
    if (result?.ok) return setSent(true);
    const fields: Record<string, string[]> = result?.error?.fields ?? {};
    setError(Object.values(fields)[0]?.[0] ?? result?.error?.message ?? "Couldn't send. Try again.");
  }

  if (sent) return <p role="status">Got it. I'll answer from my own inbox, usually the same day.</p>;

  return (
    <form onSubmit={onSubmit}>
      <label htmlFor="name">Name</label>
      <input id="name" name="name" autoComplete="name" required />
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" autoComplete="email" required />
      <label htmlFor="message">Message</label>
      <textarea id="message" name="message" rows={6} minLength={10} required />
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {error ? <p role="alert">{error}</p> : null}
      <button>Send</button>
    </form>
  );
}
```

- `Content-Type: application/json`은 Ikiform에 JSON을 파싱하라고 알리고, `Accept: application/json`은 JSON 응답을 요청합니다.
- `VITE_CONTEXT`는 예시 이름입니다. Netlify는 빌드 중에 `CONTEXT`를 설정합니다. 빌드 명령에서 Vite에 노출하거나, 이 필드를 빼세요.
- 허니팟은 JSON 키로 보냅니다. 입력이 숨겨져 있어도 본문에 남겨 두어야 이를 채우는 봇이 스스로 드러납니다.

## 배포 프리뷰에는 별도의 폼

배포 프리뷰는 `deploy-preview-12--your-site.netlify.app` 같은 호스트에 있습니다. 이는 `your-site.netlify.app`의 하위 도메인이 아니므로 **Allowed domains**에서 일치하지 않고, 프리뷰 POST는 스팸으로 분류됩니다. `netlify.toml`에서 프리뷰와 브랜치 배포에 별도의 폼을 주고, 같은 파일에서 Content-Security-Policy 헤더를 설정하세요.

`netlify.toml`:

```toml
[build]
  command = "npm run build"
  publish = "dist"

[context.production.environment]
  VITE_IKIFORM_FORM_ID = "frm_xxxxxxxxxxxxxxxx"

[context.deploy-preview.environment]
  VITE_IKIFORM_FORM_ID = "frm_yyyyyyyyyyyyyyyy"

[context.branch-deploy.environment]
  VITE_IKIFORM_FORM_ID = "frm_yyyyyyyyyyyyyyyy"

[[headers]]
  for = "/*"
  [headers.values]
    Content-Security-Policy = "default-src 'self'; form-action 'self' https://ikiform.com; connect-src 'self' https://ikiform.com"
```

프리뷰 폼의 **Allowed domains**는 비워 두거나, 테스트하는 동안 정확한 프리뷰 호스트를 추가하세요. 프로덕션 폼에는 여러분의 도메인과 `your-site.netlify.app`을 넣습니다. `netlify.toml`의 헤더는 모든 배포 컨텍스트에 적용됩니다. `_headers` 파일을 선호한다면 게시 디렉터리에 들어가야 합니다.

> **Allowed domains**에 `netlify.app`만 단독으로 추가하지 마세요. 다른 사람의 사이트에 있는 폼 복사본을 포함해 모든 Netlify 사이트가 일치하게 됩니다.

## 작동 확인하기

1. 배포 프리뷰를 열어 제출하고, 메시지가 프리뷰 폼에 도착하는지 확인하세요.
2. 병합한 뒤 프로덕션에서 제출하세요. 프로덕션 폼에 도착하고, Netlify의 **Forms** 페이지에는 새로운 것이 없는지 확인하세요.
3. 알림 수신자마다 받은편지함을 확인하세요. 각자 필드가 담긴 이메일을 받고, 폼에 `email` 필드가 있으면 **Reply**가 제출한 사람에게 갑니다.
4. 허니팟을 채워 `_gotcha=bot`으로 POST하세요. 여전히 성공 응답을 받지만, 제출은 스팸으로 저장되고 이메일이나 웹훅을 보내지 않습니다.
5. 아무것도 오지 않았나요? 대시보드의 상태 필터를 **Spam**으로 바꿔 보세요. 스팸은 실제 제출과 같은 성공 응답을 받습니다.

## 주의할 점

| 문제 | 해결 방법 |
| --- | --- |
| 제출 시 `404` | POST가 여러분의 사이트로 갔습니다. 캐시된 빌드에 이전 `action`이 남아 있거나, `fetch` 호출이 아직 `/`로 POST합니다. 게시 디렉터리에서 `form-name`을 검색하세요. |
| 제출이 여전히 Netlify에 나타남 | 페이지의 어떤 폼이 아직 `data-netlify`나 `netlify`와 함께 여러분의 사이트로 POST하므로 Netlify가 계속 감지하고 저장합니다. 속성을 지우고, `action`을 Ikiform으로 설정하고, 다시 배포하세요. |
| 프리뷰에서만 Ikiform이 `404 not_found` | 배포 프리뷰 컨텍스트에 `VITE_IKIFORM_FORM_ID`가 없어서 URL이 `undefined`로 끝납니다. `[context.deploy-preview.environment]` 아래에 추가하세요. |
| `403 origin_not_allowed`, 또는 모든 POST가 스팸으로 분류됨 | 페이지의 도메인이 폼의 **Allowed domains**에 없습니다. 도메인을 추가하고, 로컬 테스트용으로 `localhost`도 추가하세요. 등록한 도메인의 하위 도메인은 허용됩니다. |
| `422 files_not_supported` | 폼에 파일이 선택된 `<input type="file">`이 있습니다. 파일 업로드는 아직 지원하지 않습니다. 입력을 지우거나 대신 링크를 요청하세요. |

## 질문

### 사이트는 Netlify에 두고 다른 폼 백엔드를 써도 되나요?

네. Netlify는 빌드 시점에 감지한 폼만 처리합니다. data-netlify가 없고 action이 다른 엔드포인트를 가리키는 폼은 Netlify가 다른 페이지처럼 제공하며, 그 엔드포인트로 바로 POST합니다.

### Netlify에서 폼이 404를 받는 이유는 무엇인가요?

Netlify는 배포된 HTML에서 Netlify Forms가 그 폼을 감지하지 않았다면 정적 페이지로 보낸 POST에 404로 응답합니다. 폼을 Netlify Forms용으로 표시하거나, action을 다른 백엔드로 지정하세요.

### 한 폼은 Netlify Forms로 두고 다른 폼은 Ikiform을 써도 되나요?

네. 감지는 속성에 따라 폼마다 이루어집니다. Netlify가 처리할 폼에는 data-netlify를 남기고 form detection을 켜 두세요.

### 전환하면 예전 Netlify 제출은 사라지나요?

Ikiform이 아니라 Netlify에 남습니다. form detection을 끄기 전에 사이트의 Forms 페이지에서 내려받아 기록과 함께 보관하세요.

### 폼으로 파일 업로드를 받을 수 있나요?

아직은 안 됩니다. 파일이 포함된 POST는 422 files_not_supported로 거부됩니다. 대신 공유 드라이브 URL 같은 파일 링크를 요청하세요.

## 가이드 더 보기

- [정적 사이트의 문의 폼](https://ikiform.com/ko/guides/contact-form-static-site.md): 서버 없이 정적 사이트에 작동하는 문의 폼을 추가하세요. POST가 가는 곳, 스팸, 이메일 답장, 호스트와 프레임워크별 코드까지 다룹니다.
- [Vercel](https://ikiform.com/ko/guides/contact-form-static-site/vercel.md): Vercel의 정적 페이지는 폼 POST에 405 Method Not Allowed로 응답합니다. 그 이유와 폼을 백엔드로 보내는 방법을 알아보세요.
- [Cloudflare Pages](https://ikiform.com/ko/guides/contact-form-static-site/cloudflare-pages.md): Function을 작성하지 않고 Cloudflare Pages에 문의 폼을 추가하세요. 폼 백엔드로 POST하고, _headers에서 헤더를 설정하고, 직접 가진 Turnstile 키를 씁니다.
- [GitHub Pages](https://ikiform.com/ko/guides/contact-form-static-site/github-pages.md): GitHub Pages는 서버 코드를 실행할 수 없어 폼 POST가 405 Not Allowed를 받습니다. 대신 폼 백엔드로 POST하고 프로젝트 사이트의 리디렉션을 올바르게 설정하세요.
- [Amazon S3](https://ikiform.com/ko/guides/contact-form-static-site/aws-s3.md): S3 웹사이트 엔드포인트는 일반 HTTP로 GET과 HEAD만 받으므로, 문의 폼은 폼 백엔드로 POST하고 CloudFront로 리디렉션을 올바르게 설정하세요.
- [포트폴리오 웹사이트](https://ikiform.com/ko/guides/contact-form-static-site/portfolio-website.md): 첫 통화에서 물어볼 프로젝트, 예산, 일정, 링크를 미리 묻는 포트폴리오 문의 폼입니다. 서버가 필요 없고 받은편지함에서 답장합니다.
