아티클

모바일 앱에서 스크린 리더 사용자에게 영역 정보 제공해 주기 3부: iOS

엔비전스 접근성 2025-11-25 10:18:27

안녕하세요. 엔비전스입니다.

지난 2부에서는 안드로이드 Jetpack Compose에서 영역 정보를 제공해 주는 방법에 대해서 함께 살폈습니다. 이번 시간에는 마지막으로 iOS의 UIKit 그리고 Swift UI에서 영역 정보를 제공하는 방법에 대해서 함께 살펴보겠습니다.

안드로이드와 동일하게 iOS에서도 접근성 컨테이너라고 하는 개념이 존재합니다. 사실 접근성 컨테이너라고 하는 개념의 원조는 원래 iOS에서 먼저 있었고, 접근성 컨테이너를 보이스 오버가 영역 컨테이너로 인식을 해서 영역 정보로 제공해 주도록 하는 것을 말합니다.

네이티브 컨테이너와 영역 정보

기본적인 영역 정보 관련해서 네이티브 컨테이너들은 자동으로 영역 정보가 붙습니다.

  • 내비게이션 바 (Navigation Bar): 네이티브 내비게이션 바를 사용하면 VoiceOver는 해당 영역을 **'탐색 막대'**라는 컨테이너로 인식합니다. 특이하게도 영역 진입 시 바로 정보를 읽어주진 않지만, 해당 영역에서 세 손가락 탭으로 현재 위치를 확인하면 "탐색 막대에 있음"이라고 알려줍니다.
  • 탭바 (Tab Bar): UIKit의 `UITabBar`나 SwiftUI의 `TabView`는 초점이 진입할 때 **'탭 막대'**라고 영역 정보를 즉시 읽어줍니다.
  • 목록/그리드 뷰: `UITableView`, `UICollectionView`나 SwiftUI의 `List`, `LazyVGrid` 등은 VoiceOver가 기본적으로 컨테이너로 인식하여, 컨테이너 단위로 이동이 가능합니다.

이러한 네이티브 컨테이너 중 탐색 막대와 탭 막대의 영역 정보("탐색 막대", "탭 막대")는 VoiceOver에 의해 고정되어 있으므로 개발자가 `accessibilityLabel`로 변경할 수 없습니다.

반면, `UITableView`, `UICollectionView`, SwiftUI의 `List`와 `Grid`는 뷰 자체에 `accessibilityLabel`만 설정해주면 VoiceOver가 이를 영역의 제목으로 읽어줍니다.

코드 예시: UITableView 및 SwiftUI List에 레이블 추가

// UIKit: UITableView에 레이블 설정

let tableView = UITableView()
tableView.accessibilityLabel = "공지사항 목록"

// SwiftUI: List에 레이블 설정

List {
    // ... 목록 내용
}
.accessibilityLabel("과일 및 채소")

하지만 `UIView`, `UIStackView`나 SwiftUI의 `HStack`, `VStack`처럼 의미론적 정보가 없는 뷰들은 VoiceOver가 컨테이너로 인식하도록 별도의 접근성 속성을 추가해야 합니다.

참고, 커스텀 탭바 구현에 관하여: 접근성 적용 방법

만약 UIKit의 탭바(UITabBar) 또는 **SwiftUI의 탭뷰(TabView)**와 같은 네이티브 컨테이너를 사용하지 않고 **커스텀 탭바(Custom Tab Bar)**를 구현할 경우, 이에 대한 접근성(Accessibility) 적용이 필수적으로 요구됩니다.

1. UIKit에서의 커스텀 탭바 접근성 적용

UIKit 환경에서 커스텀 탭바의 접근성을 적용하는 핵심 원리는 다음과 같습니다.

  • 탭 컨테이너에 트레이트 적용 및 역할 부여: 개별 탭 요소가 아닌, 이들 탭 요소를 감싸는 상위 컨테이너 뷰`accessibilityTraits = .tabBar` 트레이트를 부여합니다.
    • 이렇게 하면 VoiceOver는 해당 컨테이너 영역으로 진입 시 **'탭 막대'**로 인식합니다. 단 탭막대 라는 음성 출력은 없습니다.
    • 더 중요한 것은, 컨테이너 내부의 접근성 초점이 가는 하위 요소들은 개별적으로 탭 트레이트가 설정되어 있지 않더라도, VoiceOver가 자동으로 **'탭' 요소 유형**으로 인식하여 읽어 주게 됩니다.
  • 선택 상태 관리: 개발자는 각각의 **접근성 요소(accessibility element)**에 대해 **선택된 탭 상태**를 나타내는 **`.selected` 트레이트**를 탭이 선택될 때 **추가(Insert)**하거나 선택이 해제될 때 **제거(Remove)**해 주면 됩니다.

