들어가며

“회원가입 폼인데, 뭐 어렵겠어?” 라고 생각하셨다면… 아마 접근성은 한 번도 테스트 안 해보셨을 거예요.

폼(Form)은 웹에서 사용자가 직접 데이터를 입력하는 가장 중요한 인터페이스입니다. 로그인, 결제, 검색, 설문… 사실상 웹의 모든 핵심 기능은 폼을 통해 이루어집니다.

그런데 이 폼이 수많은 사람들에게 완전한 장벽이 되고 있습니다.

  • 스크린 리더를 쓰는 사람은 입력 필드가 뭘 요구하는지 알 수가 없고
  • 키보드만 쓰는 사람은 날짜 선택기 앞에서 멈추고
  • 인지 장애가 있는 사람은 에러 메시지를 봐도 뭘 어떻게 고쳐야 할지 모릅니다
여러 입력 필드가 있는 웹 폼 화면은 마치 미로안의 사람들처럼 방향을 잃기 쉽습니다.
여러 입력 필드가 있는 웹 폼 화면은 마치 미로안의 사람들처럼 방향을 잃기 쉽습니다.
사진: UnsplashSusan Q Yin

이번 글에서는 WCAG 2.2 기준으로 폼 접근성을 처음부터 끝까지 살펴봅니다. 이론만 늘어놓는 게 아니라, 제가 직접 만든 데모 페이지와 함께 실전에서 바로 쓸 수 있는 코드를 중심으로 설명해 드릴게요.

🔗 데모 페이지: https://isaaceryn.github.io/demo_codes/form-accessibility-mastery/

“접근성 없는 폼"과 “접근성 있는 폼"을 나란히 두고, 키보드와 스크린 리더로 직접 비교해볼 수 있습니다.


라벨 연결: 이름표 없는 입력 필드의 비극

WCAG 2.2 성공 기준 1.3.1 (정보와 관계), 3.3.2 (레이블 또는 지시사항) 이 가장 기본으로 요구하는 건 이겁니다. 모든 입력 필드에 프로그래밍적으로 연결된 라벨을 제공할 것.

placeholder만으로는 안 됩니다

한 번쯤은 이런 폼 만들어보셨죠? 저도요.

html
<!-- ❌ 이렇게 하면 안 됩니다 -->
<input type="text" placeholder="이름을 입력하세요">

placeholder는 시각적으로 힌트처럼 보이지만 라벨이 아닙니다. 사용자가 입력을 시작하면 사라지고, 스크린 리더는 이걸 라벨로 인식하지 않는 경우도 많습니다. 색상 대비도 대부분 WCAG 기준 미달이에요.

올바른 라벨 연결 방법

방법 1: <label for> — 가장 기본, 가장 확실

html
<!-- ✅ for 속성으로 명시적 연결 -->
<label for="user-name">이름</label>
<input type="text" id="user-name" name="name">

forid가 일치하면 클릭 영역도 넓어집니다. 라벨을 클릭해도 입력 필드에 포커스가 가죠. 작은 체크박스를 정확하게 누르느라 고생해본 적 있으시죠? 이게 얼마나 친절한 UX인지 체감이 되실 거예요.

방법 2: aria-label — 라벨 텍스트를 시각적으로 숨기고 싶을 때

html
<!-- ✅ 검색창처럼 시각적 라벨이 없는 경우 -->
<input type="search" aria-label="사이트 내 검색">
<button type="submit">
  <svg aria-hidden="true"><!-- 돋보기 아이콘 --></svg>
  <span class="sr-only">검색</span>
</button>

방법 3: aria-labelledby — 이미 페이지에 있는 텍스트를 라벨로 활용

html
<!-- ✅ 기존 제목 요소를 라벨로 참조 -->
<h2 id="billing-section">결제 정보</h2>
<input type="text" id="card-number" aria-labelledby="billing-section card-number-label">
<span id="card-number-label">카드 번호</span>

aria-labelledby는 여러 id를 공백으로 이어 붙일 수 있어서, 복잡한 폼에서 맥락을 조합하는 데 유용합니다.


필수 입력 표시: 별표(*)만으로는 부족합니다

WCAG 2.2 3.3.2 (레이블 또는 지시사항) 은 필수 필드를 명확하게 표시하도록 요구합니다.

html
<!-- ❌ 시각적으로만 표시 -->
<label for="email">이메일 <span style="color:red">*</span></label>
<input type="email" id="email">

별표가 “필수"를 의미한다는 건 시각적 관례일 뿐입니다. 스크린 리더는 이걸 그냥 “별표"라고 읽거나 아예 건너뛸 수 있어요.

