ARIA 접근성 가이드

적용 방법

동적 콘텐츠와 SPA

페이지를 새로 불러오지 않고 화면을 바꾸는 SPA에서 페이지 제목, 포커스, 경로 변경 알림을 관리하는 방법과 무한 스크롤, 지연 로딩, 모달·토스트, 컴포넌트 라이브러리 사용 시 주의할 점을 정리합니다.

SPA에서 생기는 문제

전통적인 웹 페이지는 링크를 누르면 브라우저가 새 문서를 불러옵니다. 이때 브라우저와 보조기술이 알아서 처리하는 일이 있습니다.

  • 화면낭독기가 새 페이지의 제목을 읽고, 문서 처음부터 읽기를 시작합니다.
  • 포커스가 문서의 시작으로 돌아가 다음 Tab이 건너뛰기 링크부터 시작합니다.
  • 브라우저 탭과 방문 기록에 새 제목이 남습니다.

SPA(Single Page Application)는 JavaScript로 화면 일부만 바꾸므로 이 일이 하나도 일어나지 않습니다. 화면은 완전히 바뀌었는데 화면낭독기는 아무 말도 하지 않고, 포커스는 사라진 링크 위치에 남아 있거나 <body>로 떨어집니다. 사용자는 링크가 동작했는지조차 알 수 없습니다. 따라서 SPA는 브라우저가 해 주던 일을 직접 구현해야 합니다.

경로가 바뀔 때 페이지 제목 갱신

화면이 바뀔 때마다 document.title을 새 화면에 맞게 바꿉니다. 제목은 WCAG 2.4.2 페이지 제목의 대상이며, 다음 단계의 경로 변경 알림에도 그대로 쓸 수 있습니다. 작성 방법은 랜드마크와 제목 구조의 페이지 제목 부분을 참고하세요.

JavaScript
// 라우터의 경로 변경 이후 실행
function onRouteChange(route) {
  document.title = route.title + ' - 쇼핑몰';   // 예: "장바구니 - 쇼핑몰"
  moveFocusToHeading();
  announce(route.title + ' 페이지');
}
프레임워크 예시
// React: 화면 컴포넌트에서 제목 지정
useEffect(() => {
  document.title = '장바구니 - 쇼핑몰';
}, []);

// React 19 이상은 컴포넌트 안에 <title>을 렌더링하면 <head>로 옮겨 줍니다.
// Vue Router: 전역 afterEach 훅에서 처리
router.afterEach((to) => {
  document.title = (to.meta.title || '쇼핑몰') + ' - 쇼핑몰';
});

포커스 관리

경로가 바뀐 뒤 포커스를 어디에 둘지는 SPA 접근성에서 가장 중요한 결정입니다. 일반적으로 권장되는 방법은 새 화면의 주 제목(<h1>)이나 본문 영역(<main>)으로 포커스를 옮기는 것입니다. 그러면 화면낭독기가 제목을 읽어 화면이 바뀌었음을 알리고, 다음 Tab이 새 콘텐츠에서 이어집니다.

HTML
<main id="content">
  <h1 id="page-h" tabindex="-1">장바구니</h1>
  ...
</main>
JavaScript
function moveFocusToHeading() {
  var h = document.getElementById('page-h');
  if (h) h.focus();   // tabindex="-1"이라 Tab 순서에는 들어가지 않고 프로그램으로만 포커스 가능
}
  • 제목에는 tabindex="-1"을 줍니다. 0을 주면 Tab 순서에 제목이 끼어듭니다(키보드와 포커스 참고).
  • 포커스 표시가 제목에 그려지는 것이 어색하면 h1:focus:not(:focus-visible) { outline: none; }처럼 키보드 포커스가 아닌 경우만 감출 수 있습니다.
  • 같은 화면 안의 변화(필터 적용, 탭 전환, 정렬)에서는 포커스를 옮기지 않습니다. 사용자가 조작한 컨트롤에 포커스를 두고, 결과는 라이브 리전으로 알립니다.
  • 뒤로 가기로 돌아왔을 때는 이전 화면에서 누른 링크나 목록 위치로 포커스와 스크롤을 복원하면 좋습니다.
  • 포커스를 둔 요소가 화면에서 사라지면(삭제된 항목, 닫힌 패널) 포커스가 <body>로 떨어집니다. 다음 항목이나 관련된 버튼 등 논리적인 위치로 옮겨야 합니다(2.4.3 포커스 순서).

경로 변경 알림

포커스를 옮기는 것만으로 충분하지 않거나, 디자인상 포커스를 옮기기 어려운 경우에는 경로 변경 알림(route announcer)을 함께 씁니다. 페이지가 처음 로드될 때 비어 있는 라이브 리전을 만들어 두고, 경로가 바뀌면 새 페이지 이름을 넣습니다.