영역 정보 추가 (선택적): 만약 탭바 컨테이너 상위에 필터 선택과 같이 **추가적인 영역 정보**가 필요한 또 다른 컨테이너(예: `UIView`)가 있다면, 해당 컨테이너에 `accessibilityContainerType` 속성과 **`accessibilityLabel`**을 활용하여 접근성 레이블을 추가할 수 있습니다. (예: `accessibilityLabel = "필터 그룹"`, `accessibilityContainerType = .semanticGroup`)

2. SwiftUI에서의 커스텀 탭 접근성 적용

SwiftUI의 경우, TabView에 해당하는 커스텀 탭을 위한 접근성 컨테이너 모디파이어는 현재 UIKit의 `.tabBar`처럼 존재하지 않습니다. 대신 `accessibilityAddTraits` 모디파이어를 활용하여 개별 탭 요소에 역할을 부여하고 상태를 관리하는 것이 최선입니다.

  • 개별 탭 요소를 버튼으로 구성 및 트레이트 추가: 각각의 탭 요소는 **버튼(Button)**으로 만들거나, 일반 뷰(Text 등)에 **`.isButton` 트레이트**를 명시적으로 추가하여 버튼 접근성을 부여합니다.
    // 버튼 트레이트 명시적 추가
    Text("탭 이름")
        .accessibilityAddTraits(.isButton)
        // ...
    
  • 선택 상태 관리 (필수): 탭이 변경될 때마다 VoiceOver가 선택됨 상태를 읽어주도록 **`.isSelected` 트레이트**를 동적으로 추가해야 합니다.
    // 선택됨 상태를 동적으로 추가하는 패턴
    Text("탭 1")
        .accessibilityAddTraits(.isButton)
        .accessibilityAddTraits(currentTab == 1 ? .isSelected : [])
    
  • 상위 컨테이너에 레이블 지정: 탭들을 감싸는 **상위 컨테이너(예: `HStack`, `VStack`)**에 아래와 같은 모디파이어를 사용하여 탭 그룹의 영역 정보를 제공합니다.
    • **`.accessibilityElement(children: .contain)`**을 적용하여 해당 뷰를 접근성 컨테이너로 지정합니다.
    • **`.accessibilityLabel("탭 그룹 제목")`**을 사용하여 VoiceOver에게 필터 탭 그룹과 같은 영역의 제목을 지정해 줍니다.

이 방법을 통해 컨테이너 단위로 탭 그룹이라는 정보를 알려주어 사용자가 영역 정보를 인지할 수 있도록 합니다.

iOS VoiceOver 컨테이너의 특징

  • 컨테이너 벗어남 정보 부재: 안드로이드에서는 A 컨테이너를 벗어나 B 컨테이너로 진입할 때 'A 영역 벗어남'과 같은 안내를 제공할 수 있습니다. 하지만 iOS VoiceOver는 B 컨테이너에 `accessibilityLabel`이 있는 경우 "B 컨테이너 제목"이라고 진입 정보만 알려줄 뿐, 이전 컨테이너를 벗어났다는 정보는 명시적으로 읽어주지 않습니다.
  • 계층 구조(Nesting) 미지원: VoiceOver의 접근성 모델은 컨테이너의 중첩을 의미적으로 지원하지 않습니다. 예를 들어, 상위 컨테이너 안에 하위 컨테이너가 있는 구조라도 VoiceOver는 이를 '2단계 컨테이너'와 같이 계층적으로 알려주지 않습니다. 모든 컨테이너는 평면적인 관계로 인식됩니다. 이 때문에 컨테이너의 레이블 자체에 '1단계' 또는 '하위 목록'과 같은 텍스트를 포함하여 계층 구조를 암시하는 것이 현재로서는 최선입니다.

UIKit에서의 중첩 컨테이너 문제와 해결책

가장 흔한 문제는 `UITableViewCell` 안에 `UICollectionView`를 넣어 중첩된 목록을 구현하는 경우입니다. 기본적으로 VoiceOver는 `UITableView`의 접근성 메커니즘 안에서만 동작하려 하기 때문에, `UITableViewCell` 내부에 있는 `UICollectionView`의 개별 셀들을 인식하지 못하고 전체를 하나의 큰 덩어리로 취급합니다. 이처럼 VoiceOver는 기본적으로 하위 컨테이너라는 개념을 처리하지 못하기 때문에, 사용자는 수평 스크롤 영역의 각 항목을 탐색할 수 없게 됩니다.

