웹 접근성 알아보기

_sw_·2026년 2월 11일
post-thumbnail

웹 접근성?

연령, 장애 여부에 관계없이 웹 사이트에서 제공하는 정보에 동등하게 접근하고, 이용할 수 있도록 보장하는 것

웹 접근성 고려사항

  • 시각 - 저시력, 실명, 색각 이상 같은 시각 장애를 고려
  • 이동성 - 근육 속도 저하, 근육 제어 손실로 인해 손을 쓰기 어렵거나 쓸 수 없는 상태를 고려
  • 청각 - 영상, 음성 콘텐츠에 자막, 원고, 수화등의 대체 수단을 고려
  • 인지 - 정신 지체 장애, 학습 장애를 고려

웹 접근성에 도움을 주는 브라우저 보조 기술

  • 스크린 리더
  • 화면 확대 도구
  • 음성 인식
  • 키보드 오버레이

WAI-ARIA?

동적 컨텐츠와 인터페이스 컨트롤을 위한 웹 접근성 표준으로, 보조 기술를 사용해 웹 애플리케이션을 효과적으로 탐색하는데 설계되 있다.

HTML Tag들에 추가적으로 시멘틱을 부여해서 브라우저 보조 기술들이 각 요소의 역할, 상태, 설명 등을 참고 할 수 있도록 한다.


✏️ 간단 적용해보기

1. 시맨틱 HTML과 ARIA 속성 적용

1-1. 모달 다이얼로그 접근성

적용 전:

<div v-if="isOpen" class="form-modal-background" @click.self="handleClose">
  <div class="form-modal">
    <h3>{{ modalTitle }}</h3>
    <!-- 폼 내용 -->
  </div>
</div>

적용 후:

<div
  v-if="isOpen"
  class="form-modal-background"
  @click.self="handleClose"
  role="dialog"
  :aria-modal="isOpen"
  aria-labelledby="modal-title"
>
  <div class="form-modal">
    <h3 id="modal-title">{{ modalTitle }}</h3>
    <!-- 폼 내용 -->
  </div>
</div>

개선 사항:

  • role="dialog": 스크린 리더에게 이것이 대화상자임을 알림
  • aria-modal="true": 모달이 열려있을 때 배경 컨텐츠를 무시하도록 지시
  • aria-labelledby: 모달의 제목을 명시적으로 연결

추가한 접근성 속성은 개발자 도구 Elements → Accessibility 에서 접근성 트리와 각 요소에 적용된 접근성 속성을 확인할 수 있다.


1-2. 폼 입력 필드 접근성

적용 전:

<input
  :value="modelValue"
  :placeholder="placeholder"
  :required="required"
  @input="handleInput"
/>

적용 후:

<input
  :id="props.id"
  :name="props.name"
  :value="modelValue"
  :placeholder="placeholder"
  :required="required"
  @input="handleInput"
  :aria-invalid="props.invalid"
  :aria-describedby="props.invalid ? `${props.id}-error` : undefined"
/>
<p
  v-if="props.invalid && props.message"
  :id="`${props.id}-error`"
  class="error-message"
>
  {{ props.message }}
</p>

개선 사항:

  • id와 name: 폼 요소 식별 및 label 연결
  • aria-invalid: 입력값이 유효하지 않음을 스크린 리더에게 알림
  • aria-describedby: 에러 메시지를 입력 필드와 연결
  • 에러 메시지에 고유 id 부여

1-3. Label과 Input 연결

적용 전:

<label class="form-label">
  {{ field.label }}
  <span v-if="field.required">*</span>
</label>
<FormInput :model-value="getFieldValue(field.name)" />

적용 후:

<label class="form-label" :for="field.name">
  {{ field.label }}
  <span v-if="field.required" class="required-mark">*</span>
</label>
<FormInput
  :id="field.name"
  :name="field.name"
  :model-value="getFieldValue(field.name)"
/>

