Constraint Validation API, 커스텀 검증, FormData와 폼 제출 동작의 내부 메커니즘을 깊이 다룬다.
폼 심화: 검증·제약 API·상태 관리
입문편에서 <input>, <label>, required, type 같은 기초 문법을 익혔다면, 이번 레슨에서는 그 내부에서 브라우저가 어떻게 검증을 수행하는지, 그리고 JavaScript로 이 파이프라인을 어떻게 가로채고 확장할 수 있는지를 살펴봅니다. 실무에서 폼 검증은 단순히 에러 메시지를 띄우는 것이 아니라, 접근성·UX·보안을 동시에 만족시켜야 하는 복잡한 문제입니다.
이 레슨은 브라우저 내장 Constraint Validation API를 축으로, 커스텀 검증 로직 구현, FormData를 활용한 직렬화, 다중 제출 버튼 패턴, 자동 완성 토큰 제어, 그리고 <dialog>와의 통합까지 폼을 둘러싼 심층 메커니즘을 다룹니다.
학습 목표
- Constraint Validation API의 동작 원리와
:valid/:invalid의사 클래스와의 연동 방식을 설명할 수 있다. setCustomValidity와validity상태 객체를 활용해 커스텀 검증 로직을 구현할 수 있다.FormData객체로 폼을 직렬화하고,enctype별 전송 포맷 차이를 이해한다.formaction,formmethod,formnovalidate속성으로 다중 제출 버튼 패턴을 구성할 수 있다.autocomplete토큰과novalidate,<dialog>폼을 활용한 progressive enhancement 설계를 적용할 수 있다.
Constraint Validation API와 :valid/:invalid 연동
브라우저는 폼을 제출하기 전에 각 필드의 **제약 조건(constraint)**을 내부적으로 검사합니다. 이 검사를 수행하는 인터페이스가 바로 Constraint Validation API입니다. HTMLInputElement, HTMLSelectElement, HTMLTextAreaElement, HTMLButtonElement 등 폼 관련 요소에 모두 구현되어 있습니다.
checkValidity() 메서드는 해당 요소가 현재 유효한지 boolean으로 반환하고, 유효하지 않으면 invalid 이벤트를 발생시킵니다. reportValidity()는 같은 검사를 수행하되 브라우저 기본 UI(툴팁)까지 표시합니다.
<form id="signup-form" novalidate>
<label for="email">이메일</label>
<input type="email" id="email" name="email" required />
<label for="age">나이 (18~99)</label>
<input type="number" id="age" name="age" min="18" max="99" required />
<button type="submit">가입</button>
</form>
const form = document.getElementById('signup-form');
form.addEventListener('submit', (e) => {
e.preventDefault();
// 폼 전체 유효성 검사: checkValidity()는 invalid 이벤트를 발생시키되 UI는 표시하지 않음
if (!form.checkValidity()) {
// reportValidity()는 첫 번째 유효하지 않은 필드에 포커스 + 브라우저 툴팁 표시
form.reportValidity();
return;
}
console.log('제출 완료');
});
// 개별 필드의 invalid 이벤트를 가로채 커스텀 UI 적용
form.querySelectorAll('input').forEach((input) => {
input.addEventListener('invalid', (e) => {
e.preventDefault(); // 브라우저 기본 툴팁 억제
input.closest('label')?.classList.add('field-error');
});
});
:valid와 :invalid 의사 클래스는 이 API와 실시간으로 연동됩니다. 페이지 로드 직후에는 required 필드가 비어 있어도 :invalid가 바로 적용되어 빨간 테두리가 보이는 문제가 자주 발생합니다. 이를 방지하려면 아직 사용자가 건드리지 않은 필드에는 스타일을 적용하지 않는 전략이 필요합니다.
/* ❌ 페이지 로드 즉시 모든 빈 required 필드에 빨간 테두리가 생김 */
input:invalid {
border-color: red;
}
/* ✅ 사용자가 포커스를 잃은 후(touched)에만 스타일 적용 */
input.touched:invalid {
border-color: red;
}
input.touched:valid {
border-color: green;
}
// blur 이벤트로 "touched" 상태를 추적
document.querySelectorAll('input').forEach((input) => {
input.addEventListener('blur', () => input.classList.add('touched'), { once: false });
});
💡 TIP
:user-invalid의사 클래스는 CSS Selectors Level 4에서 이 패턴을 네이티브로 해결합니다. 사용자가 실제로 값을 입력하고 벗어난 후에만:invalid스타일을 적용합니다. 2024년 기준 주요 브라우저에서 지원됩니다.
setCustomValidity와 validity 상태 객체
setCustomValidity(message)는 필드를 인위적으로 유효하지 않은 상태로 만들거나 해제하는 핵심 메서드입니다. 빈 문자열을 전달하면 커스텀 오류가 해제되고, 비어 있지 않은 문자열을 전달하면 해당 메시지로 필드가 무효 상태가 됩니다.
const password = document.getElementById('password');
const confirm = document.getElementById('confirm-password');
confirm.addEventListener('input', () => {
if (confirm.value !== password.value) {
confirm.setCustomValidity('비밀번호가 일치하지 않습니다.');
} else {
confirm.setCustomValidity(''); // ✅ 오류 해제
}
});
⚠️ 주의
setCustomValidity로 오류를 설정한 후setCustomValidity('')로 해제하지 않으면, 이후 값이 올바르게 바뀌어도 필드는 계속 유효하지 않은 상태로 남습니다. 항상 조건 분기마다 해제 로직을 포함하세요.
validity 속성은 ValidityState 객체를 반환합니다. 어떤 제약이 위반되었는지 세분화해서 확인할 수 있어, 조건별로 다른 메시지를 제공할 때 유용합니다.
| 속성 | 의미 |
|---|---|
valueMissing | required인데 값이 비어 있음 |
typeMismatch | 타입 형식 불일치 (예: email 형식 오류) |
patternMismatch | pattern 정규식 불일치 |
tooShort / tooLong | minlength / maxlength 위반 |
rangeUnderflow / rangeOverflow | min / max 위반 |
stepMismatch | step 값 불일치 |
customError | setCustomValidity로 설정된 오류 |
valid | 위 조건 중 하나도 해당되지 않음 |
function getErrorMessage(input) {
const v = input.validity;
if (v.valueMissing) return `${input.labels[0]?.textContent} 항목은 필수입니다.`;
if (v.typeMismatch) return '올바른 형식으로 입력해 주세요.';
if (v.tooShort) return `최소 ${input.minLength}자 이상 입력해야 합니다.`;
if (v.tooLong) return `최대 ${input.maxLength}자까지 입력할 수 있습니다.`;
if (v.rangeUnderflow) return `${input.min} 이상의 값을 입력해 주세요.`;
if (v.rangeOverflow) return `${input.max} 이하의 값을 입력해 주세요.`;
if (v.patternMismatch) return input.title || '입력 형식이 올바르지 않습니다.';
if (v.customError) return input.validationMessage;
return '';
}
비동기 커스텀 검증 패턴
서버에 중복 확인 같은 비동기 검증이 필요한 경우, Constraint Validation API와 결합하는 방법은 다음과 같습니다.
const usernameInput = document.getElementById('username');
usernameInput.addEventListener('blur', async () => {
usernameInput.setCustomValidity(''); // 이전 오류 초기화
const { taken } = await fetch(`/api/check-username?q=${usernameInput.value}`)
.then((r) => r.json());
if (taken) {
usernameInput.setCustomValidity('이미 사용 중인 아이디입니다.');
usernameInput.reportValidity(); // 즉시 브라우저 툴팁 표시
}
});
FormData 객체와 enctype별 직렬화
FormData는 폼 필드의 이름-값 쌍을 캡슐화하는 객체로, 파일 업로드와 일반 텍스트를 동일한 API로 다룰 수 있습니다.
const form = document.getElementById('upload-form');
form.addEventListener('submit', async (e) => {
e.preventDefault();
// HTMLFormElement를 직접 생성자에 전달하면 모든 필드를 자동 수집
const fd = new FormData(form);
// 필드 추가·수정·삭제
fd.append('source', 'web'); // 값 추가
fd.set('username', 'alice'); // 값 덮어쓰기
fd.delete('honeypot'); // 필드 제거
// 수집된 데이터 순회
for (const [name, value] of fd.entries()) {
console.log(name, value);
}
await fetch('/api/submit', { method: 'POST', body: fd });
// Content-Type은 자동으로 multipart/form-data로 설정됨
});
enctype 속성은 폼 데이터가 서버로 전송될 때의 인코딩 방식을 결정합니다.
| enctype | Content-Type | 특징 |
|---|---|---|
application/x-www-form-urlencoded | application/x-www-form-urlencoded | 기본값. 키=값&키=값 형태. 파일 전송 불가. |
multipart/form-data | multipart/form-data; boundary=... | 파일 업로드 필수. 각 필드가 MIME 파트로 구분됨. |
text/plain | text/plain | 디버깅용. 실무에선 거의 사용하지 않음. |
fetch에 FormData를 body로 직접 전달하면 브라우저가 자동으로 multipart/form-data를 선택합니다. JSON 형태로 보내고 싶다면 직접 변환해야 합니다.
// FormData → 일반 객체 → JSON 변환
const fd = new FormData(form);
const payload = Object.fromEntries(fd.entries());
// ⚠️ 동일 name의 multiple 필드(체크박스 등)는 마지막 값만 남음
// ✅ 동일 name 다중 값 처리
const payloadMulti = {};
for (const [key, value] of fd.entries()) {
if (payloadMulti[key] !== undefined) {
payloadMulti[key] = [].concat(payloadMulti[key], value);
} else {
payloadMulti[key] = value;
}
}
await fetch('/api/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payloadMulti),
});
💡 TIP
formdata이벤트를 사용하면 폼 제출 시FormData객체가 생성되는 시점을 가로챌 수 있습니다.e.formData에 직접 값을 추가하는 방식으로 CSRF 토큰이나 메타데이터를 주입하기에 유용합니다.
form.addEventListener('formdata', (e) => {
e.formData.append('_csrf', getCsrfToken());
});
formaction·formmethod·formnovalidate와 다중 제출 버튼
하나의 폼에서 "저장", "임시 저장", "삭제" 같은 서로 다른 동작을 단일 폼으로 처리해야 할 때, 제출 버튼(type="submit") 또는 이미지 버튼에 폼 재정의 속성을 사용합니다.
<form action="/articles" method="POST" id="article-form">
<input type="text" name="title" required />
<textarea name="body" required></textarea>
<!-- 기본 제출: /articles로 POST, 검증 수행 -->
<button type="submit">발행</button>
<!-- formaction으로 다른 엔드포인트로 제출 -->
<button type="submit" formaction="/articles/draft" formmethod="POST">
임시 저장
</button>
<!-- formnovalidate: 검증 없이 제출 (초안 저장, 삭제 등에 유용) -->
<button type="submit" formaction="/articles/draft" formnovalidate>
검증 없이 임시 저장
</button>
<!-- formmethod="dialog": <dialog> 안에서 폼을 닫을 때 사용 (후술) -->
</form>
클릭된 제출 버튼의 속성이 <form> 요소의 해당 속성을 우선합니다. 이 우선순위는 다음과 같습니다.
| 버튼 속성 | 재정의하는 form 속성 |
|---|---|
formaction | action |
formmethod | method |
formenctype | enctype |
formnovalidate | novalidate |
formtarget | target |
어떤 버튼이 눌렸는지 서버에서 식별하려면 버튼에 name과 value를 지정합니다. 제출 버튼은 폼 데이터에 포함되는 유일한 버튼 유형입니다.
<button type="submit" name="_action" value="publish">발행</button>
<button type="submit" name="_action" value="draft">임시 저장</button>
// 서버 사이드(Node.js 예시)에서 구분
const action = formData.get('_action'); // 'publish' | 'draft'
⚠️ 주의
formnovalidate는 악의적인 사용자가 클라이언트 검증을 우회해 불완전한 데이터를 서버로 보낼 수 있게 합니다. 서버 측 검증은 항상 독립적으로 수행해야 합니다.
autocomplete 토큰과 브라우저 자동 완성 연동
autocomplete 속성은 단순한 on/off 스위치가 아닙니다. HTML 명세는 세밀한 **자동 완성 토큰(autofill detail token)**을 정의하여, 브라우저와 비밀번호 관리자가 올바른 값을 채울 수 있도록 안내합니다.
<form autocomplete="on">
<!-- 배송 주소 섹션 — 'shipping' 섹션 토큰 -->
<input autocomplete="shipping name" name="ship-name" />
<input autocomplete="shipping street-address" name="ship-street" />
<input autocomplete="shipping postal-code" name="ship-zip" />
<!-- 청구 주소 섹션 -->
<input autocomplete="billing name" name="bill-name" />
<!-- 결제 정보 -->
<input autocomplete="cc-number" name="card-number" inputmode="numeric" />
<input autocomplete="cc-exp" name="card-expiry" />
<input autocomplete="cc-csc" name="card-csc" />
<!-- 계정 정보 -->
<input type="email" autocomplete="email" name="email" />
<input type="password" autocomplete="current-password" name="password" />
<input type="password" autocomplete="new-password" name="new-password" />
<!-- OTP -->
<input type="text" autocomplete="one-time-code" inputmode="numeric" />
</form>
new-password 토큰은 비밀번호 관리자에게 "새 비밀번호를 생성하거나 저장하라"는 신호를 보냅니다. current-password는 기존 비밀번호를 채우라는 신호입니다. 이 둘을 혼용하면 관리자가 혼란을 겪을 수 있습니다.
<!-- ❌ 로그인 폼에 new-password를 잘못 사용 — 비밀번호 관리자가 새 자격증명으로 저장 시도 -->
<input type="password" autocomplete="new-password" name="password" />
<!-- ✅ 로그인 폼 -->
<input type="password" autocomplete="current-password" name="password" />
<!-- ✅ 회원가입/비밀번호 변경 폼 -->
<input type="password" autocomplete="new-password" name="new-password" />
특정 필드에만 자동 완성을 끄고 싶다면 autocomplete="off"를 사용하되, 브라우저가 이를 무시하는 경우가 많습니다. 특히 비밀번호 필드는 사용자의 편의를 위해 브라우저가 관리자 개입을 허용합니다.
💡 TIP
autocomplete="off"를 비밀번호 필드에 적용해도 주요 브라우저(Chrome, Firefox, Safari)는 비밀번호 관리자를 통한 자동 완성을 허용합니다. 보안 목적으로 자동 완성을 막으려면 서버 사이드 세션 기반 토큰 방식을 사용하는 것이 더 효과적입니다.
novalidate, dialog 폼과 progressive enhancement
novalidate와 커스텀 검증 UI 구축
novalidate 속성을 <form>에 추가하면 브라우저 기본 검증 UI(빨간 툴팁)를 완전히 비활성화합니다. 이 방식은 접근성 요건을 충족하는 커스텀 에러 메시지 UI를 직접 구현할 때 사용합니다.
<form id="custom-form" novalidate>
<div class="field">
<label for="email">이메일 <span aria-hidden="true">*</span></label>
<input
type="email"
id="email"
name="email"
required
aria-describedby="email-error"
/>
<span id="email-error" class="error-msg" role="alert" aria-live="polite"></span>
</div>
<button type="submit">제출</button>
</form>
const form = document.getElementById('custom-form');
form.addEventListener('submit', (e) => {
e.preventDefault();
let firstInvalid = null;
form.querySelectorAll('input, select, textarea').forEach((field) => {
const errorEl = document.getElementById(`${field.id}-error`);
if (!field.validity.valid) {
const msg = getErrorMessage(field); // 앞서 정의한 함수 재사용
if (errorEl) errorEl.textContent = msg;
field.setAttribute('aria-invalid', 'true');
firstInvalid = firstInvalid ?? field;
} else {
if (errorEl) errorEl.textContent = '';
field.removeAttribute('aria-invalid');
}
});
if (firstInvalid) {
firstInvalid.focus(); // 스크린 리더 사용자에게 포커스 이동
return;
}
// 제출 로직
});
aria-invalid="true", aria-describedby, role="alert", aria-live="polite"의 조합은 스크린 리더 사용자에게도 에러 상태를 전달하는 접근성의 핵심입니다.
dialog 폼과 폼 제출 메커니즘
<dialog> 요소 안에 method="dialog"를 가진 폼을 배치하면, 제출 시 다이얼로그가 닫히고 클릭된 제출 버튼의 value가 dialog.returnValue에 저장됩니다. JavaScript 없이도 모달 확인/취소 패턴을 구현할 수 있습니다.
<dialog id="confirm-dialog">
<form method="dialog">
<p>정말로 삭제하시겠습니까?</p>
<menu>
<!-- value가 dialog.returnValue에 저장됨 -->
<button type="submit" value="cancel">취소</button>
<button type="submit" value="confirm">삭제</button>
</menu>
</form>
</dialog>
<button id="delete-btn">삭제</button>
const dialog = document.getElementById('confirm-dialog');
const deleteBtn = document.getElementById('delete-btn');
deleteBtn.addEventListener('click', () => {
dialog.showModal(); // 모달로 열기
});
dialog.addEventListener('close', () => {
if (dialog.returnValue === 'confirm') {
console.log('삭제 확인됨');
// 실제 삭제 API 호출
}
});
formmethod="dialog" 속성을 버튼에 직접 지정하면, method="dialog"가 없는 일반 폼에서도 특정 버튼만 다이얼로그 닫기 동작을 수행할 수 있습니다. 단, formmethod="dialog" 버튼도 폼에 novalidate가 없으면 제출 시 제약 검증을 거칩니다. 따라서 빈 required 입력이 있으면 취소 버튼을 눌러도 검증이 실패해 다이얼로그가 닫히지 않으므로, 닫기만 수행할 버튼에는 formnovalidate를 함께 지정해 검증을 건너뛰어야 합니다.
<dialog id="edit-dialog">
<form action="/api/items" method="POST">
<input type="text" name="name" required />
<!-- ✅ 취소 버튼: formmethod="dialog"로 폼 제출 없이 다이얼로그만 닫음.
formnovalidate가 없으면 비어 있는 required 입력 때문에 제약 검증이
실패하여 다이얼로그가 닫히지 않으므로, 반드시 함께 지정해야 함 -->
<button type="submit" formmethod="dialog" formnovalidate value="cancel">취소</button>
<!-- 실제 서버 제출 -->
<button type="submit" value="save">저장</button>
</form>
</dialog>
Progressive Enhancement 설계 원칙
폼의 progressive enhancement는 "JavaScript 없이도 기본 동작이 가능해야 한다"는 원칙입니다.
<!-- ✅ JS 없이도 서버에 제출되는 기본 구조 -->
<form action="/subscribe" method="POST">
<input type="email" name="email" required autocomplete="email" />
<button type="submit">구독</button>
</form>
// JS가 로드된 경우 AJAX로 향상
const form = document.querySelector('form');
if (form) {
// 화살표 함수에는 arguments 객체가 없고, 엄격 모드/ES 모듈에서는
// arguments.callee 사용이 금지되므로, 리스너를 명명 함수로 분리해
// removeEventListener에 동일한 참조를 넘긴다.
async function onSubmit(e) {
e.preventDefault(); // 기본 동작(페이지 이동)을 막고 AJAX로 처리
const fd = new FormData(form);
try {
const res = await fetch(form.action, { method: form.method, body: fd });
if (res.ok) {
showSuccessMessage(); // 커스텀 성공 UI
}
} catch {
// 실패 시 기본 폼 제출로 폴백
form.removeEventListener('submit', onSubmit);
form.submit();
}
}
form.addEventListener('submit', onSubmit);
}
💡 TIP
form.action과form.method를 하드코딩하지 않고 HTML 속성에서 읽어오면, 마크업만 수정해도 JS 코드를 바꿀 필요가 없어 유지보수성이 높아집니다.
요약
- Constraint Validation API는
checkValidity(),reportValidity(),validity,setCustomValidity()로 구성되며,:valid/:invalidCSS 의사 클래스와 실시간으로 연동된다. ValidityState객체의 세부 속성(valueMissing,typeMismatch,patternMismatch등)을 활용하면 상황별로 정확한 에러 메시지를 제공할 수 있다.FormData는 파일과 텍스트를 동일하게 다루며,enctype에 따라application/x-www-form-urlencoded또는multipart/form-data형식으로 직렬화된다.formaction,formmethod,formnovalidate는 제출 버튼 단위로<form>속성을 재정의하며, 다중 제출 버튼 패턴을 구현하는 데 사용된다.autocomplete토큰을 정확하게 지정하면 브라우저 자동 완성과 비밀번호 관리자가 올바르게 작동하며,new-password와current-password는 명확히 구분해야 한다.<dialog>+method="dialog"조합으로 JavaScript 의존도를 줄이고,novalidate와 ARIA 속성을 활용해 접근성을 갖춘 커스텀 검증 UI를 구현한다.
연습문제
-
이메일 주소와 비밀번호 입력 필드를 가진 로그인 폼을 작성하세요. 브라우저 기본 검증 UI를 비활성화하고,
ValidityState를 활용해 각 필드의 상황별 에러 메시지를<span>요소에 동적으로 표시하세요. 접근성을 위한 ARIA 속성도 포함해야 합니다.힌트
novalidate,aria-invalid,aria-describedby,aria-live="polite"를 함께 사용하세요. -
단일
<form>에 "게시" 버튼과 "임시 저장" 버튼을 구현하세요. "게시" 버튼은/posts로 POST 제출(검증 수행)하고, "임시 저장" 버튼은/posts/draft로 POST 제출하되 검증을 건너뛰어야 합니다. 제출 시 서버가 어떤 버튼을 눌렀는지 식별할 수 있도록name/value도 추가하세요.힌트
formaction,formnovalidate, 버튼의name과value속성을 활용하세요. -
아바타 이미지 업로드와 사용자 이름을 함께 제출하는 폼을
FormData를 사용해fetch로 전송하는 코드를 작성하세요. 또한 CSRF 토큰을formdata이벤트를 통해 자동으로 주입하세요.힌트
formdata이벤트의e.formData.append()와fetch의body에FormData인스턴스를 직접 전달하면Content-Type이 자동 설정됩니다. -
삭제 확인 모달을
<dialog>와method="dialog"폼으로 구현하세요. "확인" 버튼을 누르면dialog.returnValue를 확인해 실제 삭제 API를 호출하고, "취소" 버튼은 아무 동작 없이 다이얼로그만 닫아야 합니다.힌트
dialog.returnValue는 클릭된 제출 버튼의value속성값이 됩니다.close이벤트에서 이 값을 확인하세요.
💡 연습문제 풀이
불러오는 중…
댓글 0
“HTML 심화” 강좌에 대한 댓글입니다.