회원가입 폼을 만들고 있었습니다. 이메일 입력창에 포커스를 주자마자 "이메일 형식이 올바르지 않습니다"라는 에러가 떴어요. 아직 아무것도 입력하지 않았는데요.

비밀번호 확인창은 반대였습니다. 비밀번호를 다 입력하고 폼을 제출했는데, 그제서야 "비밀번호가 일치하지 않습니다"라고 했어요. 이미 다 입력한 뒤에요.

그리고 제출 버튼을 누르면 에러 메시지가 한꺼번에 7개 등장했습니다.

폼 검증 타이밍, 메시지 표시 위치, 스크린 리더 지원. 이 세 가지가 맞아떨어져야 진짜 좋은 폼입니다.

이 글은 그 세 가지 중 "언제, 어떻게 알려줄 것인가"에 집중합니다.

언제 검증할까? — 타이밍이 전부다

폼 검증 타이밍은 생각보다 섬세한 문제입니다. 너무 이르면 사용자가 입력을 마치기도 전에 에러가 뜨고, 너무 늦으면 실수를 제때 잡지 못해요.

실무에서 잘 작동하는 타이밍 전략은 이렇습니다:

폼 검증 타이밍 3규칙 타임라인 - 첫 포커스와 입력 중에는 침묵하고, 필드를 떠나는 blur 순간 첫 검증을 하며, 에러가 표시된 뒤에는 타이핑할 때마다 즉시 재검증해 고치는 즉시 에러가 사라집니다. 너무 이른 검증과 너무 늦은 검증 사이의 균형점이라는 설명이 붙어 있습니다
폼 검증 타이밍 3규칙 타임라인 - 첫 포커스와 입력 중에는 침묵하고, 필드를 떠나는 blur 순간 첫 검증을 하며, 에러가 표시된 뒤에는 타이핑할 때마다 즉시 재검증해 고치는 즉시 에러가 사라집니다. 너무 이른 검증과 너무 늦은 검증 사이의 균형점이라는 설명이 붙어 있습니다

규칙 1: 첫 포커스에는 검증하지 않기

필드에 처음 포커스가 갔을 때는 아무 검증도 하지 마세요. 사용자가 아직 입력을 시작도 안 했으니까요. 여기서 에러를 띄우면 인사도 하기 전에 지적부터 하는 폼이 됩니다.

규칙 2: blur 이후부터 검증 시작

사용자가 필드를 떠난 순간(blur 이벤트), 그때부터 실시간 검증을 시작합니다. 이미 한 번 입력을 시도했으니, 피드백을 줘도 됩니다.

규칙 3: 에러가 뜬 뒤에는 input마다 즉시 재검증

에러가 표시된 상태에서 사용자가 수정하기 시작하면, 그때부터는 타이핑할 때마다 즉시 검증합니다. 여기서 쓰는 건 change가 아니라 input 이벤트예요. change는 필드를 떠나야 발생해서, 고치는 중에는 에러가 그대로 붙어 있게 됩니다. 수정했는데 에러가 안 사라지면 답답하거든요.

rules는 "검사 함수 + 실패 시 보여줄 말"을 담은 객체들의 배열입니다. 이 형태가 아니면 아래 코드가 에러도 없이 조용히 아무 말도 안 하니까 먼저 맞춰두고 가겠습니다.

javascript
const emailRules = [
  { test: v => v.trim() !== '',            message: '이메일을 입력해주세요' },
  { test: v => /^\S+@\S+\.\S+$/.test(v), message: '이메일 형식이 올바르지 않습니다' },
];
javascript
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 비교 데모에 나쁜 쪽과 좋은 쪽을 나란히 뒀습니다. 왼쪽 이름 칸을 클릭만 해보고, 오른쪽에서 같은 걸 해보세요.

각 폼 아래 로그는 미리 말씀드릴 게 있어요. 실제 스크린 리더가 뱉은 출력이 아니라 같은 상황에서 무엇이 읽히는지를 제가 손으로 적어둔 시뮬레이션입니다. 진짜 판정은 스크린 리더를 켜고 해보셔야 하고요.

에러 메시지는 어디에 표시할까?

