aria-live 가 안 들리는 진짜 이유
빈 영역을 먼저 두고 텍스트만 갈아 끼워야 토스트가 들리기 시작해요.
폼 검증에 실패해서 빨간 토스트가 떴어요. 시각 사용자에게는 메시지가 보이지만, 스크린 리더 사용자에게는 아무 일도 일어나지 않은 것처럼 조용하죠. 라이브 리전은 포커스를 빼앗지 않으면서 변화를 알리는 채널이고, 토스트가 안 들리는 대부분의 이유는 안내 메시지를 채운 채로 한 번에 DOM 에 끼워 넣었기 때문이에요.
토스트가 안 읽혀요
폼 제출 후 "비밀번호가 일치하지 않습니다" 같은 빨간 박스를 화면 오른쪽 위에 페이드인하는 패턴, 익숙하시죠. 시각 사용자에게는 알림이 잘 도착해요. 스크린 리더 사용자에게는요?
대부분의 토스트 구현이 여기서 막힙니다. 메시지 노드는 분명 DOM 에 들어갔는데, 스크린 리더는 입을 닫고 있어요. 접근성 일반론은 이 글에서 한 번 정리했고, 이번 글은 그 중 동적 변화 알림용 도구인 라이브 리전(live region) 한 가지에 집중할게요.
라이브 리전 자체는 어렵지 않습니다. 다만 발화 트리거가 우리가 기대하는 것과 달라서, "왜 안 들리지" 라는 첫 질문에서 한참 헤매게 돼요.
라이브 리전의 본분
WAI-ARIA 명세가 정의한 라이브 리전은 한 줄로 요약하면 "사용자의 현재 활동을 끊지 않으면서 변화를 알리는 영역" 이에요. 핵심 단어가 "끊지 않는다" 입니다.
"Live regions are meant to inform users of dynamic updates that have occurred in other areas of the current web page, but which do not necessitate interrupting the user's current activity with a change in context." - MDN
라이브 리전은 사용자 포커스를 흔들지 않고 변화 사실만 귓속말로 전달하는 채널이에요.
쉽게 말하면 모달처럼 화면을 가로채지도 않고 포커스를 옮기지도 않으면서 "지금 이런 일이 일어났어요" 만 알리는 거죠. 정말 다급한 알림이라면 모달이나 다이얼로그가 맞고, 그 사이의 가벼운 알림이 라이브 리전 자리예요.
WAI-ARIA 명세는 라이브 리전 role 다섯 개와 속성 네 개를 정의해요. role 은 alert, log, marquee, status, timer 다섯이고, 속성은 aria-live, aria-relevant, aria-atomic, aria-busy 네 개입니다. 보기엔 양이 많지만 실전에서 매일 쓰는 건 aria-live 와 role="status", role="alert" 세 개로 거의 정리돼요.
aria-live 와 role 의 관계
aria-live 속성은 발화 우선순위를 정합니다. 값은 세 가지예요.
polite 는 사용자가 잠시 멈춘 틈을 노려 발화하는 모드예요. "지금 말씀 중이라 끼어들지 못하니까 한숨 돌리실 때 알려드릴게요" 같은 톤이죠. 자동 저장 알림, 검색 결과 카운트 같이 급하지 않은 변화에 어울려요.
assertive 는 즉시 발화합니다. 사용자가 다른 걸 듣고 있어도 가로채요. 폼 검증 실패나 세션 만료처럼 지금 듣지 않으면 손해 보는 정보에만 써요. 남발하면 사용자가 화면을 떠나게 만드는 가장 빠른 방법이에요.
off 는 영역 안에 포커스가 있을 때만 변화를 알리고 평소엔 침묵해요. 대부분의 경우 속성을 안 쓰는 것과 비슷하게 동작합니다.
role 쪽은 이 셋과 암시적으로 매핑돼요. role="status" 는 aria-live="polite" 와 aria-atomic="true" 가 따라오고, role="alert" 는 aria-live="assertive" 와 aria-atomic="true" 가 따라옵니다. 그러니까 아래 두 가지 마크업은 의미상 같은 결과를 내요.
<!-- 둘 다 polite + atomic="true" 동작 -->
<div role="status">자동 저장됨</div>
<div aria-live="polite" aria-atomic="true">자동 저장됨</div>다만 실전에서는 둘 다 명시적으로 쓰는 패턴 이 더 안전해요. 일부 보조 기술이 암시적 매핑을 못 받는 경우가 있고, 반대로 iOS VoiceOver 는 role="alert" 와 aria-live="assertive" 를 함께 두면 두 번 발화하는 사례가 보고되거든요. 같은 의도라도 환경마다 결과가 달라서, "어떤 보조 기술도 들리게 만들고 싶다" 는 호환성 욕심이 마크업을 길게 만듭니다.
발화 안 되는 패턴들
이제 도입에서 던진 질문, "토스트는 떴는데 왜 안 들리지" 의 답이에요.
라이브 리전이 발화하는 트리거는 영역 안의 내용 변화 입니다. 핵심 단어가 "변화" 라는 거예요. 빈 영역에 텍스트가 채워지면 변화고, 기존 텍스트가 새 값으로 바뀌어도 변화예요. 그런데 처음부터 메시지가 채워진 노드를 DOM 에 새로 끼워 넣으면 그건 "삽입" 이지 "변화" 가 아닙니다. 보조 기술이 이걸 발화하지 않아요.
"The most important thing to know about the alert role is that it's for content that is dynamically displayed, not for content that appears on page load." - MDN
role="alert" 의 핵심은 페이지 로드 시점에 이미 존재하는 컨텐츠가 아니라, 나중에 동적으로 등장하는 컨텐츠에 대해서만 의미가 있다는 거예요.
저도 처음 토스트 라이브러리를 직접 짤 때 이 함정에 빠진 적이 있어요. role="alert" 만 잘 박으면 끝인 줄 알았는데, 발화가 한 번도 안 돼서 한참 헤맸거든요. 원인은 토스트가 열리는 순간 <div role="alert">메시지</div> 노드를 통째로 mount 해버린 거였어요. 시각적으로는 알림이 잘 떴는데 발화는 0회로 끝났던 거죠.
해결 방법은 단순합니다. 빈 라이브 리전을 미리 마크업에 두고, 나중에 textContent 만 채우는 거예요.
<!-- 페이지 초기 마크업: 빈 채로 미리 둠 -->
<div id="toast" role="status" aria-live="polite"></div>
<script>
// 이벤트 발생 시 텍스트만 채우기
function showToast(message) {
const region = document.getElementById('toast');
region.textContent = message;
}
</script>React 라면 토스트 컨테이너 자체는 항상 마운트돼 있고, children 만 상태에 따라 바꾸는 패턴이에요.
function ToastRegion({ message }) {
return (
<div role="status" aria-live="polite">
{message}
</div>
);
}message 가 빈 문자열에서 "저장됨" 으로 바뀌면 그게 "변화" 고, 보조 기술이 발화해요. 반대로 ToastRegion 자체를 조건부로 렌더하면 그건 다시 "삽입" 이 돼서 발화가 막힙니다. 라이브 리전 컨테이너는 펼침/접힘이 아니라 항상 살아 있어야 해요.
예외가 하나 있어요. role="alert" 는 페이지 로드 직후부터 이미 존재해도 대부분 발화돼요. 브라우저가 보조 기술에 별도의 alert 이벤트를 전송하기 때문이에요. 그래서 폼을 서버로 제출하고 페이지가 새로 그려지면서 에러 메시지가 함께 등장하는 SSR 패턴은 alert 만 박아도 동작합니다. 다만 SPA 안에서 같은 페이지를 유지한 채 alert 노드를 새로 마운트 하는 건 위 함정에 그대로 걸려요.
status 와 alert 사이에서, 그리고 보조 속성
role="status" 와 role="alert" 중 어느 쪽을 쓰느냐는 한 줄로 정리돼요. 그 알림이 사용자가 지금 하던 일을 끊을 만큼 급한가 입니다.
자동 저장됨, 검색 결과 12건 발견 같이 정보만 전달하는 안내는 status. 폼 검증 실패나 세션 만료처럼 모르고 지나가면 손해 보는 알림은 alert. 더 어렵게 생각할 필요 없어요.
흔한 실수가 두 가지예요. 첫 번째는 status 갱신할 때 포커스를 같이 옮기는 거예요. "결과가 나왔으니 결과 영역으로 포커스를 보내자" 는 의도가 좋은 코드인데, 그 순간 사용자가 검색창에서 작업 중이었다면 입력 흐름이 끊깁니다. 라이브 리전의 본분이 "포커스를 흔들지 않는다" 였던 걸 떠올리면 답이 보여요. 두 번째는 모든 알림에 alert 을 쓰는 거예요. 검색 결과가 12건 발견됐다는 사실은 사용자의 흐름을 끊을 정보가 아니에요. assertive 가 반복되면 사용자는 화면을 닫아버립니다.
남은 보조 속성 두 개도 짧게 정리할게요. aria-atomic 은 영역 안에서 일부만 바뀌었을 때 어디까지 발화할지를 정해요. 시계 영역이 17:33 에서 17:34 로 바뀐 상황을 생각해보면, aria-atomic="false" (기본값) 면 변경된 부분, 즉 "34" 만 읽고, aria-atomic="true" 면 "17:34" 전체를 다시 읽어요. 시계처럼 부분만 들으면 의미가 없는 경우엔 true 가 맞죠. role="status" 와 role="alert" 는 기본이 true 라서 따로 신경 쓸 일이 거의 없어요.
aria-relevant 는 어떤 변화를 알릴지 필터링해요. 기본값 additions text 가 "노드 추가 + 텍스트 변경" 인데, 대부분의 라이브 리전 시나리오를 이미 커버하거든요. 채팅에서 누가 퇴장했을 때 그것까지 발화하고 싶다는 식의 특수한 케이스에만 removals 를 추가해요. 평소엔 기본값만 신경 쓰면 됩니다.
알림과 모달의 경계는 가끔 흐려져요. 다이얼로그 표준에 대한 정리는 지난 글에서 다뤘는데, 두 패턴은 의도가 달라요. 라이브 리전은 사용자를 가만히 둔 채로 정보를 보내고, 모달은 사용자의 흐름을 멈추고 응답을 요구합니다. 토스트가 라이브 리전이고 다이얼로그가 모달인 건 우연이 아니에요.
라이브 리전은 결국 청각 채널의 작은 약속이에요. 빈 영역을 미리 마크업에 두고, 텍스트만 갈아 끼우는 것. 그리고 긴급도에 따라 status 와 alert 를 나누는 것. 마크업 한두 줄로 끝나는 일이고, 한 번 패턴이 손에 잡히면 토스트나 폼 검증 같은 동적 알림이 전부 같은 모양으로 정리됩니다.