SOYOYU
기술 SEO

Toast로 띄운 에러는 누구에게 안 들립니다 — form 에러 메시지 코드 리뷰

폼 에러를 toast나 alert로만 띄우면 스크린리더 사용자는 못 듣고, AI 에이전트는 폼 자동 입력에서 실패합니다. aria-describedby와 aria-live의 차이부터 코드로 짚습니다.

전승엽2026년 8월 1일9 min read
A11y 코드 리뷰시맨틱 HTMLaccessibility treeAI 에이전트웹 접근성폼 에러aria-describedbyaria-liveKWCAG

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 rateengagement 신호가 좋아집니다. 구글의 페이지 평가에서 간접적으로 작용합니다.

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-invalidaria-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.MessageForm.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"

다른 글 읽기

기술 SEO

사이트맵을 제출했는데도 구글이 내 페이지를 무시하는 이유

사이트맵 제출 성공 = 색인 보장이 아닙니다. 구글이 제출된 URL을 무시하는 8가지 진짜 이유와 각각의 해결 순서를 실무 체크리스트로 정리했습니다.

6 min read
기술 SEO

OG 이미지에 alt를 자동 생성한다는 발상 — alt와 이미지 텍스트 코드 리뷰

alt 속성은 시각장애 사용자만을 위한 게 아닙니다. AI Overviews가 이미지를 답변에 포함하고, 검색엔진이 이미지를 인덱싱하는 가장 강한 신호입니다. 빈 alt와 누락된 alt의 차이부터 봅니다.

9 min read
기술 SEO

구글 서치콘솔에 "색인 생성 안 됨"이 뜨는 진짜 이유

크롤링됨·감지됨·제외됨의 차이부터 실제로 손볼 포인트까지 — 서치콘솔 색인 오류의 숨은 원인과 회복 순서를 실무 관점에서 정리했습니다.

6 min read
기술 SEO

관리자 table은 OK, 가격표는 div grid — table 코드 리뷰

데이터는 table이고 레이아웃은 grid입니다. 이 한 줄을 거꾸로 적용한 페이지는 AI가 가격을 못 읽고, 스크린리더가 행과 열을 못 잡습니다. 두 케이스의 경계를 코드로 그어봅니다.

9 min read
기술 SEO

IndexNow vs Google Indexing API: 실시간 색인 요청 비교 가이드

IndexNow와 Google Indexing API의 지원 엔진, 콘텐츠 제한, 쿼터를 10가지 항목으로 비교합니다. 네이버 IndexNow 설정 방법과 Next.js 구현 가이드를 포함합니다.

6 min read
기술 SEO

프로그래매틱 SEO: 대규모 페이지 자동 생성과 품질 통제

Google의 Scaled Content Abuse 정책 강화 속에서 프로그래매틱 SEO를 안전하게 실행하는 방법을 다룹니다. Zapier, Canva, Wise의 실제 사례와 30-40% 고유 콘텐츠 기준을 정리합니다.

7 min read

검색 최적화가 필요하신가요?

무료 상담을 통해 비즈니스에 맞는 최적화 전략을 확인하세요.