# Cloudflare Pages 사이트에 문의 폼 추가하기

> Function을 작성하지 않고 Cloudflare Pages에 문의 폼을 추가하세요. 폼 백엔드로 POST하고, _headers에서 헤더를 설정하고, 직접 가진 Turnstile 키를 씁니다.

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

Cloudflare Pages의 정적 에셋은 `POST`에 `405`로 응답하므로, Pages Function을 작성하거나 폼을 폼 백엔드로 POST해야 합니다. Ikiform을 쓰면 Function을 작성하지 않습니다. 폼의 `action`을 엔드포인트로 설정하고, 모든 프리뷰 URL까지 포함하는 `your-project.pages.dev`를 **Allowed domains**에 추가하고, `_headers`의 Content-Security-Policy에 Ikiform을 넣으세요. 이미 Cloudflare를 쓰고 있다면 직접 가진 Turnstile 키를 폼에 연결할 수 있습니다.

## Cloudflare Pages에서 다른 점

Pages는 빌드 결과물을 Cloudflare 네트워크에서 제공합니다. 정적 에셋은 `GET`에 응답하고, `POST`에는 본문 없는 `405`로 응답합니다.

`POST에 대한 응답`:

```http
HTTP/2 405
server: cloudflare
content-length: 0
```

Pages는 `functions/` 폴더의 Pages Functions로 코드를 실행할 수 있습니다. `functions/contact.ts`로 POST를 받을 수는 있지만, 그러면 파싱, 검증, 이메일 제공업체, 스팸 필터링, 저장까지 갖춘 백엔드를 작성하게 됩니다. 또한 Cloudflare 문서는 새 프로젝트를 Pages가 아닌 Workers로 시작하라고 권합니다. 폼 백엔드로 POST하면 두 문제를 모두 피할 수 있고, 나중에 Workers 정적 에셋으로 옮겨도 같은 폼이 그대로 작동합니다.

| Pages의 특성 | 폼에 미치는 영향 |
| --- | --- |
| 프리뷰 URL은 `abc123.your-project.pages.dev` 같은 하위 도메인입니다 | 허용 도메인 `your-project.pages.dev` 하나로 프로덕션과 모든 프리뷰를 포함합니다. Vercel과 Netlify 프리뷰는 따로 추가해야 합니다 |
| 헤더는 빌드 결과물의 `_headers` 파일에서 옵니다 | 규칙은 최대 100개, 한 줄에 2,000자까지입니다. Pages Functions의 응답에는 적용되지 않습니다 |
| Turnstile은 Cloudflare 제품입니다 | 키 없이 Ikiform의 관리형 검사를 쓰거나, Cloudflare 대시보드에서 만든 자체 위젯을 쓸 수 있습니다 |

다이어그램: 직접 가진 Turnstile 키를 쓰는 Cloudflare Pages 문의 폼

1. Pages가 _headers 파일의 Content-Security-Policy와 함께 문의 페이지를 제공합니다.
2. Turnstile 위젯이 여러분의 사이트 키로 실행되어 폼에 토큰을 넣습니다.
3. 폼이 필드와 토큰을 Ikiform으로 POST합니다.
4. Ikiform이 여러분의 시크릿 키로 토큰을 검증하고, 토큰의 호스트 이름을 허용 도메인과 비교합니다.
5. 유효한 POST는 저장되고 이메일로 전달됩니다. 토큰이 없거나, 만료되었거나, 재사용되었다면 403 captcha_failed를 받고, 페이지가 위젯을 초기화합니다.

대신 Ikiform의 관리형 검사를 쓰면 페이지에 위젯이 없고, HTML 폼도 전혀 바꿀 필요가 없습니다.

## 폼

이 버전은 자체 Turnstile 위젯을 씁니다. Cloudflare 계정에서 이미 Turnstile을 관리하고 있다면 합리적인 선택입니다. 폼 설정에서 **Spam check**를 **Your own Turnstile keys**로 설정하고 사이트 키와 시크릿을 붙여 넣으세요. 위젯은 폼에 `cf-turnstile-response` 필드를 추가하고, `team` 선택 항목은 누가 답해야 하는지 알려 줍니다.

`public/contact.html`:

```html
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>

<form id="contact" action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post">
  <label for="name">Name</label>
  <input id="name" name="name" autocomplete="name" required />

  <label for="email">Email</label>
  <input id="email" name="email" type="email" autocomplete="email" required />

  <label for="team">Who should read this?</label>
  <select id="team" name="team">
    <option value="support">Support</option>
    <option value="sales">Sales</option>
    <option value="press">Press</option>
  </select>

  <label for="message">Message</label>
  <textarea id="message" name="message" rows="6" minlength="10" required></textarea>

  <div class="cf-turnstile" data-sitekey="0x4AAAAAAAxxxxxxxxxxxxxx"></div>
  <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
  <button type="submit">Send</button>
  <p id="contact-status" role="status"></p>
</form>

<script src="/contact.js" defer></script>
```

