“저장되었습니다"라는 초록색 토스트가 화면 오른쪽 아래에 뜹니다. 2초 뒤 사라지고요. 마우스를 쓰는 사람에게는 완결된 피드백입니다.

그런데 화면을 보지 않는 사람에게는요? 그 토스트는 일어나지 않은 일입니다. 저장이 됐는지 안 됐는지 알 방법이 없어요.

이걸 해결하라고 있는 게 라이브 리전(live region)입니다. 화면 갱신 없이 내용이 바뀌는 영역을 미리 지정해두면, 그 안의 글자가 바뀔 때 스크린 리더가 알아서 읽어줍니다. aria-live="polite" 한 줄이면 되죠.

문제는 그 한 줄을 정확히 달았는데도 아무 일도 일어나지 않는 경우가 꽤 흔하다는 겁니다. 저도 그랬고요. 이 글은 그 침묵의 원인을 하나씩 짚습니다.

속성의 기본 문법과 ARIA 전반의 원칙은 ARIA 실전 가이드에, 알림을 언제·얼마나 보낼지의 설계와 WCAG 4.1.3 기준은 aria-live 상태 메시지 편에 정리해뒀습니다. 여기서는 “달았는데 왜 안 읽히나"에 집중할게요.

가장 흔한 실패: 요소와 내용을 같이 만들었다

먼저 실패하는 코드부터 보겠습니다. 토스트 알림을 띄우는, 아주 자연스러워 보이는 구현입니다.

js
function showToast(message) {
  const box = document.createElement('div')
  box.setAttribute('aria-live', 'polite')   // 속성 제대로 달았고
  box.textContent = message                  // 내용도 넣었고
  document.body.appendChild(box)             // 화면에도 붙였는데
}
showToast('저장되었습니다')

눈으로 보면 완벽합니다. aria-live도 있고, 텍스트도 있고, DOM에도 들어갔어요. 그런데 이 코드는 읽힐 수도 있고, 조용히 무시될 수도 있습니다. 어느 쪽이 될지는 스크린 리더와 브라우저 조합이 정하고, 여러분은 미리 알 방법이 없어요. 최근 실측에서는 읽어주는 조합이 늘었지만, 침묵하는 조합이 여전히 남아 있습니다.

읽지 못하는 조합의 이유는 스크린 리더가 일하는 방식에 있습니다. 라이브 리전은 “이 영역을 지켜보다가 바뀌면 알려줘"라는 약속인데, 보조기술은 이미 알고 있는 영역만 지켜봅니다. 위 코드는 지켜볼 영역과 그 내용을 같은 순간에 만들어버려요. 감시를 시작할 틈이 없으니, 이 타이밍을 관대하게 처리해주는 구현에서만 읽힙니다.

CCTV를 설치하는 바로 그 순간 도둑이 물건을 들고 지나가는 상황이라고 할까요. 녹화가 됐을 수도 있지만, 그건 카메라 성능에 기대는 도박이지 설계가 아닙니다.

라이브 리전이 읽히는 경우와 보장이 없는 경우를 비교한 다이어그램 - 요소 생성과 내용 주입이 같은 시점에 일어나면 보조기술이 감시를 시작할 틈이 없어 조합에 따라 읽히기도 침묵하기도 하고, 빈 컨테이너가 페이지 로드 시점부터 DOM에 존재하면 보조기술이 이미 감시 중이라 어느 조합에서나 안전하게 읽힌다
라이브 리전이 읽히는 경우와 보장이 없는 경우를 비교한 다이어그램 - 요소 생성과 내용 주입이 같은 시점에 일어나면 보조기술이 감시를 시작할 틈이 없어 조합에 따라 읽히기도 침묵하기도 하고, 빈 컨테이너가 페이지 로드 시점부터 DOM에 존재하면 보조기술이 이미 감시 중이라 어느 조합에서나 안전하게 읽힌다

고치는 법: 빈 그릇을 미리 놔둔다

해법은 단순합니다. 빈 컨테이너를 처음부터 마크업에 넣어두고, 나중에 내용만 갈아끼웁니다.

