웹 접근성 실전: ARIA Live Regions와 동적 콘텐츠 접근성

웹 개발

웹 접근성ARIALive Regions스크린 리더접근성 구현

이 글은 누구를 위한 것인가

  • 폼 에러 메시지, 토스트 알림이 스크린 리더에 읽히지 않아 민원이 온 팀
  • WCAG 2.1 Level AA 준수가 필요한 공공기관·금융 서비스 개발자
  • 접근성을 "나중에 하자"에서 "지금 하자"로 전환하려는 팀

들어가며

페이지가 로드될 때의 접근성은 잘 알려져 있다. 이미지에 alt, 제목에 heading 계층, 폼에 레이블 — 정적 콘텐츠 접근성이다. 하지만 SPA 시대에는 페이지 이동 없이 콘텐츠가 바뀐다. 알림 토스트, 폼 에러, 장바구니 수량 변경 — 이런 동적 변경을 스크린 리더가 어떻게 알 수 있을까?

ARIA Live Regions가 그 답이다.

이 글은 bluefoxdev.kr의 웹 접근성 구현 가이드 를 참고하고, ARIA Live Regions 실전 적용 관점에서 확장하여 작성했습니다.


1. ARIA Live Regions 개념

[Live Regions 속성]

aria-live 값:
  off       : 변경을 알리지 않음 (기본값)
  polite    : 현재 읽는 내용이 끝난 후 알림 (대부분의 경우)
  assertive : 즉시 중단하고 알림 (긴급 알림만)

role shortcuts:
  role="alert"   → aria-live="assertive" + aria-atomic="true"
  role="status"  → aria-live="polite"    + aria-atomic="true"
  role="log"     → aria-live="polite"    + aria-relevant="additions"

aria-atomic:
  true  : 영역 전체를 한 번에 읽음 (변경 부분만 읽지 않음)
  false : 변경된 부분만 읽음

[언제 무엇을 쓸까]
  긴급 오류:          role="alert"
  성공/정보 토스트:    role="status"
  실시간 피드:        aria-live="polite" + aria-relevant="additions"
  로딩 상태:          aria-live="polite" + aria-busy="true"
  폼 유효성 오류:      aria-live="polite" + aria-describedby 연결

2. 토스트 알림 접근성

// 접근성을 고려한 Toast 컴포넌트

import { useState, useEffect, useCallback } from 'react';

interface Toast {
  id: string;
  message: string;
  type: 'success' | 'error' | 'info';
}

// 전역 Live Region — 앱 루트에 한 번만 렌더링
function GlobalAnnouncer() {
  const [politeMessage, setPoliteMessage] = useState('');
  const [assertiveMessage, setAssertiveMessage] = useState('');

  useEffect(() => {
    // 전역 이벤트로 메시지 수신
    const handlePolite = (e: CustomEvent) => {
      setPoliteMessage('');  // 빈 값으로 초기화 후 (스크린 리더가 변경 감지)
      requestAnimationFrame(() => setPoliteMessage(e.detail));
    };
    const handleAssertive = (e: CustomEvent) => {
      setAssertiveMessage('');
      requestAnimationFrame(() => setAssertiveMessage(e.detail));
    };

    window.addEventListener('announce:polite', handlePolite as EventListener);
    window.addEventListener('announce:assertive', handleAssertive as EventListener);
    return () => {
      window.removeEventListener('announce:polite', handlePolite as EventListener);
      window.removeEventListener('announce:assertive', handleAssertive as EventListener);
    };
  }, []);

  return (
    <>
      {/* 화면에는 보이지 않지만 스크린 리더는 읽음 */}
      <div
        role="status"
        aria-live="polite"
        aria-atomic="true"
        className="sr-only"
      >
        {politeMessage}
      </div>
      <div
        role="alert"
        aria-live="assertive"
        aria-atomic="true"
        className="sr-only"
      >
        {assertiveMessage}
      </div>
    </>
  );
}

// 유틸리티 함수
export function announce(message: string, priority: 'polite' | 'assertive' = 'polite') {
  window.dispatchEvent(new CustomEvent(`announce:${priority}`, { detail: message }));
}

