# HTML 폼 제출이 동작하는 방식

> 폼이 제출될 때 브라우저가 하는 일: 어떤 필드를 보내는지, 어떻게 인코딩하는지, 서버가 어떻게 응답해야 하는지, 그리고 Ikiform이 어떻게 응답하는지 설명합니다.

2026-10-25 게시 · https://ikiform.com/ko/guides/html-form-submission

누군가 제출을 누르면 브라우저는 폼의 제약 조건을 확인하고, 이름이 있고 활성화된 모든 컨트롤을 모아 폼의 `enctype`으로 인코딩한 뒤, 폼의 `method`로 `action` URL에 보냅니다. 서버는 일반 페이지 로드처럼 응답하며, 잘 동작하는 서버는 성공한 POST에 `303` 리디렉션으로 응답해 새로고침해도 폼이 두 번 전송되지 않게 합니다. `action`이 Ikiform 같은 폼 백엔드를 가리키게 하면 자체 서버 없이 그 리디렉션, 필드 검증, 스팸 필터링을 얻습니다.

HTML 폼은 JavaScript보다 오래되었고 지금도 웹 페이지에서 데이터를 보내는 가장 안정적인 방법입니다. 스크립트가 꺼져 있어도, 느린 휴대폰에서도, 마크업을 붙여 넣을 수 있는 모든 빌더에서 동작합니다. 폼 버그의 대부분은 브라우저가 스스로 하는 일을 모르는 데서 생기므로, 이 가이드는 제출을 단계별로 살펴본 뒤 폼 백엔드가 어디에 들어가는지 보여 줍니다.

## 제출을 누르면 일어나는 일

제출은 본문이 붙은 일반 탐색입니다. 브라우저는 페이지에서 무언가 나가기 전에 네 가지 일을 한 뒤, 요청을 네트워크에 넘기고 돌아오는 것을 불러옵니다.

다이어그램: 제출 클릭과 다음 페이지 사이에 브라우저가 하는 일

1. 제출 버튼, 텍스트 필드의 Enter 키, 또는 form.requestSubmit()이 제출을 시작하고 submit 이벤트를 발생시킵니다.
2. 폼에 novalidate가 없으면 브라우저가 모든 컨트롤의 제약 조건을 확인합니다. 첫 번째로 유효하지 않은 컨트롤에 메시지가 표시되고 아무것도 전송되지 않습니다.
3. 브라우저가 이름이 있고 비활성화되지 않은 모든 컨트롤로 폼 데이터 세트를 만듭니다.
4. 따로 지정하지 않으면 urlencoded인 폼의 enctype으로 그 세트를 인코딩합니다.
5. 폼의 method로 action URL에 이동하며 본문을 보냅니다.
6. 서버가 응답합니다. 303은 브라우저에 다른 URL을 GET으로 불러오라고 알리며, 방문자가 최종적으로 보게 되는 페이지가 됩니다.

선 위의 모든 일은 네트워크 없이 일어납니다. submit 이벤트 리스너는 첫 단계에서 흐름을 멈추고 fetch로 넘겨받을 수 있습니다.

## 요청을 결정하는 속성

`<form>`의 다섯 가지 속성이 데이터가 어디로 어떻게 가는지 정합니다. 각각은 대응하는 `form*` 속성으로 버튼 하나에 대해 덮어쓸 수 있으며, 그래서 한 폼에 서로 다르게 동작하는 **Send**와 **Save draft** 버튼을 둘 수 있습니다.