html
<!-- 페이지 로드 시점부터 존재한다. 비어 있어도 괜찮다 -->
<div id="toast" aria-live="polite" class="sr-only"></div>
js
function showToast(message) {
  document.getElementById('toast').textContent = message  // 내용만 교체
}

이제 어느 조합에서나 읽힙니다. 컨테이너는 페이지가 뜰 때부터 접근성 트리(스크린 리더가 참조하는 페이지 구조)에 올라가 있고, 보조기술은 그때부터 이 영역을 지켜보고 있었으니까요.

여기서 sr-only는 눈에는 안 보이지만 스크린 리더에게는 남아 있게 하는 CSS 클래스입니다(전체 코드는 아래 침묵의 나머지 원인들 절에 있어요). 눈에 보이는 초록색 토스트 UI는 지금처럼 따로 그리시고, 낭독은 이 숨은 영역이 병행 채널로 맡는 구조입니다. 물론 보이는 토스트 자체를 라이브 리전으로 만들어도 됩니다 — 핵심은 컨테이너가 미리 있어야 한다는 것 하나뿐이에요.

프레임워크를 쓴다면 이 지점을 특히 조심해야 합니다. React나 Vue에서 알림 컴포넌트를 조건부 렌더링({isOpen && <Toast/>})으로 만들면, 열릴 때 요소와 내용이 함께 생성돼 정확히 같은 함정에 빠집니다. 컨테이너는 항상 렌더하고 안의 텍스트만 비웠다 채우는 구조로 가야 해요.