html
<!-- ✅ 프로그래밍적으로 필수 표시 -->
<label for="email">
  이메일
  <span aria-hidden="true">*</span>
  <span class="sr-only">(필수)</span>
</label>
<input type="email" id="email" required aria-required="true">

required는 브라우저 기본 검증을 활성화하고, aria-required="true"는 스크린 리더에 명시적으로 알립니다. aria-hidden="true"를 붙인 별표는 시각적으로만 보이고, 스크린 리더엔 “(필수)“라는 텍스트가 읽힙니다. 아직도 required 기능을 javascript로 구현하는 경우를 종종 볼 수 있는데, 브라우저의 기본 검증을 활용하는게 좋습니다.

폼 상단에 안내 문구를 두는 것도 좋은 방법입니다.

html
<p id="required-notice">
  <span aria-hidden="true">*</span> 표시가 있는 항목은 필수 입력입니다.
</p>

입력 형식 안내: aria-describedby의 힘

입력 필드 아래 힌트 텍스트(“8자 이상 입력해주세요”)는 어떻게 연결해야 할까요?

html
<!-- ✅ aria-describedby로 설명 텍스트 연결 -->
<label for="password">비밀번호 <span class="sr-only">(필수)</span></label>
<input
  type="password"
  id="password"
  required
  aria-describedby="password-hint"
>
<p id="password-hint" class="field-hint">
  영문, 숫자, 특수문자를 포함해 8자 이상 입력해주세요.
</p>

aria-describedby는 라벨(aria-labelledby)과 달리, 추가적인 설명 을 연결합니다. 스크린 리더는 필드에 포커스가 가면 라벨을 먼저 읽고, 잠시 후 설명 텍스트도 읽어줍니다.

여러 설명이 있다면 공백으로 이어 붙입니다.

html
<input
  type="password"
  id="password"
  aria-describedby="password-hint password-strength"
>
<p id="password-hint">8자 이상 입력해주세요.</p>
<p id="password-strength">강도: 보통</p>
잘 연결된 입력 필드와 설명 텍스트는 스크린 리더를 사용하는 사용자에게 읽는 순서를 도와줄 수 있습니다.
잘 연결된 입력 필드와 설명 텍스트는 스크린 리더를 사용하는 사용자에게 읽는 순서를 도와줄 수 있습니다.
사진: UnsplashToa Heftiba

에러 처리: “빨간 테두리"만으론 아무것도 모릅니다

그런데, 에러 메시지를 곁들인..

접근성 있는 에러 메시지를 곁들이는 셰프 - 네, 에러 메시지요.
접근성 있는 에러 메시지를 곁들이는 셰프 - 네, 에러 메시지요.

입력 검증 실패 시 가장 많이 보이는 패턴은 입력 필드 테두리를 빨갛게 바꾸는 겁니다. 시각적으로는 확실하지만, 스크린 리더 사용자는 아무것도 알 수 없습니다.

WCAG 2.2 3.3.1 (오류 식별), 3.3.3 (오류 제안) 이 요구하는 건 세 가지입니다:

  1. 오류가 발생했음을 알릴 것
  2. 어느 필드에서 오류가 났는지 알릴 것
  3. 어떻게 고치면 되는지 제안할 것

aria-invalid + aria-describedby 패턴

html
<!-- 에러 발생 후 동적으로 추가되는 상태 -->
<label for="email">이메일 <span class="sr-only">(필수)</span></label>
<input
  type="email"
  id="email"
  aria-invalid="true"
  aria-describedby="email-error"
>
<p id="email-error" class="field-error" role="alert">
  올바른 이메일 형식으로 입력해주세요. 예: [email protected]
</p>

aria-invalid="true"는 스크린 리더에 “이 필드에 오류가 있습니다"라고 알립니다. role="alert"는 DOM에 추가되는 순간 스크린 리더가 즉시 읽어줍니다.

오류가 없을 때는 aria-invalid를 아예 제거하거나 "false"로 설정하세요.

javascript
// 검증 통과 시
input.removeAttribute('aria-invalid');
errorEl.textContent = '';

// 검증 실패 시
input.setAttribute('aria-invalid', 'true');
errorEl.textContent = '올바른 이메일 형식으로 입력해주세요.';

폼 제출 후 여러 에러 처리

여러 필드에 에러가 동시에 발생했을 때는 폼 상단에 요약 안내를 제공하고, 해당 필드로 바로 이동할 수 있는 링크를 제공하는 게 좋습니다. (WCAG 2.2 3.3.1 오류 식별 / 3.3.3 오류 제안)

