회원가입 폼을 만들고 있었습니다. 이메일 입력창에 포커스를 주자마자 "이메일 형식이 올바르지 않습니다"라는 에러가 떴어요. 아직 아무것도 입력하지 않았는데요.
비밀번호 확인창은 반대였습니다. 비밀번호를 다 입력하고 폼을 제출했는데, 그제서야 "비밀번호가 일치하지 않습니다"라고 했어요. 이미 다 입력한 뒤에요.
그리고 제출 버튼을 누르면 에러 메시지가 한꺼번에 7개 등장했습니다.
폼 검증 타이밍, 메시지 표시 위치, 스크린 리더 지원. 이 세 가지가 맞아떨어져야 진짜 좋은 폼입니다.
이 글은 그 세 가지 중 "언제, 어떻게 알려줄 것인가"에 집중합니다.
언제 검증할까? — 타이밍이 전부다#
폼 검증 타이밍은 생각보다 섬세한 문제입니다. 너무 이르면 사용자가 입력을 마치기도 전에 에러가 뜨고, 너무 늦으면 실수를 제때 잡지 못해요.
실무에서 잘 작동하는 타이밍 전략은 이렇습니다:

규칙 1: 첫 포커스에는 검증하지 않기#
필드에 처음 포커스가 갔을 때는 아무 검증도 하지 마세요. 사용자가 아직 입력을 시작도 안 했으니까요. 여기서 에러를 띄우면 인사도 하기 전에 지적부터 하는 폼이 됩니다.
규칙 2: blur 이후부터 검증 시작#
사용자가 필드를 떠난 순간(blur 이벤트), 그때부터 실시간 검증을 시작합니다. 이미 한 번 입력을 시도했으니, 피드백을 줘도 됩니다.
규칙 3: 에러가 뜬 뒤에는 input마다 즉시 재검증#
에러가 표시된 상태에서 사용자가 수정하기 시작하면, 그때부터는 타이핑할 때마다 즉시 검증합니다. 여기서 쓰는 건 change가 아니라 input 이벤트예요. change는 필드를 떠나야 발생해서, 고치는 중에는 에러가 그대로 붙어 있게 됩니다. 수정했는데 에러가 안 사라지면 답답하거든요.
rules는 "검사 함수 + 실패 시 보여줄 말"을 담은 객체들의 배열입니다. 이 형태가 아니면 아래 코드가 에러도 없이 조용히 아무 말도 안 하니까 먼저 맞춰두고 가겠습니다.
const emailRules = [
{ test: v => v.trim() !== '', message: '이메일을 입력해주세요' },
{ test: v => /^\S+@\S+\.\S+$/.test(v), message: '이메일 형식이 올바르지 않습니다' },
];class FieldValidator {
constructor(input, rules) {
this.input = input;
this.rules = rules;
this.hasBeenBlurred = false;
this.hasError = false;
// 에러를 그릴 자리 — aria-describedby는 id를 여럿 담을 수 있으니 첫 번째만 쓴다
const describedBy = (input.getAttribute('aria-describedby') || '').split(/\s+/)[0];
this.errorEl = document.getElementById(describedBy);
if (!this.errorEl) {
throw new Error(`${input.id}: aria-describedby로 에러 메시지 요소를 먼저 연결하세요`);
}
input.addEventListener('blur', () => {
this.hasBeenBlurred = true;
this.validate();
});
input.addEventListener('input', () => {
// blur 이후에만 실시간 검증 (에러가 있을 때도)
if (this.hasBeenBlurred) {
this.validate();
}
});
}
validate() {
const value = this.input.value;
const error = this.rules.find(rule => !rule.test(value))?.message;
if (error) {
this.showError(error);
this.hasError = true;
} else {
this.clearError();
this.hasError = false;
}
}
// 에러 표시·해제는 아래 「배선」 절에서 자세히 다룹니다.
// 여기서는 동작하는 최소 형태만 둡니다.
showError(message) {
this.errorEl.textContent = message;
this.input.setAttribute('aria-invalid', 'true');
}
clearError() {
this.errorEl.textContent = '';
this.input.setAttribute('aria-invalid', 'false');
}
}세 가지 규칙, 생각보다 복잡하죠. 근데 사용자 입장에서 생각해보면 당연한 거예요. 처음 보는 사람한테 "왜 아직 입력 안 했어요?"라고 먼저 따지는 폼, 쓰고 싶지 않잖아요.
타이밍 차이는 글로 읽으면 "그렇겠네" 하고 넘어가기 쉽습니다. 직접 손가락으로 겪어야 알아요. 폼 검증 UX 비교 데모에 나쁜 쪽과 좋은 쪽을 나란히 뒀습니다. 왼쪽 이름 칸을 클릭만 해보고, 오른쪽에서 같은 걸 해보세요.
각 폼 아래 로그는 미리 말씀드릴 게 있어요. 실제 스크린 리더가 뱉은 출력이 아니라 같은 상황에서 무엇이 읽히는지를 제가 손으로 적어둔 시뮬레이션입니다. 진짜 판정은 스크린 리더를 켜고 해보셔야 하고요.
에러 메시지는 어디에 표시할까?#
라벨 연결이나 오류 식별처럼 폼 접근성의 기본기는 폼 접근성 마스터하기에서 따로 다뤘습니다. 여기서는 그 위에 얹는 이야기만 합니다.