라벨 연결이나 오류 식별처럼 폼 접근성의 기본기는 폼 접근성 마스터하기에서 따로 다뤘습니다. 여기서는 그 위에 얹는 이야기만 합니다.

접근 가능한 에러 필드의 해부도 - 레이블은 htmlFor로, 에러 메시지는 aria-describedby로 입력창과 연결되고, aria-invalid가 에러 상태를 표시하며, 에러 요소의 aria-live polite가 변경을 스크린 리더에게 알립니다. 스크린 리더는 이메일, 편집창, 잘못된 입력, 이메일 형식이 올바르지 않습니다를 차례로 읽어줍니다
접근 가능한 에러 필드의 해부도 - 레이블은 htmlFor로, 에러 메시지는 aria-describedby로 입력창과 연결되고, aria-invalid가 에러 상태를 표시하며, 에러 요소의 aria-live polite가 변경을 스크린 리더에게 알립니다. 스크린 리더는 이메일, 편집창, 잘못된 입력, 이메일 형식이 올바르지 않습니다를 차례로 읽어줍니다

입력창 바로 아래가 정답입니다. 이것만큼 명확한 위치는 없어요.

html
<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"로 바꾸면 스크린 리더가 "잘못된 입력"임을 알립니다.

javascript
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로 갈립니다.

html
<!-- 필드별 실시간 검증 -->
<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()도 각자의 앱에 맞게 채우는 자리예요.

javascript
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();
}

키보드 사용자는 제출 버튼에 있다가 갑자기 에러 메시지 근처로 포커스가 이동하면 당황할 수 있어요. 첫 번째 에러 필드로 포커스를 옮겨주면, "아, 여기부터 수정하면 되는구나"를 바로 알 수 있습니다.

제출 실패 시 포커스를 어디로 보낼지 비교하는 다이어그램 - A안은 첫 번째 에러 입력창으로 보내 짧은 폼에 맞고, B안은 폼 상단의 에러 요약으로 보내 여러 개를 한 번에 파악해야 하는 긴 폼에 맞습니다. 두 방법 중 하나만 골라야 하며 B안을 골랐다면 A안의 focus 호출은 빼야 합니다
제출 실패 시 포커스를 어디로 보낼지 비교하는 다이어그램 - A안은 첫 번째 에러 입력창으로 보내 짧은 폼에 맞고, B안은 폼 상단의 에러 요약으로 보내 여러 개를 한 번에 파악해야 하는 긴 폼에 맞습니다. 두 방법 중 하나만 골라야 하며 B안을 골랐다면 A안의 focus 호출은 빼야 합니다

에러가 여러 개라면 상단에 요약 메시지를 두는 방법도 있습니다.

다만 포커스는 한 곳만 받아야 해요. 요약과 첫 필드가 둘 다 focus()를 부르면 나중 것이 이겨서, 사용자는 자기가 왜 거기 있는지 모르는 채로 떨어집니다.

요약을 쓰기로 했다면 위 handleSubmit에서 errors[0].input.focus() 한 줄을 빼세요. 그 자리는 요약이 가져가고, 요약 안의 링크가 각 필드로 데려다줍니다.

html
<!-- 폼 상단에 에러 요약 영역 -->
<div id="form-error-summary" role="alert" tabindex="-1"></div>

role="alert"은 그 자체로 aria-live="assertive"를 뜻합니다. 둘을 같이 쓸 필요는 없어요.

javascript
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는 필드에 포커스가 갈 때 한 번 읽어주는 연결일 뿐, 그 안의 글자가 바뀐다고 다시 읽어주지는 않거든요. 포커스를 둔 채로 내용만 갈아끼우면 화면을 못 보는 사용자에게는 아무 일도 안 일어난 셈이 됩니다.

html
<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>
javascript
// 성공과 실패를 한 함수에서 뒤집습니다. 둘을 따로 두면 한쪽이 클래스를
// 되돌리지 않아, 에러 문구가 초록 성공 스타일로 뜨는 일이 생겨요.
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);
}
javascript
const usernameInput = document.getElementById('username');
const feedbackEl    = document.getElementById('username-feedback');