한 가지 덧붙이면, 이건 스크린 리더의 결함이 아닙니다. 윈도우용 오픈소스 스크린 리더인 NVDA의 저장소에는 동적으로 주입한 role="status"가 안 읽힌다는 이슈(#14591)가 올라온 적이 있는데, 메인테이너는 “명세는 업데이트를 읽으라고 했지 초기값을 읽으라고 하지 않았다"며 계획 없음으로 닫았습니다. 흥미롭게도 같은 스레드에는 VoiceOver는 읽더라는 기록이 나란히 있어요. 고쳐지길 기다리거나 특정 제품의 관대함에 기댈 일이 아니라, 우리가 마크업을 조합 무관하게 안전한 쪽으로 짜야 한다는 뜻입니다.


polite와 assertive, 그리고 role

원인 하나를 걷어냈으니 이제 무엇을 쓸지 골라야 합니다. 선택지가 여럿으로 보이는데, 요소의 역할을 보조기술에 알려주는 role 속성 중 몇 개가 라이브 동작을 내장하고 있어서, 결국 두 동작(polite/assertive)에 성질을 얹은 변형들입니다.

데이터 표
지정 방식동작쓰는 곳
aria-live="polite"읽던 걸 끝내고 나서 알림대부분의 상황
aria-live="assertive"읽던 걸 끊고 즉시 알림정말 급한 것만
role="status"polite와 동일(+ 영역 전체 읽기)저장 완료, 검색 결과 개수
role="alert"assertive와 동일(+ 영역 전체 읽기)세션 만료, 결제 실패
role="log"polite, 끝에 덧붙는 순서 있는 콘텐츠용채팅, 실시간 로그

role="status"aria-live="polite"를, role="alert"aria-live="assertive"를 암묵값으로 갖습니다. 그러니 중복 지정이 필수는 아니에요. 다만 MDNrole="status"에는 호환성을 최대로 높이려면 aria-live="polite"를 겹쳐 달라고 권합니다. 반대로 role="alert"assertive를 겹치면 iOS VoiceOver에서 같은 알림이 두 번 읽히는 문제가 보고돼 있어, alert은 단독으로 두는 편이 낫습니다.

html
<div role="status" aria-live="polite"></div>   <!-- 필수는 아니지만 호환성 보험 -->
<div role="alert"></div>                       <!-- alert은 단독 — 겹치면 iOS VoiceOver가 두 번 읽는다 -->

기본값은 polite로 두세요. assertive는 사용자가 읽고 있던 문장을 끊습니다. 문서를 정독하는 중에 “장바구니에 담겼습니다"가 끼어드는 경험을 상상해보면, 왜 아껴 써야 하는지 바로 와닿을 겁니다. 세션이 곧 만료된다거나 결제가 실패했다거나, 정말 지금 알려야 하는 것에만 쓰는 게 맞습니다.

aria-atomic은 언제 필요한가

aria-atomic="true"는 “바뀐 부분만 읽지 말고 영역 전체를 읽어라"입니다. 숫자만 바뀌는 영역에서 특히 필요해요.

html
<div aria-live="polite" aria-atomic="true">
  검색 결과 <span id="count">23</span></div>
js
document.getElementById('count').textContent = 3  // 숫자만 갱신해도

aria-atomic이 없으면 숫자가 3으로 바뀔 때 스크린 리더가 그냥 “3"이라고만 읽을 수 있습니다. 뭐가 3이라는 건지 알 수 없죠. 전체를 읽게 하면 “검색 결과 3건"이 나옵니다. 참고로 role="status"role="alert"은 이 성질(atomic)까지 암묵값으로 갖고 있어서 따로 달 필요가 없고, 위처럼 aria-live만 붙인 영역은 기본이 부분 읽기라 명시해야 합니다.

반대로 형제 속성인 aria-relevant쓰지 마시길 권합니다. 어떤 종류의 변경(추가·삭제·텍스트)에 반응할지 고르는 속성인데, 브라우저와 스크린 리더 조합마다 지원이 제각각이라는 평가가 오랫동안 이어져 왔습니다. 스펙에 있다고 다 쓸 수 있는 건 아니더라고요.


스크린 리더마다 다르게 읽습니다

라이브 리전은 스펙은 안정적인데 구현이 제각각입니다.

role="alert" 하나만 봐도 이렇게 갈립니다. Adrian Roselli의 2026년 1월 실측에서는 NVDA만 메시지 앞에 “alert"을 붙여 읽었고, JAWS(윈도우·유료)·VoiceOver(맥·아이폰 내장)·TalkBack(안드로이드)·내레이터(윈도우 내장)는 접두어 없이 문장만 읽었으며, Orca(리눅스)는 파이어폭스 조합에서 role="alert"을 아예 읽지 않았습니다. 그런데 TetraLogical의 2024년 테스트에는 JAWS도 “Alert"을 붙였다는 반대 기록이 있어요. 같은 제품인데도 버전·브라우저·설정에 따라 실측 결과가 갈리는 겁니다.

VoiceOver는 자기 안에서도 엇갈립니다. 맥에서 “모두 읽기”(Read All) 기능으로 문서를 훑는 동안에는 polite 알림을 건너뛰는 것이 Roselli의 실측에 기록돼 있고요.

이 얘기를 왜 하냐면, “내 NVDA에서 잘 되니까 됐다"가 성립하지 않기 때문입니다. 그렇다고 모든 조합을 다 맞출 수도 없고요. 현실적인 선은 이 정도라고 봅니다.

  • 메시지 자체에 필요한 정보를 다 담기. “alert"이라고 안 읽어주는 환경이 있으니, 급한 내용이면 문장 안에 “오류:“처럼 성격을 적어둡니다.
  • 알림 하나가 못 읽혀도 작업이 막히지 않게 설계하기. 라이브 리전은 보조 채널이지 유일한 채널이 아니어야 합니다.
  • 최소한 무료로 지금 바로 켤 수 있는 조합(윈도우 NVDA+크롬, 맥 VoiceOver)에서 직접 들어보기. 국내 사용자를 겨냥한 서비스라면 국내 시각장애인 사용자가 많이 쓰는 센스리더까지 확인하면 더 좋습니다.

마지막 항목이 제일 중요한데 제일 안 지켜집니다. 저도 자동 검사만 돌리고 넘어간 적이 많거든요. axe 같은 자동 검사aria-live 값이 유효한지는 봐주지만, 그게 실제로 읽히는지는 못 봅니다. 이건 사람이 귀로 확인하는 수밖에 없어요.


침묵의 나머지 원인들

DOM 타이밍 말고도 몇 가지가 더 있습니다. 겪어보면 다 한 번씩 당하는 것들입니다.

display: none으로 숨긴 영역은 영원히 침묵합니다

접근성 트리에서 아예 빠지기 때문에, 나중에 보이게 만들어도 그 사이의 변경은 읽히지 않습니다. visibility: hidden이나 aria-hidden="true"도 마찬가지고요. 시각적으로만 감추고 싶다면 화면 밖으로 밀어내는 방식을 써야 합니다.

css
/* 눈에는 안 보이지만 접근성 트리에는 살아 있는 상태 */
.sr-only {
  position: absolute;
  width: 1px; height: 1px;
  margin: -1px; padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

한 메시지를 나눠 쓰면 여러 번 읽힐 수 있습니다

아래처럼 DOM을 세 번 건드리면 스크린 리더가 그때마다 반응해 알림이 여러 번 울리거나 쪼개질 수 있습니다.

js
// 나쁨: 세 번의 변경 → 알림이 여러 번 울리거나 쪼개질 수 있다
box.textContent = ''
box.appendChild(icon)
box.appendChild(text)

// 좋음: 한 번에 완성해서 한 번만 쓴다
box.textContent = '저장되었습니다'

덩어리 갱신이 불가피하다면 갱신 동안 aria-busy="true"로 감시를 잠시 멈추게 하는 방법도 명세에 있습니다. 다만 이 역시 지원이 고르지 않아, 한 번에 쓰는 쪽이 최선이에요.

같은 문장을 연속으로 쓰면 두 번째는 무시될 수 있습니다

내용이 바뀌지 않았으니 변경으로 안 치는 거죠. 같은 오류가 반복될 때 두 번째 알림이 조용하다면 이걸 의심해보세요. 그리고 장바구니 개수처럼 연속으로 바뀌는 값을 언제·몇 번 알릴지는 별개의 설계 문제인데, aria-live 상태 메시지 편에서 디바운스 패턴과 데모로 다뤘습니다.


라이브 리전으로 풀 문제가 아닌 것

마지막이 실은 제일 중요한 이야기입니다. 라이브 리전을 잘 쓰는 것보다 안 쓸 자리를 아는 것이 먼저거든요.

라이브 리전은 지나가도 되는 소식을 전하는 도구입니다. 저장 완료, 결과 개수, 자동 저장 시각 같은 것들요. 사용자가 지금 하던 일을 계속해도 아무 문제 없는 정보 말입니다.

반대로 사용자가 반응해야만 하는 상황이라면 알림으로는 부족합니다. 폼을 제출했는데 이메일 형식이 틀렸다고 해봅시다. “이메일 형식이 올바르지 않습니다"를 라이브 리전으로 읽어주는 건 친절하지만, 그 사용자는 이제 그 입력칸을 직접 찾아가야 합니다. 스크린 리더로 폼을 거슬러 올라가는 건 생각보다 번거로운 일이에요.

이럴 땐 포커스를 옮겨야 합니다. 오류가 하나면 그 입력칸으로, 여러 개면 오류 요약 블록으로요.

html
<!-- div는 tabindex="-1"이 있어야 focus()가 동작한다 -->
<div id="error-summary" tabindex="-1" role="alert">
  입력 내용을 확인해주세요 — 오류 2건
</div>
js
if (errors.length) {   // errors: 폼 검증에서 모은 오류 배열
  document.getElementById('error-summary').focus()   // 알리는 데서 끝내지 않고, 갈 곳까지 데려다준다
}

경계는 이렇게 잡으면 깔끔합니다. 알기만 하면 되는 것은 라이브 리전, 가야 하는 곳이 있는 것은 포커스. 폼 오류 처리의 자세한 패턴은 폼 접근성 편에, 포커스를 옮길 때의 원칙은 키보드 접근성 편에 정리해뒀습니다.


한 장 요약

  • 라이브 리전은 빈 컨테이너를 미리 마크업에 넣어두고 내용만 교체해야 어느 조합에서나 읽힌다. 요소와 내용을 동시에 만들면 읽어주는 조합도 있지만 보장이 없다
  • 기본은 polite, 정말 급한 것만 assertive. 중복 지정은 필수가 아니며, role="status"에만 aria-live="polite"를 호환성 보험으로 겹칠 만하다(role="alert"은 단독)
  • 숫자만 바뀌는 영역은 aria-atomic="true"로 문맥까지 읽히게(role="status"·alert에는 내장). aria-relevant는 지원이 불안정하니 의존하지 말 것
  • 스크린 리더마다 동작이 갈리고, 실측 자료끼리도 엇갈린다(같은 role="alert" 접두어도 조사마다 기록이 다르다). 메시지 자체에 성격을 담고, 하나쯤 못 읽혀도 작업이 막히지 않게 설계한다
  • display:none·aria-hidden으로 감춘 영역은 영원히 안 읽힌다. 감추려면 화면 밖으로 밀어내는 CSS(sr-only)를 쓴다
  • 알기만 하면 되는 것은 라이브 리전, 가야 할 곳이 있는 것은 포커스 이동. 폼 오류는 대개 후자다

질문으로 다시 보기

aria-live를 달았는데 스크린 리더가 안 읽어요. 가장 흔한 원인이 뭔가요?
요소를 만들면서 내용까지 채워 한 번에 DOM에 넣는 코드가 가장 흔한 원인입니다. 보조기술은 이미 알고 있는 영역만 감시하기 때문에, 빈 라이브 리전 컨테이너를 페이지 로드 때부터 두고 나중에 텍스트만 교체해야 어느 조합에서나 읽힙니다. React 등에서 알림 컴포넌트를 조건부 렌더링으로 만들면 같은 함정에 빠집니다.
role='alert'에 aria-live='assertive'를 겹쳐 써야 하나요?
아니요, alert은 단독으로 두세요. role=‘alert’에는 assertive가 암묵값으로 내장돼 있고, 겹쳐 쓰면 iOS VoiceOver에서 같은 알림이 두 번 읽히는 문제가 보고돼 있습니다. 반면 role=‘status’에는 MDN이 호환성을 위해 aria-live=‘polite’를 겹쳐 달기를 권합니다.
display:none으로 숨긴 라이브 리전은 왜 안 읽히나요?
display:none, visibility:hidden, aria-hidden=‘true’로 숨긴 요소는 접근성 트리에서 빠져서 그 안의 변경이 보조기술에 전달되지 않습니다. 시각적으로만 숨기려면 position:absolute로 화면 밖에 두는 sr-only 방식의 CSS를 써야 스크린 리더에 살아 있습니다.
aria-relevant 속성은 써도 되나요?
권하지 않습니다. 어떤 종류의 변경(추가·삭제·텍스트)에 반응할지 고르는 속성이지만, 브라우저와 스크린 리더 조합마다 지원이 제각각이라는 평가가 오랫동안 이어져 왔습니다. 기본 동작에 맡기고, 영역 전체를 다시 읽어야 하면 aria-atomic=‘true’를 쓰는 편이 안전합니다.
폼 오류도 라이브 리전으로 알리면 되나요?
알리는 것만으로는 부족합니다. 사용자가 반응해야 하는 오류라면 tabindex=’-1’을 준 오류 요약 블록이나 해당 입력칸으로 포커스를 옮겨서 갈 곳까지 데려다줘야 합니다. 알기만 하면 되는 소식은 라이브 리전, 가야 할 곳이 있는 상황은 포커스 이동이 기준입니다.

조용한 실패를 줄이는 일

라이브 리전의 골치 아픈 점은 실패가 조용하다는 겁니다. 콘솔에 빨간 줄이 뜨지도 않고, 자동 검사도 통과합니다. 코드는 멀쩡해 보이는데 사용자에게만 아무것도 도착하지 않아요.

그래서 이 영역은 결국 한 번 켜서 들어보는 수밖에 없습니다. NVDA를 설치하고 자기 사이트의 저장 버튼을 눌러보는 데 5분이면 충분하거든요. 그 5분이 “저장이 된 건가?” 하고 같은 버튼을 세 번 누르는 사용자를 없애줍니다.

오늘 만든 토스트, 한 번 들어보시는 건 어떨까요. 아직 직접 만든 게 없다면 상태 메시지 데모 페이지의 알림들을 켜고 들어보셔도 좋고요.

참고 자료