# 페이지 새로고침 없이 HTML 폼 제출하기

> fetch와 FormData로 폼을 보내 방문자를 페이지에 머물게 하세요. 필드 오류, 로딩 상태, 재시도, 스크립트 실패 시 대체 경로까지 다룹니다.

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

폼의 `submit` 이벤트를 듣고, `preventDefault()`를 호출하고, `Accept: application/json` 헤더와 함께 `fetch`로 `new FormData(form)`을 보내세요. 그러면 Ikiform이 리디렉션 대신 JSON으로 응답합니다. 성공하면 `201`, 아니면 각 입력 옆에 보여 줄 필드 오류입니다. 스크립트가 실행되지 않아도 일반 방식으로 제출되도록 폼에 실제 `action`과 `method`를 유지하세요.

여기서 예시는 문서 페이지 하단의 **Was this page helpful?**(이 페이지가 도움이 되었나요?) 상자입니다. 페이지에 머무는 전형적인 경우입니다. 독자는 작업 중이고, 클릭 한 번을 기록하려고 감사 페이지로 보내는 것은 터무니없습니다. 같은 패턴은 어떤 폼에도 통합니다.

## 제출을 넘겨받으면 바뀌는 것

일반 폼 POST는 많은 것을 공짜로 줍니다. 브라우저가 로딩 표시를 보여 주고, 더블 클릭이 큰 문제가 되지 않게 하며, 뒤로 가기 버튼을 처리하고, 돌아오는 페이지를 보여 줍니다. `preventDefault()`를 호출하는 순간 그 모든 것이 내 일이 됩니다. fetch 호출 자체는 세 줄이고, 이 페이지의 나머지는 브라우저가 하던 일입니다.

다이어그램: 브라우저가 이동하게 두는 대신 스크립트가 fetch로 폼을 제출할 때 하는 일

1. 브라우저가 먼저 제약 조건 확인을 실행합니다. submit 이벤트는 폼이 유효할 때만 발생합니다.
2. 리스너가 preventDefault를 호출하므로 탭이 이동하지 않습니다.
3. fetch로 폼 데이터를 보내고 JSON을 요청합니다.
4. 201이면 폼을 상태 메시지로 바꾸고 포커스를 그곳으로 옮깁니다.
5. 422나 네트워크 실패면 필드 옆에 메시지를 보여 주고, 그 필드에 포커스를 주고, 다시 보낼 수 있게 합니다.

브라우저 검증은 여전히 내 코드보다 먼저 실행됩니다. preventDefault 이후의 모든 것은 직접 처리해야 합니다.

| 브라우저가 하던 일 | 이제 스크립트가 해야 할 일 |
| --- | --- |
| 탭에 로딩 상태 표시 | 로딩 상태를 표시하고 스크린 리더에 알리기 |
| 더블 클릭을 대체로 무해하게 만듦 | 요청이 진행 중이면 두 번째 제출 무시 |
| 오류 페이지 표시 | `error.fields`를 각 입력에 연결 |
| 네트워크 오류 페이지 표시 | 실패한 요청과 타임아웃 잡기 |
| 새 페이지로 포커스 이동 | 결과나 첫 번째 잘못된 필드로 포커스 이동 |

## 폼

필드는 세 개입니다. 필수 예/아니요, 선택 입력 의견, 스크립트가 채우는 페이지 주소입니다. 아래 정의를 시험해 보세요. Ikiform이 서버에서 강제하는 내용이므로 2,000자를 넘는 의견은 여기서도 `422`를 받습니다.

폼: Was this page helpful

- **이 페이지가 도움이 되었나요?** (`helpful`, radio, 필수)
- **무엇이 빠져 있었나요?** (`comment`, textarea)
- **페이지** (`page`, url)

폼의 **Fields** 탭에 이것을 붙여 넣으세요. 필드를 정의하면 Ikiform은 모르는 필드도 거부합니다.

`feedback.html`:

