# 서버 없는 정적 사이트의 이메일 가입 폼

> Hugo, Jekyll, Eleventy, Astro 정적 사이트에 뉴스레터 가입 폼을 추가하고, 이메일 도구의 API 키는 페이지에서 빼고, 확인은 도구에서 합니다.

2026-10-25 게시 · https://ikiform.com/ko/guides/newsletter-signup/static-site

정적 사이트에는 POST할 내 서버가 없으므로 폼은 독자의 브라우저에서 Ikiform으로 바로 POST합니다. 이메일 도구의 API 키는 사이트에서 완전히 빼세요. 브라우저는 공개 Ikiform 엔드포인트만 알고, 다른 곳에 호스팅된 릴레이가 구독자를 추가합니다. 폼은 partial이나 include로 한 번만 작성하고, 위치마다 다른 `source`를 넘기고, 새로고침 없는 버전을 위해 작은 스크립트 하나를 추가하세요.

## 이 경우가 다른 점

정적 사이트는 GitHub Pages, Netlify, Cloudflare Pages, S3 버킷 같은 호스트에 있는 파일입니다. 독자가 폼을 제출할 때 내가 작성한 어떤 것도 서버에서 실행되지 않습니다. 그래서 페이지에서 이메일 도구의 API를 호출하는 흔한 지름길은 쓸 수 없습니다. 브라우저가 실행하는 것은 누구나 읽을 수 있기 때문입니다.

이메일 도구 API 키가 구독자 한 명 추가로만 제한되는 경우는 드뭅니다. 구독자를 추가할 수 있는 키는 대개 목록 전체를 읽고, 내보내고, 삭제할 수도 있습니다. 정적 사이트의 JavaScript에 넣으면 모든 방문자에게 배포되고, 거기서 개발자 도구를 여는 누구에게나 전해집니다.

다이어그램: 정적 사이트에서 이메일 도구 API 키가 있는 곳

1. 키가 페이지에 있으면 모든 방문자가 내려받고, 누구나 그 키로 이메일 도구의 API를 호출할 수 있습니다.
2. 릴레이를 쓰면 페이지는 폼 POST만 받는 Ikiform 엔드포인트만 압니다.
3. Ikiform이 스팸을 걸러 내고 릴레이로 서명된 웹훅을 보냅니다.
4. 정적 사이트 밖에 호스팅된 릴레이가 API 키를 가지고 구독자를 추가합니다.

폼 엔드포인트는 공개해도 안전합니다. API 키는 절대 안 됩니다.

## 폼은 한 번만 작성하기

뉴스레터 폼은 여러 템플릿에 나타납니다. 푸터, 각 글의 끝, 뉴스레터 페이지입니다. `source` 값을 받는 partial로 만들면 각 위치가 스스로를 기록합니다. 마크업은 생성기마다 같고, `source`의 템플릿 문법만 다릅니다.

**Hugo** (`layouts/partials/newsletter.html`)

```html
<form data-newsletter action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post">
  <input name="email" type="email" required autocomplete="email" placeholder="you@example.com" aria-label="Email" />
  <label><input name="consent" type="checkbox" value="true" required /> Email me the newsletter. I can unsubscribe at any time.</label>
  <input type="hidden" name="source" value="{{ .source | default "footer" }}" />
  <input type="hidden" name="_redirect" value="https://example.com/subscribed/" />
  <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
  <button>Subscribe</button>
  <p role="status" hidden></p>
</form>
```

**Jekyll** (`_includes/newsletter.html`)

```html
<form data-newsletter action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post">
  <input name="email" type="email" required autocomplete="email" placeholder="you@example.com" aria-label="Email" />
  <label><input name="consent" type="checkbox" value="true" required /> Email me the newsletter. I can unsubscribe at any time.</label>
  <input type="hidden" name="source" value="{{ include.source | default: "footer" }}" />
  <input type="hidden" name="_redirect" value="https://example.com/subscribed/" />
  <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
  <button>Subscribe</button>
  <p role="status" hidden></p>
</form>
```

**Eleventy** (`_includes/newsletter.njk`)