html
<div role="alert" aria-labelledby="error-summary-title" class="error-summary">
  <h2 id="error-summary-title">3개의 입력 오류가 있습니다</h2>
  <ul>
    <li><a href="#email">이메일: 올바른 형식으로 입력해주세요</a></li>
    <li><a href="#password">비밀번호: 8자 이상 입력해주세요</a></li>
    <li><a href="#phone">전화번호: 숫자만 입력해주세요</a></li>
  </ul>
</div>

데모에서 직접 확인하기: form-a11y-demo — 에러 처리 섹션 스크린 리더를 켜고 제출 버튼을 눌러보세요. 에러가 어떻게 안내되는지 직접 들을 수 있습니다.


그룹화: fieldset과 legend

라디오 버튼이나 체크박스가 여러 개 있을 때, 각각의 라벨만으로는 맥락이 부족합니다.

html
<!-- ❌ 그룹 맥락 없음 -->
<input type="radio" id="card" name="payment"> <label for="card">신용카드</label>
<input type="radio" id="transfer" name="payment"> <label for="transfer">계좌이체</label>

스크린 리더 사용자는 “신용카드"라는 라벨만 들을 뿐, 이게 “결제 수단 선택” 항목인지 알 방법이 없습니다.

html
<!-- ✅ fieldset + legend으로 그룹 맥락 제공 -->
<fieldset>
  <legend>결제 수단 <span class="sr-only">(필수, 1개 선택)</span></legend>
  <div class="radio-group">
    <input type="radio" id="card" name="payment" value="card">
    <label for="card">신용카드</label>
  </div>
  <div class="radio-group">
    <input type="radio" id="transfer" name="payment" value="transfer">
    <label for="transfer">계좌이체</label>
  </div>
  <div class="radio-group">
    <input type="radio" id="phone-pay" name="payment" value="phone">
    <label for="phone-pay">휴대폰 결제</label>
  </div>
</fieldset>

스크린 리더는 라디오 버튼에 포커스가 가면 “결제 수단, 신용카드, 라디오 버튼, 1/3"처럼 그룹 컨텍스트를 함께 읽어줍니다.

<fieldset>은 긴 폼의 섹션을 구분할 때도 활용할 수 있습니다.


키보드 내비게이션: Tab 순서와 포커스 관리

WCAG 2.2 2.1.1 (키보드), 2.4.3 (포커스 순서) 는 모든 폼 컨트롤이 키보드로 조작 가능해야 하고, 포커스 순서가 논리적이어야 한다고 요구합니다.

tabindex는 신중하게

html
<!-- ❌ 탭 순서를 강제로 바꾸면 혼란스럽습니다 -->
<input tabindex="3" ...>
<input tabindex="1" ...>
<input tabindex="2" ...>

tabindex로 순서를 억지로 바꾸기보다 HTML 구조 자체가 논리적인 순서를 갖도록 설계하는 게 맞습니다.

tabindex="0"은 원래 포커스를 받지 못하는 요소(예: <div>)를 탭 순서에 포함시킬 때만 씁니다.

html
<!-- ✅ 커스텀 컴포넌트에 키보드 접근성 부여 -->
<div
  role="combobox"
  tabindex="0"
  aria-expanded="false"
  aria-haspopup="listbox"
>
  국가 선택
</div>

제출 버튼과 키보드 동작

폼 제출은 Enter 키로 동작해야 합니다. <button type="submit">을 쓰면 브라우저가 자동으로 처리해줍니다.

html
<!-- ✅ 표준 버튼 사용 -->
<button type="submit">회원가입</button>
<button type="button" onclick="cancelForm()">취소</button>

<div onclick="...">으로 만든 가짜 버튼은 키보드로 활성화할 수 없고, Enter 키도 작동하지 않습니다. 접근성 측면에서 최악의 선택이에요.


실전 데모: 직접 확인해보세요

말보다 직접 경험하는 게 최고입니다.

🔗 form-a11y-demo 데모 페이지

데모 페이지는 두 개의 같은 회원가입 폼을 나란히 보여줍니다.

데이터 표
접근성 없는 폼접근성 있는 폼
라벨placeholder만 사용<label> 명시적 연결
필수 표시시각적 별표만aria-required + 숨김 텍스트
에러 메시지빨간 테두리만aria-invalid + role="alert"
그룹화<div> 묶음<fieldset> + <legend>
키보드일부 컨트롤 불가전체 키보드 조작 가능

테스트 방법:

  1. Tab 키로 폼을 이동하며 포커스가 어디로 가는지 확인
  2. macOS VoiceOver(Cmd + F5) 또는 NVDA를 켜고 각 필드에서 무엇이 읽히는지 비교
  3. 일부러 오류를 내고 제출 — 에러 안내가 어떻게 다른지 확인