| 속성 | 기본값 | 하는 일 | 버튼별 덮어쓰기 |
| --- | --- | --- | --- |
| `action` | 페이지 자체의 URL | 데이터를 받는 URL입니다. 상대 값은 페이지를 기준으로 해석됩니다. [action 속성](https://ikiform.com/ko/guides/html-form-submission/action-attribute.md)을 참고하세요 | `formaction` |
| `method` | `get` | `get`은 필드를 URL의 쿼리 문자열에 넣습니다. `post`는 본문으로 보냅니다. `dialog`는 아무것도 보내지 않고 `<dialog>`를 닫습니다 | `formmethod` |
| `enctype` | `application/x-www-form-urlencoded` | `post` 본문을 인코딩하는 방식입니다. 파일에는 `multipart/form-data`가 필요합니다. `text/plain`은 디버깅용입니다 | `formenctype` |
| `novalidate` | 끔 | 제출 시 브라우저의 제약 조건 확인을 건너뜁니다 | `formnovalidate` |
| `target` | `_self` | 응답이 불러와지는 곳입니다. `_blank`는 새 탭을 열고, 이름이 있는 iframe은 페이지를 그대로 둡니다 | `formtarget` |

버튼 두 개, 동작 두 가지. 두 번째 버튼은 검증을 건너뛰며, 클릭한 버튼의 `name`과 `value`가 데이터에 추가되므로 서버는 어느 버튼이 눌렸는지 압니다.

`제출 버튼 두 개`:

```html
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post">
  <input name="email" type="email" required />
  <textarea name="details" required></textarea>
  <button name="intent" value="report">Send report</button>
  <button name="intent" value="draft" formnovalidate>Save as draft</button>
</form>
```

### 전송되는 컨트롤

브라우저는 HTML 명세가 폼 데이터 세트라고 부르는 것만 보냅니다. 제출 로그의 놀라운 점 대부분은 다음 규칙에서 옵니다.

- 컨트롤에는 `name`이 필요합니다. `id`만 있으면 아무것도 보내지 않습니다.
- `disabled` 컨트롤은 건너뜁니다. 값을 보여 주고 잠그면서 보내려면 `readonly`를 쓰세요.
- 체크하지 않은 체크박스나 라디오는 `false`가 아니라 아무것도 보내지 않습니다. `value`가 없는 체크된 체크박스는 `on`을 보냅니다.
- 폼을 제출한 버튼만 `name`과 `value`와 함께 포함됩니다.
- `<select multiple>`과 이름을 공유하는 체크박스 그룹은 선택된 값마다 쌍을 하나씩 보냅니다.
- `<form>` 요소 밖의 입력은 폼의 `id`를 지정하는 `form` 속성으로 폼에 합류할 수 있습니다.

### 본문이 인코딩되는 방식

기본 `enctype`에서 본문은 `&`로 이어진 `name=value` 쌍 한 줄이며, 공백은 `+`가 되고 안전한 문자 집합 밖의 나머지는 퍼센트 인코딩됩니다. 아래쪽 신고 폼에 대해 브라우저가 실제로 보내는 요청은 다음과 같습니다.

`application/x-www-form-urlencoded`:

```http
POST /f/frm_xxxxxxxxxxxxxxxx HTTP/1.1
Host: ikiform.com
Content-Type: application/x-www-form-urlencoded
Origin: https://example.com
Sec-Fetch-Mode: navigate

email=ada%40example.com&page=https%3A%2F%2Fexample.com%2Fpricing&severity=broken&details=The+annual+toggle+does+nothing&_redirect=https%3A%2F%2Fexample.com%2Freport-sent&_gotcha=
```

`multipart/form-data`는 브라우저가 고른 경계 문자열로 각 필드를 별도 파트로 감쌉니다. 더 크지만 파일을 담을 수 있습니다. Ikiform은 둘 다 읽습니다. `text/plain`은 값이 어디서 끝나는지 확실히 알 방법이 없으므로 Ikiform이 `415 unsupported_media_type`으로 거부합니다.

`multipart/form-data`:

```http
POST /f/frm_xxxxxxxxxxxxxxxx HTTP/1.1
Host: ikiform.com
Content-Type: multipart/form-data; boundary=----formdata7f3a

------formdata7f3a
Content-Disposition: form-data; name="email"

ada@example.com
------formdata7f3a
Content-Disposition: form-data; name="severity"

broken
------formdata7f3a--
```

> 인코딩이 무엇이든 페이지는 UTF-8로 유지하세요. Ikiform은 모든 본문을 UTF-8로 디코딩하고 다른 문자셋은 `415 unsupported_charset`으로 거부합니다. 파일 업로드는 아직 지원하지 않습니다. 파일이 있는 POST는 `422 files_not_supported`를 받습니다.

## 폼을 제출하는 네 가지 방법

일반 POST는 선택지 중 하나일 뿐입니다. 아래의 각 방식은 어딘가에서는 타당한 선택입니다. 달라지는 것은 누가 응답을 처리하는지, 그리고 스크립트가 꺼졌을 때 무슨 일이 일어나는지입니다.

| 방식 | 잘하는 것 | 단점 |
| --- | --- | --- |
| 리디렉션을 쓰는 일반 `method="post"` | 어떤 빌더에서든 JavaScript 없이 동작합니다. 브라우저가 로딩 상태, 뒤로 가기, 새로고침을 처리합니다 | 페이지 전체를 다시 불러옵니다. 필드 오류에는 서버 렌더링 페이지나 백엔드의 오류 페이지가 필요합니다 |
| `FormData`를 쓰는 `fetch` | 새로고침이 없습니다. 성공과 각 필드 오류의 모습을 직접 정합니다 | 로딩 상태, 네트워크 오류, 포커스를 직접 맡아야 합니다. 신경 쓰지 않으면 스크립트가 꺼졌을 때 깨집니다. [페이지 새로고침 없이](https://ikiform.com/ko/guides/html-form-submission/without-page-reload.md)를 참고하세요 |
| Next.js 서버 액션이나 SvelteKit 폼 액션 같은 프레임워크 폼 액션 | 서버가 POST를 먼저 보며, 같은 폼이 하이드레이션 전후 모두 동작합니다 | 서버 런타임이 필요하고, 백엔드로 가는 요청이 서버에서 출발하므로 레이트 리밋과 origin 확인이 달라집니다 |
| `method="get"` | 검색 및 필터 폼. 결과에 공유 가능한 URL이 생깁니다 | 필드가 URL, 방문 기록, 서버 로그에 남습니다. 개인 데이터나 상태를 바꾸는 것에는 절대 쓰지 마세요 |

일반 POST로 시작하고 그 위에 `fetch`를 더하세요. 그러면 스크립트 로드에 실패해도 폼이 동작하며, 이런 일은 대부분의 분석 도구가 보여 주는 것보다 자주 일어납니다.

## 서버가 응답해야 하는 방식

브라우저는 POST에 대한 응답을 새 페이지로 취급합니다. 그 페이지가 상태 `200`의 응답 자체라면 브라우저는 그것이 POST에서 왔다고 기억합니다. 새로고침을 누르면 폼을 다시 보내겠냐고 묻고, 뒤로 갔다 앞으로 가면 일부 브라우저도 같은 일을 합니다. 그렇게 두 번 결제되고, 고객 지원에 같은 메시지가 세 번 옵니다.

해결책은 Post/Redirect/Get 패턴입니다. 서버가 POST를 처리한 뒤 `Location` 헤더와 함께 `303 See Other`로 응답합니다. 브라우저는 그 URL을 GET으로 불러오고, 방문자가 보는 페이지는 GET에서 왔으므로 새로고침해도 문제가 없습니다.

다이어그램: POST에 페이지로 응답하는 것과 303 리디렉션으로 응답하는 것의 비교

1. 200 응답이면 브라우저가 폼을 POST하고 응답 본문을 페이지로 보여 줍니다.
2. 그 페이지는 POST에 묶여 있으므로 새로고침하면 다시 제출할지 묻고, 확인하면 데이터를 다시 보냅니다.
3. 303 응답이면 브라우저가 폼을 POST하고 다른 URL을 불러오라는 안내를 받습니다.
4. 그 URL을 GET으로 불러옵니다. 새로고침은 GET만 반복하므로 아무것도 두 번 전송되지 않습니다.

Ikiform은 브라우저 POST에 항상 303으로 응답하므로 폼은 이 동작을 공짜로 얻습니다.

| 상태 | 브라우저가 다음에 하는 일 | 사용처 |
| --- | --- | --- |
| `303 See Other` | `Location`을 GET, 본문은 버림 | 성공한 모든 폼 POST 뒤 |
| `302 Found` | 실제로 브라우저는 GET으로 바꾸지만, 명세는 메서드 유지를 허용합니다 | 오래된 서버. `303`을 권장 |
| `307`과 `308` | 같은 본문으로 같은 POST를 새 URL에 반복 | 엔드포인트 이전용이며, 감사 페이지에는 절대 쓰지 않음 |
| HTML과 함께 `200` | 페이지를 보여 줌. 새로고침하면 다시 POST할지 물음 | 오류와 함께 폼을 다시 보여 줄 때 |
| HTML과 함께 `4xx` | 오류 페이지를 보여 줌 | 방문자가 뒤로 가서 고칠 수 있는 오류 |

`Post/Redirect/Get`:

```http
HTTP/1.1 303 See Other
Location: https://example.com/report-sent
X-Request-Id: req_01k7v2c9t3e8m5q4x6z0a1b2c3

GET /report-sent HTTP/1.1
Host: example.com
```

## Ikiform이 응답하는 방식

엔드포인트 하나 `https://ikiform.com/f/<formId>`가 브라우저와 코드 모두에 응답합니다. 브라우저가 스스로 설정하는 헤더를 보고 요청마다 페이지로 응답할지 JSON으로 응답할지 정합니다.

| 요청 | 응답 |
| --- | --- |
| `Accept`에 `application/json` 포함 | JSON |
| 본문이 JSON | JSON |
| `Sec-Fetch-Mode: navigate`, 모든 일반 폼 POST | 리디렉션 또는 호스팅 페이지 |
| `fetch`의 `cors` 같은 그 밖의 `Sec-Fetch-Mode` | JSON |
| `Sec-Fetch-Mode`가 없고 `Accept`에 `text/html` 포함 | 리디렉션 또는 호스팅 페이지 |
| curl 같은 그 밖의 모든 것 | JSON |

`Sec-Fetch-Mode`는 브라우저가 설정하며 페이지 스크립트가 바꿀 수 없는 헤더입니다. 탭을 이동시키는 폼 POST는 `navigate`를 보내고, `fetch` 호출은 크로스 사이트 요청에 `cors`를 보냅니다. 그래서 같은 폼이 일반 방식으로 POST하면 리디렉션을, 스크립트가 POST하면 JSON을 받으며, 직접 전환할 것이 없습니다. 전체 표는 [JSON 또는 리디렉션](https://ikiform.com/ko/docs/how-it-works.md#response-modes)에 있습니다.

다이어그램: 일반 폼 POST와 fetch 호출에 응답하는 같은 Ikiform 엔드포인트

1. 일반 폼 POST는 탭을 이동시키므로 브라우저가 Sec-Fetch-Mode: navigate를 보냅니다.
2. Ikiform은 페이지 모드로 응답합니다. 성공하면 303 리디렉션, 아니면 오류의 실제 상태가 담긴 호스팅 오류 페이지입니다.
3. fetch 호출은 Sec-Fetch-Mode: cors를 보내거나, 코드가 Accept 헤더로 JSON을 요청합니다.
4. Ikiform은 JSON 모드로 응답합니다. 제출 id와 함께 201, 또는 필드 id별 필드 오류가 담긴 오류 봉투입니다.

모드는 설정하지 않습니다. 요청이 만들어진 방식에 따라 정해집니다.

### 성공한 POST가 도착하는 곳

성공한 브라우저 POST는 `303`을 받습니다. Ikiform은 다음 순서로 목적지를 고릅니다.

1. 대시보드에 있는 폼의 **Redirect after submit** 설정. 내 워크스페이스만 설정할 수 있으므로 모든 `http:` 또는 `https:` URL이 동작합니다.
2. POST의 `_redirect` 필드, `_redirect`가 없으면 `_next`. 사용자 이름, 비밀번호, 커스텀 포트가 없는 `https:` URL이고, 폼의 **Allowed domains** 중 하나이거나 그 서브도메인일 때입니다.
3. 그 밖에는 `/f/<formId>/thanks`의 호스팅 감사 페이지. 허용 도메인의 `https:` 페이지에서 온 POST라면 그곳으로 돌아가는 링크가 있습니다.

규칙을 어긴 `_redirect`는 오류가 아니라 무시되며, 허용 도메인이 없는 폼은 `_redirect`를 아예 무시합니다. 그래서 누구도 내 폼을 이용해 방문자를 자기가 고른 사이트로 보낼 수 없습니다. [제출 후 리디렉션](https://ikiform.com/ko/guides/html-form-submission/redirect-after-submit.md)이 감사 페이지를 깊이 다루고, 전체 규칙은 [HTML 폼](https://ikiform.com/ko/docs/html.md#redirect-rules)에 있습니다.

### POST가 거부될 때

페이지 모드에서 거부된 POST는 오류의 실제 HTTP 상태가 담긴 호스팅 오류 페이지를 보여 줍니다. 검증 오류라면 각 문제를 필드 레이블 아래에 나열하고 POST가 출발한 페이지로 링크합니다. 브라우저의 **Back** 버튼은 대개 방문자가 입력한 내용이 남은 폼으로 돌아가고, 링크는 페이지를 새로 불러옵니다. JSON 모드에서는 같은 오류가 하나의 봉투로 돌아옵니다. 모든 코드는 [오류](https://ikiform.com/ko/docs/errors.md)에 있습니다.

`JSON 모드의 422`:

```json
{
  "ok": false,
  "error": {
    "code": "validation_failed",
    "message": "Some fields are invalid.",
    "fields": {
      "page": ["Enter a valid URL"],
      "details": ["Must be at least 20 characters"]
    }
  },
  "request_id": "req_01k7v2c9t3e8m5q4x6z0a1b2c3"
}
```

스팸만은 절대 오류처럼 보이지 않습니다. 스팸으로 판정된 POST도 실제 POST와 같은 `303`이나 `201`을 받으므로 봇은 아무것도 알아내지 못하며, 이메일이나 웹훅도 보내지 않습니다.

## 내 스택의 폼

다음은 모든 스택으로 만든 실제 폼, 깨진 페이지 신고 폼입니다. 스택을 고르면 그 선택이 이 가이드들의 모든 코드 샘플에 이어집니다. 아래 필드 정의는 Ikiform이 서버에서 강제하는 것과 같으니 시험해 보세요. 상세 내용을 짧게 쓰거나 주소에 `example`을 입력하면 폼이 받을 `422`를 받게 됩니다.

폼: Report a broken page

- **이메일** (`email`, email, 필수)
- **페이지 주소** (`page`, url, 필수)
- **얼마나 심각한가요** (`severity`, select, 필수)
- **무슨 일이 있었나요** (`details`, textarea, 필수)

대시보드에서 폼의 **Fields** 탭에 이 정의를 붙여 넣으면 Ikiform이 모든 POST를 이에 맞춰 검증합니다. [필드와 검증](https://ikiform.com/ko/docs/fields.md)을 참고하세요.

**HTML**

스크립트가 전혀 없습니다. 브라우저가 `required`, `type`, `minlength`를 확인하고, 필드를 urlencoded 텍스트로 POST하고, Ikiform의 `303`을 따라 `_redirect` 페이지로 가며, 서버가 POST를 거부하면 Ikiform의 오류 페이지를 보여 줍니다.

`report.html`:

```html
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post">
  <label for="email">Your email</label>
  <input id="email" name="email" type="email" autocomplete="email" required />

  <label for="page">Page address</label>
  <input id="page" name="page" type="url" required placeholder="https://example.com/pricing" />

  <label for="severity">How bad is it</label>
  <select id="severity" name="severity">
    <option value="cosmetic">Looks wrong</option>
    <option value="broken">Something doesn't work</option>
    <option value="blocking">I can't continue</option>
  </select>

  <label for="details">What happened</label>
  <textarea id="details" name="details" rows="5" minlength="20" maxlength="4000" required></textarea>

  <input type="hidden" name="_redirect" value="https://example.com/report-sent" />
  <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
  <button>Send report</button>
</form>
```

**Next.js**

서버 액션이 `FormData`를 받아 시크릿 키와 함께 JSON으로 전달하고 Ikiform의 필드 오류를 반환합니다. `useActionState`가 각 메시지를 해당 입력 아래에 두고 방문자가 입력한 내용을 유지합니다.

`app/report/actions.ts`:

```ts
"use server";

import { headers } from "next/headers";

export interface ReportState {
  sent: boolean;
  fields: Record<string, string[]>;
  message: string;
  values: Record<string, string>;
}

const NAMES = ["email", "page", "severity", "details"] as const;

export async function sendReport(_previous: ReportState, formData: FormData): Promise<ReportState> {
  const values = Object.fromEntries(NAMES.map((name) => [name, String(formData.get(name) ?? "").trim()]));
  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({ ...values, _gotcha: String(formData.get("_gotcha") ?? "") }),
  });
  const result = await response.json().catch(() => null);
  if (result?.ok) return { sent: true, fields: {}, message: "Thanks. We'll look at that page today.", values: {} };
  return {
    sent: false,
    fields: result?.error?.fields ?? {},
    message: result?.error?.message ?? "The report didn't go through. Try again.",
    values,
  };
}
```

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

```tsx
"use client";

import { useActionState } from "react";
import { type ReportState, sendReport } from "./actions";

const initial: ReportState = { sent: false, fields: {}, message: "", values: {} };

function FieldError({ id, messages }: { id: string; messages?: string[] }) {
  return messages?.length ? <p id={`${id}-error`}>{messages[0]}</p> : null;
}

export function ReportForm() {
  const [state, action, pending] = useActionState(sendReport, initial);

  if (state.sent) return <p role="status">{state.message}</p>;

  return (
    <form action={action}>
      <label htmlFor="email">Your email</label>
      <input id="email" name="email" type="email" required defaultValue={state.values.email}
        aria-invalid={Boolean(state.fields.email)} aria-describedby="email-error" />
      <FieldError id="email" messages={state.fields.email} />

      <label htmlFor="page">Page address</label>
      <input id="page" name="page" type="url" required defaultValue={state.values.page}
        aria-invalid={Boolean(state.fields.page)} aria-describedby="page-error" />
      <FieldError id="page" messages={state.fields.page} />

      <label htmlFor="severity">How bad is it</label>
      <select id="severity" name="severity" defaultValue={state.values.severity ?? "broken"}>
        <option value="cosmetic">Looks wrong</option>
        <option value="broken">Something doesn't work</option>
        <option value="blocking">I can't continue</option>
      </select>

      <label htmlFor="details">What happened</label>
      <textarea id="details" name="details" rows={5} required minLength={20} defaultValue={state.values.details}
        aria-invalid={Boolean(state.fields.details)} aria-describedby="details-error" />
      <FieldError id="details" messages={state.fields.details} />

      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {state.message ? <p role="alert">{state.message}</p> : null}
      <button disabled={pending}>{pending ? "Sending" : "Send report"}</button>
    </form>
  );
}
```

**Astro**

`accept: "form"`을 쓰면 무엇이든 서버를 떠나기 전에 Zod가 잘못된 이메일이나 URL을 거부하고, `isInputError`가 그 메시지를 필드별로 노출합니다. Ikiform 자체의 `422` 필드 오류도 같은 형태의 액션 데이터로 돌아옵니다.

`src/actions/index.ts`:

```ts
import { defineAction } from "astro:actions";
import { getSecret } from "astro:env/server";
import { z } from "astro/zod";

export const server = {
  reportPage: defineAction({
    accept: "form",
    input: z.object({
      email: z.email(),
      page: z.url(),
      severity: z.enum(["cosmetic", "broken", "blocking"]),
      details: z.string().min(20, "Tell us a little more, at least 20 characters."),
      _gotcha: z.string().nullish(),
    }),
    handler: async (input, context) => {
      const origin = context.request.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 ${getSecret("IKIFORM_SECRET_KEY")}`,
          ...(origin ? { Origin: origin } : {}),
        },
        body: JSON.stringify(input),
      });
      const result = await response.json();
      if (result.ok) return { sent: true, fields: {} as Record<string, string[]> };
      return { sent: false, fields: (result.error.fields ?? {}) as Record<string, string[]> };
    },
  }),
};
```

`src/pages/report.astro`:

```astro
---
import { actions, isInputError } from "astro:actions";

