모바일 앱에서 스크린 리더 사용자에게 영역 정보 제공해 주기 1부: 안드로이드 View system
모바일 앱에서 스크린 리더 사용자에게 영역 정보 제공해 주기 1부: 안드로이드 View system
안녕하세요, 엔비전스입니다.
스크린 리더 사용자가 웹이나 앱의 다양한 화면을 탐색할 때 겪는 가장 큰 어려움 중 하나는 현재 어떤 영역에 있는지와 특정 지점으로 빠르게 갈 수 없다는 것입니다.
과거 PC 웹 시대에서 모바일로 넘어오던 초기, 저는 작은 화면의 모바일 앱이 PC 웹보다 단순한 구조를 가질 것이라 기대했습니다. 하지만 기술이 발전하면서 모바일 앱 역시 수많은 콘텐츠와 스크롤 기능을 포함한 복잡한 다중 영역 구조를 가지게 되었습니다.
시각적으로 화면을 보는 사용자는 디자인, 레이아웃 등 다양한 시각적 단서를 통해 각 영역의 경계와 내용을 직관적으로 파악하고, 빠른 스크롤로 원하는 정보를 쉽게 탐색할 수 있습니다.
반면, 스크린 리더 사용자는 화면의 각 요소를 하나씩 순차적으로 음성 출력에 의존해 탐색합니다. 이 때문에 여러 요소를 듣다 보면 현재 읽고 있는 정보가 어디까지 하나의 그룹인지 경계를 알기 어려울 수 있습니다. 또한, 다른 영역으로 이동하기 위해 얼마나 스크롤해야 할지 예측할 수 없어, 조금씩 스크롤하고 화면을 다시 터치하는 비효율적인 탐색을 반복하게 됩니다. 접근성이 제대로 구현되지 않은 앱에서는 이러한 불편함이 가중되어, 결국 앱 기획자가 의도한 대로 콘텐츠를 이해하고 탐색하는 것 자체가 불가능해지는 상황에 이릅니다.
웹 접근성의 핵심 '헤딩'과 모바일 앱의 한계
웹 접근성에서 '헤딩(Heading)'은 매우 중요한 역할을 합니다. 명확한 헤딩은 페이지의 구조를 알려주는 이정표가 되어, 사용자가 키보드나 제스처를 통해 헤딩 단위로 건너뛰며 원하는 콘텐츠 영역으로 빠르게 이동하고 전체 맥락을 예측할 수 있게 돕습니다.
최신 모바일 앱 역시 iOS(VoiceOver)와 Android(TalkBack) 모두 텍스트를 '섹션 제목'으로 지정하여 웹의 헤딩처럼 탐색할 수 있는 API를 지원합니다.
하지만 모바일 앱은 작은 화면 공간을 효율적으로 사용하기 위해 디자인상 명시적인 제목을 생략하는 경우가 많습니다. 예를 들어, '시간별 일기 예보'와 '10일간 일기 예보' 영역이 시각적으로는 명확히 구분되더라도, 각 영역에 제목이 없으면 스크린 리더 사용자는 시간별 예보를 듣다가 갑자기 날짜별 예보로 넘어가는 흐름을 이해하기 어렵습니다. 반복적인 경험을 통해 다른 영역임을 추론할 수는 있겠지만, 이는 콘텐츠를 이해하는 데 명백한 접근성 장벽으로 작용합니다.
해결책: 접근성 컨테이너 (Accessibility Container)
이러한 문제를 해결하기 위해 iOS는 오래전부터, Android는 비교적 최근에 '접근성 컨테이너(Accessibility Container)'라는 메커니즘을 도입했습니다.
접근성 컨테이너는 화면에 보이는 제목이 없더라도, 개발자가 의미 있는 콘텐츠 그룹을 논리적인 영역으로 묶어 스크린 리더에 정보를 제공하는 기능입니다. 마치 웹에서 도입된 랜드마크와 비슷한 개념이라고 보면 됩니다.
접근성 컨테이너를 잘 구현하면 다음과 같은 장점이 있습니다.
- 컨텍스트 정보 제공: 스크린 리더가 컨테이너에 처음 진입했을 때, 해당 영역의 정보를 음성으로 알려줍니다. 예를 들어, '시간별 날씨 예보' 컨테이너에 포커스가 들어가면, 현재 포커스된 요소의 정보와 함께 "시간별 날씨 예보"라는 영역 정보를 읽어주어 사용자가 맥락을 즉시 파악할 수 있습니다. 이 영역 정보는 화면에 시각적으로 보이지 않고 스크린 리더 사용자에게만 전달됩니다.
- 지능적인 정보 필터링: 영역 정보는 컨테이너에 진입할 때 한 번만 안내되며, 컨테이너 내에서 다른 요소로 포커스를 이동할 때는 반복해서 읽어주지 않습니다. 이는 불필요한 정보의 과잉 출력을 막아 사용자가 콘텐츠에 집중할 수 있도록 돕는 스크린 리더의 기능입니다.
- 비순차적 탐색 지원: 헤딩은 순차적으로 탐색해야만 그 내용을 알 수 있지만, 컨테이너는 사용자가 화면의 특정 부분을 임의로 터치했을 때도 작동합니다. 예를 들어 '10일간의 일기 예보' 컨테이너의 중간 요소를 터치하더라도, 해당 컨테이너에 처음 진입한 것이라면 영역 정보를 알려주어 길을 잃지 않게 도와줍니다.
- 효율적인 탐색 기능: 헤딩 탐색과 마찬가지로 컨테이너 단위로 이동하는 기능도 지원합니다. 사용자는 로터(Rotor)나 탐색 설정을 '컨테이너'로 맞추고 스와이프하여 각 영역을 빠르게 건너뛰며 탐색할 수 있습니다.
- 초점 순서 재조정: 특정 그룹(레이아웃) 사이에서 접근성 초점 순서가 화면상의 시각적 순서와 다르게 움직이는 경우, 접근성 컨테이너를 활용하여 초점 순서를 재조정할 수 있습니다. 안드로이드 뷰 시스템의 경우, 기존에는 Accessibility Traversal Before/After 속성을 통해 개별 '요소' 단위의 초점 순서만 변경할 수 있었지만, 레이아웃 같은 '그룹' 단위의 순서는 변경할 방법이 없었습니다. 하지만 각 레이아웃을 별개의 컨테이너로 지정하면, 컨테이너를 기준으로 초점 순서를 제어하여 그룹 단위의 어긋난 순서를 바로잡을 수 있습니다. 웹과 달리 앱의 초점 순서는 코드 순서가 아닌 중첩된 레이아웃 구조의 영향을 많이 받으므로, 컨테이너는 초점 순서를 논리적으로 재구성하는 데 큰 도움을 줍니다.
위에서 잠깐 살펴본 바와 같이 스크린 리더를 위한 화면 영역 설정은 사용자가 화면을 더 잘 이해하고, 효과적으로 탐색할 수 있는 중요한 수단입니다. 따라서 이번 아티클과 이어질 두 번째 아티클에서는 각 모바일 플랫폼에서 지원하는 컨테이너 접근성의 개념을 살펴보고, API의 특성과 실제 활용 사례를 통해 올바른 구현 방법을 심도 있게 다루고자 합니다.
이번 시리즈는 총 3회에 거쳐 연재될 예정입니다.
- 1부: Android 접근성 컨테이너 개요 및 View 시스템에서의 구현 방법, 그리고 네이티브에서 기본적으로 지원하는 컨테이너 요소를 살펴봅니다.
- 2부: Jetpack Compose 환경에서 컨테이너 접근성을 구현하는 방법을 알아봅니다.
- 3부: iOS의 UIKit과 SwiftUI에서 컨테이너 접근성을 구현하는 방법을 다룹니다.
안드로이드에서 컨테이너 정보 관련 톡백 설정 살펴보기
iOS의 보이스오버와 달리 안드로이드 톡백에서는 사용자가 컨테이너 정보를 들을지 여부를 직접 설정할 수 있습니다.
참고로 안드로이드의 접근성 컨테이너는 비교적 최신 기능으로, 안드로이드 14 및 톡백 15 버전 이후부터 공식 지원합니다. 따라서 이 기능을 사용하려면 기기의 운영체제와 톡백이 해당 버전 이상이어야 합니다.
톡백 설정 방법은 다음과 같습니다.
- 컨테이너 정보 읽기 설정: 톡백 설정 > 상세 출력 > 컨테이너 정보 말하기 스위치를 통해 기능을 켜거나 끌 수 있습니다. 기본값은 '사용'으로 설정되어 있으며, 만약 이 설정을 끄면 개발자가 접근성 컨테이너를 구현했더라도 해당 영역에 진입 시 정보를 읽어주지 않습니다.
- 컨테이너 단위 이동 설정: 톡백 설정 > 메뉴 맞춤설정 > 읽기 제어 기능 맞춤설정으로 이동합니다. 이곳에서는 세 손가락 스와이프(보이스오버의 로터와 유사) 시 사용할 탐색 단위를 선택할 수 있습니다. 여러 옵션(글자, 단어, 줄 등) 중에서 '컨테이너' 항목에 체크합니다.
설정이 완료되면, 세 손가락을 좌우로 스와이프하여 탐색 단위를 '컨테이너'로 맞춘 뒤, 한 손가락으로 위아래로 스와이프하여 컨테이너 단위로 빠르게 이동할 수 있습니다.
참고로 안드로이드 톡백은 명시적 컨테이너뿐만 아니라 다음에 나열하는 네이티브 뷰 요소를 컨테이너와 동일하게 취급합니다.
- RecyclerView
- GridView
- RadioGroup
- TabLayout
이러한 요소들은 시맨틱한 그룹 요소이므로 별도의 컨테이너 구현 없이도 톡백이 컨테이너처럼 취급합니다. 따라서 사용자는 톡백에서 제공하는 컨테이너 단위 이동 기능을 이용해 이 영역을 그룹 단위로 탐색할 수 있습니다.
컨테이너 타이틀 접근성 제공: XML, 코틀린, 그리고 AccessibilityNodeInfo 활용 정리 (Android 14/API 34+)
컨테이너 타이틀의 역할과 한계
containerTitle API는 톡백이 해당 그룹을 하나의 '영역'으로 인식하게 하지만, '목록'으로 취급하지는 않습니다.
이 속성은 하단에 고정된 '적용'과 '취소' 버튼 그룹처럼, 목록의 성격이 아닌 단순한 영역 정보를 제공할 때 매우 유용합니다. 예를 들어, 이 속성을 사용하면 톡백은 "취소 버튼, 하단 고정 영역에 있음"과 같이 안내하여 사용자에게 위치 맥락을 알려줄 수 있습니다.
그러나 커스텀 라디오 그룹, 탭 그룹, 또는 목록화된 커스텀 리스트와 같이 목록 정보가 중요한 컴포넌트에는 이 정보만으로는 부족합니다. containerTitle만으로는 전체 아이템 개수나 현재 포커스된 요소가 몇 번째 항목인지와 같은 상세한 목록 정보를 전달할 수 없기 때문입니다.
1. XML에서 컨테이너 타이틀 지정하기
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:containerTitle="설문지 섹션">
<!-- 자식 뷰들 -->
</LinearLayout>
2. 코틀린 코드에서 컨테이너 타이틀 동적으로 지정
// linearLayout은 예를 들어 findViewById로 참조한 객체입니다.
if (Build.VERSION.SDK_INT >= 34) { // Android 14(API 34) 이상
linearLayout.containerTitle = "설문지 섹션"
}
3. AccessibilityNodeInfo에서 컨테이너 타이틀 확인 및 커스터마이즈
접근성 서비스나 커스텀 뷰에서 AccessibilityNodeInfo로 컨테이너 타이틀을 확인하거나 수정할 수 있습니다.
이 코드는 커스텀 뷰에서 직접 AccessibilityNodeInfo에 타이틀 정보를 추가하는 방식입니다.
override fun onInitializeAccessibilityNodeInfo(info: AccessibilityNodeInfo) {
super.onInitializeAccessibilityNodeInfo(info)
if (Build.VERSION.SDK_INT >= 34) {
info.containerTitle = "설문지 섹션"
}
}
요약
- XML: android:containerTitle 속성 사용.
- 코틀린: containerTitle 프로퍼티 사용.
- AccessibilityNodeInfo: 접근성 노드에서 containerTitle 값 확인 및 필요한 경우 직접 추가 가능.
- 지원 버전: 모두 Android 14(API 34) 이상에서 지원됩니다.
이렇게 하면 접근성 서비스 나아가 톡백이 해당 뷰 그룹의 영역감을 이해하고 사용자에게 알릴 수 있습니다.
컬렉션 정보를 활용한 고급 접근성 구현 (CollectionInfo)
containerTitle이 단순 영역 정보를 제공하는 데 그친다면, CollectionInfo와 CollectionItemInfo는 리스트, 그리드, 테이블과 같은 복잡한 컬렉션 구조의 접근성을 구현하는 핵심 요소입니다. 이 두 클래스를 함께 사용하면 스크린 리더는 "총 10개 중 3번째 항목"과 같이 훨씬 더 풍부하고 구조적인 정보를 사용자에게 안내할 수 있습니다.
이는 RecyclerView나 ListView처럼 반복되는 UI 요소를 가진 커스텀 뷰를 구현할 때 필수적입니다.
주요 클래스 및 사용 예시
1. AccessibilityNodeInfo.CollectionInfo
뷰 그룹이 '컬렉션'(목록, 그리드 등)임을 정의하고, 전체 행/열의 개수나 선택 모드(단일/다중 선택) 같은 구조적 정보를 담습니다. 이 정보는 컬렉션을 감싸는 부모 ViewGroup에 설정합니다.
Kotlin 예시:
// 5행 2열, 다중 선택이 가능한 그리드 구조를 정의
val collectionInfo = AccessibilityNodeInfo.CollectionInfo.obtain(
rowCount = 5,
columnCount = 2,
// SELECTION_MODE_NONE(정보성), SELECTION_MODE_SINGLE(단일 선택), SELECTION_MODE_MULTIPLE(다중 선택) 중 설정 가능합니다.
// 참고: 이 선택 모드 설정은 톡백의 음성 안내에 직접적인 영향을 주지는 않습니다.
selectionMode = AccessibilityNodeInfo.CollectionInfo.SELECTION_MODE_MULTIPLE,
isHierarchical = false
)
// 부모 View의 접근성 노드에 컬렉션 정보 설정
accessibilityNodeInfo.collectionInfo = collectionInfo
2. AccessibilityNodeInfo.CollectionItemInfo
컬렉션 내의 개별 '아이템'에 대한 상세 정보를 정의합니다. 아이템의 행/열 위치, 행/열 병합 정보(span), 선택 상태 등을 담습니다. 이 정보는 각 자식 View에 개별적으로 설정합니다.
Kotlin 예시:
// 2행 3열에 위치하며, 선택되지 않은 아이템 정보를 정의
val collectionItemInfo = AccessibilityNodeInfo.CollectionItemInfo.obtain(
rowIndex = 2, // 3번째 행 (0부터 시작)
rowSpan = 1, // 1개 행을 차지
columnIndex = 1, // 2번째 열 (0부터 시작)
columnSpan = 1, // 1개 열을 차지
heading = false, // 헤더가 아님
selected = false // 선택되지 않음
)
// 자식 View의 접근성 노드에 아이템 정보 설정
accessibilityNodeInfo.collectionItemInfo = collectionItemInfo
참고: 가독성과 확장성을 위해 CollectionInfo.Builder와 CollectionItemInfo.Builder를 사용하여 단계적으로 객체를 생성하는 방법을 권장합니다.
심화 사용 가이드 및 주의사항
- importantForAccessibility 설정: containerTitle과 달리, CollectionInfo를 사용하는 레이아웃은 importantForAccessibility="yes"로 설정해야 톡백이 이를 목록 또는 그리드 그룹으로 정확히 인식합니다. 이 설정을 누락하면 CollectionInfo가 적용되지 않을 수 있습니다.
XML 예시:
Kotlin 예시:<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:importantForAccessibility="yes"> <!-- Collection Items --> </LinearLayout>val container = findViewById<LinearLayout>(R.id.container) container.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_YES - 목록(List) vs 그리드(Grid) 인식: 톡백은 CollectionInfo의 행/열 개수에 따라 그룹의 유형을 다르게 해석합니다.
- 목록: rowCount 또는 columnCount 중 하나가 1이면(예: 1열 20행), 톡백은 이를 세로 또는 가로로 스크롤 가능한 단일 목록으로 간주합니다.
// 20개 항목을 가진 세로 목록으로 인식 val listInfo = AccessibilityNodeInfo.CollectionInfo.obtain(20, 1, false) accessibilityNodeInfo.collectionInfo = listInfo - 그리드: rowCount와 columnCount가 모두 2 이상이면, 톡백은 이를 그리드(테이블)로 인식하고 각 항목에 대해 "1행 3열"과 같이 위치 좌표를 함께 읽어줍니다.
// 5행 2열 그리드로 인식 val gridInfo = AccessibilityNodeInfo.CollectionInfo.obtain(5, 2, false) accessibilityNodeInfo.collectionInfo = gridInfo
- 목록: rowCount 또는 columnCount 중 하나가 1이면(예: 1열 20행), 톡백은 이를 세로 또는 가로로 스크롤 가능한 단일 목록으로 간주합니다.
- 그룹 제목 제공 (contentDescription): CollectionInfo가 설정된 부모 레이아웃에 contentDescription을 추가하면, 이는 containerTitle과 유사한 역할을 합니다. 사용자가 해당 그룹에 처음 진입했을 때 설정된 설명을 그룹의 제목처럼 읽어줍니다.
XML 예시:<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:importantForAccessibility="yes" android:contentDescription="월별 예보 목록"> <!-- Collection Items --> </LinearLayout> - 항목 위치 정보의 중요성: CollectionInfo만 설정하면 사용자는 "목록, 20개 항목"처럼 전체 개수만 알 수 있습니다. 각 항목에 포커스했을 때 "5/20"과 같은 현재 위치 정보를 제공하려면, 반드시 각 자식 뷰 요소에 CollectionItemInfo를 개별적으로 설정해야 합니다.
- 동적 콘텐츠와 항목 개수: 동적으로 콘텐츠가 로드되어 전체 항목 수를 정확히 알기 어려운 경우가 있습니다. 이럴 때는 rowCount와 columnCount를 -1로 설정할 수 있습니다. 이렇게 하면 톡백은 전체 개수를 알려주지 않는 대신 "목록 안에 있음"과 같이 그룹 정보만 제공합니다. 부정확한 개수를 알려주는 것보다 그룹화 정보만이라도 제공하는 것이 사용자에게 더 나은 경험을 줄 수 있습니다.
// 항목 개수를 알 수 없을 때 val unknownSizeInfo = AccessibilityNodeInfo.CollectionInfo.obtain(-1, -1, false) accessibilityNodeInfo.collectionInfo = unknownSizeInfo
동적 컬렉션 관리를 위한 실용적인 팁
앱의 콘텐츠는 동적으로 변하는 경우가 많아 모든 아이템의 위치 정보를 수동으로 지정하기는 어렵습니다. 이럴 때 다음과 같은 방법을 활용하면 CollectionInfo를 훨씬 효율적으로 관리할 수 있습니다.
- 팁 1: 항목 개수를 동적으로 계산하기
CollectionInfo에 전체 항목 개수를 하드코딩하는 대신, 런타임에 실제 접근성 항목의 개수를 계산하여 설정할 수 있습니다. ViewGroup의 자식 뷰를 순회하며 importantForAccessibility="yes"이거나 isScreenReaderFocusable="true"인 뷰의 개수만 세어 rowCount 또는 columnCount에 동적으로 반영하는 방식입니다. 이렇게 하면 아이템이 추가되거나 삭제될 때마다 정확한 전체 개수 정보를 제공할 수 있습니다.// 접근성에 중요한 자식 뷰의 개수만 계산하는 로직 private fun calculateActualItemCount(container: ViewGroup): Int { var count = 0 // ... 자식 뷰를 재귀적으로 탐색하며 접근성에 중요한 뷰만 count ... return count } // AccessibilityDelegate에서 동적으로 계산된 값을 사용 override fun onInitializeAccessibilityNodeInfo(host: View, info: AccessibilityNodeInfoCompat) { super.onInitializeAccessibilityNodeInfo(host, info) val dynamicRowCount = calculateActualItemCount(host as ViewGroup) val collectionInfo = AccessibilityNodeInfoCompat.CollectionInfoCompat.obtain(dynamicRowCount, 1, false) info.setCollectionInfo(collectionInfo) } - 팁 2: 재귀 탐색으로 그룹 아이템 자동화하기
커스텀 탭, 라디오 그룹, 체크박스 그룹 등을 만들 때, 각 항목에 CollectionItemInfo를 일일이 설정하는 것은 번거롭습니다. 대신, 부모 ViewGroup을 탐색하여 접근성 초점을 받을 수 있는(예: 클릭 가능한) 자식 뷰 목록을 동적으로 생성할 수 있습니다.- 부모 ViewGroup 내의 모든 자식 뷰를 재귀적으로 탐색하여 목록 아이템으로 사용할 뷰(예: isClickable이 true인 뷰)들을 리스트에 담습니다.
- 찾아낸 아이템 리스트의 크기를 사용하여 부모 ViewGroup의 CollectionInfo를 설정합니다. (columnCount = itemList.size)
- 리스트를 순회(forEachIndexed)하며 각 아이템 뷰에 CollectionItemInfo를 설정합니다. 이때 루프의 index를 columnIndex로 사용하면 각 아이템의 위치 정보가 자동으로 정확하게 부여됩니다.
이 패턴을 활용하면, 단일 유틸리티 함수(예: setAsTabGroup(viewGroup)) 호출만으로 전체 그룹의 접근성 설정이 완료되어 코드의 유지보수성이 크게 향상됩니다.
글을 마치며
지금까지 안드로이드의 시스템 뷰에서 스크린 리더 사용자가 보다 구조적으로 화면을 이해하고, 탐색의 효율성을 개선할 수 있는 컨테이너의 개념과 다양한 구현 패턴을 알아보았습니다. 다음 아티클에서는 Jetpack Compose를 이용해 뷰와 유사하게 컨테이너를 구현하는 방법에 대해 소개해 드리도록 하겠습니다. 읽어주셔서 감사합니다.