컴포넌트 패턴
폼 오류 검증
입력 오류를 텍스트로 알리고 입력란과 연결하며, 제출 시 오류 요약으로 포커스를 옮겨 어떤 칸을 어떻게 고쳐야 하는지 모든 사용자가 알 수 있게 만드는 방법입니다.
언제 쓰나요
사용자가 입력한 값을 검사해 잘못된 부분을 알려야 하는 모든 폼에 적용합니다. 회원 가입, 결제, 문의 폼처럼 필수 항목이나 형식 제한이 있는 곳이 대표적입니다. 오류를 빨간 테두리로만표시하면 색을 구분하기 어려운 사용자와 화면낭독기 사용자는 무엇이 잘못되었는지 알 수 없습니다.
오류 검증에는 세 가지 요소가 함께 필요합니다.
- 인라인 오류: 각 입력란 가까이에 무엇이 잘못되었고 어떻게 고치는지 텍스트로 보여 주고, 입력란과 프로그램으로 연결합니다.
- 오류 요약: 제출했을 때 폼 위쪽에 오류 목록을 보여 주고, 각 항목에서 해당 입력란으로 바로 이동할 수 있게 합니다.
- 포커스 이동: 제출에 실패하면 오류 요약(또는 첫 번째 오류 입력란)으로 포커스를 옮겨 사용자가 결과를 바로 알게 합니다.
네이티브 기능 먼저
required, type="email", pattern, minlength 같은 HTML 속성은 필수 여부와 형식 제한을 보조기술에 그대로 전달하고, input.validity로 검사 결과를 얻을 수 있습니다. 다만 브라우저 기본 오류 말풍선은 금방 사라지고 모양을 바꿀 수 없으며 화면 확대 시 보기 어려운 경우가 있어, 많은 서비스는 <form novalidate>로 기본 말풍선을 끄고 검사 API만 활용해 직접 오류를 표시합니다.
구조
- 모든 입력란에 보이는
<label>이 있습니다. 필수 항목은 레이블에 “(필수)”처럼 텍스트로 표시하고, 입력란에required(또는 직접 만든 위젯이라면aria-required="true")를 줍니다. - 검사에 실패한 입력란에만
aria-invalid="true"를 줍니다. 아직 검사하지 않았거나 통과한 입력란에는 주지 않습니다(또는false). - 오류 메시지 요소는
aria-describedby로 입력란에 연결합니다. 입력 형식 안내(힌트)가 있으면 함께 나열합니다:aria-describedby="email-hint email-err". aria-errormessage를 쓸 수도 있지만, 이 속성은aria-invalid="true"일 때만 의미가 있고 보조기술 지원이 아직 고르지 않습니다. 가장 널리 동작하는 방법은aria-describedby입니다.- 오류 요약은 제목(
<h2>등)과 링크 목록으로 만들고, 프로그램으로 포커스를 받을 수 있도록tabindex="-1"을 줍니다. 각 링크는 해당 입력란의id를 가리킵니다.
<form novalidate>
<div id="summary" tabindex="-1" aria-labelledby="summary-title">
<h2 id="summary-title">입력 내용을 확인해 주세요. 오류 1건</h2>
<ul><li><a href="#email">이메일 형식이 올바르지 않습니다.</a></li></ul>
</div>
<label for="email">이메일 (필수)</label>
<p id="email-hint">예: name@example.com</p>
<input type="email" id="email" required
aria-invalid="true" aria-describedby="email-hint email-err">
<p id="email-err" class="error">오류: 이메일 형식이 올바르지 않습니다.</p>
</form>키보드 동작
폼 검증은 별도의 키 조작을 추가하지 않습니다. 대신 다음 흐름이 키보드만으로 이어져야 합니다.
| 키 | 동작 |
|---|---|
| Enter | 입력란 안에서 누르면 폼을 제출합니다(네이티브 동작). 오류가 있으면 포커스가 오류 요약으로 이동합니다. |
| Tab | 오류 요약 안의 링크로 이동합니다. |
| Enter (요약 링크에서) | 해당 입력란으로 포커스를 옮깁니다. |
필요한 ARIA
| 역할·속성 | 붙이는 곳 | 설명 |
|---|---|---|
aria-invalid | 검사에 실패한 입력란 | true이면 “잘못된 값”으로 안내됩니다. 검사하기 전부터 넣지 않습니다. |
aria-describedby | 입력란 | 힌트와 오류 메시지 요소의 id를 연결합니다. 포커스할 때 설명으로 읽힙니다. |
aria-errormessage | 입력란 | 오류 메시지 요소의 id. aria-invalid="true"일 때만 유효합니다. 지원이 고르지 않아 aria-describedby를 대신하거나 함께 써서 생기는 중복을 점검합니다. |
aria-required | 직접 만든 입력 위젯 | 필수 입력임을 알립니다. 네이티브 입력란에는 required를 씁니다. |
aria-labelledby | 오류 요약 영역 | 요약의 제목으로 영역 이름을 줍니다. 포커스가 이동했을 때 제목이 함께 읽힙니다. |
status / aria-live | 완료 메시지 | 포커스를 옮기지 않는 성공 메시지(“신청이 완료되었습니다”)를 알릴 때 씁니다. |
alert | (선택) 즉시 알릴 오류 | 포커스를 옮기지 않고 오류를 알려야 할 때만 씁니다. 오류 요약으로 포커스를 옮긴다면 함께 쓰지 않습니다(두 번 읽힘). |
빈 오류 요소와 hidden
aria-describedby가 가리키는 요소는 hidden으로 숨겨져 있어도 그 텍스트가 설명으로 읽힐 수 있습니다. 오류가 없을 때는 요소를 숨기기보다 텍스트를 비워 두는 편이 안전합니다.
직접 해 보기
두 폼 모두 빈 칸으로 가입하기를 눌러 보세요. 왼쪽은 빨간 테두리만 생기고, 오른쪽은 오류 요약으로 포커스가 이동하며 각 입력란에 오류 문구가 연결됩니다. 요약의 링크를 누르면 해당 칸으로 이동합니다.
잘못된 구현
오류가 색으로만 표시되고, 포커스도 그대로여서 화면낭독기 사용자는 제출이 실패한 사실조차 알기 어렵습니다.
올바른 구현
실시간 코드
오류 문구 앞에 아이콘을 넣는다면 “오류:” 같은 텍스트나 대체 텍스트를 함께 두어 색과 아이콘 없이도 오류임을 알 수 있게 합니다. 위 데모는 문구 자체가 고치는 방법을 설명하도록 썼습니다.
전체 코드
<label for="email">이메일*</label>
<input id="email" style="border-color: red"><label for="email">이메일 (필수)</label>
<input type="email" id="email" required
aria-invalid="true" aria-describedby="email-err">
<p id="email-err">이메일 형식이 올바르지 않습니다.
예: name@example.com</p><form id="signup" novalidate>
<div id="summary" tabindex="-1" aria-labelledby="summary-title" hidden>
<h2 id="summary-title">입력 내용을 확인해 주세요.</h2>
<ul id="summary-list"></ul>
</div>
<label for="name">이름 (필수)</label>
<input id="name" autocomplete="name" required aria-describedby="name-err">
<p id="name-err" class="error"></p>
<label for="email">이메일 (필수)</label>
<p id="email-hint">예: name@example.com</p>
<input type="email" id="email" autocomplete="email" required
aria-describedby="email-hint email-err">
<p id="email-err" class="error"></p>
<button type="submit">가입하기</button>
<p id="done" role="status"></p>
</form>var form = document.getElementById('signup');
var summary = document.getElementById('summary');
var title = document.getElementById('summary-title');
var list = document.getElementById('summary-list');
var fields = [
{ input: document.getElementById('name'), label: '이름', obj: '이름을' },
{ input: document.getElementById('email'), label: '이메일', obj: '이메일을' }
];
function message(f) {
var v = f.input.validity; // 브라우저 검사 결과만 사용
if (v.valueMissing) return f.obj + ' 입력해 주세요.';
if (v.typeMismatch) return '이메일 형식이 올바르지 않습니다. 예: name@example.com';
return '';
}
function check(f) {
var msg = message(f);
var err = document.getElementById(f.input.id + '-err');
if (msg) f.input.setAttribute('aria-invalid', 'true');
else f.input.removeAttribute('aria-invalid');
err.textContent = msg; // 오류가 없으면 비워 둠
return msg;
}
fields.forEach(function (f) {
// 입력을 마치고 벗어날 때 검사 (키를 누를 때마다 검사하지 않음)
f.input.addEventListener('blur', function () {
if (f.input.value) check(f);
});
// 이미 오류인 칸은 고치는 즉시 오류를 지움
f.input.addEventListener('input', function () {
if (f.input.getAttribute('aria-invalid') === 'true' && !message(f)) check(f);
});
});
form.addEventListener('submit', function (e) {
var errors = fields.map(function (f) {
return { id: f.input.id, msg: check(f) };
}).filter(function (x) { return x.msg; });
if (!errors.length) { summary.hidden = true; return; } // 정상 제출
e.preventDefault();
title.textContent = '입력 내용을 확인해 주세요. 오류 ' + errors.length + '건';
list.textContent = '';
errors.forEach(function (er) {
var li = document.createElement('li');
var a = document.createElement('a');
a.href = '#' + er.id;
a.textContent = er.msg;
a.addEventListener('click', function (ev) {
ev.preventDefault();
document.getElementById(er.id).focus();
});
li.appendChild(a);
list.appendChild(li);
});
summary.hidden = false;
summary.focus(); // 제출 실패를 바로 알림
});aria-errormessage를 쓰는 경우
<label for="birth">생년월일</label>
<input id="birth" aria-invalid="true" aria-errormessage="birth-err">
<p id="birth-err">날짜를 YYYY-MM-DD 형식으로 입력해 주세요.</p>aria-errormessage 지원 현황
명세상 aria-errormessage는 오류 메시지를 연결하는 전용 속성이지만, 화면낭독기와 브라우저 조합에 따라 읽히지 않는 경우가 아직 있습니다. 공개 서비스에서는 aria-describedby로 연결하는 방법이 가장 안정적입니다. aria-errormessage를 쓸 때는 대상 화면낭독기에서 실제로 읽히는지 확인합니다.
제출 실패를 페이지 제목에도
서버에서 검증해 페이지를 새로 그리는 방식이라면 <title> 앞에 “오류 2건 - ”처럼 붙이고, 페이지가 열리면 오류 요약으로 포커스를 옮깁니다. 화면낭독기는 새 페이지의 제목부터 읽으므로 실패 사실을 바로 알 수 있습니다.
화면낭독기에서는
| 상황 | 들리는 안내 (예) |
|---|---|
| 필수 입력란에 처음 포커스 | 이름 (필수), 편집창, 필수, 입력란 |
| 빈 칸으로 제출해 오류 요약으로 포커스 이동 | 입력 내용을 확인해 주세요. 오류 2건, 입력 내용을 확인해 주세요. 오류 2건, 목록 2개 항목 … |
| 요약의 링크를 눌러 이메일 칸으로 이동 | 이메일 (필수), 편집창, 필수, 잘못된 입력, 예: name@example.com 이메일을 입력해 주세요. |
| 오류를 고치고 다시 제출해 성공 | 회원 가입 신청이 완료되었습니다. |
| 잘못된 구현에서 제출 | 아무 안내도 없음 (테두리 색만 바뀜) |
점검 체크리스트
- 모든 입력란에 보이는 레이블이 있고, 필수 항목은 텍스트(예: “(필수)”)와
required로 표시했습니다. - 오류를 색이나 테두리만이 아니라 텍스트로 알립니다.
- 오류 메시지가 무엇이 잘못되었는지와 어떻게 고치는지를 알려 줍니다.
- 오류 메시지가
aria-describedby(또는aria-errormessage)로 입력란에 연결되어 있습니다. aria-invalid="true"는 검사에 실패한 입력란에만 있고, 고치면 제거됩니다.- 키를 누를 때마다 오류를 띄우지 않고, 입력란을 벗어날 때나 제출할 때 검사합니다.
- 제출에 실패하면 포커스가 오류 요약이나 첫 번째 오류 입력란으로 이동합니다.
- 오류 요약의 각 항목에서 해당 입력란으로 이동할 수 있습니다.
- 제출 성공처럼 포커스를 옮기지 않는 결과 메시지는
role="status"로 알립니다. - 사용자가 입력한 값은 오류가 나도 지우지 않고 유지합니다.
관련 WCAG 성공 기준
- 1.3.1 정보와 관계 (A): 레이블·오류 메시지와 입력란의 관계를 마크업으로 전달합니다.
- 1.4.1 색 사용 (A): 오류를 색으로만 구분하지 않습니다.
- 2.4.3 포커스 순서 (A): 제출 실패 후 포커스를 의미 있는 위치로 옮깁니다.
- 3.3.1 오류 식별 (A): 오류가 난 항목을 식별하고 텍스트로 설명합니다.
- 3.3.2 레이블 또는 지시 (A): 필수 여부와 입력 형식을 미리 알려 줍니다.
- 3.3.3 오류 수정 제안 (AA): 고치는 방법을 제안합니다.
- 3.3.4 오류 예방(법률, 금융, 데이터) (AA)
- 4.1.2 이름, 역할, 값 (A): 잘못된 입력 상태가 보조기술에 전달됩니다.
- 4.1.3 상태 메시지 (AA): 포커스를 옮기지 않는 결과 메시지를 보조기술이 알 수 있게 합니다.
APG에는 폼 검증 전용 패턴이 없습니다. 더 자세한 내용은 W3C WAI 폼 튜토리얼: 사용자 알림 (새 창)과 입력 검증 (새 창)을 참고하세요.