이 글은 누구를 위한 것인가
- 폼 에러 메시지, 토스트 알림이 스크린 리더에 읽히지 않아 민원이 온 팀
- 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)는 무료로 바로 쓸 수 있다.