# 브라우저와 서버에서의 HTML 폼 검증

> required, type, pattern, min으로 폼을 검증하고, :user-invalid로 오류에 스타일을 입히고, 필드 정의로 서버에서 같은 규칙을 강제하세요.

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

`required`, `type="email"`, `min`, `max`, `pattern` 같은 HTML 내장 제약 조건을 써서 무엇이든 전송되기 전에 브라우저가 잘못된 입력을 막게 하세요. 그런 다음 봇과 스크립트는 브라우저를 완전히 건너뛰므로 서버에서도 같은 규칙을 강제하세요. Ikiform에서는 폼의 필드를 정의하면 모든 POST가 확인됩니다. 잘못된 POST는 필드별 메시지와 함께 `422 validation_failed`를 받으며, 이를 `setCustomValidity`로 보여 줄 수 있습니다.

예시는 워크숍 예약 폼입니다. 검증이 제값을 하는 종류의 필드가 있습니다. 한도가 있는 팀 규모, 과거일 수 없는 날짜, 한 가지 형식의 전화번호, 형태가 정해진 예약 코드입니다.

## 이 경우가 다른 점

검증기는 두 개이고, 둘이 보는 요청은 같지 않습니다. 브라우저는 스크립트 유무와 관계없이 내 페이지를 쓰는 사람만 확인합니다. 서버는 엔드포인트로 바로 POST하는 봇과, 브라우저 확인을 건너뛰는 `form.submit()`을 호출하는 내 코드까지 모두 확인합니다. 할 일은 둘이 같은 말을 하게 만드는 것입니다.

다이어그램: 브라우저 검증을 거친 뒤 Ikiform 필드 검증을 거치는 POST

1. 사람이 폼을 제출하면 브라우저가 HTML의 제약 조건을 확인합니다. 유효하지 않은 필드에 메시지가 표시되고 아무것도 전송되지 않습니다.
2. 유효한 POST가 Ikiform에 도달하면 폼의 필드 정의와 대조하고 타입이 지정된 값을 저장합니다.
3. 봇이나 스크립트는 엔드포인트로 바로 POST하므로 브라우저 확인이 실행되지 않습니다.
4. Ikiform이 같은 정의와 대조해 무엇이든 저장하기 전에 422로 거부합니다.

브라우저 계층은 사람을 위한 것입니다. 실제로 지켜지는 것은 서버 계층입니다.

## 브라우저 계층

폼: Book a workshop

- **이름** (`name`, text, 필수)
- **업무용 이메일** (`email`, email, 필수)
- **참석 인원** (`team_size`, number, 필수)
- **희망 날짜** (`date`, date, 필수)
- **전화번호** (`phone`, phone)
- **예약 코드** (`code`, text)

Ikiform이 강제하는 정의입니다. 팀 규모 25, 11월 2일 이전 날짜, 또는 `ws-12` 같은 코드를 시험해 보세요.

`workshop.html`:

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

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

  <label for="team_size">People attending</label>
  <input id="team_size" name="team_size" type="number" min="2" max="20" step="1" required />

  <label for="date">Preferred date</label>
  <input id="date" name="date" type="date" min="2026-11-02" required />

  <label for="phone">Phone</label>
  <input id="phone" name="phone" type="tel" autocomplete="tel" placeholder="+14155550123"
    pattern="\+[1-9][0-9]{6,14}" title="International format, like +14155550123" />

  <label for="code">Booking code</label>
  <input id="code" name="code" pattern="WS-[0-9]{4}" title="Codes look like WS-1234" />

  <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
  <button>Request the workshop</button>
</form>
```

각 속성은 하나의 유효성 상태에 대응하며, 콘솔에서 `input.validity`로 읽을 수 있습니다.

| 속성 | 유효성 상태 | 참고 |
| --- | --- | --- |
| `required` | `valueMissing` | 라디오 그룹에서는 `required`가 있는 라디오 하나가 그룹 전체를 덮습니다 |
| `type="email"`, `type="url"` | `typeMismatch` | 느슨함: 브라우저에서 `a@b`는 이메일로 통과합니다 |
| `pattern` | `patternMismatch` | 값 전체와 일치해야 합니다. 비어 있는 선택 입력 필드에서는 무시됩니다 |
| `minlength`, `maxlength` | `tooShort`, `tooLong` | 사람이 값을 편집한 뒤에만 확인됩니다 |
| `min`, `max` | `rangeUnderflow`, `rangeOverflow` | 숫자, 날짜, 시간 |
| `step` | `stepMismatch` | `step="1"`은 소수를 거부합니다 |
| 파싱할 수 없는 입력 | `badInput` | 일부 브라우저에서 숫자 필드에 입력한 문자 |
| `setCustomValidity()` | `customError` | 설정한 모든 메시지, 다시 빈 값으로 설정할 때까지 |

### 두 엔진의 패턴

브라우저는 `pattern`을 `v` 플래그로 컴파일하며, 이는 대부분의 사람이 쓰는 정규 표현식보다 엄격합니다. 문자 클래스 안의 리터럴 `-`는 `[a-z\-]`처럼 이스케이프해야 합니다. Ikiform은 필드의 `pattern`을 `u` 플래그로 컴파일하며 역시 값 전체와 일치시킵니다. 위 예약 코드처럼 양쪽에서 유효한 패턴을 쓰고, 게시하기 전에 폼 미리보기에서 각각 테스트하세요.

### 오류에 스타일 입히기

`:invalid`는 페이지가 로드되는 순간 비어 있는 필수 필드와 일치하므로, 누군가 입력하기 전에 모든 필드가 빨갛게 보입니다. `:user-invalid`는 사람이 필드와 상호작용했거나 제출을 시도한 뒤에만 일치하며, 거의 항상 원하는 동작입니다.

`workshop.html`:

```html
<style>
  #workshop :user-invalid {
    border-color: #b42318;
  }
