HTML 폼 제출

HTML 폼 제출5분 분량

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

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

Preet SutharIkiform 창업자

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

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

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

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

브라우저가 이동하게 두는 대신 스크립트가 fetch로 폼을 제출할 때 하는 일submit 이벤트브라우저 자체 확인 뒤preventDefault()내비게이션 없음fetchFormData 전송Accept:application/json201감사 문구로 교체필드에 메시지 표시포커스 이동, 버튼 다시 동작422 또는 네트워크 오류브라우저가 이동하게 두는 대신 스크립트가 fetch로 폼을 제출할 때 하는 일submit 이벤트브라우저 자체 확인 뒤preventDefault()내비게이션 없음fetch FormData 전송Accept:application/json201감사 문구로 교체필드에 메시지표시포커스 이동, 버튼 다시동작422 또는 네트워크 오류
  1. 브라우저가 먼저 제약 조건 확인을 실행합니다. submit 이벤트는 폼이 유효할 때만 발생합니다.
  2. 리스너가 preventDefault를 호출하므로 탭이 이동하지 않습니다.
  3. fetch로 폼 데이터를 보내고 JSON을 요청합니다.
  4. 201이면 폼을 상태 메시지로 바꾸고 포커스를 그곳으로 옮깁니다.
  5. 422나 네트워크 실패면 필드 옆에 메시지를 보여 주고, 그 필드에 포커스를 주고, 다시 보낼 수 있게 합니다.
브라우저 검증은 여전히 내 코드보다 먼저 실행됩니다. preventDefault 이후의 모든 것은 직접 처리해야 합니다.
브라우저가 하던 일이제 스크립트가 해야 할 일
탭에 로딩 상태 표시로딩 상태를 표시하고 스크린 리더에 알리기
더블 클릭을 대체로 무해하게 만듦요청이 진행 중이면 두 번째 제출 무시
오류 페이지 표시error.fields를 각 입력에 연결
네트워크 오류 페이지 표시실패한 요청과 타임아웃 잡기
새 페이지로 포커스 이동결과나 첫 번째 잘못된 필드로 포커스 이동

폼

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

미리보기

Ikiform의 검증기를 브라우저에서 실행합니다. 아무것도 전송되지 않습니다.

이 페이지가 도움이 되었나요?
폼의 Fields 탭에 이것을 붙여 넣으세요. 필드를 정의하면 Ikiform은 모르는 필드도 거부합니다.
feedback.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
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에 있습니다.
  • AbortSignal.timeout(15000)은 멈춘 요청을 끝없는 Sending... 대신 catch 분기로 보냅니다.
  • 상태 문구에는 role="status"가 있어 포커스를 옮기지 않고 텍스트가 읽힙니다. 성공하면 주변 필드가 사라지므로 포커스가 그곳으로 이동합니다.

폼에 사람 확인이 있을 때

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

그때도 페이지에 머물려면 먼저 SDK의 challenge(formId)로 토큰을 받아 submit()에 넘기세요. 스팸 문서에 흐름이 있습니다.

JavaScript 없는 대안

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

옛 요령
<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합니다. 개요에서 각각을 보여 줍니다.

작동 확인하기

  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도 추가하세요. 등록된 도메인의 하위 도메인은 허용됩니다.

질문

막히는 단계가 있거나 내 상황에 맞는 가이드가 필요하다면 이메일을 보내세요: hello@ikiform.com.