HTML
<!-- 앱의 최상위 레이아웃에 한 번만, 미리 만들어 둠 -->
<div id="route-announcer" class="sr-only" aria-live="polite" aria-atomic="true"></div>
JavaScript
function announce(msg) {
  var el = document.getElementById('route-announcer');
  el.textContent = '';                          // 같은 문구가 반복돼도 다시 읽히도록 비운 뒤
  setTimeout(function () { el.textContent = msg; }, 50);
}

프레임워크의 기본 지원

Next.js는 경로가 바뀌면 페이지 제목(없으면 <h1> 등)을 읽어 주는 경로 변경 알림을 기본으로 제공하고, Nuxt는 <NuxtRouteAnnouncer> 컴포넌트를 제공합니다. React Router, Vue Router 자체에는 이런 기능이 없으므로 직접 구현합니다. 어느 쪽이든 포커스 위치는 여전히 직접 관리해야 하는 경우가 많으니 실제 화면낭독기로 확인하세요.

아래 데모는 페이지 이동을 흉내 낸 것입니다. 포커스 이동과 알림을 켜고 끄면서 차이를 비교해 보세요. 화면낭독기를 켜고 해 보면 효과가 분명하게 드러납니다.

홈

추천 상품을 둘러보세요.

document.title(예시): 홈 - 쇼핑몰

화면낭독기 예상 안내: (링크를 눌러 보세요)

무한 스크롤과 “더보기”

스크롤이 끝에 닿을 때마다 항목을 자동으로 붙이는 무한 스크롤은 여러 문제를 일으킵니다.

  • 키보드 사용자는 바닥글의 링크에 도달할 수 없습니다. Tab으로 끝에 다가갈 때마다 새 항목이 붙어 바닥글이 계속 밀려납니다.
  • 새 항목이 붙었다는 사실이 화면낭독기에 전달되지 않고, 전체 개수와 현재 위치를 알 수 없습니다.
  • 다른 페이지에 갔다가 돌아오면 읽던 위치가 사라지는 경우가 많습니다.

가능하면 “더보기” 버튼이나 페이지 번호를 씁니다. 버튼을 누른 뒤에는 포커스를 버튼에 그대로 두거나 새로 추가된 첫 항목으로 옮기고, 추가된 개수를 알립니다.

비권장스크롤 이벤트만으로 자동 추가
<ul id="list">...</ul>
<script>
  window.addEventListener('scroll', loadMoreIfNearBottom);
</script>
권장사용자가 요청하고 결과를 안내
<ul id="list">...</ul>
<button type="button" id="more">상품 더보기 (20 / 135)</button>
<div role="status" class="sr-only" id="more-status"></div>
<script>
  more.addEventListener('click', async function () {
    var first = await appendItems();       // 새로 붙인 첫 항목 반환
    first.querySelector('a').focus();     // 새 항목으로 포커스 이동
    document.getElementById('more-status').textContent = '상품 20개를 더 불러왔습니다.';
  });
</script>

무한 스크롤을 꼭 써야 한다면 feed 역할을 검토합니다. feed 안의 각 항목은 article이고 aria-posinset·aria-setsize로 위치를 알리며, 로딩 중에는 feed에 aria-busy="true"를 줍니다. APG는 Page Down/Page Up으로 항목 사이를 이동하는 키보드 동작을 정의합니다. 바닥글이 꼭 필요하다면 무한 스크롤 영역 바깥(예: 사이드바)에도 같은 링크를 둡니다.

지연 로딩과 로딩 상태

데이터를 불러오는 동안의 상태도 사용자에게 전달되어야 합니다.

  • 로딩 시작과 끝을 알립니다. 오래 걸리는 작업은 “불러오는 중” 같은 상태 메시지를 status로 알리고, 끝나면 결과(“검색 결과 12건”)로 바꿉니다(4.1.3 상태 메시지). 요청마다 알리면 시끄러우므로 짧은 로딩은 결과만 알립니다.
  • aria-busy는 영역이 갱신 중임을 나타냅니다. 여러 번에 나누어 내용을 바꾸는 라이브 리전에 aria-busy="true"를 주고 끝나면 false로 바꾸면, 지원하는 보조기술은 완료 후 한 번에 안내합니다. 지원 수준이 고르지 않으므로 이것만으로 상태를 알린다고 가정하지 않습니다.
  • 스켈레톤 UI(회색 자리 표시자)는 시각적인 장식이므로 aria-hidden="true"로 숨기고, 대신 텍스트 상태 메시지를 둡니다.
  • 스피너 이미지는 role="img"와 이름(“불러오는 중”)을 주거나, 숨기고 상태 메시지로 대신합니다. 진행률을 알 수 있으면 progressbar를 씁니다.
  • 이미지 지연 로딩(loading="lazy")은 접근성에 영향이 거의 없습니다. 다만 대체 텍스트(alt)는 처음부터 마크업에 있어야 합니다.
  • 로딩이 끝난 뒤 내용이 갑자기 밀려 내려가 포커스나 클릭 대상이 이동하지 않도록 자리를 미리 잡습니다.