// Toast 시스템
function useToast() {
  const [toasts, setToasts] = useState<Toast[]>([]);

  const addToast = useCallback((toast: Omit<Toast, 'id'>) => {
    const id = crypto.randomUUID();
    setToasts(prev => [...prev, { ...toast, id }]);

    // 스크린 리더에게 알림
    announce(
      toast.message,
      toast.type === 'error' ? 'assertive' : 'polite'
    );

    // 자동 제거
    setTimeout(() => {
      setToasts(prev => prev.filter(t => t.id !== id));
    }, 5000);
  }, []);

  return { toasts, addToast };
}

3. 폼 유효성 오류 접근성

// 접근성 있는 폼 에러 표시

function AccessibleForm() {
  const [errors, setErrors] = useState<Record<string, string>>({});
  const [errorSummary, setErrorSummary] = useState('');

  const validate = (data: FormData): boolean => {
    const newErrors: Record<string, string> = {};

    if (!data.get('email')) {
      newErrors.email = '이메일 주소를 입력해주세요';
    } else if (!/\S+@\S+\.\S+/.test(data.get('email') as string)) {
      newErrors.email = '올바른 이메일 형식으로 입력해주세요';
    }

    if (!data.get('password')) {
      newErrors.password = '비밀번호를 입력해주세요';
    }

    setErrors(newErrors);

    if (Object.keys(newErrors).length > 0) {
      // 스크린 리더에게 오류 요약 알림
      const count = Object.keys(newErrors).length;
      setErrorSummary(`${count}개의 오류가 있습니다. 아래 항목을 확인해주세요.`);
      return false;
    }

    return true;
  };

  return (
    <form onSubmit={(e) => { e.preventDefault(); validate(new FormData(e.currentTarget)); }}>

      {/* 오류 요약 (폼 상단에 위치) */}
      {errorSummary && (
        <div
          role="alert"
          aria-live="assertive"
          className="error-summary"
          tabIndex={-1}  // 포커스 이동 가능하게
        >
          <h2>입력 오류</h2>
          <p>{errorSummary}</p>
          <ul>
            {Object.entries(errors).map(([field, msg]) => (
              <li key={field}>
                <a href={`#${field}`}>{msg}</a>
              </li>
            ))}
          </ul>
        </div>
      )}

      {/* 개별 필드 */}
      <div className="field">
        <label htmlFor="email">
          이메일 <span aria-label="필수">*</span>
        </label>
        <input
          id="email"
          name="email"
          type="email"
          aria-required="true"
          aria-invalid={!!errors.email}
          aria-describedby={errors.email ? 'email-error' : undefined}
          autoComplete="email"
        />
        {errors.email && (
          <p id="email-error" role="alert" className="field-error">
            {errors.email}
          </p>
        )}
      </div>

      <button type="submit">로그인</button>
    </form>
  );
}

4. 로딩 상태 접근성

// 비동기 작업의 로딩 상태를 스크린 리더에 전달

function LoadingAwareButton({ onClick, label }: { onClick: () => Promise<void>; label: string }) {
  const [isLoading, setIsLoading] = useState(false);
  const [statusMessage, setStatusMessage] = useState('');

  const handleClick = async () => {
    setIsLoading(true);
    setStatusMessage('처리 중입니다...');

    try {
      await onClick();
      setStatusMessage('완료되었습니다.');
    } catch {
      setStatusMessage('오류가 발생했습니다. 다시 시도해주세요.');
    } finally {
      setIsLoading(false);
    }
  };

  return (
    <>
      <button
        onClick={handleClick}
        disabled={isLoading}
        aria-busy={isLoading}
      >
        {isLoading ? (
          <>
            <span aria-hidden="true">⏳</span>
            <span className="sr-only">처리 중...</span>
          </>
        ) : label}
      </button>

      {/* 상태 변경 알림 */}
      <div role="status" aria-live="polite" aria-atomic="true" className="sr-only">
        {statusMessage}
      </div>
    </>
  );
}

/* sr-only CSS (화면에 숨기되 스크린 리더는 읽음) */
/*
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}
*/

마무리

ARIA Live Regions의 핵심은 단순하다: 동적으로 변경되는 콘텐츠가 있다면 role="status" (일반 알림) 또는 role="alert" (긴급 알림)으로 감싸라.

흔한 실수는 Live Region을 JavaScript로 DOM에 추가하는 것이다. 스크린 리더는 페이지 로드 시 Live Region을 등록한다. 숨겨진 상태로 처음부터 HTML에 있어야 한다.

실제 스크린 리더(NVDA, VoiceOver)로 테스트하지 않으면 동작을 예측할 수 없다. 개발 중 VoiceOver(Mac)는 무료로 바로 쓸 수 있다.