접근성 없는 폼과 접근성 있는 폼을 나란히 비교한 데모 화면 스크린샷
접근성 없는 폼과 접근성 있는 폼을 나란히 비교한 데모 화면 스크린샷
직접 만든 데모 페이지 - 두 폼의 차이를 키보드와 스크린 리더로 체험할 수 있습니다

보너스: WCAG 3.0에서는 어떤 점을 주의해야 할까?

WCAG 3.0은 아직 Draft 단계이지만, 폼 접근성에서 몇 가지 방향 변화가 예고되어 있습니다.

💡 WCAG 3.0의 전반적인 변화가 궁금하다면, 이 블로그에서 연재 중인 WCAG 3.0 시대 준비하기 시리즈 를 함께 읽어보세요.

메모가 놓인 책상 - WCAG 3.0 Draft는 아직 작업 중입니다
메모가 놓인 책상 - WCAG 3.0 Draft는 아직 작업 중입니다
사진: UnsplashDevon Beard

1. 인지 접근성이 더 중요해집니다

WCAG 3.0은 Cognitive Accessibility를 본격적으로 다룹니다. 폼 설계에서 이 의미는:

  • 에러 메시지가 사람이 이해할 수 있는 언어로 작성되어야 한다 (코드나 기술 용어 금지)
  • 복잡한 폼은 **여러 단계(multi-step)**로 나눠야 한다
  • 입력 도중 자동 저장 또는 재입력 방지를 제공해야 한다

지금도 좋은 UX이지만, 3.0에서는 테스트 가능한 기준으로 명시될 가능성이 높습니다.

2. 성공 기준이 아닌 “Outcome” 기반 평가

WCAG 2.2는 각 성공 기준을 개별적으로 Pass/Fail로 평가합니다. WCAG 3.0은 사용자 목표(Outcome) 달성을 기준으로 전체적인 경험을 평가하는 방향으로 바뀝니다.

폼에 대입하면: “사용자가 이 폼을 오류 없이 완성할 수 있는가?“가 핵심 Outcome이 됩니다. 기술적으로 aria-invalid가 붙어 있어도, 실제 사용자가 오류를 이해하고 수정하지 못한다면 기준을 충족하지 못할 수 있어요.

3. 지금 준비할 수 있는 것

데이터 표
현재 (WCAG 2.2)WCAG 3.0 대비
aria-invalid + 에러 텍스트에러 메시지를 명확한 일상 언어로 작성
필수 필드 표시입력 예시(example) 제공 확대
포커스 관리폼 완료 후 성공 안내까지 포커스 흐름 설계
자동 검증실시간 인라인 검증 (너무 이른 에러는 피하기)

WCAG 3.0이 정식 표준이 되려면 아직 시간이 더 필요합니다. 하지만 위 방향으로 지금부터 준비해두면, 표준이 바뀌어도 큰 수정 없이 대응할 수 있을 거예요.


마무리: 폼 접근성 체크리스트

구현한 폼이 접근성 기준을 갖추고 있는지, 이 리스트로 빠르게 확인해보세요.

라벨 연결 (WCAG 1.3.1 / 3.3.2)

  • 모든 입력 필드에 <label for>, aria-label, aria-labelledby 중 하나 적용
  • placeholder를 라벨 대용으로 사용하지 않음

필수 표시 (WCAG 3.3.2)

  • 필수 필드에 required + aria-required="true" 적용
  • 시각적 필수 표시(*)가 스크린 리더에 의미 있게 전달됨

에러 처리 (WCAG 3.3.1 / 3.3.3)

  • 오류 시 aria-invalid="true" 설정
  • 에러 메시지가 aria-describedby로 연결됨
  • 에러 메시지가 즉시 스크린 리더에 안내됨 (role="alert" 또는 live region)
  • 에러 메시지에 수정 방법이 구체적으로 포함됨

그룹화 (WCAG 1.3.1)

  • 라디오/체크박스 그룹에 <fieldset> + <legend> 사용

키보드 접근성 (WCAG 2.1.1 / 2.4.3)

  • 모든 폼 컨트롤이 Tab으로 접근 가능
  • tabindex로 억지로 순서를 바꾸지 않음
  • Enter 키로 폼 제출 가능
  • 커스텀 컨트롤에 적절한 키보드 동작 구현

폼 하나를 제대로 만드는 게 생각보다 품이 많이 들지만, 그만큼 가치가 있는 작업입니다. 여러분이 만든 폼을 통해 누군가가 처음으로 원하는 서비스를 신청할 수 있게 된다면, 그게 바로 접근성의 의미 아닐까요.

데모 페이지에서 직접 테스트해보시고, 궁금한 점은 댓글로 남겨주세요!

🔗 form-a11y-demo 데모 페이지