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

Radix Dialog도 깨지는 그 케이스 — focus trap 코드 리뷰

포커스 트랩은 native <dialog>가 무료로 해주는 일이지만, 라이브러리·SPA 라우팅·autoFocus가 섞이면 미세하게 깨집니다. 깨지는 다섯 시나리오를 코드로 짚어봅니다.

10 min read
기술 SEO

구글에 우리 회사 정보가 잘못 표시되는 문제 해결법

주소가 틀리거나 폐업 표시가 뜨거나 옛 로고가 보일 때. 구글 비즈니스 프로필·지식 패널·검색 결과 3채널 각각의 수정 경로와 우선순위를 정리했습니다.

6 min read
기술 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

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

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