```html
<form id="page-feedback" action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post">
  <fieldset>
    <legend>Was this page helpful?</legend>
    <label><input type="radio" name="helpful" value="yes" required /> Yes</label>
    <label><input type="radio" name="helpful" value="no" /> No</label>
  </fieldset>

  <label for="comment">What was missing?</label>
  <textarea id="comment" name="comment" rows="3" maxlength="2000" aria-describedby="comment-error"></textarea>
  <p id="comment-error" hidden></p>

  <input type="hidden" name="page" />
  <input type="hidden" name="_redirect" value="https://docs.example.com/feedback-sent" />
  <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
  <button>Send</button>
  <p id="feedback-status" role="status" tabindex="-1"></p>
</form>

<script type="module" src="/page-feedback.js"></script>
```

폼은 스크립트 없이도 완전합니다. JavaScript가 꺼져 있거나 차단되면 일반 방식으로 POST하고, Ikiform이 `303`으로 응답하며, 독자는 `_redirect` 페이지로 이동합니다. 스크립트는 이를 개선할 뿐입니다.

## 스크립트

`page-feedback.js`:

```js
const form = document.querySelector("#page-feedback");
const status = form.querySelector("#feedback-status");
const button = form.querySelector("button");
const pageInput = form.elements.namedItem("page");
const idempotencyKey = crypto.randomUUID();
let sending = false;

pageInput.value = location.href;

function showFieldErrors(fields) {
  for (const input of form.querySelectorAll("[aria-invalid]")) input.removeAttribute("aria-invalid");
  for (const hint of form.querySelectorAll("[id$='-error']")) hint.hidden = true;
  let first = null;
  for (const [name, messages] of Object.entries(fields)) {
    const control = form.elements.namedItem(name);
    const hint = form.querySelector(`#${name}-error`);
    if (control instanceof HTMLElement) control.setAttribute("aria-invalid", "true");
    if (hint) {
      hint.textContent = messages[0];
      hint.hidden = false;
    }
    first ??= control instanceof RadioNodeList ? control[0] : control;
  }
  first?.focus();
}