```html
<form data-newsletter action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post">
  <input name="email" type="email" required autocomplete="email" placeholder="you@example.com" aria-label="Email" />
  <label><input name="consent" type="checkbox" value="true" required /> Email me the newsletter. I can unsubscribe at any time.</label>
  <input type="hidden" name="source" value="{{ source or "footer" }}" />
  <input type="hidden" name="_redirect" value="https://example.com/subscribed/" />
  <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
  <button>Subscribe</button>
  <p role="status" hidden></p>
</form>
```

**Astro** (`src/components/Newsletter.astro`)

```astro
---
interface Props {
  source?: string;
}
const { source = "footer" } = Astro.props;
---

<form data-newsletter action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post">
  <input name="email" type="email" required autocomplete="email" placeholder="you@example.com" aria-label="Email" />
  <label><input name="consent" type="checkbox" value="true" required /> Email me the newsletter. I can unsubscribe at any time.</label>
  <input type="hidden" name="source" value={source} />
  <input type="hidden" name="_redirect" value="https://example.com/subscribed/" />
  <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
  <button>Subscribe</button>
  <p role="status" hidden></p>
</form>
```

그런 다음 위치 이름과 함께 템플릿에 넣으세요.

**Hugo** (`layouts/_default/single.html`)

```html
{{ partial "newsletter.html" (dict "source" "post") }}
```

**Jekyll** (`_layouts/post.html`)

```html
{% include newsletter.html source="post" %}
```

**Eleventy** (`_includes/post.njk`)

```html
{% set source = "post" %}
{% include "newsletter.njk" %}
```

- Astro에서는 `<Newsletter source="post" />`를 쓰세요. 정적 Astro 빌드는 Actions를 실행할 수 없으므로 컴포넌트가 다른 것들처럼 Ikiform으로 바로 POST합니다.
- Ikiform에서 폼의 필드 정의에 `source`를 최대 길이가 짧은 텍스트로 추가하세요. 필드를 정의하면 정의에 없는 입력은 `unknown_field`로 거부됩니다.
- `_redirect`는 폼의 **Allowed domains** 중 하나에 있는 `https:` URL이어야 합니다. 독자에게 받은편지함을 확인하라고 안내하는 `/subscribed/` 페이지를 만드세요.

## 페이지의 모든 폼을 위한 스크립트 하나

글 페이지에는 뉴스레터 폼이 두 개 있을 수 있습니다. 글 뒤에 하나, 푸터에 하나입니다. 이 스크립트는 `data-newsletter`가 표시된 모든 폼을 처리하고, 하나가 성공하면 전부 교체하므로 조금 아래에서 다시 구독하라는 요청을 받지 않습니다. 기본 레이아웃에서 한 번만 로드하세요.

`static/newsletter.js`:

```js
for (const form of document.querySelectorAll("form[data-newsletter]")) {
  const status = form.querySelector('[role="status"]');
  const button = form.querySelector("button");

  form.addEventListener("submit", async (event) => {
    event.preventDefault();
    button.disabled = true;
    status.hidden = false;
    status.textContent = "Subscribing";
    try {
      const response = await fetch(form.action, {
        method: "POST",
        headers: { Accept: "application/json" },
        body: new FormData(form),
      });
      const result = await response.json();
      if (result.ok) {
        for (const other of document.querySelectorAll("form[data-newsletter]")) {
          other.replaceChildren(Object.assign(document.createElement("p"), {
            textContent: "Almost done. Check your inbox for a confirmation link.",
          }));
        }
        return;
      }
      if (response.status === 429 && response.headers.has("Retry-After")) {
        status.textContent = "Too many tries from this network. Wait a minute and try again.";
      } else {
        const errors = result.error.fields ?? {};
        status.textContent = errors.email?.[0] ?? errors.consent?.[0] ?? result.error.message;
      }
    } catch {
      form.submit();
      return;
    }
    button.disabled = false;
  });
}
```

- 엄격한 네트워크 필터 뒤 등에서 `fetch`가 실패하면 스크립트는 `form.submit()`으로 대체합니다. 일반 방식으로 POST하고 `/subscribed/`로 이동합니다.
- `429`에는 별도 메시지가 있습니다. 한 사무실이나 캠퍼스 네트워크의 독자는 IP를 공유하므로 함께 방문자당 한도에 걸릴 수 있습니다.
- 폼은 JavaScript가 꺼져 있어도 동작합니다. 스크립트는 응답을 보여 주는 방식만 바꿉니다.