해결 방법 1: `accessibilityElements`를 이용한 컨테이너 재지정

이 문제를 해결하기 위해, VoiceOver에게 기존 컨테이너(`UITableViewCell`)의 접근성 처리를 잠시 잊고, 새로운 컨테이너(`UICollectionView`)에 집중하라고 명시적으로 알려주어야 합니다. 이는 부모 뷰의 `accessibilityElements` 속성을 자식 컨테이너 뷰로 지정함으로써 가능합니다。

`UITableViewCell`의 `accessibilityElements`를 내부에 있는 `collectionView`로 설정하면, VoiceOver는 이 셀에 진입했을 때 셀 자체가 아닌 컬렉션 뷰를 새로운 접근성 컨테이너로 인식하고 그 내부의 요소들을 탐색하기 시작합니다.

class AccessibleCollectionViewContainerCell: UITableViewCell {

    // ... UI Components

    lazy var collectionView: UICollectionView = {
        // ... CollectionView 설정
        let cv = UICollectionView() // 예시를 위해 임시로 추가
        return cv
    }()

    func configure(with category: ExpandableItem) {
        // self.category = category // ExpandableItem 정의가 없어 주석 처리
        let categoryName = NSLocalizedString(category.title, comment: "")

        // 1. 컬렉션 뷰 자체에 컨테이너 제목을 부여합니다.
        collectionView.accessibilityLabel = "\(categoryName) 계절 목록"

        // 2. UITableViewCell의 접근성 요소를 collectionView로 지정합니다.
        // 이렇게 하면 VoiceOver는 이 셀을 무시하고 컬렉션 뷰를 새로운 컨테이너로 인식합니다.
        accessibilityElements = [collectionView]

        collectionView.reloadData()
    }

    // ...
}

참고: UITableViewCell 내 커스텀 탭 구현 시 주의사항

특정 `UITableViewCell` 내부에 과일 탭, 채소 탭 등과 같은 **커스텀 탭 요소**를 만들고, 다음 셀에서 해당 탭의 목록을 `UICollectionView` 등으로 구현하는 경우가 있습니다.

이때, 커스텀 탭바의 `tabBar` 트레이트는 탭 요소의 컨테이너에 부여해야 VoiceOver가 하위 요소를 **'탭'**으로 인식하게 됩니다.

  1. 컨테이너 역할 부여: 테이블 뷰 셀 하위 컨테이너(예: `UIView`)에 `tabBar` 트레이트를 부여하게 되면, VoiceOver는 해당 셀을 `UITableView`의 일반 셀로만 인식하여 하위 요소를 탭으로 인식하지 않습니다. 따라서 **커스텀 탭 요소들을 포함하는 셀 자체**에 **`accessibilityTraits = .tabBar`**를 부여하여 셀이 컨테이너 역할을 하도록 명시해야 합니다.
  2. 초점 분리 및 탐색: VoiceOver는 기본적으로 테이블 뷰 셀 하위의 요소들을 **하나의 초점 덩어리**로 인식하는 특성이 있습니다. 따라서 셀 하위에 두 개 이상의 탭 요소가 들어있는 경우, 반드시 **각각의 탭 요소**에 **`.button` 트레이트**를 주어 초점을 분리시켜야 합니다. 이후 셀에 `tabBar` 트레이트를 지정하고, 개별 탭 요소에 **`.selected` 트레이트**를 적용해야 탐색에 이슈가 발생하지 않습니다.

해결 방법 2: UIView와 UIStackView를 컨테이너로 만들기

`UIView`나 `UIStackView` 같이 시맨틱한 의미가 없는 뷰를 컨테이너로 만들려면 `accessibilityContainerType`을 지정해야 합니다。

  1. `accessibilityContainerType`을 **`.semanticGroup`**으로 설정합니다.
  2. `accessibilityLabel`을 설정하여 컨테이너의 제목을 제공합니다.

이 경우에도 내부에 또 다른 하위 컨테이너를 중첩시키면 VoiceOver는 하위 컨테이너에 포커스는 가능하더라도 기본적으로 하위 컨테이너를 인식하지 못합니다. 해결책은 동일합니다. 계층 구조가 명확히 존재할 때, 상위 `UIView`의 `accessibilityElements`를 하위 `UIView` 컨테이너로 지정해주어야 합니다.