form.addEventListener("submit", async (event) => {
  event.preventDefault();
  if (sending) return;
  const body = new FormData(form, event.submitter);
  body.set("_idempotency", idempotencyKey);
  sending = true;
  button.setAttribute("aria-disabled", "true");
  status.textContent = "Sending...";

  try {
    const response = await fetch(form.action, {
      method: "POST",
      headers: { Accept: "application/json" },
      body,
      signal: AbortSignal.timeout(15000),
    });
    const result = await response.json();

    if (result.ok) {
      form.replaceChildren(status);
      status.textContent = "Thanks, that helps.";
      status.focus();
      return;
    }
    if (result.error.code === "challenge_required") {
      form.submit();
      return;
    }
    if (result.error.fields) {
      status.textContent = "";
      showFieldErrors(result.error.fields);
    } else if (response.status === 429 && response.headers.has("Retry-After")) {
      const wait = response.headers.get("Retry-After");
      status.textContent = `Too many tries. Wait ${wait} seconds and send again.`;
    } else {
      status.textContent = result.error.message;
    }
  } catch {
    status.textContent = "Couldn't reach the server. Check your connection and send again.";
  } finally {
    sending = false;
    button.removeAttribute("aria-disabled");
  }
});
```

- `new FormData(form, event.submitter)`는 이름이 있는 모든 컨트롤과, 클릭한 버튼에 `name`과 `value`가 있으면 그것까지 읽습니다. 폼에서 무엇이든 바꾸기 전에 만드세요.
- 버튼에는 `disabled`가 아니라 `aria-disabled`를 줍니다. 실제 차단은 `sending` 플래그가 하고, 버튼은 포커스를 받을 수 있게 남아 스크린 리더 사용자가 위치를 잃지 않습니다.
- `Content-Type` 헤더는 없습니다. `FormData` 본문이면 브라우저가 올바른 경계와 함께 `multipart/form-data`를 설정합니다.
- `Accept: application/json`은 Ikiform이 JSON으로 응답하게 합니다. `fetch` 호출은 `Sec-Fetch-Mode: cors`를 보내므로 어차피 JSON이 선택되지만, 이 헤더가 의도를 분명히 합니다.
- 페이지 로드마다 멱등성 키 하나를 `_idempotency` 필드로 보냅니다. 독자가 보냈는데 네트워크가 응답을 잃어 다시 보내면, Ikiform은 두 번째 제출을 저장하는 대신 첫 응답을 재생합니다. 거부된 POST는 키를 차지하지 않으므로 고쳐서 다시 보내면 통과합니다. `Idempotency-Key` 헤더도 같은 일을 하지만, 커스텀 헤더는 브라우저가 먼저 프리플라이트 요청을 보내게 합니다. 정확한 규칙은 [JavaScript와 fetch](https://ikiform.com/ko/docs/frameworks.md#idempotency)에 있습니다.
- `AbortSignal.timeout(15000)`은 멈춘 요청을 끝없는 **Sending...** 대신 catch 분기로 보냅니다.
- 상태 문구에는 `role="status"`가 있어 포커스를 옮기지 않고 텍스트가 읽힙니다. 성공하면 주변 필드가 사라지므로 포커스가 그곳으로 이동합니다.

## 폼에 사람 확인이 있을 때

폼의 **Spam check**가 **Ikiform**으로 설정되어 있으면 Turnstile 토큰이 없는 `fetch` POST는 `403 challenge_required`를 받습니다. 위 스크립트는 `form.submit()`을 호출해 이를 처리합니다. 이 메서드는 submit 리스너를 건너뛰고 일반 방식으로 POST하며, 일반 POST에 대해 Ikiform은 자체 짧은 확인 페이지를 보여 준 뒤 `_redirect` 페이지로 이어갑니다. 독자는 확인이 켜져 있을 때만 페이지를 떠납니다.

그때도 페이지에 머물려면 먼저 [SDK](https://ikiform.com/ko/docs/sdk.md)의 `challenge(formId)`로 토큰을 받아 `submit()`에 넘기세요. [스팸 문서](https://ikiform.com/ko/docs/spam.md#human-check)에 흐름이 있습니다.

## JavaScript 없는 대안

`fetch` 이전의 요령은 숨겨진 iframe을 대상으로 지정해 응답이 그곳에 불러와지고 페이지는 그대로 있게 하는 것이었습니다.

`옛 요령`:

```html
<iframe name="feedback-sink" hidden></iframe>
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post" target="feedback-sink">
  ...