검색 결과 갱신과 건수 안내
<section aria-labelledby="res-h">
  <h2 id="res-h">검색 결과</h2>
  <div role="status" class="sr-only" id="res-status"></div>
  <ul id="results" aria-busy="false">...</ul>
</section>
<script>
  async function search(q) {
    results.setAttribute('aria-busy', 'true');
    var items = await fetchResults(q);
    render(items);
    results.setAttribute('aria-busy', 'false');
    document.getElementById('res-status').textContent = '검색 결과 ' + items.length + '건';
  }
</script>

모달과 토스트

동적으로 띄우는 대표적인 UI인 모달 대화상자와 토스트는 포커스 처리가 정반대입니다.

모달 대화상자와 토스트 비교
구분모달 대화상자토스트(짧은 알림)
역할dialog, 확인이 필요한 경고는 alertdialogstatus(일반), 긴급한 오류만 alert
포커스열 때 대화상자 안으로 옮기고, 닫으면 연 버튼으로 돌려놓음옮기지 않음. 사용자의 작업을 방해하지 않음
배경배경을 inert 처리해 조작 불가배경 조작 가능
닫기Esc 키와 닫기 버튼자동으로 사라지거나 닫기 버튼

모달은 HTML <dialog> 요소의 showModal()을 쓰면 배경 비활성화, Esc 닫기, 대화상자 안으로의 포커스 이동을 브라우저가 처리합니다. 자세한 구현은 대화상자 패턴을 참고하세요.

토스트는 다음 사항에 주의합니다. 알림 영역 구현 방법은 알림 패턴과 라이브 리전을 참고하세요.

  • 토스트 컨테이너(라이브 리전)는 미리 만들어 두고 그 안에 메시지만 추가합니다. 토스트가 뜰 때 리전을 새로 만들면 읽히지 않을 수 있습니다.
  • 너무 빨리 사라지면 읽기 어려운 사용자가 내용을 놓칩니다. 충분히 오래 보여 주고, 마우스를 올리거나 포커스가 있을 때는 사라지지 않게 합니다(2.2.1 시간 조절).
  • “실행 취소” 같은 버튼을 토스트에 넣지 않는 편이 안전합니다. 포커스가 가지 않는 곳에서 곧 사라지는 버튼은 키보드·화면낭독기 사용자가 누르기 어렵습니다. 넣는다면 같은 기능을 다른 곳에서도 제공하거나, 사라지지 않게 합니다.

컴포넌트 라이브러리를 쓸 때 주의할 점

React·Vue용 UI 라이브러리(MUI, Radix, Headless UI, Vuetify 등) 다수가 APG 패턴을 따라 ARIA를 구현해 두었습니다. 하지만 “접근성 지원”이라고 적혀 있어도 다음을 직접 확인해야 합니다.

  • 이름은 대부분 개발자 몫입니다. 아이콘 버튼, 대화상자, 입력 필드의 레이블 prop(aria-label, label, title 등)을 빠뜨리면 라이브러리도 이름을 만들 수 없습니다.
  • 래핑하면서 속성을 잃지 않는지 확인합니다. 자체 컴포넌트로 감쌀 때 aria-* 속성, id, ref를 안쪽 실제 요소까지 전달해야 합니다.
  • 클릭 가능한 div를 만드는 컴포넌트(카드, 리스트 아이템 onClick)는 버튼이나 링크로 렌더링되도록 옵션(as="button", component="a" 등)을 지정합니다.
  • 포털(portal)로 <body> 끝에 렌더링되는 팝업은 DOM 순서가 화면 위치와 달라집니다. 열 때 포커스를 옮기고 닫을 때 돌려놓는지 확인합니다.
  • ID 충돌: 같은 컴포넌트를 여러 번 쓰면 하드코딩된 ID가 겹쳐 aria-labelledby·aria-controls 연결이 깨집니다. React의 useId(), Vue 3.5 이상의 useId()처럼 고유 ID를 만드는 기능을 씁니다.
  • 상태 속성이 실제 상태와 연결되어 있는지 확인합니다(상태 동기화).
  • 라이브러리 버전을 올린 뒤에는 키보드와 화면낭독기로 다시 확인합니다. 검수 방법은 테스트 도구와 점검 체크리스트를 참고하세요.
React: 이름을 필수로 받는 아이콘 버튼 컴포넌트
function IconButton({ label, icon, ...rest }) {
  // label을 필수로 받아 aria-label로 전달하고, 나머지 aria-* 속성도 그대로 넘김
  return (
    <button type="button" aria-label={label} {...rest}>
      <Icon name={icon} aria-hidden="true" />
    </button>
  );
}