JavaScript가 꺼진 독자는 `/subscribed/`로 이동하므로, 그 페이지가 다른 사람에게 스크립트가 하는 일을 대신하게 하세요. 확인 이메일이 가고 있다고 말하고, 찾아야 할 발신자 이름을 알려 주고, 스팸함을 언급하고, 블로그로 돌아가는 링크를 두세요. 생성기에서 다른 페이지처럼 만드는 일반 페이지이며, 나중에 이메일 도구의 확인 링크가 가리킬 곳이기도 합니다.

## 릴레이를 둘 곳

릴레이에는 코드를 실행하는 공개 HTTPS URL이 필요합니다. 정적 호스트가 이를 제공할 수도, 아닐 수도 있습니다.

| 정적 호스트 | 릴레이 선택지 |
| --- | --- |
| GitHub Pages, S3 | 함수 없음. 커스텀 도메인의 Cloudflare Worker나 자동화 도구를 쓰세요 |
| Netlify | 사이트 도메인의 Netlify Function, 또는 위의 아무것이나 |
| Cloudflare Pages | 커스텀 도메인의 별도 Worker. Ikiform은 `pages.dev`와 `workers.dev` URL을 거부합니다 |
| Vercel | 내 도메인의 함수. 정적 출력 옆에 API 라우트를 둘 수 있습니다 |