export const prerender = false;

const result = Astro.getActionResult(actions.reportPage);
const fields = result?.error && isInputError(result.error) ? result.error.fields : (result?.data?.fields ?? {});
---

{result?.data?.sent ? (
  <p role="status">Thanks. We'll look at that page today.</p>
) : (
  <form method="POST" action={actions.reportPage}>
    <label for="email">Your email</label>
    <input id="email" name="email" type="email" required />
    {fields.email && <p>{fields.email[0]}</p>}

    <label for="page">Page address</label>
    <input id="page" name="page" type="url" required />
    {fields.page && <p>{fields.page[0]}</p>}

    <label for="severity">How bad is it</label>
    <select id="severity" name="severity">
      <option value="cosmetic">Looks wrong</option>
      <option value="broken" selected>Something doesn't work</option>
      <option value="blocking">I can't continue</option>
    </select>

    <label for="details">What happened</label>
    <textarea id="details" name="details" rows="5" minlength="20" required></textarea>
    {fields.details && <p>{fields.details[0]}</p>}

    <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
    <button>Send report</button>
  </form>
)}
```

**TanStack Start**

POST 서버 함수가 `.validator()`에서 `FormData`를 읽고 필드 오류를 일반 데이터로 반환하므로, 컴포넌트는 아무것도 catch하지 않고 이를 렌더링할 수 있습니다.

`src/routes/report.tsx`:

```tsx
import { createFileRoute } from "@tanstack/react-router";
import { createServerFn, useServerFn } from "@tanstack/react-start";
import { getRequestHeader } from "@tanstack/react-start/server";
import { type FormEvent, useState } from "react";