개선 사항:

  • <label for="field-name">: label과 input을 명시적으로 연결
  • 스크린 리더가 입력 필드에 포커스할 때 레이블을 읽어줌
  • 레이블을 클릭하면 해당 입력 필드에 포커스 이동

2. 키보드 접근성

2-1. 버튼 접근성

<button
  :type="type"
  :disabled="disabled"
  :aria-label="ariaLabel"
  class="icon-button"
>
  <slot />
</button>

개선 사항:

  • 아이콘만 있는 버튼에 aria-label 추가
  • disabled 상태를 명시적으로 관리
  • 키보드로 포커스 및 Enter/Space로 실행 가능

탭(Tab) 키 입력시 Todo 버튼에 먼저 포커스가 가는 것을 확인할 수 있다.


2-2. 포커스 스타일 명시

.form-input:focus,
.form-textarea:focus {
  border-color: #3b82f6;
  box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}

.form-input.form-error:focus {
  border-color: #ef4444;
  box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1);
}

개선 사항:

  • :focus 상태에서 명확한 시각적 피드백 제공
  • outline을 제거하는 대신 box-shadow로 포커스 표시
  • 에러 상태에서도 구분되는 포커스 스타일

폼 내에서도 동일하게 설정한 속성에 따라 포커스가 이동한다.


3. 반응형 디자인과 접근성

// 모바일에서도 충분한 터치 영역 확보
.icon-button {
  padding: 0.5rem;  // 최소 44x44px 터치 영역
  cursor: pointer;
}

// 가독성을 위한 반응형 폰트 크기
h1 {
  font-size: 1.5rem;

  @media (min-width: 768px) {
    font-size: 2rem;
  }
}

개선 사항:

  • WCAG 권장 최소 터치 영역(44x44px) 준수
  • 작은 화면에서도 가독성 있는 폰트 크기
  • 충분한 색상 대비 (텍스트와 배경)

4. 의미 있는 에러 메시지

적용 전:

if (!value) {
  showToast({ message: '입력해주세요.', variant: 'error' });
}

적용 후:

if (!value || String(value).trim() === '') {
  field.invalid = true;
  field.message = `${field.label}을(를) 입력해주세요.`;
  showToast({ message: '필수 항목을 입력해주세요.', variant: 'error' });
}

개선 사항:

  • 어떤 필드에 문제가 있는지 명확히 표시
  • Toast와 인라인 에러 메시지를 함께 제공
  • 스크린 리더가 aria-describedby를 통해 에러 메시지 읽기

5. 스크린 리더 테스트

실제로 스크린 리더(macOS VoiceOver, Windows Narrator)로 테스트한 결과:

개선 전:

  • "버튼" (아이콘 버튼의 역할을 알 수 없음)
  • "입력 필드" (어떤 정보를 입력해야 하는지 모름)
  • 모달이 열려도 배경 컨텐츠를 계속 읽음

개선 후:

  • "Todo 추가 버튼"
  • "제목, 필수 항목, 입력 필드"
  • "모달 대화상자 - Todo 추가"
  • 유효하지 않은 입력: "제목을 입력해주세요"

정리

웹 접근성을 적용한 결과:

  • ✅ 키보드만으로 모든 기능 사용 가능
  • ✅ 스크린 리더로 의미 있는 정보 전달
  • ✅ 시각적 피드백이 명확함 (포커스, 에러 상태)
  • ✅ 모바일에서도 충분한 터치 영역
  • ✅ WCAG 2.1 Level A 기준 충족

그럼 앱에서 접근성을 고려하려면?

모바일 서비스를 이용하는 누구나 불편한 없이 기기를 이용할 수 있게 하는 개념

  • 다크모드
  • 큰 글씨 모드
  • UX 동작 수정 ( 길게 터치 후 끌어다 놓기 → 손이 불편하신 분들은 활용이 어려움)

⇒ 웹 접근성은 특별한 기능이나, 정해진 규칙에 맞추는 것이 아닌 다양한 어려움을 예방하는 UX/UI를 제공하는 것이 핵심


🤨 오? 이거 뭔가 만들 수 있을 것 같은데