</form>
```

여전히 동작하지만 iframe에는 ikiform.com의 페이지가 들어 있고, 브라우저는 스크립트가 크로스 오리진 프레임을 읽지 못하게 합니다. 성공과 검증 오류를 구분할 수 없으므로 독자도 어느 쪽이든 피드백을 받지 못합니다. 스크립트를 정말 실행할 수 없는 곳에서만 쓰세요.

> 서버 프레임워크는 클라이언트 fetch 코드 없이 새로고침 없는 제출을 제공할 수 있습니다. Next.js 서버 액션, SvelteKit의 `use:enhance`, React Router의 `<Form>`은 페이지가 하이드레이션되기 전에는 일반 방식으로 POST하고, 그 뒤에는 새로고침 없이 POST합니다. [개요](https://ikiform.com/ko/guides/html-form-submission.md#implementation)에서 각각을 보여 줍니다.

## 작동 확인하기

1. 폼의 **Allowed domains**에 사이트 도메인을 추가하고, 개발하는 동안에는 `localhost`도 추가하세요. 비워 두면 어디서든 POST를 받습니다.
2. 의견 없이 **Yes**를 제출하세요. 폼이 **Thanks, that helps.**로 바뀌고 Network 패널에 `201`이 표시됩니다.
3. 의견에 2,000자 넘게 붙여 넣고, 개발자 도구에서 `maxlength`를 제거한 뒤 보내세요. 의견 아래에 메시지가 나타나고 포커스가 그곳으로 이동합니다.
4. **Send**를 더블 클릭하세요. 대시보드에 제출이 두 개가 아니라 하나만 표시됩니다.
5. JavaScript를 끄고 제출하세요. `_redirect` 페이지로 이동합니다.
6. 스로틀링을 **Offline**으로 설정하고 보내세요. 상태 문구가 서버에 연결할 수 없다고 알리고, **Send**가 다시 동작합니다.

## 주의할 점

| 문제 | 원인과 해결 |
| --- | --- |
| POST에서 필드가 빠짐 | `FormData`를 만들 때 `disabled` 상태였습니다. 비활성화된 컨트롤은 절대 전송되지 않습니다. 데이터를 먼저 만들거나 `readonly`로 입력을 잠그세요 |
| `400 invalid_body` 또는 빈 제출 | 코드가 경계 없이 `Content-Type: multipart/form-data`를 직접 설정했습니다. 헤더를 삭제하세요 |
| 그래도 페이지가 새로고침됨 | 리스너가 `preventDefault()` 전에 예외를 던졌거나, 스크립트가 로드되기 전에 클릭했습니다. `preventDefault()`를 맨 먼저 호출하세요. 이른 클릭은 일반 방식으로 POST하며 그래도 동작합니다 |
| `response.json()`이 예외를 던짐 | 요청이 HTML로 응답하는 곳으로 갔습니다. 대개 상대 `action` 때문입니다. 콘솔에서 `form.action`을 읽어 보세요. Ikiform URL이어야 합니다 |
| `403 challenge_required` | 폼의 사람 확인이 켜져 있습니다. 위처럼 `form.submit()`으로 대체하거나 `challenge()`로 토큰을 받으세요 |
| `403 origin_not_allowed`, 또는 모든 POST가 스팸으로 분류됨 | 페이지의 도메인이 폼의 **Allowed domains**에 없습니다. 도메인을 추가하고, 로컬 테스트용으로 `localhost`도 추가하세요. 등록된 도메인의 하위 도메인은 허용됩니다. |

## 질문

### 제출할 때 폼이 페이지를 새로고침하지 않게 하려면 어떻게 하나요?

submit 리스너를 추가하고 첫 줄에서 event.preventDefault()를 호출한 뒤 fetch로 직접 데이터를 보내세요. Enter 키도 처리되도록 버튼의 click이 아니라 폼의 submit 이벤트를 들으세요.

### fetch로 FormData를 POST할 때 Content-Type을 설정해야 하나요?

아니요, 설정하면 안 됩니다. 브라우저가 경계 문자열과 함께 multipart/form-data를 설정합니다. 직접 쓴 헤더에는 경계가 없어 서버가 본문을 파싱할 수 없습니다.

### JavaScript로 제출해도 브라우저가 폼을 검증하나요?

네, submit 이벤트를 듣는다면 그렇습니다. 이 이벤트는 브라우저의 제약 조건 확인을 통과한 뒤에만 발생합니다. 코드에서 form.submit()을 호출하면 확인과 이벤트를 모두 건너뛰고, form.requestSubmit()은 둘 다 실행합니다.

### 중복 제출은 어떻게 막나요?

요청이 진행 중일 때는 제출을 무시하고, 페이지 로드마다 멱등성 키 하나를 _idempotency 필드나 Idempotency-Key 헤더로 보내세요. 재시도가 그래도 Ikiform에 도달하면 제출을 다시 저장하는 대신 첫 응답을 재생합니다.

## 가이드 더 보기

- [HTML 폼 제출](https://ikiform.com/ko/guides/html-form-submission.md): 폼이 제출될 때 브라우저가 하는 일: 어떤 필드를 보내는지, 어떻게 인코딩하는지, 서버가 어떻게 응답해야 하는지, 그리고 Ikiform이 어떻게 응답하는지 설명합니다.
- [폼 검증](https://ikiform.com/ko/guides/html-form-submission/validation.md): required, type, pattern, min으로 폼을 검증하고, :user-invalid로 오류에 스타일을 입히고, 필드 정의로 서버에서 같은 규칙을 강제하세요.
- [제출 후 리디렉션](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로 응답하는 이유를 설명합니다.