setFeedback(usernameInput, feedbackEl, { ok: true,  message: '사용 가능한 이름입니다' });
setFeedback(usernameInput, feedbackEl, { ok: false, message: '이미 사용 중인 이름입니다' });

체크 표시 같은 아이콘을 쓰고 싶다면, 아이콘은 장식으로 두고 상태는 글자로 말하게 하세요. 이모지를 메시지 문자열에 그대로 섞는 방법이 간편해 보이지만, 스크린 리더는 그걸 이름 그대로 읽습니다. ✅는 "체크 표시 버튼", ✔는 "체크 표시"예요(CLDR 한국어 기준). 필드마다 그 소리가 앞에 붙으면 꽤 거슬립니다.

html
<span class="feedback-icon" aria-hidden="true"></span>
<span id="username-feedback" class="feedback" aria-live="polite"></span>

아이콘을 aria-hidden="true"로 감춘 별도 요소에 두면, textContent로 메시지만 갈아끼워도 아이콘이 지워지지 않습니다. 같은 요소 안에 넣으면 첫 갱신에서 함께 날아가거든요. 대신 아이콘도 상태에 맞춰 같이 뒤집어야 합니다 — 빨간 에러 문구 옆에 초록 체크가 남아 있으면 그게 더 헷갈리니까요. .feedback-icon::beforecontentis-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는 시각적으로는 훌륭하지만, 스크린 리더 사용자는 아무것도 모를 수 있습니다.

html
<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를 줍시다.

눈으로 보는 카운터와 읽히는 카운터를 나눠 두는 게 요령이에요.

html
<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>
javascript
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는 사용자가 실수를 예방하고, 실수했을 때 빠르게 수정할 수 있도록 도와주는 것입니다. 시각적으로 보기 좋은 에러 메시지도 중요하지만, 스크린 리더 사용자도 같은 정보를 얻을 수 있어야 해요.

핵심을 정리하면:

  1. 검증 타이밍: 첫 포커스엔 침묵, blur부터 검증, 에러가 뜬 뒤엔 input마다 재검증
  2. 에러 위치: 입력창 바로 아래
  3. aria-describedby: 입력창과 에러 메시지를 연결
  4. aria-invalid: 에러 상태를 시맨틱하게 전달
  5. submit 후 포커스: 첫 번째 에러 필드로 이동
  6. 성공 피드백: 에러뿐 아니라 성공도 알림

폼은 대개 사용자가 그 서비스와 처음 주고받는 말입니다. 그 첫 대화에서 누구는 지적만 받고 누구는 아무 말도 못 듣는다면, 뒤에 무엇을 잘 만들어도 이미 한 번 어긋난 뒤예요.

폼 하나 고치는 데 규칙이 여섯 개나 되나 싶으시죠. 그런데 실제로 손대는 건 이벤트 두 개(blur·input)와 속성 두 개(aria-describedby·aria-invalid)가 전부입니다. 지금 만들고 있는 폼에서 딱 그 네 군데만 확인해보세요.


질문으로 다시 보기

폼 유효성 검증은 언제 실행하는 것이 좋나요?
처음에는 검증하지 않다가, 사용자가 필드를 떠나는 blur 시점부터 검증을 시작하고, 에러가 표시된 뒤에는 input(타이핑)마다 즉시 재검증하는 3단계 전략이 실무에서 잘 작동합니다. 입력을 시작하기도 전에 에러를 띄우거나, 제출할 때까지 침묵하는 양극단을 피하는 방법입니다.
에러 메시지를 스크린 리더 사용자에게 어떻게 전달하나요?
입력창에 aria-describedby로 에러 메시지 요소를 연결하고 aria-invalid='true'로 상태를 표시합니다. 에러 메시지 요소에는 aria-live='polite'를 두면 값이 바뀔 때 스크린 리더가 자연스럽게 읽어줍니다. 실시간 검증에는 polite를, 제출 실패 같은 중요 알림에는 role='alert'(assertive)를 사용하세요.

이 시리즈의 다른 글

프론트엔드 × 접근성 시리즈 전체 보기 — 일반 프론트엔드 주제에 접근성 시각을 연결하는 연재입니다.