이메일 도구별 릴레이 코드는 [Kit](https://ikiform.com/ko/guides/newsletter-signup/kit.md), [MailerLite](https://ikiform.com/ko/guides/newsletter-signup/mailerlite.md), [beehiiv](https://ikiform.com/ko/guides/newsletter-signup/beehiiv.md) 페이지에 있습니다. 코드를 전혀 쓰지 않으려면 웹훅을 [Zapier](https://ikiform.com/blog/form-submissions-to-zapier.md)나 [n8n](https://ikiform.com/blog/form-submissions-to-n8n.md)으로 보내고 이메일 도구 액션을 쓰세요. URL 규칙은 [웹훅 문서](https://ikiform.com/ko/docs/webhooks.md)에 있습니다.

> 정적 사이트는 별도 도메인에 배포 미리보기가 있는 경우가 많습니다. **Allowed domains**에 없는 도메인에서 온 POST는 스팸으로 표시되거나 거부되므로, 테스트하는 동안 미리보기 도메인을 추가하거나 실제 도메인과 `localhost`에서 테스트하세요.

## 사이트가 Content Security Policy를 보낸다면

많은 정적 호스트가 Netlify와 Cloudflare Pages의 `_headers` 같은 파일로 헤더를 설정하게 해 줍니다. 사이트가 Content-Security-Policy를 보낸다면, 정책이 Ikiform을 허용하지 않는 한 브라우저가 폼을 막습니다. `form-action`은 일반 POST를, `connect-src`는 스크립트의 `fetch`를 다룹니다.

`_headers`:

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

막힌 POST는 브라우저 콘솔에 CSP 오류로 나타나고 Ikiform에 도달하지 않으므로 대시보드에도 아무것도 나타나지 않습니다.

## 작동 확인하기

1. **Allowed domains**에 실제 도메인과 `localhost`를 추가하세요.
2. 사이트를 빌드하고 글 페이지를 여세요. 두 폼이 모두 있고, 페이지 소스에서 각각 고유한 `source` 값을 가집니다.
3. 글 폼에서 내 주소로 구독하세요. 두 폼 모두 받은편지함을 확인하라는 문구로 바뀝니다.
4. JavaScript를 끄고 푸터에서 구독하세요. `/subscribed/`로 이동합니다.
5. 대시보드에서 **Submissions**를 여세요. 두 가입의 `source`가 `post`와 `footer`로 표시됩니다.
6. 빌드된 사이트에서 이메일 도구의 API 키를 검색하세요. 없습니다.

## 주의할 점

| 문제 | 해결 방법 |
| --- | --- |
| 대시보드에 아무것도 없고 콘솔에 CSP 오류가 있음 | Content-Security-Policy의 `form-action`과 `connect-src`에 `https://ikiform.com`을 추가하세요. |
| `source`에 대해 `unknown_field`와 함께 `422` | 폼의 필드 정의에 `source`를 추가하거나 hidden 입력을 제거하세요. |
| `403 origin_not_allowed`, 또는 모든 POST가 스팸으로 분류됨 | 페이지의 도메인이 폼의 **Allowed domains**에 없습니다. 도메인을 추가하고, 로컬 테스트용으로 `localhost`도 추가하세요. 등록된 도메인의 하위 도메인은 허용됩니다. |
| 브라우저가 내 페이지가 아니라 Ikiform의 감사 페이지로 이동합니다 | `_redirect`는 도메인이 **Allowed domains**에 있고 `https:` URL일 때만 따릅니다. 도메인을 추가하거나, 대신 폼 설정에서 **Redirect after submit**을 설정하세요. |

## 질문

### 백엔드 없이 정적 사이트에 뉴스레터 가입 폼을 추가할 수 있나요?

네. 폼은 브라우저에서 Ikiform 같은 폼 백엔드로 POST하고, 백엔드가 각 가입을 저장하고 걸러 냅니다. 다른 곳에 호스팅된 릴레이나 자동화 도구가 이메일 도구로 넘기므로 정적 사이트 자체는 서버 코드를 실행하지 않습니다.

### 정적 페이지에서 이메일 도구의 API를 직접 호출해도 되나요?

하지 마세요. 페이지 JavaScript에 든 키는 모든 방문자에게 보이고, 이메일 도구 키는 대개 목록 전체를 읽거나 바꿀 수 있습니다. 키는 사이트 밖에서 실행되는 릴레이에 두세요.

### GitHub Pages에서도 동작하나요?

네. 폼은 일반 HTML이고 Ikiform으로 POST합니다. GitHub Pages는 릴레이를 실행할 수 없으므로 커스텀 도메인의 Cloudflare Worker에 호스팅하거나 Zapier, n8n 같은 자동화 도구를 쓰세요.

### 확인 이메일은 누가 보내나요?

이메일 도구입니다. Ikiform은 가입한 사람에게 절대 이메일을 보내지 않습니다. 릴레이가 독자를 미확인으로 추가하면 도구가 자체 확인 링크를 보냅니다.

## 가이드 더 보기

- [뉴스레터 가입 폼](https://ikiform.com/ko/guides/newsletter-signup.md): 직접 작성한 마크업으로 뉴스레터 가입 폼을 만들고, 동의를 기록하고, 봇 가입을 막고, 각 구독자를 Kit, MailerLite, beehiiv 등 어떤 도구로든 넘깁니다.
- [더블 옵트인](https://ikiform.com/ko/guides/newsletter-signup/double-opt-in.md): Ikiform은 확인 이메일을 보내지 않으므로 더블 옵트인은 이메일 도구에서 실행됩니다. 릴레이가 각 가입을 미확인으로 추가하고 도구가 링크를 보냅니다.
- [Kit (구 ConvertKit)](https://ikiform.com/ko/guides/newsletter-signup/kit.md): 구독자를 만들고, 인센티브 이메일을 위해 Kit 폼에 추가하고, 가입 위치를 태그하는 릴레이로 내 뉴스레터 폼을 Kit에 연결합니다.
- [beehiiv](https://ikiform.com/ko/guides/newsletter-signup/beehiiv.md): 내 가입 폼을 beehiiv에 연결합니다. 릴레이가 더블 옵트인을 켜고, 위치를 UTM source로, 페이지를 referrer로 해서 각 구독을 만듭니다.
- [MailerLite](https://ikiform.com/ko/guides/newsletter-signup/mailerlite.md): 내 가입 폼을 MailerLite에 연결합니다. 릴레이가 새 독자를 더블 옵트인을 위해 미확인으로 추가하고, 구독자에게 두 번 확인을 요청하지 않습니다.
- [Next.js 뉴스레터 폼](https://ikiform.com/ko/guides/newsletter-signup/nextjs.md): Next.js 푸터 뉴스레터 폼입니다. 동의 확인과 Turnstile이 있는 서버 액션이 Ikiform으로 POST하고, 라우트 핸들러가 독자를 Kit에 추가합니다.