type Outcome = { sent: boolean; fields: Record<string, string[]>; message: string };

const sendReport = createServerFn({ method: "POST" })
  .validator((data: unknown) => {
    if (!(data instanceof FormData)) throw new Error("Expected form data");
    const read = (name: string) => String(data.get(name) ?? "").trim();
    return {
      email: read("email"),
      page: read("page"),
      severity: read("severity"),
      details: read("details"),
      _gotcha: read("_gotcha"),
    };
  })
  .handler(async ({ data }): Promise<Outcome> => {
    const origin = getRequestHeader("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(data),
    });
    const result = await response.json();
    if (result.ok) return { sent: true, fields: {}, message: "Thanks. We'll look at that page today." };
    return { sent: false, fields: result.error.fields ?? {}, message: String(result.error.message) };
  });

export const Route = createFileRoute("/report")({ component: Report });

function Report() {
  const send = useServerFn(sendReport);
  const [outcome, setOutcome] = useState<Outcome | null>(null);
  const [pending, setPending] = useState(false);

  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    setPending(true);
    try {
      setOutcome(await send({ data: new FormData(event.currentTarget) }));
    } catch {
      setOutcome({ sent: false, fields: {}, message: "The report didn't go through. Try again." });
    } finally {
      setPending(false);
    }
  }

  if (outcome?.sent) return <p role="status">{outcome.message}</p>;
  const fields = outcome?.fields ?? {};

  return (
    <form method="post" onSubmit={onSubmit}>
      <label htmlFor="email">Your email</label>
      <input id="email" name="email" type="email" required aria-invalid={Boolean(fields.email)} />
      {fields.email ? <p>{fields.email[0]}</p> : null}
      <label htmlFor="page">Page address</label>
      <input id="page" name="page" type="url" required aria-invalid={Boolean(fields.page)} />
      {fields.page ? <p>{fields.page[0]}</p> : null}
      <label htmlFor="severity">How bad is it</label>
      <select id="severity" name="severity" defaultValue="broken">
        <option value="cosmetic">Looks wrong</option>
        <option value="broken">Something doesn't work</option>
        <option value="blocking">I can't continue</option>
      </select>
      <label htmlFor="details">What happened</label>
      <textarea id="details" name="details" rows={5} minLength={20} required aria-invalid={Boolean(fields.details)} />
      {fields.details ? <p>{fields.details[0]}</p> : null}
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {outcome?.message ? <p role="alert">{outcome.message}</p> : null}
      <button disabled={pending}>{pending ? "Sending" : "Send report"}</button>
    </form>
  );
}
```

**React Router**

라우트 `action`이 폼의 실제 대상이므로 `<Form>`은 하이드레이션 전에도 동작합니다. `data()`가 필드 오류와 입력값과 함께 Ikiform의 `422` 상태를 그대로 전달합니다.

`app/routes/report.tsx`:

```tsx
import { data, Form, useNavigation } from "react-router";
import type { Route } from "./+types/report";

