# 폼 실시간 검증 제대로 하기: 타이밍부터 aria-invalid까지

> 포커스만 줘도 에러부터 띄우는 폼, 다 입력해야 알려주는 폼 — 검증 타이밍이 전부입니다. blur 이후 검증, aria-describedby·aria-invalid 연결, role=alert와 aria-live 구분을 실전 코드로 정리합니다.

**Published:** 2026-09-15 | **Updated:** 2026-09-15

---


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

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

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

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

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

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

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

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

{{< img src="images/contents/validation-timing.png" alt="폼 검증 타이밍 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 비교 데모](https://isaaceryn.github.io/demo_codes/form-ux-validation/)에 나쁜 쪽과 좋은 쪽을 나란히 뒀습니다. 왼쪽 이름 칸을 클릭만 해보고, 오른쪽에서 같은 걸 해보세요.

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

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

라벨 연결이나 오류 식별처럼 폼 접근성의 기본기는 [폼 접근성 마스터하기]({{< relref "/posts/form-accessibility-mastery" >}})에서 따로 다뤘습니다. 여기서는 그 위에 얹는 이야기만 합니다.

{{< img src="images/contents/error-anatomy.png" alt="접근 가능한 에러 필드의 해부도 - 레이블은 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 실전 가이드]({{< relref "/posts/aria-practical-guide" >}})에서 더 넓게 다룹니다.

`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 라이브 리전 제대로 쓰기]({{< relref "/posts/aria-live-regions" >}})에 따로 정리해뒀습니다. 폼 바깥에서도 쓰는 도구라 거기서 넓게 다뤘어요.

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

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

{{< img src="images/contents/submit-focus.png" alt="제출 실패 시 포커스를 어디로 보낼지 비교하는 다이어그램 - 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::before`의 `content`를 `is-success`/`is-error` 클래스로 갈라두면 위 `setFeedback` 한 번에 함께 바뀝니다.

반대로 CSS의 `::before`로만 아이콘을 그리고 글자를 빼면, 화면을 못 보는 사용자에게는 아무 메시지도 남지 않습니다. 아이콘은 거들 뿐이고, 뜻은 언제나 텍스트가 져야 합니다.

## 실제로 재보면 — 화면은 같아 보여도 배선이 다릅니다

화면만 보면 두 폼은 거의 같아 보입니다. 그래서 눈이 아니라 DOM을 봤어요. 앞에서 소개한 [비교 데모](https://isaaceryn.github.io/demo_codes/form-ux-validation/)로 돌아가, 두 폼을 실제로 클릭하고 타이핑하면서 단계마다 상태를 읽었습니다. 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)]({{< relref "/posts/wcag-22-new-criteria" >}}#337-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`)가 전부입니다. 지금 만들고 있는 폼에서 딱 그 네 군데만 확인해보세요.

---

{{< faq >}}

## 이 시리즈의 다른 글

- [SPA에서 포커스 관리 제대로 하기]({{< relref "/posts/spa-focus-management" >}}) — 라우트가 바뀌어도 조용한 화면 고치기
- [색은 맞는데 대비는 틀렸다]({{< relref "/posts/opacity-contrast-trap" >}}) — 비활성 필드가 안 읽히는 이유

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