</style>
```

## 서버 계층

위 정의를 폼의 **Fields** 탭에 붙여 넣으세요. 그때부터 Ikiform은 출처와 관계없이 모든 POST를 이에 맞춰 검증하고 변환된 값을 저장합니다. `team_size`는 숫자로, `phone`은 구분 기호 없이, `date`는 `YYYY-MM-DD`로 저장합니다. 규칙을 어긴 POST는 거부되고, 아무것도 저장되지 않으며, 응답이 어느 필드가 실패했는지 알려 줍니다.

| 필드 | 전송한 값 | 메시지 |
| --- | --- | --- |
| `team_size` | `25` | `Must be at most 20` |
| `team_size` | `2.5` | `Enter a whole number` |
| `date` | `2026-10-30` | `Must be on or after 2026-11-02` |
| `phone` | `415 555 0123` | `Use international format, e.g. +14155550123` |
| `code` | `ws-12` | `Codes look like WS-1234` |
| `website` | 모든 값 | `Unknown field "website"` |

마지막 행이 중요합니다. 필드를 정의하면 Ikiform은 정의에 없는 필드를 거부하므로, 이름이 바뀐 입력은 잘못된 이름으로 저장되는 대신 분명하게 실패합니다. `_`로 시작하는 필드와 허니팟은 포함되지 않습니다. 모든 타입과 규칙은 [필드와 검증](https://ikiform.com/ko/docs/fields.md)에 있습니다.

### 일반 POST의 경우

코드가 필요 없습니다. 거부된 일반 POST는 `422` 상태, 필드 레이블 아래의 각 문제, POST가 출발한 페이지로 가는 링크가 담긴 Ikiform 호스팅 오류 페이지를 보여 줍니다. 브라우저의 **Back** 버튼은 대개 입력한 내용을 유지하고, 링크는 폼을 빈 상태로 다시 불러옵니다.

### 서버 오류를 필드에 표시하기

스크립트에서는 각 서버 메시지를 브라우저 자체 오류 UI에 넣으세요. `setCustomValidity`가 그 텍스트로 필드를 유효하지 않게 표시하고, `reportValidity`가 이를 평소의 말풍선으로 보여 주며 첫 번째 필드에 포커스를 줍니다.

`workshop.js`:

```js
const form = document.querySelector("#workshop");

for (const control of form.elements) {
  control.addEventListener("input", () => control.setCustomValidity?.(""));
}

form.addEventListener("submit", async (event) => {
  event.preventDefault();
  const response = await fetch(form.action, {
    method: "POST",
    headers: { Accept: "application/json" },
    body: new FormData(form),
  });
  const result = await response.json();
  if (result.ok) {
    form.replaceWith("Thanks. We'll confirm the date by email.");
    return;
  }
  const fields = result.error.fields ?? {};
  for (const [name, messages] of Object.entries(fields)) {
    form.elements.namedItem(name)?.setCustomValidity?.(messages[0]);
  }
  if (!form.reportValidity()) return;
  alert(result.error.message);
});
```

- `input`에서 커스텀 메시지를 지우세요. 커스텀 오류가 있는 필드는 빈 문자열로 되돌릴 때까지 유효하지 않은 상태로 남고, 그동안 전혀 제출할 수 없습니다.
- 브라우저 확인은 여전히 `submit` 이벤트 전에 실행되므로 서버는 이를 통과한 POST만 봅니다.
- `429 rate_limited`처럼 오류에 `fields`가 없으면 `reportValidity()`가 `true`를 반환하고 스크립트는 일반 메시지로 대체합니다.

## 보내기 전에 서버 규칙 확인하기

이메일 도메인 목록 같은 서버 전용 규칙을 요청 전에 잡으려면 폼의 공개 정의를 불러와 [SDK](https://ikiform.com/ko/docs/sdk.md)의 `validate`를 실행하세요. 같은 필드 규칙과 메시지를 브라우저에서 적용합니다.

`workshop.js`:

```js
import { getSchema, validate } from "@ikiform/sdk";

