A11y 코드 리뷰 #009 · 오늘 도감에 등록할 패턴 — form 에러 메시지 (
aria-describedby,aria-invalid,aria-live)이번 도감 카드는 눈에는 보이지만 귀에는 안 들리는 에러 메시지에 관한 글입니다. AI 에이전트가 폼을 자동 입력할 때 에러 처리를 못 하는 가장 큰 이유이기도 합니다.
TL;DR
- 폼 에러를 toast 한 번으로만 띄우면 스크린리더 사용자는 못 듣거나, 들었더라도 어느 input이 문제인지를 모릅니다.
- 인라인 에러 메시지를
aria-describedby로 input과 연결하면 — 스크린리더가 input 포커스 시 입력 라벨 + 에러 메시지를 한 번에 발화합니다. aria-invalid="true"를 추가하면 — "잘못된 입력" 상태가 명시적으로 트리에 노출됩니다.- 비동기로 발생하는 에러(예: 서버 응답 후 표시)는
aria-live="polite"또는role="alert"로 변경 사실을 알려야 합니다. - AI 에이전트가 폼을 자동 입력할 때 — 에러를 감지하고 자동 수정하는 작업은 에러 메시지가 시맨틱하게 연결돼 있을 때만 가능합니다. toast-only 에러는 에이전트도 못 봅니다.
1. 어디서나 본 그 에러
다음 네 패턴은 한국 사이트의 폼 검증에서 매일 보는 안티패턴입니다.
toast-only 에러
async function handleSubmit(data) {
try {
await api.signup(data);
} catch (e) {
toast.error("회원가입에 실패했습니다");
}
}
alert 한 줄
if (!email.includes("@")) {
alert("올바른 이메일을 입력하세요");
return;
}
input 옆에 색깔만 — 메시지 없음
<input
className={errors.email ? "border-red-500" : ""}
{...register("email")}
/>
aria 연결 없는 에러 메시지
<input {...register("email")} />
{errors.email && <p className="text-red-500">이메일이 올바르지 않습니다</p>}
네 패턴 모두 시각 사용자에게는 에러가 발생했다는 사실이 전달됩니다. 다만 — 스크린리더 사용자는 toast/alert를 못 듣거나, 어느 input의 문제인지를 모르거나, 에러 자체를 모릅니다. AI 에이전트도 같은 문제를 겪습니다.
2. 한 가지 질문
에러가 발생한 직후 — 어떤 사용자도 (시각·청각·자동화 도구 포함) 어떤 input에 어떤 문제가 있는지를 즉시 알 수 있는가요?
이 질문에 예로 답할 수 있는 폼만 진짜 검증된 폼입니다.
3. form 에러 — 점진적 해부
3.1 인라인 에러 + aria-describedby
<label for="email">이메일</label>
<input
id="email"
type="email"
aria-describedby="email-error"
aria-invalid="true"
/>
<p id="email-error" className="error">
올바른 이메일 주소를 입력해 주세요. 예: [email protected]
</p>
이 구조가 만드는 동작:
사용자가 email input에 포커스
→ NVDA: "이메일, 잘못된 입력, 편집, 올바른 이메일 주소를 입력해 주세요"
label + invalid 상태 + describedby의 에러 메시지가 한 번에 발화됩니다. 사용자는 어디서 무엇이 문제인지를 들으면서 동시에 어떻게 고쳐야 하는지도 압니다.
3.2 aria-invalid의 의미
<input aria-invalid="false"> ← 기본 (또는 속성 생략)
<input aria-invalid="true"> ← 잘못된 입력
<input aria-invalid="grammar"> ← 문법 오류 (드물게)
<input aria-invalid="spelling"> ← 철자 오류 (드물게)
aria-invalid="true"는 시각적 스타일 변경(빨간 테두리)과 별개입니다. 시맨틱으로 "이 input은 현재 유효하지 않다"를 트리에 노출합니다.
CSS에서 같은 신호로 스타일링 가능:
input[aria-invalid="true"] {
border-color: #d00;
background: #fff5f5;
}
이 패턴이 좋은 이유 — 시각 스타일과 시맨틱이 같은 source of truth에서 나옵니다. 따로 관리하면 어긋납니다.
3.3 aria-live — 비동기 에러 알림
폼 submit 후 서버 응답으로 에러가 표시될 때 — 변경된 사실을 보조 기술이 자동으로 알아채지 못합니다. aria-live가 그 역할을 합니다.
<div role="alert" aria-live="assertive">
<!-- 비어 있다가 에러 발생 시 메시지가 들어옴 -->
</div>
function SignupForm() {
const [serverError, setServerError] = useState("");
return (
<>
<form onSubmit={handleSubmit}>...</form>
{serverError && (
<div role="alert" aria-live="assertive">
{serverError}
</div>
)}
</>
);
}
role="alert"는 aria-live="assertive" + aria-atomic="true"의 단축입니다. 메시지가 추가/변경되면 스크린리더가 현재 발화를 중단하고 새 메시지를 읽습니다.
aria-live="polite"는 현재 발화가 끝난 후에 읽습니다. 덜 긴급한 알림에 어울립니다.
3.4 에러 요약 (form 상단)
긴 폼의 경우 — submit 후 모든 에러를 상단에 요약하고 각 에러가 해당 input으로 점프 가능한 링크가 되도록 합니다.
<div role="alert" aria-live="polite">
<h2>3개 항목을 확인해 주세요</h2>
<ul>
<li><a href="#email">이메일 형식이 올바르지 않습니다</a></li>
<li><a href="#password">비밀번호는 8자 이상이어야 합니다</a></li>
<li><a href="#birthdate">생년월일을 선택해 주세요</a></li>
</ul>
</div>
<form>
<label for="email">이메일</label>
<input id="email" aria-invalid="true" aria-describedby="email-error" />
<p id="email-error">이메일 형식이 올바르지 않습니다</p>
...
</form>
요약은 상단에 — 사용자가 submit 직후 전체 상태를 한 번에 파악할 수 있게. 인라인 에러는 각 input 근처에 — 사용자가 input으로 돌아왔을 때 그 input의 문제를 확인할 수 있게. 두 가지가 보완 관계.
4. 왜 문제인가 — 3중 영향
4a. 스크린리더 사용자
toast-only 에러의 가장 큰 문제 — toast가 화면에서 사라진 후에 사용자가 폼으로 돌아왔을 때, 문제가 무엇인지 다시 알 길이 없습니다.
사용자가 submit
→ toast "회원가입에 실패했습니다" (3초 후 사라짐)
→ 사용자가 어디가 문제인지 확인하려고 폼을 다시 살핌
→ 각 input에 에러 표시가 없음 (toast가 사라졌으니까)
→ ?
인라인 에러 + aria-describedby가 있으면:
사용자가 submit
→ alert 알림 "3개 항목 확인 필요"
→ 사용자가 email input으로 이동
→ "이메일, 잘못된 입력, 편집, 이메일 형식이 올바르지 않습니다"
언제든 input으로 돌아오면 — 에러 메시지가 그대로 들립니다.
4b. 키보드 사용자
키보드 사용자의 가장 흔한 답답함 — submit 후 에러가 나도 포커스가 그대로 마지막 input에 있는 패턴. 사용자는 어디로 가야 하는지 모릅니다.
해결책 — submit 실패 시 첫 번째 에러 input으로 포커스 이동:
async function onSubmit(data) {
const result = await validate(data);
if (!result.success) {
const firstError = result.errors[0];
document.getElementById(firstError.field)?.focus();
return;
}
await api.submit(data);
}
4c. AI 에이전트
AI 에이전트의 폼 자동 입력 워크플로:
1. 폼 식별
2. 각 input 식별 (label + autocomplete)
3. 값 입력
4. submit
5. 에러 확인
6. 에러가 있으면 자동 수정 후 재시도
5단계와 6단계는 에러가 시맨틱하게 노출돼 있을 때만 가능합니다. toast로만 띄운 에러는 에이전트의 accessibility tree 입력에서 사라집니다.
<!-- ❌ 에이전트가 못 봄 -->
<div className="toast">회원가입 실패</div>
<!-- ✅ 에이전트가 alert로 인식 -->
<div role="alert">회원가입 실패: 이메일이 이미 사용 중입니다</div>
<!-- ✅ 인라인 에러 — 어떤 input의 문제인지 명확 -->
<input id="email" aria-invalid="true" aria-describedby="email-err" />
<p id="email-err">이미 사용 중인 이메일입니다</p>
세 패턴 모두 화면에는 표시되지만 — 에이전트가 어떤 input을 어떻게 수정해야 하는지를 판단 가능한 건 마지막 패턴뿐입니다.
선행 글 #2 label·fieldset·legend에서 봤듯 — 폼 자체의 시맨틱이 살아 있어야 에러 메시지의 시맨틱도 의미가 있습니다.
5. SEO/GEO/AEO 영향
5.1 SEO — 폼 전환율과 페이지 품질 신호
폼의 에러 처리가 명확할수록 완료율이 올라갑니다. 완료율이 높은 폼이 있는 페이지는 task success rate와 engagement 신호가 좋아집니다. 구글의 페이지 평가에서 간접적으로 작용합니다.
WebAIM의 한 연구에 따르면 — 명확한 인라인 에러 메시지가 있는 폼은 toast-only 폼보다 완료율이 평균 24% 높습니다.
5.2 GEO — AI 에이전트의 폼 완료율
GEO의 실용적 정의 중 하나 — AI 에이전트가 우리 사이트에서 작업을 끝까지 완료하는가. 회원가입·결제·문의 폼이 가장 자주 실패하는 지점이고, 그중에서도 에러 후 재시도에서 가장 자주 멈춥니다.
에이전트가 시맨틱 에러 메시지를 보면 — 자동으로 "이메일 형식 오류 → 다른 형식으로 재시도" 같은 self-correction이 가능합니다. toast-only면 에이전트는 왜 실패했는지 모른 채 같은 값으로 재시도하다 멈춥니다.
5.3 AEO — FAQ로의 변환 가능성
명확한 에러 메시지는 "왜 가입이 안 되나요?" 같은 검색 질문에 대한 답변 자료가 됩니다. 에러 메시지가 구체적일수록 사용자 검색 패턴과 직접 매칭됩니다. "이메일 형식이 올바르지 않습니다"는 검색되는 문장이지만, "오류가 발생했습니다"는 아닙니다.
6. React에서
6.1 react-hook-form + aria 자동 연결
react-hook-form은 에러 상태를 제공하지만 aria 연결은 사용자가 해야 합니다.
import { useForm } from "react-hook-form";
function SignupForm() {
const { register, handleSubmit, formState: { errors } } = useForm();
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<div>
<label htmlFor="email">이메일</label>
<input
id="email"
type="email"
autoComplete="email"
aria-invalid={errors.email ? "true" : "false"}
aria-describedby={errors.email ? "email-error" : undefined}
{...register("email", {
required: "이메일을 입력해 주세요",
pattern: {
value: /^[^@\s]+@[^@\s]+\.[^@\s]+$/,
message: "올바른 이메일 형식을 입력해 주세요",
},
})}
/>
{errors.email && (
<p id="email-error" className="error">
{errors.email.message}
</p>
)}
</div>
<button type="submit">가입하기</button>
</form>
);
}
핵심 — aria-invalid와 aria-describedby가 조건부로 토글됩니다. 에러가 없을 때는 false/undefined, 있을 때는 true와 에러 메시지 id.
noValidate로 브라우저 native validation을 끄고 react-hook-form의 검증으로 일원화합니다 (브라우저 native 에러 메시지는 다국어 처리가 어렵습니다).
6.2 Radix Form — 자동 aria 처리
import * as Form from "@radix-ui/react-form";
<Form.Root>
<Form.Field name="email">
<Form.Label>이메일</Form.Label>
<Form.Control asChild>
<input type="email" autoComplete="email" required />
</Form.Control>
<Form.Message match="valueMissing">이메일을 입력해 주세요</Form.Message>
<Form.Message match="typeMismatch">올바른 이메일 형식이 아닙니다</Form.Message>
</Form.Field>
<Form.Submit>가입하기</Form.Submit>
</Form.Root>
Radix Form은 — Form.Message를 Form.Control의 input에 aria-describedby로 자동 연결합니다. aria-invalid도 검증 상태에 따라 자동 토글. 매뉴얼 연결이 자주 빠지는 사이트라면 Radix Form이 안전합니다.
6.3 비동기 에러와 aria-live
서버 응답으로 받는 에러는 — 페이지에 나중에 나타나기 때문에 aria-live가 필요합니다.
function SignupForm() {
const [serverError, setServerError] = useState("");
async function onSubmit(data) {
try {
await api.signup(data);
} catch (e) {
setServerError("이미 사용 중인 이메일입니다");
}
}
return (
<>
<form>...</form>
{/* role="alert"는 메시지 변경 시 자동 발화 */}
{serverError && (
<div role="alert" className="server-error">
{serverError}
</div>
)}
</>
);
}
{serverError && ...} 패턴은 조건부 렌더링이라 alert 영역이 DOM에 나타나는 시점에 메시지가 함께 있습니다. 일부 스크린리더는 — 이미 존재하던 빈 alert에 메시지가 채워질 때는 발화하지만, alert 영역 자체가 새로 추가될 때는 발화하지 않을 수 있습니다.
안전한 패턴:
return (
<>
<form>...</form>
<div role="alert" className="server-error">
{serverError}
</div>
</>
);
alert 컨테이너는 항상 DOM에 있고, 내용만 바뀝니다. 모든 스크린리더에서 일관되게 동작합니다.
7. 한국 맥락 — 가볍게
KWCAG 관련 항목:
- 3.3.1 오류 정정: 입력 오류가 검출되면 오류 발생 항목과 오류 내용을 알 수 있어야 합니다. toast-only 에러는 오류 항목을 알 수 없어 위반.
- 3.3.3 오류 수정 제안: 가능한 경우 오류 수정 방법을 제안해야 합니다 — "올바른 이메일 형식을 입력해 주세요. 예: [email protected]"처럼.
한국 사이트에서 자주 보는 패턴 — 모든 에러를 한 줄 alert로. "잘못된 입력입니다" 한 줄로 끝나는 케이스. 어떤 input인지, 무엇이 잘못됐는지, 어떻게 고치는지 셋 다 빠집니다. 이 패턴은 KWCAG 3.3.1과 3.3.3 동시 위반.
8. "toast가 더 사용자 친화적이지 않나요?"
자주 듣는 반론들:
"toast가 모던하고 깔끔한 UX 아닌가요?" — toast는 알림에 어울리는 패턴입니다 (예: "메시지가 저장되었습니다"). 에러는 사용자가 액션을 해야 하는 정보입니다 — toast로 3초 후 사라지게 두면 안 됩니다.
"toast + 인라인 둘 다 쓰면 되지 않나요?" — 가능합니다. 다만 우선순위는 인라인이 1순위, toast는 보조. toast 없이 인라인만 있어도 a11y는 충족됩니다.
"alert()이 가장 명확하지 않나요?" — window.alert()는 modal 다이얼로그를 띄웁니다. 동기 차단, 디자인 불가능, 모바일에서 위치 강제. UX적으로 가장 안 좋은 패턴입니다.
"native browser validation으로 충분하지 않나요?" — 브라우저 native validation은 언어가 브라우저 설정 따라 달라지고, 디자인 커스텀이 안 되고, 제출 시 첫 에러만 표시합니다. 한국 사이트에 부적합. noValidate로 끄고 직접 처리하는 게 표준.
"에러 발생 시 자동 스크롤이면 충분하지 않나요?" — 스크롤은 시각 사용자에게만 도움됩니다. 스크린리더 사용자에게는 발화가 필요합니다.
9. 체크리스트
- 각 input의 에러 메시지가 인라인으로 input 근처에 표시되는가
- 에러 메시지가
aria-describedby로 input과 연결돼 있는가 - 에러 상태가
aria-invalid="true"로 명시돼 있는가 - 서버 응답 에러가
role="alert"또는aria-live="assertive"영역에 표시되는가 - 에러 alert 컨테이너가 항상 DOM에 있고 내용만 바뀌는가 (조건부 렌더링이 아닌가)
- submit 실패 시 첫 번째 에러 input으로 포커스가 이동하는가
- 긴 폼은 상단에 에러 요약(점프 링크 포함)이 있는가
- 에러 메시지가 구체적 수정 방법을 제안하는가 (KWCAG 3.3.3)
마무리
이 글이 16편 시리즈의 아홉 번째 도감 카드입니다. 다음 카드는 focus trap — Radix Dialog 안에서도 깨지는 그 미세한 케이스들을 봅니다.
form 에러의 단 한 줄 규칙 — 에러는 toast가 아닙니다. 사용자가 액션을 해야 하는 정보이고, 어떤 input의 어떤 문제인지가 시맨틱하게 input에 연결돼야 합니다.
폼 검증·전환율·a11y 통합 컨설팅이 필요하시면 문의하기로 연락 주세요.
codex:
primary_tag: input
secondary_tags: [form, label]
aria_roles: [alert, status]
aria_attributes: [aria-describedby, aria-invalid, aria-live, aria-atomic, aria-errormessage]
related_patterns: [inline-error, error-summary, server-error, async-validation, focus-on-error]
kwcag: ["3.3.1", "3.3.3"]
wcag: ["3.3.1", "3.3.3", "3.3.4"]
ai_agent_impact: high
seo_geo_impact: medium
difficulty: intermediate
test_envs:
- "NVDA 2024.x + Chrome 125"
- "VoiceOver macOS 14 + Safari 17"
references:
- "WAI-ARIA Authoring Practices, Alert and Status patterns"
- "react-hook-form Documentation"
- "Radix UI Form Documentation"
- "WebAIM, Usable and Accessible Form Validation"