export async function action({ request }: Route.ActionArgs) {
  const form = await request.formData();
  const values = {
    email: String(form.get("email") ?? "").trim(),
    page: String(form.get("page") ?? "").trim(),
    severity: String(form.get("severity") ?? ""),
    details: String(form.get("details") ?? "").trim(),
  };
  const origin = request.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({ ...values, _gotcha: String(form.get("_gotcha") ?? "") }),
  });
  const result = await response.json();
  if (result.ok) return { sent: true as const };
  return data(
    {
      sent: false as const,
      fields: (result.error.fields ?? {}) as Record<string, string[]>,
      message: String(result.error.message),
      values,
    },
    { status: response.status },
  );
}

export default function Report({ actionData }: Route.ComponentProps) {
  const sending = useNavigation().state === "submitting";

  if (actionData?.sent) return <p role="status">Thanks. We'll look at that page today.</p>;
  const fields = actionData?.fields ?? {};
  const values = actionData?.values;

  return (
    <Form method="post">
      <label htmlFor="email">Your email</label>
      <input id="email" name="email" type="email" required defaultValue={values?.email} />
      {fields.email ? <p>{fields.email[0]}</p> : null}
      <label htmlFor="page">Page address</label>
      <input id="page" name="page" type="url" required defaultValue={values?.page} />
      {fields.page ? <p>{fields.page[0]}</p> : null}
      <label htmlFor="severity">How bad is it</label>
      <select id="severity" name="severity" defaultValue={values?.severity ?? "broken"}>
        <option value="cosmetic">Looks wrong</option>
        <option value="broken">Something doesn't work</option>
        <option value="blocking">I can't continue</option>
      </select>
      <label htmlFor="details">What happened</label>
      <textarea id="details" name="details" rows={5} minLength={20} required defaultValue={values?.details} />
      {fields.details ? <p>{fields.details[0]}</p> : null}
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {actionData && !Object.keys(fields).length ? <p role="alert">{actionData.message}</p> : null}
      <button disabled={sending}>{sending ? "Sending" : "Send report"}</button>
    </Form>
  );
}
```

**SvelteKit**

기본 폼 액션이 Ikiform의 상태와 필드 오류와 함께 `fail()`을 반환합니다. JavaScript가 없으면 페이지가 그것으로 다시 렌더링되고, `use:enhance`가 있으면 그 자리에서 업데이트됩니다. `$app/env/private`가 읽을 수 있도록 `src/env.ts`에서 `defineEnvVars`로 `IKIFORM_SECRET_KEY`를 선언하세요.

`src/routes/report/+page.server.ts`:

```ts
import { fail } from "@sveltejs/kit";
import { IKIFORM_SECRET_KEY } from "$app/env/private";
import type { Actions } from "./$types";

export const actions = {
  default: async ({ request, fetch, url }) => {
    const form = await request.formData();
    const values = {
      email: String(form.get("email") ?? "").trim(),
      page: String(form.get("page") ?? "").trim(),
      severity: String(form.get("severity") ?? ""),
      details: String(form.get("details") ?? "").trim(),
    };

    const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
      method: "POST",
      headers: {
        Accept: "application/json",
        "Content-Type": "application/json",
        Authorization: `Bearer ${IKIFORM_SECRET_KEY}`,
        Origin: request.headers.get("origin") ?? url.origin,
      },
      body: JSON.stringify({ ...values, _gotcha: String(form.get("_gotcha") ?? "") }),
    });
    const result = await response.json();
    if (!result.ok) {
      return fail(response.status, { values, fields: (result.error.fields ?? {}) as Record<string, string[]> });
    }
    return { sent: true };
  },
} satisfies Actions;
```

`src/routes/report/+page.svelte`:

```svelte
<script lang="ts">
  import { enhance } from "$app/forms";
  import type { PageProps } from "./$types";

  let { form }: PageProps = $props();
  let fields = $derived(form?.fields ?? {});

  const SEVERITIES = [
    ["cosmetic", "Looks wrong"],
    ["broken", "Something doesn't work"],
    ["blocking", "I can't continue"],
  ];
</script>