입력창 바로 아래가 정답입니다. 이것만큼 명확한 위치는 없어요.
<div class="field">
<label for="email">이메일</label>
<input
type="email"
id="email"
name="email"
aria-describedby="email-error"
aria-invalid="false"
/>
<span
id="email-error"
class="error-message"
aria-live="polite"
></span>
</div>role="alert"을 붙이지 않은 게 의도적입니다. 그건 스크린 리더가 읽던 걸 끊고 끼어드는 역할이라, 타이핑 중에 계속 발동하면 오히려 방해가 됩니다. 이유는 아래 「role="alert"과 aria-live="polite", 폼에서는 어느 쪽인가」 절에서 따로 다룹니다.
여기서 중요한 속성 두 가지:
aria-describedby#
aria-describedby·aria-invalid를 비롯한 ARIA 속성 전반은 ARIA 실전 가이드에서 더 넓게 다룹니다.
aria-describedby="email-error"는 스크린 리더에게 "이 입력창의 설명이 email-error 요소에 있어요"라고 알려줍니다.
사용자가 입력창에 포커스를 주면 스크린 리더가 "이메일, 편집 가능, 이메일 형식으로 입력해주세요" 처럼 라벨과 함께 설명을 읽어줍니다. 에러가 발생하면 에러 메시지도 함께 읽혀요.
aria-invalid#
에러 상태를 시맨틱하게 전달합니다. "true"로 바꾸면 스크린 리더가 "잘못된 입력"임을 알립니다.
function showError(input, errorEl, message) {
input.setAttribute('aria-invalid', 'true');
errorEl.textContent = message;
errorEl.classList.add('is-error'); // 스타일은 클래스로만 건드린다
}
function clearError(input, errorEl) {
input.setAttribute('aria-invalid', 'false');
errorEl.textContent = '';
errorEl.classList.remove('is-error');
}배선을 정리하면 — htmlFor는 이름을, aria-describedby는 설명(에러)을, aria-invalid는 상태를 잇는 전선입니다. 세 가닥이 다 이어져 있어야 스크린 리더가 이 필드의 사정을 온전히 압니다.
role="alert"과 aria-live="polite", 폼에서는 어느 쪽인가#
role="alert"은 aria-live="assertive"와 같습니다. 읽고 있던 걸 끊고 즉시 끼어들어요. 타이핑 중에 이게 매번 발동하면 입력이 계속 끊깁니다. 그래서 실시간 검증에는 polite, 제출 실패처럼 한 번에 크게 알려야 할 때는 alert로 갈립니다.
<!-- 필드별 실시간 검증 -->
<span aria-live="polite" id="email-error"></span>
<!-- 제출 실패 같은 한 방짜리 알림 -->
<div role="alert" id="form-submit-error"></div>두 역할의 차이, 언제 읽히고 언제 안 읽히는지, 빈 요소를 미리 둬야 하는 이유 같은 live region의 동작 원리는 aria-live 라이브 리전 제대로 쓰기에 따로 정리해뒀습니다. 폼 바깥에서도 쓰는 도구라 거기서 넓게 다뤘어요.
submit 시 에러 처리#
폼 제출 버튼을 눌렀을 때 여러 필드에 에러가 있다면 에러를 표시하는 것만으로는 부족합니다. 첫 번째 에러 필드로 포커스를 이동시켜야 해요.
validateAllFields()는 필드마다 만들어 둔 FieldValidator를 모아 실패한 것만 { input, label, message } 형태로 돌려주는 함수라고 하겠습니다. getErrorEl()·submitForm()도 각자의 앱에 맞게 채우는 자리예요.
function handleSubmit(event) {
event.preventDefault();
const errors = validateAllFields(); // [{ input, label, message }, ...]
if (errors.length > 0) {
// 에러 메시지 표시
errors.forEach(({ input, message }) => {
showError(input, getErrorEl(input), message);
});
// 첫 번째 에러 필드로 포커스 이동
errors[0].input.focus();
return;
}
// 성공 처리
submitForm();
}키보드 사용자는 제출 버튼에 있다가 갑자기 에러 메시지 근처로 포커스가 이동하면 당황할 수 있어요. 첫 번째 에러 필드로 포커스를 옮겨주면, "아, 여기부터 수정하면 되는구나"를 바로 알 수 있습니다.