스크립트는 방문자를 페이지에 머물게 합니다. Turnstile 토큰은 한 번만 쓸 수 있으므로, 전송이 실패하면 다음 시도를 위해 위젯을 초기화합니다.

`public/contact.js`:

```js
const form = document.querySelector("#contact");
const status = document.querySelector("#contact-status");

form.addEventListener("submit", async (event) => {
  event.preventDefault();
  status.textContent = "Sending";
  const response = await fetch(form.action, {
    method: "POST",
    headers: { Accept: "application/json" },
    body: new FormData(form),
  }).catch(() => null);
  const result = await response?.json().catch(() => null);

  if (result?.ok) {
    form.replaceWith(Object.assign(document.createElement("p"), { textContent: "Sent. We'll reply by email." }));
    return;
  }

  window.turnstile?.reset();
  if (result?.error?.code === "captcha_failed") {
    status.textContent = "The human check expired. Complete it again, then send.";
    return;
  }
  const fields = result?.error?.fields ?? {};
  status.textContent = Object.values(fields)[0]?.[0] ?? result?.error?.message ?? "Couldn't send. Try again.";
});
```

- Cloudflare의 Turnstile 위젯 설정에 폼이 실행되는 호스트 이름을 나열하세요. Ikiform은 토큰의 호스트 이름도 폼의 **Allowed domains**와 비교합니다.
- 유효한 토큰이 없는 POST는 `403 captcha_failed`로 거부됩니다. 위젯이 끝나기 전에 보낸 일반 HTML POST도 마찬가지입니다.
- 키를 아예 쓰고 싶지 않나요? **Spam check**를 **Ikiform**으로 설정하고 위젯과 스크립트를 지우면, HTML POST가 ikiform.com에서 검사를 실행합니다. 그러면 `fetch` 버전에는 [SDK](https://ikiform.com/ko/docs/sdk.md)의 `challenge()`가 필요합니다. [사람 확인](https://ikiform.com/ko/docs/spam.md#human-check)을 참고하세요.

## 내 키 또는 Ikiform의 검사

|   | Ikiform의 검사 | 직접 가진 Turnstile 키 |
| --- | --- | --- |
| 관리할 키 | 없음 | Cloudflare 대시보드의 사이트 키와 시크릿 |
| HTML 폼 변경 | 없음. 검사는 ikiform.com 페이지에서 실행됩니다 | 위젯과 스크립트 추가 |
| `fetch` 버전 | SDK의 `challenge()` 호출 | 위젯의 `cf-turnstile-response` 전송 |
| Content-Security-Policy | Ikiform용 `form-action`과 `connect-src`만 | `script-src`와 `frame-src`에 `challenges.cloudflare.com`도 추가 |
| Turnstile 분석 | 여러분의 계정에는 없음 | Cloudflare 대시보드에서 다른 위젯 옆에 표시 |

Ikiform의 검사가 설정할 것이 적습니다. 이미 Turnstile 분석을 보고 있거나 위젯의 모양과 위치를 직접 제어하고 싶다면 자체 키가 합리적입니다.

### 로컬에서 위젯 테스트하기

Cloudflare는 항상 통과하거나 항상 실패하는 테스트용 사이트 키와 시크릿을 공개합니다. 예를 들어 항상 통과하는 쌍은 `1x00000000000000000000AA`와 `1x0000000000000000000000000000000AA`입니다. 프로덕션 시크릿은 테스트 키로 만든 토큰을 거부하므로 한 폼에서 섞어 쓰지 마세요. 로컬 작업용 Ikiform 폼을 하나 더 만들어 테스트 키 쌍을 주고, `data-sitekey`와 폼 ID를 함께 바꾸세요. 그 폼의 **Allowed domains**는 비워 두세요. Ikiform은 목록이 설정된 경우에만 토큰의 호스트 이름을 검사하며, Cloudflare는 테스트 토큰이 어떤 호스트 이름을 보고하는지 문서화하지 않았습니다.

## _headers 파일

`_headers`를 `public/` 같은 정적 에셋 디렉터리에 두어 빌드 결과물에 포함되게 하세요. Turnstile에는 `https://challenges.cloudflare.com`에 대한 `script-src`와 `frame-src`가, Ikiform에는 `form-action`과 `connect-src`가 필요합니다.

`public/_headers`:

```text
/*
  Content-Security-Policy: default-src 'self'; script-src 'self' https://challenges.cloudflare.com; frame-src https://challenges.cloudflare.com; form-action 'self' https://ikiform.com; connect-src 'self' https://ikiform.com
```

나중에 같은 프로젝트에 Pages Function을 추가하면, 그 응답에는 이 헤더가 붙지 않습니다. Function 코드에서 설정하세요.

## Workers로 옮긴다면

사이트를 Workers 정적 에셋으로 옮겨도 바뀌는 것은 배포 방식이지 폼의 작동 방식이 아닙니다. 폼은 여전히 ikiform.com으로 POST하므로 Cloudflare 쪽에서는 아무것도 처리하지 않습니다. 옮긴 뒤 두 가지를 확인하세요. 새 `workers.dev` 호스트나 라우트가 **Allowed domains**에 있는지, 그리고 Workers는 헤더를 설정하는 방식이 다르므로 헤더가 여전히 페이지에 적용되는지입니다.

> **Allowed domains**에 `your-project.pages.dev`와 커스텀 도메인을 추가하세요. `pages.dev`만 단독으로 추가하지 마세요. 모든 Pages 프로젝트가 일치하게 됩니다.

## 작동 확인하기

1. 브랜치를 푸시하고 프리뷰 URL을 여세요. 제출한 뒤 메시지가 도착하고 스팸으로 표시되지 않았는지 확인하세요. 프리뷰는 프로젝트 호스트의 하위 도메인입니다.
2. 프로덕션에서 다시 제출한 뒤, 뒤로 가기를 눌러 위젯을 완료하지 않고 같은 폼을 한 번 더 보내세요. 제출이 저장되는 대신 초기화 메시지가 나와야 합니다.
3. 대시보드에서 **Submissions**를 여세요. 보낸 모든 필드가 담긴 새 제출이 있습니다.
4. 알림 수신자마다 받은편지함을 확인하세요. 각자 필드가 담긴 이메일을 받고, 폼에 `email` 필드가 있으면 **Reply**가 제출한 사람에게 갑니다.
5. 아무것도 오지 않았나요? 대시보드의 상태 필터를 **Spam**으로 바꿔 보세요. 스팸은 실제 제출과 같은 성공 응답을 받습니다.

## 주의할 점

| 문제 | 해결 방법 |
| --- | --- |
| 빈 본문의 `405` | 폼이 Pages URL로 POST했습니다. 배포된 `action`을 확인하고, Function이나 리디렉션 규칙이 `/contact`를 다시 쓰지 않는지 확인하세요. |
| Turnstile 위젯이 나타나지 않음 | Content-Security-Policy가 `challenges.cloudflare.com`을 차단합니다. `script-src`와 `frame-src`에 추가하고, `_headers`가 빌드 결과물에 있는지 확인하세요. |
| 모든 POST에서 `403 captcha_failed` | 사이트 키와 시크릿이 서로 다른 위젯의 것이거나, 호스트 이름이 위젯에 등록되지 않았거나, 테스트 시크릿이 실제 사이트 키와 짝지어져 있습니다. |
| `403 origin_not_allowed`, 또는 모든 POST가 스팸으로 분류됨 | 페이지의 도메인이 폼의 **Allowed domains**에 없습니다. 도메인을 추가하고, 로컬 테스트용으로 `localhost`도 추가하세요. 등록한 도메인의 하위 도메인은 허용됩니다. |

## 질문

### Cloudflare Pages의 문의 폼에 Pages Functions가 필요한가요?

아니요. Function은 POST를 받는 한 가지 방법이지만, 그러면 검증, 이메일 전송, 스팸 필터링을 직접 작성해야 합니다. 폼 백엔드로 POST하면 프로젝트가 완전히 정적으로 유지됩니다.

### 프리뷰 배포에 별도의 허용 도메인이 필요한가요?

아니요. Pages 프리뷰 URL은 your-project.pages.dev의 하위 도메인이므로, 그 호스트를 허용하면 모두 포함됩니다.

### 기존 Turnstile 위젯을 Ikiform과 함께 쓸 수 있나요?

네. 폼의 스팸 검사를 자체 Turnstile 키로 설정하고 사이트 키와 시크릿을 붙여 넣으세요. Ikiform이 각 토큰을 검증하고 호스트 이름을 허용 도메인과 비교합니다.

### Cloudflare Pages에서 폼이 405를 받는 이유는 무엇인가요?

폼이 여러분의 Pages URL로 POST했는데, 그곳은 GET 요청에만 응답하는 정적 에셋을 제공합니다. 폼의 action을 폼 백엔드로 지정하거나, 그 경로에 Pages Function을 추가하세요.

### Ikiform은 무료인가요?

아니요, 무료 플랜은 없습니다. 테스트는 무료입니다. 결제하기 전에 localhost에서 한 달에 50건의 테스트 제출을 보낼 수 있습니다. 실제로 운영하려면 Pro가 제출 500건에 월 $6부터이며, 한 번만 결제해 제출 10,000건을 $49에, 50,000건을 $149에 살 수도 있습니다. 크레딧은 만료되지 않습니다.

## 가이드 더 보기

- [정적 사이트의 문의 폼](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로 응답합니다. 그 이유와 폼을 백엔드로 보내는 방법을 알아보세요.
- [Netlify](https://ikiform.com/ko/guides/contact-form-static-site/netlify.md): Netlify Forms 없이 Netlify 사이트에서 문의 폼을 운영하세요. 빌드 시점 감지 없이 React에서 JSON을 POST하고, 배포 프리뷰용 폼을 따로 둡니다.
- [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): 첫 통화에서 물어볼 프로젝트, 예산, 일정, 링크를 미리 묻는 포트폴리오 문의 폼입니다. 서버가 필요 없고 받은편지함에서 답장합니다.