const form = document.querySelector("#workshop");
const result = await getSchema("frm_xxxxxxxxxxxxxxxx");
const fields = result.ok ? result.schema.fields : [];

form.addEventListener("submit", (event) => {
  const errors = validate(fields, new FormData(form));
  for (const control of form.elements) control.setCustomValidity?.(errors[control.name] ?? "");
  if (!form.reportValidity()) event.preventDefault();
});
```

> 이는 편의 기능으로 여기세요. 기준은 여전히 서버이며, `getSchema`는 어차피 공개인 필드 정의만 노출합니다. 누구나 `/f/<formId>/schema`에서 읽을 수 있습니다.

## 작동 확인하기

1. 폼의 **Allowed domains**에 사이트 도메인을 추가하고, 개발하는 동안에는 `localhost`도 추가하세요. 비워 두면 어디서든 POST를 받습니다.
2. 빈 폼을 제출하세요. 브라우저가 **Your name**에서 멈추고 Network 패널에는 아무것도 나타나지 않습니다.
3. 개발자 도구에서 팀 규모의 `max`를 제거하고 `25`를 입력해 제출하세요. 일반 POST는 Ikiform의 오류 페이지를 보여 주고, 스크립트 버전은 필드에 **Must be at most 20**을 보여 줍니다.
4. 브라우저를 건너뛰고 curl로 POST하세요. `curl -H "Accept: application/json" -H "Origin: https://example.com" -d team_size=abc https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx`에서 origin은 자신의 허용 도메인으로 바꾸세요. 응답은 `team_size`와 빠진 모든 필수 필드에 대한 메시지가 담긴 `422`입니다.
5. 대시보드에서 **Submissions**를 여세요. 보낸 모든 필드가 담긴 새 제출이 있습니다.

## 주의할 점

| 문제 | 원인과 해결 |
| --- | --- |
| 서버 오류 뒤 폼이 전혀 제출되지 않음 | 커스텀 유효성 메시지를 지우지 않았습니다. `input`에서 `setCustomValidity("")`로 초기화하세요 |
| 미리 채워진 값에 `minlength`가 발생하지 않음 | `tooShort`는 사람이 필드를 편집한 뒤에만 적용됩니다. 서버 규칙이 이를 잡습니다 |
| `pattern`이 완전히 무시됨 | `v` 플래그 기준으로 유효하지 않으며, 브라우저는 유효하지 않은 패턴을 통째로 건너뜁니다. 클래스 안의 `-`와 기타 특수 문자를 이스케이프하세요 |
| 입력하기 전에 오류가 표시됨 | CSS가 `:invalid`를 씁니다. `:user-invalid`로 바꾸세요 |
| `422 validation_failed` | POST가 폼의 필드 정의와 맞지 않습니다. `error.fields`에 각 필드와 이유가 나옵니다. 입력의 `name`을 필드 ID와 맞추세요. |

전체 제출 흐름은 [개요](https://ikiform.com/ko/guides/html-form-submission.md#validation)에 있고, [오류](https://ikiform.com/ko/docs/errors.md)에 POST가 받을 수 있는 모든 코드가 나열되어 있습니다.

## 질문

### HTML 폼 검증만으로 충분한가요?

아니요. 브라우저 검증은 내 페이지의 브라우저에서만 실행되며, 봇이나 스크립트는 엔드포인트로 바로 POST합니다. 서버에서도 검증하세요. Ikiform에서는 폼의 필드를 정의하면 모든 POST가 확인됩니다.

### 서버 오류 메시지를 입력에 어떻게 표시하나요?

input.setCustomValidity(message)를 호출한 뒤 form.reportValidity()를 호출하면 브라우저의 평소 말풍선에 메시지가 표시됩니다. 사람이 필드를 편집하면 setCustomValidity("")로 지우세요.

### :invalid와 :user-invalid의 차이는 무엇인가요?

:invalid는 페이지 로드 시 비어 있는 필수 필드를 포함해 제약 조건을 통과하지 못한 모든 필드와 일치합니다. :user-invalid는 사람이 필드와 상호작용했거나 제출을 시도한 뒤에만 일치합니다.

### 필드가 검증에 실패하면 Ikiform은 무엇을 반환하나요?

아무것도 저장하지 않고 422 validation_failed를 반환합니다. JSON 요청은 각 필드 id를 메시지에 대응시킨 error.fields를 받고, 일반 폼 POST는 필드 레이블별로 나열한 호스팅 오류 페이지를 받습니다.

## 가이드 더 보기

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