지난 우아한 테크 컴퍼런스에서 AST관련한 세션을 들었었다. AST를 통해서 코드에 대한 다양한 처리가 가능하다는 가능성을 실감했던 세션이라서 이번 기회에 활용해보면 좋지 않을까 하는 생각이 들었다.

🎯 문제 정의

개발자들이 ARIA 속성을 사용할 때 겪는 어려움:

  1. ARIA 속성과 값에 대한 이해 부족
    • 어떤 속성에 어떤 값을 넣어야 하는지 헷갈림
    • 예: aria-expanded에 true를 넣어야 할까, "true"를 넣어야 할까?
  2. 프로젝트별 맥락 차이
    • 다른 리소스를 참고해도 내 프로젝트에는 다르게 적용해야 하는 경우
    • 예: 같은 토글 버튼이라도 디자인 시스템에 따라 구현이 다름
  3. 동적 상태 관리의 어려움 ⭐ (핵심)
    • 상태에 따라 동적으로 변경되어야 하는 ARIA 속성
    • 예: 토글 버튼의 aria-expanded가 항상 "false"로 고정됨

🔧 기존 해결 방식의 한계

기존 도구eslint-plugin-jsx-a11y

✅ 해결 가능

  • ARIA 속성과 값에 대한 이해 부족 → 문법 검증으로 해결
  • 프로젝트별 맥락 차이 → 설정 커스터마이징으로 해결

❌ 해결 불가능

  • 동적 상태 관리 → 정적 분석의 한계
    // ❌ 이런 오류를 기존 도구는 찾지 못함
    <button onClick={toggle} aria-expanded="false">
      {/* 클릭해도 aria-expanded가 항상 false! */}
    </button>
    

왜 불가능한가?

  • 동적 값은 런타임에 결정됨
  • 기존 ESLint 플러그인은 빌드 타임 정적 분석만 수행
  • 상태(State)와 ARIA 속성의 연결 관계를 추론하지 못함

💡 해결 방식: eslint-plugin-aria-state-validator

핵심 아이디어: AST 분석으로 상태와 ARIA 속성의 연결 관계를 추론

작동 원리

// 1. JSXElement 순회: role 및 aria-* 속성 식별
<button onClick={() => setOpen(!isOpen)} aria-expanded="false">

// 2. 스코프 추적: context.getScope()로 변수 추적
//    → onClick 핸들러에서 'isOpen' 변수 발견

// 3. 상태 추론: useState 훅에서 파생된 상태인지 판단
//    → const [isOpen, setOpen] = useState(false)

// 4. 패턴 검증 & 자동 수정
//    ❌ aria-expanded="false" (정적)
//    ✅ aria-expanded={isOpen ? 'true' : 'false'} (동적)

두 가지 검증 규칙

  1. state-dependent-aria-validator (동적 상태 검증)
    • 상태와 ARIA 속성의 바인딩 검증
    • 80% 자동 수정 가능
  2. static-aria-validator (정적 ARIA 검증)
    • 기존 도구 보완 (오타, 잘못된 값, 충돌 감지)

⚖️ 트레이드오프

React 생태계로 범위 한정

  • useState 훅 또는 Props 통한 상태 전달 추론
  • React 외 프레임워크(Vue, Svelte 등)는 미지원

이유:

  • 프레임워크마다 상태 관리 패턴이 다름
    • Vue: refreactive

    • Svelte: $: reactive statements

    • Solid: createSignal

      // React
      const [isOpen, setOpen] = useState(false)
      
      // Vue (향후 지원 가능)
      const isOpen = ref(false)
      
      // Svelte (향후 지원 가능)
      let isOpen = $state(false)
  • React의 useState 패턴이 가장 명확하고 일관적

Reference

한국 접근성 인증 평가원 / 웹 접근성이란? - https://www.wa.or.kr/m1/sub1.asp

토스의 모바일 접근성 - https://toss.im/tossfeed/article/tinyquestions-disability-5

0개의 댓글