// ...

// '과일' 카테고리를 확장했을 때,
// 상위 뷰(예: categoryWrapperView)의 accessibilityElements를
// 하위 컨테이너 뷰(itemsInnerView)로 지정해주어야 합니다.
// categoryWrapperView.accessibilityElements = [itemsInnerView]

// 아래 코드는 하위 컨테이너 자체를 설정하는 부분입니다.

/* // category, itemsContainer 등의 정의가 없어 주석 처리
if category.id == "fruits" {
    if let itemsInnerView = itemsContainer.subviews.first {
        // 1. 하위 뷰를 시맨틱 그룹 컨테이너로 설정합니다.
        itemsInnerView.accessibilityContainerType = .semanticGroup
        itemsInnerView.accessibilityLabel = "과일 목록"

        // 2. 하위 뷰의 접근성 요소들을 그 자신의 자식 뷰들(각 과일 아이콘)로 설정합니다.
        itemsInnerView.accessibilityElements = itemsInnerView.subviews
    }
}
*/
// ...

SwiftUI에서의 컨테이너 처리

SwiftUI에서는 **`.accessibilityElement(children: .contain)`** 수정자를 사용하여 뷰를 접근성 컨테이너로 지정합니다. `HStack`이나 `VStack` 같은 레이아웃 뷰에 이 수정자를 적용하고 `.accessibilityLabel`로 제목을 부여하면, VoiceOver는 이를 별도의 영역으로 인식합니다. UIKit처럼 `accessibilityElements`를 수동으로 재지정하는 복잡한 과정 없이도 중첩된 구조에서 각 컨테이너가 잘 인식됩니다.

// 과일 항목들을 담고 있는 HStack

/* // isFruitsExpanded, fruits 등의 정의가 없어 주석 처리
if isFruitsExpanded {
    HStack(spacing: 15) {
        ForEach(fruits, id: \.self) { fruit in
            // ... 각 과일 아이템 뷰
        }
    }
    .padding()
    // 1. .accessibilityElement를 사용하여 이 HStack을 컨테이너로 만듭니다.
    .accessibilityElement(children: .contain)
    // 2. 컨테이너의 제목을 설정합니다.
    .accessibilityLabel("과일 목록")
}
*/

고급: list 컨테이너 타입

`accessibilityContainerType`에는 `.semanticGroup` 외에 **`.list`**도 있습니다.

  • `.list`로 지정된 컨테이너에 진입하면 VoiceOver는 **"목록 시작"**, 벗어날 때 **"목록 끝"**을 알려줍니다.
  • 하지만 안드로이드와 달리 **"총 N개 중 M번째"**와 같은 위치 정보는 읽어주지 않습니다.
  • 제약사항: 중첩된 구조의 최하위 리프(leaf) 노드에서만 제대로 동작하며, 상위 컨테이너에는 사용할 수 없습니다. 또한, 단순히 타입만 지정해서는 안 되고 `accessibilityElementCount` 속성을 통해 목록의 전체 항목 개수를 알려주어야만 목록으로 인식됩니다.

이러한 제약 때문에, 대부분의 경우 **`.semanticGroup`**을 사용하는 것이 더 일반적이고 Apple에서도 권장하는 방식입니다.

글을 마치며

지금까지 3부에 걸쳐서 iOS와 안드로이드의 접근성 정보, 영역 정보를 제공해 주는 방법에 대해서 우리가 함께 살펴보았습니다. 스크린 리더는 시각 정보와 다르게 전체적인 맥락을 한꺼번에 다 설명을 해 주지 못합니다。

그래서 사용자가 읽고 있는 요소가 어떤 영역에 해당하는지를 적절한 시기에 읽어 주도록 하는 것이 중요합니다. 그렇다고 또 너무 많은 정보를 읽으면 사용자가 듣고 싶은 정보를 빨리 들을 수가 없는 아쉬움이 있고, 또 그렇다고 정보가 너무 없으면 사용자가 읽는 정보가 어디에 속한 것인지는 알 수 없기 때문에 이런 부분에 대한 좀 더 고민이 필요할 때입니다.

본 아티클이 여러분의 접근성을 개선하는 데 있어서 조금이나마 도움이 되었으면 좋겠습니다. 감사합니다.

댓글 0
댓글을 작성하려면 해주세요.