에러가 여러 개라면 상단에 요약 메시지를 두는 방법도 있습니다.
다만 포커스는 한 곳만 받아야 해요. 요약과 첫 필드가 둘 다 focus()를 부르면 나중 것이 이겨서, 사용자는 자기가 왜 거기 있는지 모르는 채로 떨어집니다.
요약을 쓰기로 했다면 위 handleSubmit에서 errors[0].input.focus() 한 줄을 빼세요. 그 자리는 요약이 가져가고, 요약 안의 링크가 각 필드로 데려다줍니다.
<!-- 폼 상단에 에러 요약 영역 -->
<div id="form-error-summary" role="alert" tabindex="-1"></div>role="alert"은 그 자체로 aria-live="assertive"를 뜻합니다. 둘을 같이 쓸 필요는 없어요.
function showErrorSummary(errors) {
const summary = document.getElementById('form-error-summary');
summary.textContent = ''; // 이전 내용 비우기
const heading = document.createElement('p');
const strong = document.createElement('strong');
strong.textContent = `${errors.length}개의 항목을 수정해주세요.`;
heading.append(strong);
const list = document.createElement('ul');
errors.forEach(({ input, label, message }) => {
const item = document.createElement('li');
const link = document.createElement('a');
link.href = `#${input.id}`;
link.textContent = `${label}: ${message}`; // 사용자 입력이 섞여도 안전
item.append(link);
list.append(item);
});
summary.append(heading, list);
summary.focus();
}문자열을 innerHTML로 밀어 넣지 않고 textContent로 채우는 이유가 있습니다. 에러 메시지에 사용자가 입력한 값(예: 입력한 이메일 주소)이 섞이는 경우가 흔한데, 그 값에 태그가 들어 있으면 그대로 HTML로 해석되거든요.
에러 요약의 링크를 누르면 해당 필드로 이동합니다. 스크린 리더 사용자, 키보드 사용자 모두에게 유용해요.
성공 피드백도 알려주세요#
에러만 알리고 성공은 무시하는 폼이 많습니다. 하지만 사용자 입장에서 "내가 올바르게 입력했구나"를 확인하는 것도 중요해요.
여기서 aria-live="polite"를 빠뜨리기 쉽습니다. aria-describedby는 필드에 포커스가 갈 때 한 번 읽어주는 연결일 뿐, 그 안의 글자가 바뀐다고 다시 읽어주지는 않거든요. 포커스를 둔 채로 내용만 갈아끼우면 화면을 못 보는 사용자에게는 아무 일도 안 일어난 셈이 됩니다.
<div class="field">
<label for="username">사용자명</label>
<input type="text" id="username" aria-describedby="username-feedback" />
<span id="username-feedback" class="feedback" aria-live="polite"></span>
</div>// 성공과 실패를 한 함수에서 뒤집습니다. 둘을 따로 두면 한쪽이 클래스를
// 되돌리지 않아, 에러 문구가 초록 성공 스타일로 뜨는 일이 생겨요.
function setFeedback(input, feedbackEl, { ok, message }) {
input.setAttribute('aria-invalid', ok ? 'false' : 'true');
feedbackEl.textContent = message;
feedbackEl.classList.toggle('is-success', ok);
feedbackEl.classList.toggle('is-error', !ok);
}const usernameInput = document.getElementById('username');
const feedbackEl = document.getElementById('username-feedback');
setFeedback(usernameInput, feedbackEl, { ok: true, message: '사용 가능한 이름입니다' });
setFeedback(usernameInput, feedbackEl, { ok: false, message: '이미 사용 중인 이름입니다' });체크 표시 같은 아이콘을 쓰고 싶다면, 아이콘은 장식으로 두고 상태는 글자로 말하게 하세요. 이모지를 메시지 문자열에 그대로 섞는 방법이 간편해 보이지만, 스크린 리더는 그걸 이름 그대로 읽습니다. ✅는 "체크 표시 버튼", ✔는 "체크 표시"예요(CLDR 한국어 기준). 필드마다 그 소리가 앞에 붙으면 꽤 거슬립니다.
<span class="feedback-icon" aria-hidden="true"></span>
<span id="username-feedback" class="feedback" aria-live="polite"></span>아이콘을 aria-hidden="true"로 감춘 별도 요소에 두면, textContent로 메시지만 갈아끼워도 아이콘이 지워지지 않습니다. 같은 요소 안에 넣으면 첫 갱신에서 함께 날아가거든요. 대신 아이콘도 상태에 맞춰 같이 뒤집어야 합니다 — 빨간 에러 문구 옆에 초록 체크가 남아 있으면 그게 더 헷갈리니까요. .feedback-icon::before의 content를 is-success/is-error 클래스로 갈라두면 위 setFeedback 한 번에 함께 바뀝니다.
반대로 CSS의 ::before로만 아이콘을 그리고 글자를 빼면, 화면을 못 보는 사용자에게는 아무 메시지도 남지 않습니다. 아이콘은 거들 뿐이고, 뜻은 언제나 텍스트가 져야 합니다.
실제로 재보면 — 화면은 같아 보여도 배선이 다릅니다#
화면만 보면 두 폼은 거의 같아 보입니다. 그래서 눈이 아니라 DOM을 봤어요. 앞에서 소개한 비교 데모로 돌아가, 두 폼을 실제로 클릭하고 타이핑하면서 단계마다 상태를 읽었습니다. 2026년 9월 15일, 크롬 152 기준입니다.
표의 "live region"은 앞에서 본 aria-live 영역을 가리키고, aria-describedby에 id가 두 개 들어간 건 에러 메시지와 성공 메시지를 각각 연결해뒀기 때문입니다.
| 단계 | 나쁜 폼 | 좋은 폼 |
|---|---|---|
| 빈 칸을 클릭(포커스만) | "이름은 2자 이상이어야 합니다" 즉시 표시 · aria-invalid 없음 · aria-describedby 없음 · live region 아님 | 에러 없음 · aria-invalid="false" |
| "김" 한 글자만 치고 떠남 | — | 에러 표시 · aria-invalid="true" · aria-describedby="good-name-err good-name-ok" · aria-live="polite" |
| 에러 상태에서 이어서 타이핑 | — | 에러 사라지고 "올바릅니다" · aria-invalid="false" |
| 이름만 채우고 제출 | 포커스가 제출 버튼에 그대로 | 포커스가 첫 에러 필드로 이동 — 여기선 이메일 |
첫 줄이 이 글의 요지를 그대로 보여줍니다. 나쁜 폼은 클릭만 했는데 이미 에러를 띄웁니다. 그런데 그 에러는 화면에만 있어요. aria-invalid도, aria-describedby도, 그 에러 요소의 live region도 없습니다. 입력창에 포커스를 줬을 때 함께 읽히지 않는다는 뜻이에요. 빨간 글씨로 혼내면서 정작 안 보이는 사람에게는 그 사정을 전하지 않는 셈입니다.
마지막 줄이 좀 아픕니다. 나쁜 폼은 제출을 눌러도 포커스가 제출 버튼에 그대로 있습니다. 화면을 못 보는 사용자는 "오류가 있습니다"라는 말만 듣고, 어느 칸이 문제인지 찾으러 폼 전체를 다시 훑어야 해요.
마지막 행은 이름 칸을 먼저 올바르게 채운 상태에서 제출한 결과입니다. 아무것도 안 넣고 바로 제출하면 당연히 첫 에러는 이름 칸이고 포커스도 그리로 가요. "첫 에러 필드"가 어디냐는 그때그때 다릅니다.
크롬 152에서 한 번 잰 값입니다. 사파리·파이어폭스에서는 다를 수 있고, 스크린 리더 낭독 자체는 측정하지 않았습니다 — DOM 배선만 읽었어요. 판독은 콘솔에서 속성을 직접 조회했고요.
그리고 한 가지 더. 왼쪽 "나쁜 폼"은 제가 흔한 실수대로 일부러 만든 대조군입니다. 거기서 나쁜 값이 나온 건 당연해요. 이 표는 "잘못 만들면 이렇게 된다"의 구체적인 모양에 가깝습니다.
폼에서 자주 빠지는 셋 — 중복 입력, 비밀번호 강도, 글자 수#
같은 정보를 앞 단계에서 이미 입력했다면 다시 묻지 않는 것도 폼의 몫입니다. WCAG 2.2가 3.3.7 중복 입력(Redundant Entry)로 새로 넣은 기준이에요.
비밀번호 강도 표시기: 비밀번호를 입력하면서 강도를 보여주는 UI는 시각적으로는 훌륭하지만, 스크린 리더 사용자는 아무것도 모를 수 있습니다.
<input type="password" id="password" aria-describedby="password-strength" />
<div id="password-strength" aria-live="polite">
<!-- "약함", "보통", "강함" 텍스트를 포함해야 함 -->
<span class="strength-bar" aria-hidden="true"></span>
<span class="strength-text">비밀번호를 입력해주세요</span>
</div>문자 수 카운터: 140자 제한 텍스트에어리어에 현재 몇 자를 입력했는지 보여줄 때도 aria-live가 필요합니다. 단, 타이핑할 때마다 읽히면 짜증스러우니 조금 debounce를 줍시다.
눈으로 보는 카운터와 읽히는 카운터를 나눠 두는 게 요령이에요.
<textarea id="bio" aria-describedby="bio-count"></textarea>
<span id="bio-visual" aria-hidden="true"></span>
<span id="bio-count" class="sr-only" aria-live="polite"></span>const textarea = document.getElementById('bio');
const counter = document.getElementById('bio-count');
const visualCounter = document.getElementById('bio-visual');
let announceTimer;
textarea.addEventListener('input', () => {
const remaining = 140 - textarea.value.length;
clearTimeout(announceTimer);
// 타이핑을 멈춘 후 1초 뒤에 알림
announceTimer = setTimeout(() => {
counter.textContent = `${remaining}자 남음`;
}, 1000);
// 시각적 카운터는 즉시 업데이트
visualCounter.textContent = `${remaining}/140`;
});정리#
좋은 폼 UX는 사용자가 실수를 예방하고, 실수했을 때 빠르게 수정할 수 있도록 도와주는 것입니다. 시각적으로 보기 좋은 에러 메시지도 중요하지만, 스크린 리더 사용자도 같은 정보를 얻을 수 있어야 해요.
핵심을 정리하면:
- 검증 타이밍: 첫 포커스엔 침묵, blur부터 검증, 에러가 뜬 뒤엔
input마다 재검증 - 에러 위치: 입력창 바로 아래
- aria-describedby: 입력창과 에러 메시지를 연결
- aria-invalid: 에러 상태를 시맨틱하게 전달
- submit 후 포커스: 첫 번째 에러 필드로 이동
- 성공 피드백: 에러뿐 아니라 성공도 알림
폼은 대개 사용자가 그 서비스와 처음 주고받는 말입니다. 그 첫 대화에서 누구는 지적만 받고 누구는 아무 말도 못 듣는다면, 뒤에 무엇을 잘 만들어도 이미 한 번 어긋난 뒤예요.
폼 하나 고치는 데 규칙이 여섯 개나 되나 싶으시죠. 그런데 실제로 손대는 건 이벤트 두 개(blur·input)와 속성 두 개(aria-describedby·aria-invalid)가 전부입니다. 지금 만들고 있는 폼에서 딱 그 네 군데만 확인해보세요.
질문으로 다시 보기#
폼 유효성 검증은 언제 실행하는 것이 좋나요?
에러 메시지를 스크린 리더 사용자에게 어떻게 전달하나요?
이 시리즈의 다른 글#
- SPA에서 포커스 관리 제대로 하기 — 라우트가 바뀌어도 조용한 화면 고치기
- 색은 맞는데 대비는 틀렸다 — 비활성 필드가 안 읽히는 이유
프론트엔드 × 접근성 시리즈 전체 보기 — 일반 프론트엔드 주제에 접근성 시각을 연결하는 연재입니다.
