iBetter Books
수정

도입 스토리

"파일 업로드가 완료됐을 때 토스트 알림이 뜨는데, 스크린리더는 전혀 말을 안 해요." 김개발이 말했습니다.

"게다가 알림 배지 숫자가 바뀌어도 스크린리더는 모르고요."

박멘토가 고개를 끄덕였습니다. "사용자에게 피드백을 주는 모든 요소는 스크린리더에도 피드백이 가야 해요. 눈에 보이는 변화가 보조기기에도 전달되어야 한다는 거죠. Live Region과 역할 속성으로 해결할 수 있어요."

핵심 개념 설명

토스트 알림

<!-- 스크린리더 공지 영역 (항상 DOM에 존재) --><div  id="toast-container"  aria-live="polite"  aria-atomic="true"  class="toast-container">  <!-- 토스트가 동적으로 추가됨 --></div>
function showToast(message, type = 'info') {  const container = document.getElementById('toast-container');  const toast = document.createElement('div');  toast.className = `toast toast-${type}`;  toast.setAttribute('role', 'status');  toast.innerHTML = `    <span class="toast-icon" aria-hidden="true">      ${type === 'success' ? '✓' : type === 'error' ? '✕' : 'ℹ'}    </span>    <span class="toast-message">${message}</span>    <button      type="button"      class="toast-close"      aria-label="알림 닫기"      onclick="this.closest('.toast').remove()"    >      ×    </button>  `;  container.appendChild(toast);  // 5초 후 자동 제거  setTimeout(() => {    toast.remove();  }, 5000);}// 사용showToast('파일이 성공적으로 업로드되었습니다.', 'success');showToast('네트워크 오류가 발생했습니다.', 'error');

오류 토스트는 role="alert"로 즉시 읽히게 합니다.

function showErrorToast(message) {  const container = document.getElementById('toast-container');  const toast = document.createElement('div');  toast.setAttribute('role', 'alert');  // 즉시 읽기  toast.className = 'toast toast-error';  toast.textContent = message;  container.appendChild(toast);}

알림 배지

<!-- 장바구니 배지 --><button type="button" aria-label="장바구니">  <svg aria-hidden="true"><!-- 장바구니 아이콘 --></svg>  <span    class="badge"    aria-label="담긴 상품 3개"  >    3  </span></button>

배지 숫자가 바뀌면 aria-label도 함께 업데이트합니다.

function updateCartBadge(count) {  const badge = document.querySelector('.badge');  badge.textContent = count;  badge.setAttribute('aria-label', `담긴 상품 ${count}개`);  // 버튼 전체의 aria-label도 업데이트  document.querySelector('[aria-label^="장바구니"]')    .setAttribute('aria-label', `장바구니, 상품 ${count}개`);  // 변경 사항을 Live Region으로 알림  document.getElementById('status-message').textContent =    `${count}개 상품이 장바구니에 있습니다.`;}

진행 표시줄

<!-- 파일 업로드 진행 표시줄 --><div class="upload-progress">  <label id="progress-label">업로드 진행률</label>  <progress    id="upload-progress"    max="100"    value="0"    aria-labelledby="progress-label"  >    0%  </progress>  <span id="progress-value" aria-live="off">0%</span></div>
function updateProgress(percent) {  const progressBar = document.getElementById('upload-progress');  const progressValue = document.getElementById('progress-value');  progressBar.value = percent;  progressValue.textContent = `${percent}%`;  progressBar.setAttribute('aria-valuenow', percent);  // 25%, 50%, 75%, 100% 등 주요 지점에만 알림  if (percent % 25 === 0) {    document.getElementById('status-message').textContent =      `업로드 ${percent}% 완료`;  }}

커스텀 진행 표시줄 (div 기반):

<div  role="progressbar"  aria-valuenow="45"  aria-valuemin="0"  aria-valuemax="100"  aria-label="파일 업로드 진행률"  style="width: 45%">  <span class="sr-only">45%</span></div>

스피너 (로딩 표시)

<!-- 버튼 내 스피너 --><button type="submit" id="submit-btn">  <span class="spinner" aria-hidden="true"></span>  <span class="btn-text">저장</span></button>
function setLoading(isLoading) {  const btn = document.getElementById('submit-btn');  const spinner = btn.querySelector('.spinner');  const text = btn.querySelector('.btn-text');  btn.setAttribute('aria-busy', isLoading);  btn.disabled = isLoading;  spinner.hidden = !isLoading;  text.textContent = isLoading ? '저장 중...' : '저장';}

페이지 전체 로딩은 별도 공지 영역으로 알립니다.

<div  id="loading-indicator"  role="status"  aria-live="polite"  aria-label="데이터를 불러오는 중입니다."  hidden>  <div class="spinner" aria-hidden="true"></div></div>

별점 평가 위젯

<!-- 읽기 전용 별점 --><div aria-label="평점 4.5점 (5점 만점)">  <span aria-hidden="true">★★★★☆</span></div><!-- 입력 가능한 별점 --><fieldset>  <legend>상품 평점을 선택하세요</legend>  <div class="star-rating">    <input type="radio" id="star5" name="rating" value="5">    <label for="star5" title="5점 — 매우 만족">★</label>    <input type="radio" id="star4" name="rating" value="4">    <label for="star4" title="4점 — 만족">★</label>    <input type="radio" id="star3" name="rating" value="3">    <label for="star3" title="3점 — 보통">★</label>    <input type="radio" id="star2" name="rating" value="2">    <label for="star2" title="2점 — 불만족">★</label>    <input type="radio" id="star1" name="rating" value="1">    <label for="star1" title="1점 — 매우 불만족">★</label>  </div></fieldset>

단계별 실습

따라하기: Live Region 테스트

토스트 알림 구현 후 스크린리더로 확인합니다.

  1. 스크린리더를 켭니다.
  2. "저장" 버튼을 클릭합니다.
  3. 토스트 알림이 뜰 때 스크린리더가 내용을 읽는지 확인합니다.
  4. 오류 토스트는 성공 토스트보다 빠르게 읽히는지 비교합니다.

변형하기: 업로드 진행 상태 안내 개선

const milestones = [25, 50, 75, 100];let lastMilestone = 0;function updateProgress(percent) {  // ...기존 코드...  const nextMilestone = milestones.find(m => m >= percent && m > lastMilestone);  if (nextMilestone && percent >= nextMilestone) {    lastMilestone = nextMilestone;    const msg = nextMilestone === 100      ? '업로드가 완료되었습니다.'      : `업로드 ${nextMilestone}% 완료`;    document.getElementById('status-message').textContent = msg;  }}

정리와 확인

핵심 내용 요약

  • 토스트: aria-live="polite" 컨테이너에 동적 추가
  • 오류 토스트: role="alert" (즉시 읽기)
  • 알림 배지: 숫자 변경 시 aria-label 함께 업데이트
  • 진행 표시줄: <progress> 또는 role="progressbar" + aria-valuenow
  • 스피너: aria-busy="true" + 상태 메시지

확인 문제

문제 1. 업로드 완료 토스트에 role="alert" 대신 aria-live="polite"를 쓰는 이유는?

"완료" 알림은 사용자가 현재 하던 작업(다른 텍스트 읽기 등)을 방해할 필요가 없습니다.
오류나 긴급 알림만 assertive(즉시 읽기)로 처리합니다.

문제 2. 진행 표시줄을 스크린리더가 1%씩 읽게 하면 왜 나쁜 사용자 경험인가?

스크린리더가 "1%, 2%, 3%..."로 계속 읽어서 사용자 작업을 방해합니다.
25% 단위나 완료 시점 등 의미 있는 구간에만 알리는 것이 좋습니다.

PART 07을 마쳤습니다. 다음 PART에서는 스크린리더를 직접 사용해보는 실습을 진행합니다. NVDA와 VoiceOver의 실제 사용법과 테스트 방법을 알아봅니다.