{#if form?.sent}
  <p role="status">Thanks. We'll look at that page today.</p>
{:else}
  <form method="POST" use:enhance>
    <label for="email">Your email</label>
    <input id="email" name="email" type="email" required value={form?.values?.email ?? ""} />
    {#if fields.email}<p>{fields.email[0]}</p>{/if}

    <label for="page">Page address</label>
    <input id="page" name="page" type="url" required value={form?.values?.page ?? ""} />
    {#if fields.page}<p>{fields.page[0]}</p>{/if}

    <label for="severity">How bad is it</label>
    <select id="severity" name="severity">
      {#each SEVERITIES as [value, label]}
        <option {value} selected={(form?.values?.severity ?? "broken") === value}>{label}</option>
      {/each}
    </select>

    <label for="details">What happened</label>
    <textarea id="details" name="details" rows="5" minlength="20" required value={form?.values?.details ?? ""}></textarea>
    {#if fields.details}<p>{fields.details[0]}</p>{/if}

    <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
    <button>Send report</button>
  </form>
{/if}
```

**Nuxt**

서버 라우트가 `NUXT_IKIFORM_SECRET_KEY`로 설정되는 `runtimeConfig.ikiformSecretKey`로 신고를 전달합니다. 필드 오류를 `data`에 담아 `createError`를 throw하며, `$fetch`가 클라이언트에서 이를 돌려줍니다.

`server/api/report.post.ts`:

```ts
export default defineEventHandler(async (event) => {
  const body = await readBody<Record<string, string>>(event);
  const { ikiformSecretKey } = useRuntimeConfig(event);
  const origin = getRequestHeader(event, "origin");

  const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
    method: "POST",
    headers: {
      Accept: "application/json",
      "Content-Type": "application/json",
      Authorization: `Bearer ${ikiformSecretKey}`,
      ...(origin ? { Origin: origin } : {}),
    },
    body: JSON.stringify({
      email: body.email ?? "",
      page: body.page ?? "",
      severity: body.severity ?? "",
      details: body.details ?? "",
      _gotcha: body._gotcha ?? "",
    }),
  });
  const result = await response.json();
  if (!result.ok) {
    throw createError({
      status: response.status,
      message: String(result.error.message),
      data: { fields: result.error.fields ?? {} },
    });
  }
  return { sent: true };
});
```

`app/components/ReportForm.vue`:

```vue
<script setup lang="ts">
const sent = ref(false);
const sending = ref(false);
const fields = ref<Record<string, string[]>>({});

async function send(event: Event) {
  const form = event.target as HTMLFormElement;
  sending.value = true;
  fields.value = {};
  try {
    await $fetch("/api/report", { method: "POST", body: Object.fromEntries(new FormData(form)) });
    sent.value = true;
  } catch (failure) {
    const reply = failure as { data?: { data?: { fields?: Record<string, string[]> } } };
    fields.value = reply.data?.data?.fields ?? { details: ["The report didn't go through. Try again."] };
  } finally {
    sending.value = false;
  }
}
</script>

<template>
  <p v-if="sent" role="status">Thanks. We'll look at that page today.</p>
  <form v-else method="post" action="/api/report" @submit.prevent="send">
    <label for="email">Your email</label>
    <input id="email" name="email" type="email" required />
    <p v-if="fields.email">{{ fields.email[0] }}</p>
    <label for="page">Page address</label>
    <input id="page" name="page" type="url" required />
    <p v-if="fields.page">{{ fields.page[0] }}</p>
    <label for="severity">How bad is it</label>
    <select id="severity" name="severity">
      <option value="cosmetic">Looks wrong</option>
      <option value="broken" selected>Something doesn't work</option>
      <option value="blocking">I can't continue</option>
    </select>
    <label for="details">What happened</label>
    <textarea id="details" name="details" rows="5" minlength="20" required />
    <p v-if="fields.details">{{ fields.details[0] }}</p>
    <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
    <button :disabled="sending">{{ sending ? "Sending" : "Send report" }}</button>
  </form>
</template>
```

**React**

싱글 페이지 앱은 브라우저에서 POST하므로 여기에는 시크릿 키가 없고, **Allowed domains**가 폼을 보호합니다. `Accept: application/json`이 리디렉션 대신 JSON 응답을 받게 합니다.

`src/ReportForm.tsx`:

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

const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
const SEVERITIES = [
  ["cosmetic", "Looks wrong"],
  ["broken", "Something doesn't work"],
  ["blocking", "I can't continue"],
];

export function ReportForm() {
  const [sent, setSent] = useState(false);
  const [sending, setSending] = useState(false);
  const [fields, setFields] = useState<Record<string, string[]>>({});
  const [problem, setProblem] = useState("");

  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    setSending(true);
    setFields({});
    setProblem("");
    try {
      const response = await fetch(ENDPOINT, {
        method: "POST",
        headers: { Accept: "application/json" },
        body: new FormData(event.currentTarget),
      });
      const result = await response.json();
      if (result.ok) setSent(true);
      else if (result.error.fields) setFields(result.error.fields);
      else setProblem(result.error.message);
    } catch {
      setProblem("Couldn't reach the server. Check your connection and try again.");
    } finally {
      setSending(false);
    }
  }

  if (sent) return <p role="status">Thanks. We'll look at that page today.</p>;

  return (
    <form action={ENDPOINT} method="post" onSubmit={onSubmit}>
      <label htmlFor="email">Your email</label>
      <input id="email" name="email" type="email" required aria-invalid={Boolean(fields.email)} />
      {fields.email ? <p>{fields.email[0]}</p> : null}
      <label htmlFor="page">Page address</label>
      <input id="page" name="page" type="url" required aria-invalid={Boolean(fields.page)} />
      {fields.page ? <p>{fields.page[0]}</p> : null}
      <label htmlFor="severity">How bad is it</label>
      <select id="severity" name="severity" defaultValue="broken">
        {SEVERITIES.map(([value, label]) => (
          <option key={value} value={value}>{label}</option>
        ))}
      </select>
      <label htmlFor="details">What happened</label>
      <textarea id="details" name="details" rows={5} minLength={20} required aria-invalid={Boolean(fields.details)} />
      {fields.details ? <p>{fields.details[0]}</p> : null}
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {problem ? <p role="alert">{problem}</p> : null}
      <button disabled={sending}>{sending ? "Sending" : "Send report"}</button>
    </form>
  );
}
```

**Vue**

Vue 컴포넌트에서 같은 브라우저 POST입니다. 폼이 실제 `action`과 `method`를 유지하므로 스크립트 로드에 실패해도 일반 방식으로 제출됩니다.

`src/components/ReportForm.vue`:

```vue
<script setup lang="ts">
import { ref } from "vue";

const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";

const sent = ref(false);
const sending = ref(false);
const fields = ref<Record<string, string[]>>({});
const problem = ref("");

async function send(event: SubmitEvent) {
  sending.value = true;
  fields.value = {};
  problem.value = "";
  try {
    const response = await fetch(ENDPOINT, {
      method: "POST",
      headers: { Accept: "application/json" },
      body: new FormData(event.target as HTMLFormElement),
    });
    const result = await response.json();
    if (result.ok) sent.value = true;
    else if (result.error.fields) fields.value = result.error.fields;
    else problem.value = result.error.message;
  } catch {
    problem.value = "Couldn't reach the server. Check your connection and try again.";
  } finally {
    sending.value = false;
  }
}
</script>

<template>
  <p v-if="sent" role="status">Thanks. We'll look at that page today.</p>
  <form v-else :action="ENDPOINT" method="post" @submit.prevent="send">
    <label for="email">Your email</label>
    <input id="email" name="email" type="email" required :aria-invalid="Boolean(fields.email)" />
    <p v-if="fields.email">{{ fields.email[0] }}</p>
    <label for="page">Page address</label>
    <input id="page" name="page" type="url" required :aria-invalid="Boolean(fields.page)" />
    <p v-if="fields.page">{{ fields.page[0] }}</p>
    <label for="severity">How bad is it</label>
    <select id="severity" name="severity">
      <option value="cosmetic">Looks wrong</option>
      <option value="broken" selected>Something doesn't work</option>
      <option value="blocking">I can't continue</option>
    </select>
    <label for="details">What happened</label>
    <textarea id="details" name="details" rows="5" minlength="20" required :aria-invalid="Boolean(fields.details)" />
    <p v-if="fields.details">{{ fields.details[0] }}</p>
    <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
    <p v-if="problem" role="alert">{{ problem }}</p>
    <button :disabled="sending">{{ sending ? "Sending" : "Send report" }}</button>
  </form>
</template>
```

> 서버 측 버전은 시크릿 키로 POST하므로 Ikiform이 **Allowed domains**와 사람 확인을 건너뜁니다. 그러면 모든 POST가 서버의 IP에서 오고, 방문자당 분당 5회인 Standard 한도가 모든 방문자를 합쳐서 계산합니다. 서버를 거쳐 POST하는 폼에는 Relaxed 또는 Custom [레이트 리밋](https://ikiform.com/ko/docs/rate-limits.md)을 고르고, `IKIFORM_SECRET_KEY`는 서버 전용 환경 변수에 두세요.

각 서버 버전은 브라우저의 `Origin` 헤더가 있으면 전달합니다. 서버 측 `fetch`는 이 헤더를 보내지 않는데, Ikiform은 플랜이 없는 워크스페이스에서 무료 `localhost` 테스트를 판별하는 데 이 헤더를 쓰기 때문입니다.

- 모든 버전이 실제 `method`를 유지하고, Ikiform으로 바로 POST하는 경우에는 실제 `action`도 유지합니다. 스크립트가 꺼져 있어도 폼은 제출되고 `303`을 받습니다.
- 각 입력의 `name`이 정의의 필드 `id`입니다. `page` 대신 `url`처럼 일치하지 않으면 `Unknown field "url"`와 함께 `422`를 받습니다.
- 숨겨진 `_gotcha` 입력이 허니팟입니다. 이를 채우는 봇은 조용히 스팸으로 저장됩니다.
- JavaScript 버전은 각 필드의 첫 번째 메시지를 써서 각 입력 아래에 `error.fields`를 보여 줍니다. 일반 HTML 버전은 이를 Ikiform의 오류 페이지에 맡깁니다.

문서는 프레임워크별로 더 깊이 다룹니다. [Next.js](https://ikiform.com/ko/docs/guides/nextjs.md), [Astro](https://ikiform.com/ko/docs/guides/astro.md), [SvelteKit](https://ikiform.com/ko/docs/guides/sveltekit.md), [Nuxt](https://ikiform.com/ko/docs/guides/nuxt.md), [React](https://ikiform.com/ko/docs/guides/react.md), [Vue](https://ikiform.com/ko/docs/guides/vue.md), [서버에서](https://ikiform.com/ko/docs/guides/server.md)를 참고하세요.

## 두 겹의 검증

브라우저 검증과 서버 검증은 하는 일이 다릅니다. 브라우저의 제약 조건 확인(`required`, `type="email"`, `minlength`, `pattern`)은 즉각적인 피드백을 주지만 누구나 건너뛸 수 있습니다. 봇은 직접 POST하고, 스크립트는 확인을 전혀 실행하지 않는 `form.submit()`을 호출할 수 있습니다. 규칙이 실제로 지켜지는 곳은 서버뿐입니다.

| 규칙 | 브라우저에서 | Ikiform 필드 정의에서 |
| --- | --- | --- |
| 필수 | `required` | `"required": true` |
| 이메일 | `type="email"` | `"type": "email"`, 선택적으로 `allowed_domains` |
| URL | `type="url"` | `"type": "url"`, 선택적으로 `https_only` |
| 길이 | `minlength`, `maxlength` | `validation.min_length`, `max_length` |
| 나열된 선택지만 | 봇이 무시하는 `<select>` | `options`. 그 밖의 값은 거부됨 |
| 추가 필드 없음 | 불가능 | 정의에 없는 필드는 모두 거부됨 |

정의가 없으면 Ikiform은 어떤 필드든 받아 도착한 그대로 저장합니다. 정의를 게시하면 검증하고, 숫자와 체크박스를 실제 숫자와 boolean으로 저장하며, 무엇이든 저장하기 전에 `422 validation_failed`로 POST를 거부합니다. [검증](https://ikiform.com/ko/guides/html-form-submission/validation.md)에서 두 겹이 같은 말을 하도록 유지하는 방법을 보여 줍니다.

## JavaScript로 제출하기

방문자를 페이지에 머물게 하려면 `submit`을 듣고, `preventDefault()`를 호출하고, `fetch`로 `new FormData(form)`을 보내세요. 처음부터 제대로 동작할지는 몇 가지 세부 사항이 결정합니다.

- 본문이 `FormData`일 때는 `Content-Type`을 직접 설정하지 마세요. 브라우저가 경계와 함께 `multipart/form-data`를 설정합니다. 직접 쓴 헤더에는 경계가 없어 본문을 파싱할 수 없습니다.
- `Accept: application/json`을 보내세요. Ikiform은 어차피 `Sec-Fetch-Mode`로 JSON을 고르지만, 이 헤더가 이를 명시하며 브라우저가 프리플라이트 없이 크로스 사이트로 보낼 수 있는 요청 헤더 중 하나입니다.
- `FormData`나 `URLSearchParams` 본문은 단순 크로스 사이트 요청이므로 프리플라이트가 없습니다. `Content-Type: application/json`인 JSON 본문은 먼저 프리플라이트를 일으킵니다. Ikiform이 응답하지만 왕복 한 번의 비용이 듭니다.
- `form.submit()`은 검증과 `submit` 이벤트를 건너뜁니다. `form.requestSubmit()`은 클릭할 때처럼 둘 다 실행합니다.
- 클릭한 버튼의 `name`과 `value`가 중요하다면 두 번째 인수로 버튼을 넘기세요. `new FormData(form, event.submitter)`입니다.

로딩 상태, 포커스, 네트워크 오류를 포함한 전체 패턴은 [페이지 새로고침 없이](https://ikiform.com/ko/guides/html-form-submission/without-page-reload.md)에 있습니다. [JavaScript와 fetch](https://ikiform.com/ko/docs/frameworks.md) 문서에 엔드포인트가 받는 모든 헤더와 본문 형식이 나열되어 있습니다.

## 상황별 가이드

각 페이지는 제출의 한 부분을 맡아 위 개요보다 더 깊이 다루며, 그 경우를 위한 코드가 있습니다.

- [페이지 새로고침 없이](https://ikiform.com/ko/guides/html-form-submission/without-page-reload.md): fetch와 FormData로 폼을 보내 방문자를 페이지에 머물게 하세요. 필드 오류, 로딩 상태, 재시도, 스크립트 실패 시 대체 경로까지 다룹니다.
- [제출 후 리디렉션](https://ikiform.com/ko/guides/html-form-submission/redirect-after-submit.md): 폼이 POST된 뒤 방문자를 직접 만든 감사 페이지로 보내세요. 303 리디렉션, _redirect 필드, 허용되는 도메인, 새로고침 시 동작을 다룹니다.
- [action 속성](https://ikiform.com/ko/guides/html-form-submission/action-attribute.md): 폼의 action 속성이 하는 일, 상대 값과 빈 값이 해석되는 방식, formaction으로 한 폼을 두 곳에 보내는 방법, 그리고 정적 호스트가 405로 응답하는 이유를 설명합니다.
- [폼 검증](https://ikiform.com/ko/guides/html-form-submission/validation.md): required, type, pattern, min으로 폼을 검증하고, :user-invalid로 오류에 스타일을 입히고, 필드 정의로 서버에서 같은 규칙을 강제하세요.

## 작동 확인하기

폼마다 비공개 창에서 한 번씩 확인하세요. Ikiform에는 무료 플랜이 없습니다. 테스트는 무료입니다. 플랜이 없는 워크스페이스는 `localhost`에서 월 50건의 테스트 제출을 받습니다. 실제 운영하려면 월 $6부터 500건을 제공하는 Pro를 고르거나, 한 번 결제로 10,000건 $49 또는 50,000건 $149를 구매하세요. 크레딧은 만료되지 않습니다. [요금](https://ikiform.com/ko/pricing)을 참고하세요.

1. 폼의 **Allowed domains**에 사이트 도메인을 추가하고, 개발하는 동안에는 `localhost`도 추가하세요. 비워 두면 어디서든 POST를 받습니다.
2. 브라우저 개발자 도구에서 JavaScript를 끄고 폼을 제출하세요. 여전히 `303`과 함께 감사 페이지로 이동해야 합니다.
3. JavaScript를 켜고 Network 패널을 연 뒤 다시 제출하세요. `/f/<formId>`로 가는 요청이 JSON과 함께 `201`로 응답하고 페이지는 새로고침되지 않아야 합니다.
4. 너무 짧은 메시지로 제출하세요. 일반 POST는 필드 레이블이 있는 Ikiform 오류 페이지를 보여 주고, 스크립트 버전은 필드 아래에 메시지를 보여 줍니다.
5. 감사 페이지에서 새로고침을 누르세요. 아무것도 다시 제출되지 않고 두 번째 제출도 나타나지 않아야 합니다.
6. 대시보드에서 **Submissions**를 여세요. 보낸 모든 필드가 담긴 새 제출이 있습니다.
7. 허니팟을 채워서 `_gotcha=bot`으로 POST하세요. 여전히 성공 응답을 받지만, 제출은 스팸으로 저장되고 이메일이나 웹훅을 보내지 않습니다.

## 문제 해결

| 문제 | 해결 방법 |
| --- | --- |
| 제출 뒤 `405 Method Not Allowed` | 폼이 POST를 받을 수 없는 자체 정적 호스트로 POST했습니다. `action`을 확인하세요. 상대 경로나 빈 값이 아니라 전체 `https://ikiform.com/f/<formId>` URL이어야 합니다. |
| 탭에서 엔드포인트를 열면 `404 not_found`가 표시됨 | 정상입니다. 탭은 GET을 보내고 엔드포인트는 POST만 받습니다. 폼의 `method`가 `post`인지 확인하세요. method가 없는 폼은 필드를 URL에 담아 GET을 보냅니다. |
| `415 unsupported_media_type` | 폼에 `enctype="text/plain"`이 있거나, 스크립트가 Ikiform이 읽지 않는 타입을 보냈습니다. `enctype`을 제거하거나 `multipart/form-data`를 쓰세요. |
| `422 validation_failed` | POST가 폼의 필드 정의와 맞지 않습니다. `error.fields`에 각 필드와 이유가 나옵니다. 입력의 `name`을 필드 ID와 맞추세요. |
| 브라우저가 내 페이지가 아니라 Ikiform의 감사 페이지로 이동합니다 | `_redirect`는 도메인이 **Allowed domains**에 있고 `https:` URL일 때만 따릅니다. 도메인을 추가하거나, 대신 폼 설정에서 **Redirect after submit**을 설정하세요. |
| 제출에서 필드가 빠짐 | `name`이 없거나, `disabled`이거나, 체크하지 않은 체크박스입니다. `name`을 주고, `disabled` 대신 `readonly`를 쓰거나, 체크하지 않은 상자가 `false`로 저장되도록 `checkbox` 필드를 정의하세요. |
| 새로고침하면 폼을 다시 제출할지 물음 | 페이지가 POST에 `200`으로 응답했습니다. Ikiform으로 바로 가는 POST는 항상 `303`을 받습니다. 중간에 자체 서버가 있다면 POST를 처리한 뒤 리디렉션하세요. |
| `403 origin_not_allowed`, 또는 모든 POST가 스팸으로 분류됨 | 페이지의 도메인이 폼의 **Allowed domains**에 없습니다. 도메인을 추가하고, 로컬 테스트용으로 `localhost`도 추가하세요. 등록된 도메인의 하위 도메인은 허용됩니다. |

## 질문

### HTML 폼에서 action 속성은 무엇을 하나요?

폼의 데이터를 받는 URL입니다. 상대 값은 페이지 주소를 기준으로 해석되고, action이 없는 폼은 페이지 자체로 POST합니다. 제출 버튼은 formaction으로 이를 덮어쓸 수 있습니다.

### 폼은 GET과 POST 중 무엇을 써야 하나요?

문의, 가입, 주문 폼처럼 개인 데이터를 보내거나 상태를 바꾸는 것에는 POST를 쓰세요. GET은 필드가 사람들이 공유할 수 있는 URL에 들어가야 하는 검색 및 필터 폼용입니다.

### 페이지를 새로고침하면 왜 폼이 다시 제출되나요?

보고 있는 페이지가 POST에 대한 직접 응답이므로 브라우저가 이를 반복하겠다고 제안합니다. 대신 성공한 POST에 감사 페이지로 가는 303 리디렉션으로 응답하세요. Ikiform은 모든 브라우저 POST에 이렇게 합니다.

### HTML 폼이 JSON을 보낼 수 있나요?

자체적으로는 안 됩니다. 폼은 urlencoded나 multipart 데이터를 보냅니다. JSON을 보내려면 submit 핸들러에서 FormData로 폼을 읽고 fetch로 JSON.stringify(Object.fromEntries(data))를 POST하세요. Ikiform은 세 가지 모두 받습니다.

### required 속성만으로 폼 검증이 충분한가요?

아니요. 사람에게는 도움이 되지만 봇은 직접 POST해서 이를 건너뜁니다. 서버에서도 검증하세요. Ikiform에서는 필드를 정의하면 모든 POST가 저장되기 전에 정의와 대조됩니다.

### 백엔드 없이 폼을 제출할 수 있나요?

POST를 받을 무언가는 필요하지만, 꼭 내 서버일 필요는 없습니다. 폼의 action이 Ikiform 같은 폼 백엔드를 가리키게 하면 페이지는 완전히 정적인 상태로 둘 수 있습니다.

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

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

## 가이드 더 보기

- [페이지 새로고침 없이](https://ikiform.com/ko/guides/html-form-submission/without-page-reload.md): fetch와 FormData로 폼을 보내 방문자를 페이지에 머물게 하세요. 필드 오류, 로딩 상태, 재시도, 스크립트 실패 시 대체 경로까지 다룹니다.
- [제출 후 리디렉션](https://ikiform.com/ko/guides/html-form-submission/redirect-after-submit.md): 폼이 POST된 뒤 방문자를 직접 만든 감사 페이지로 보내세요. 303 리디렉션, _redirect 필드, 허용되는 도메인, 새로고침 시 동작을 다룹니다.
- [action 속성](https://ikiform.com/ko/guides/html-form-submission/action-attribute.md): 폼의 action 속성이 하는 일, 상대 값과 빈 값이 해석되는 방식, formaction으로 한 폼을 두 곳에 보내는 방법, 그리고 정적 호스트가 405로 응답하는 이유를 설명합니다.
- [폼 검증](https://ikiform.com/ko/guides/html-form-submission/validation.md): required, type, pattern, min으로 폼을 검증하고, :user-invalid로 오류에 스타일을 입히고, 필드 정의로 서버에서 같은 규칙을 강제하세요.
- [행사 등록](https://ikiform.com/ko/guides/event-registration.md): 티켓 앱 없이 내 행사 페이지에서 RSVP를 받는 방법: 폼, 동반 인원 제한, 정확한 인원 집계, 마감, 체크인 명단.
- [웹훅 서명 검증](https://ikiform.com/ko/guides/webhook-signature-verification.md): 폼 웹훅이 정말 Ikiform에서 왔는지 확인하세요. Standard Webhooks HMAC-SHA256 방식, 테스트 벡터, 원본 본문, 타임스탬프, 시크릿 교체를 다룹니다.
- [새 SaaS용 대기자 명단](https://ikiform.com/ko/guides/waitlist.md): 내 랜딩 페이지에 출시 전 대기자 명단을 만듭니다. 폼, 스팸 방지, 중복 처리, 확인 이메일, 출시일의 깔끔한 내보내기까지 다룹니다.
