아티클

복잡한 iOS 계층 구조에서 접근성 속성을 부여할 때의 주의 사항 살펴보기

엔비전스 접근성 2026-01-13 10:43:15

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

일반적인 앱 사용자가 화면을 탐색할 때는 시각적인 레이아웃이 매우 중요한 역할을 합니다. 사용자는 아이콘의 형태나 배치된 위치를 보고 해당 요소를 탭해야 할지, 스와이프해야 할지, 혹은 일반 텍스트인지 인지하며 어떻게 조작해야 할지를 예측하게 됩니다. 개발자들 역시 중요한 콘텐츠를 시각적으로 돋보이게 하여 사용자가 바로 캐치할 수 있도록 화면 배치를 고민합니다.

이러한 논리는 시각 장애인을 위한 스크린 리더 접근성 환경에서도 예외가 아닙니다. 음성 피드백에만 의존해야 하는 전맹 시각 장애인의 경우, 각각의 정보를 차이를 두어 스크린 리더가 어떻게 읽게 해야 할 것인가에 대한 고민은 매우 중요합니다. 단순히 정보를 읽어주게 만드는 수준을 넘어, 스크린 리더 사용자의 탐색 패턴을 연구하고 고민하는 것이 필요한 때입니다.

이때 시각적인 디자인을 대신할 수 있는 것이 스크린 리더의 요소 유형(Role, Accessibility Traits)상태 정보(State)입니다. 시각적으로 강조된 배치가 사용자의 주의를 집중시키듯, 스크린 리더에서는 피드백(Announce)을 통해 알림을 보내거나 인터랙션이 가능한 정보에 초점을 보내주어 연속적인 탐색을 유도하는 것이 시각적인 배치와 유사한 역할을 수행합니다.

하지만 iOS 네이티브 앱 개발 시 Accessibility Traits를 통해 롤과 스테이트를 지정했음에도 VoiceOver가 이를 읽어주지 않거나, UIAccessibility.layoutChanged 이벤트를 통해 초점을 보냈음에도 의도대로 동작하지 않는 경우를 자주 겪으셨을 것입니다.

1. VoiceOver 초점 탐색의 기본 원칙

Voiceover가 화면 요소에서 초점을 이동하는 원칙의 핵심은 "접근성 엘리먼트(Accessibility Element)가 설정된 곳에만 초점을 보낼 수 있다"는 점입니다. 모든 UI 요소는 기본적으로 접근성 엘리먼트로 설정할 수 있으며, 이 설정이 활성화된 요소만이 스크린 리더의 탐색 대상이 됩니다.

초점 이동과 딜레이

일반적으로 보이스오버 사용자가 화면을 왼쪽에서 오른쪽으로, 위에서 아래로 스와이프하며 순차적으로 탐색합니다. 이때 초점이 너무 잘게 쪼개져 있으면 사용자는 정보를 파악하는 데 피로감을 느끼게 됩니다. 예를 들어 상품 리스트에서 상품명, 별점, 가격, 찜하기 버튼이 각각 개별적으로 초점이 잡힌다면 사용자는 하나의 상품 정보를 확인하기 위해 여러 번 스와이프를 해야 하며, 이 과정에서 정보가 분절되어 전달될 위험이 있습니다.

2. 뷰 구조에 따른 초점 병합 (UIKit vs SwiftUI)

UIKit에서의 초점 관리

UIKit 프로젝트에서는 보통 UIViewUITableViewCell 등의 상위 컨테이너에 isAccessibilityElement = true를 설정하여 하위 요소들을 하나로 묶습니다. 하지만 이 경우 하위 요소들의 텍스트가 자동으로 병합되지 않기 때문에, 개발자가 직접 accessibilityLabel에 하위 요소들의 텍스트를 수동으로 조합해서 넣어주어야 하는 번거로움이 있습니다.

SwiftUI의 그룹화 방식

반면 SwiftUI에서는 .accessibilityElement(children: .combine) 또는 .accessibilityElement(children: .ignore)와 같은 모디파이어를 통해 하위 요소들을 매우 직관적으로 병합하거나 제외할 수 있습니다. 특히 .combine을 사용하면 하위의 모든 텍스트 정보가 자동으로 하나의 레이블로 합쳐져 사용자의 탐색 경험을 크게 향상시킵니다.

3. UIKit을 위한 접근성 자동 병합 유틸리티: MergedFocus

SwiftUI의 편리한 병합 기능을 UIKit에서도 쉽게 사용할 수 있도록, 하위 모든 텍스트를 자동으로 집계하여 상위 엘리먼트의 레이블로 만들어주는 MergedFocus 클래스를 공유합니다.

이 유틸리티를 사용하면 다음과 같은 장점이 있습니다:

  • 자동 텍스트 수집: 하위 뷰의 모든 텍스트(Label, Button, Value 등)를 재귀적으로 탐색하여 하나의 맥락으로 합쳐줍니다.
  • 중복 제거: 동일한 텍스트가 중첩되어 시각적으로 배치된 경우 이를 지능적으로 걸러내어 중복 낭독을 방지합니다.
  • 동적 대응: 텍스트가 실시간으로 변경되거나 Dynamic Type 크기가 조절되어도 레이아웃 변경을 감지하여 보이스오버 레이블을 최신 상태로 유지합니다.

권장 사용법: 상하위 기능이 연결된 경우, 메인 기능은 이 유틸리티를 통해 하나의 초점으로 묶어 실행하게 하고, 세부 기능은 Custom Action으로 분리하여 구현하는 것이 가장 표준적인 접근성 가이드라인입니다.

4. MergedFocus 사용 시 주의사항: 가로 스크롤 캐러셀과의 기술적 차이

지난 달 아티클인 ['가로 스크롤 UI에서의 자동 선택 문제와 해결 방안']에서 다루었던 캐러셀(Adjustable Trait) UI와 이번 MergedFocus 유틸리티는 정보를 수집하고 가공하는 목적 자체가 다릅니다.

1. 정보 제공의 목적: 요약 레이블(Label) vs 현재 상태 값(Value)

  • MergedFocus: 하위의 파편화된 정보를 모두 수집하여 accessibilityLabel 하나로 병합합니다. "이 요소가 어떤 정보들을 한 덩어리로 포함하고 있는가"를 알려주는 것이 목적입니다.
  • 캐러셀(Adjustable): 컨테이너가 "결제 카드 선택"이라는 레이블을 고정한 상태에서, 현재 중앙에 위치한 특정 카드의 정보를 accessibilityValue를 통해 실시간으로 조절하며 읽어주어야 합니다.

2. 전체 병합(Combine) vs 선별적 추출(Select)

  • MergedFocus는 하위 계층의 모든 텍스트를 하나도 빠짐없이 긁어모으는 구조입니다. 만약 다섯 개의 카드가 있다면 보이스오버는 "삼성카드, 현대카드, 신한카드..."처럼 리스트 전체를 한 문장으로 읽어버립니다.
  • 캐러셀 UI에서는 상위 컨테이너를 접근성 엘리먼트로 설정하여 하위 요소들이 개별적으로 탐색되지 않게 막더라도, 컨테이너의 accessibilityValue에는 '현재 선택된 카드' 한 장의 정보만을 정교하게 담아내야 합니다. 따라서 '모든 하위 정보를 합쳐버리는' MergedFocus는 이 선별적인 정보 추출에 적합하지 않습니다.

3. ignoreMarker 제어의 실효성 문제

  • 물론 MergedFocusa11y-ignore 마커를 활용해 현재 선택되지 않은 나머지 4개의 카드를 집계에서 제외할 수도 있습니다. 하지만 이는 사용자가 스크롤을 할 때마다 현재 카드를 제외한 나머지 카드들에 매번 이그노어 마커를 동적으로 붙였다 떼었다 하는 불필요한 연산 로직을 수반하게 됩니다.
  • 이미 지난 아티클에서 제시한 모델처럼, 데이터 모델에서 현재 인덱스(currentIndex)에 해당하는 정보만 직접 추출하여 accessibilityValue에 대입하는 것이 훨씬 더 직관적이고 가벼운 구현 방식입니다.

결론적으로: MergedFocus"하위의 파편화된 정보를 하나로 묶어 통합된 맥락을 제공"할 때 사용하는 도구이며, 캐러셀처럼 "전체 중 활성화된 특정 데이터의 정보를 선별하여 전달"해야 하는 구조에서는 지난 아티클의 조절 가능(Adjustable) 모델에 따라 전용 로직을 작성하는 것이 바람직합니다.

5. MergedFocus.swift 실전 가이드: 상품 리스트 예제

제공해 드리는 MergedFocus 클래스는 복잡한 커스텀 컴포넌트의 접근성을 효율적으로 제어하기 위한 도구입니다. 프로젝트의 ProductListCell.swift에 구현된 실제 시나리오를 통해 활용법을 살펴보겠습니다.

실제 구현 시나리오: 쇼핑 앱의 상품 카드

쇼핑 앱의 상품 카드는 브랜드명, 상품명, 가격, 별점 등 매우 많은 정보를 담고 있습니다. 이를 처리하는 가장 효율적인 방법은 다음과 같습니다.

정보 보강 및 필터링

MergedFocus는 하위 뷰의 텍스트를 자동으로 수집하지만, 상황에 따라 정보를 보강하거나 제외할 수 있습니다.

  • 불필요한 정보 제외 (a11y-ignore): 커스텀 액션으로 추가할 찜하기 버튼처럼 대표 맥락에 포함될 필요가 없는 요소는 accessibilityIdentifier = "a11y-ignore"를 설정하여 자동 집계 대상에서 제외할 수 있습니다.
  • 정보 보강: 별점 정보가 단순히 "4.8"이라고 되어 있다면, ratingLabel.accessibilityLabel = "평점 4.8점"과 같이 설정하여 MergedFocus가 더 명확한 정보를 수집하도록 돕습니다.

트레이트(Traits) 및 상태 제어

  • 선택 상태(isSelected): 상품이 선택된 상태라면 isToggled = true를 설정하여 내부적으로 .selected 트레이트가 추가되어 보이스오버가 "선택됨"을 함께 음성 출력하도록 지정할 수 있습니다.
  • 커스텀 액션과 병합: 앞에서 설명했듯 초점을 하나로 합치면 하위 버튼에 직접 접근하기 어려워지므로, accessibilityCustomActions를 사용하여 컨테이너에서 제외한 기능을 등록합니다.

실제 적용 예시 코드 (ProductListCell 기반)

let mainContainer = MergedFocus()

// 1. 제외할 요소 설정  
productImageView.accessibilityIdentifier = "a11y-ignore"  
likeButton.accessibilityIdentifier = "a11y-ignore"

// 2. 하위 레이블 보강 (MergedFocus가 이 값을 우선 수집함)  
priceLabel.accessibilityLabel = "할인가 \(price)원"  
reviewLabel.accessibilityLabel = "리뷰 \(count)개"

// 3. 커스텀 액션 등록  
mainContainer.accessibilityCustomActions = [  
    UIAccessibilityCustomAction(name: "찜하기", target: self, selector: #selector(likeAction))  
]

// 4. 내용 변경 알림  
mainContainer.contentDidChange()

6. [Source Code] MergedFocus.swift

import UIKit

/// 상위 요소 하나만 포커스되도록 만들고,  
/// 하위 자식들의 텍스트/레이블을 자동 집계하여 상위 accessibilityLabel에 넣는 컨테이너.  
/// - 접근성 Traits 기본값: .button  
final class MergedFocus: UIView {  
      
    // MARK: - Tunables  
    private let throttleInterval: CFTimeInterval = 0.10  
    private let separator: String = ", "  
    private let ignoreMarker: String = "a11y-ignore"  
    private let minAlphaVisible: CGFloat = 0.01  
      
    // MARK: - Statics for Optimization  
    private static let spaceRegex: NSRegularExpression? = try? NSRegularExpression(pattern: "\\s+")  
      
    // MARK: - State  
    private var cachedLabel: String = ""  
    private var lastUpdateTime: CFTimeInterval = 0  
    private var contentSignature: Int = 0  
      
    // 선택/토글 상태를 반영하고 싶은 경우 공개 API로 노출  
    var isToggled: Bool = false {  
        didSet { updateTraits() }  
    }  
    var isSelectedForA11y: Bool = false {  
        didSet { updateTraits() }  
    }  
      
    // MARK: - Lifecycle  
    override func didMoveToWindow() {  
        super.didMoveToWindow()  
        // 상위만 포커스  
        isAccessibilityElement = true  
        updateTraits()  
        rebuildAccessibilityIfNeeded(force: true)  
    }  
      
    override func layoutSubviews() {  
        super.layoutSubviews()  
        rebuildAccessibilityIfNeeded()  
    }  
      
    override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {  
        super.traitCollectionDidChange(previousTraitCollection)  
        // Dynamic Type 변화 등으로 텍스트가 달라질 수 있으므로 재구축  
        rebuildAccessibilityIfNeeded()  
    }  
      
    // 공개 훅: 내부 텍스트/상태가 바뀌었을 때 명시적으로 호출 가능  
    func contentDidChange() {  
        rebuildAccessibilityIfNeeded(force: true)  
    }  
      
    // MARK: - Core Logic  
    private func updateTraits() {  
        var traits: UIAccessibilityTraits = .button  
        if isToggled || isSelectedForA11y { traits.insert(.selected) }  
        accessibilityTraits = traits  
    }

    private func rebuildAccessibilityIfNeeded(force: Bool = false) {  
        let now = CACurrentMediaTime()  
        if !force && (now - lastUpdateTime < throttleInterval) { return }  
          
        let newSignature = MergedFocus.calculateSignature(in: self)  
        if !force && (newSignature == contentSignature) { return }  
          
        let text = MergedFocus.aggregateA11yText(  
            in: self,  
            separator: separator,  
            ignoreMarker: ignoreMarker,  
            minAlpha: minAlphaVisible  
        )  
          
        let normalized = MergedFocus.normalizeSpaces(text)  
          
        if cachedLabel != normalized {  
            cachedLabel = normalized  
            accessibilityLabel = normalized  
            // 레이아웃 변경 알림을 통해 보이스오버가 갱신된 레이블을 다시 읽게 함  
            UIAccessibility.post(notification: .layoutChanged, argument: self)  
        }  
          
        self.contentSignature = newSignature  
        self.lastUpdateTime = now  
    }  
      
    // MARK: - Helpers  
      
    /// 하위 뷰들의 텍스트 요소를 바탕으로 가벼운 Hash(시그니처) 생성  
    static func calculateSignature(in root: UIView) -> Int {  
        var hash = 0  
        func traverse(_ v: UIView) {  
            if v.isHidden || v.alpha < 0.01 { return }  
            if let text = extractText(from: v) {  
                hash = hash ^ text.hashValue  
            }  
            v.subviews.forEach(traverse)  
        }  
        traverse(root)  
        return hash  
    }  
      
    /// 지정된 컨테이너 내부의 모든 텍스트를 재귀적으로 수집하여 합침  
    static func aggregateA11yText(  
        in root: UIView,  
        separator: String,  
        ignoreMarker: String,  
        minAlpha: CGFloat  
    ) -> String {  
        var rawTexts: [String] = []  
          
        func traverse(_ v: UIView) {  
            // 숨겨진 뷰 제외  
            if v.isHidden || v.alpha < minAlpha { return }  
              
            // 명시적 제외 마커 확인 (accessibilityIdentifier 등 활용)  
            if v.accessibilityIdentifier == ignoreMarker { return }  
              
            if let text = extractText(from: v), !text.isEmpty {  
                rawTexts.append(text)  
                // 텍스트를 찾았다면 그 하위는 더 깊게 탐색하지 않음 (중복 방지)  
                return  
            }  
              
            v.subviews.forEach(traverse)  
        }  
          
        traverse(root)  
          
        // 중복 및 포함 관계 정리  
        var uniqueTexts: [String] = []  
        for text in rawTexts {  
            let isContained = rawTexts.contains { other in  
                return other != text && other.contains(text)  
            }  
            if !isContained && !uniqueTexts.contains(text) {  
                uniqueTexts.append(text)  
            }  
        }  
          
        return uniqueTexts.joined(separator: separator)  
    }  
      
    /// 개별 뷰에서 접근성 텍스트 추출 (접근성 레이블 우선)  
    static func extractText(from v: UIView) -> String? {  
        if let attr = v.accessibilityAttributedLabel, attr.length > 0 { return attr.string }  
        if let l = v.accessibilityLabel, !l.isEmpty { return l }  
        if let val = v.accessibilityValue, !val.isEmpty { return val }  
        switch v {  
        case let lbl as UILabel: return lbl.text  
        case let btn as UIButton: return btn.title(for: .normal)  
        case let tf as UITextField: return tf.text  
        case let tv as UITextView: return tv.text  
        default: return nil  
        }  
    }  
      
    private static func normalizeSpaces(_ s: String) -> String {  
        guard let regex = spaceRegex else { return s }  
        let range = NSRange(location: 0, length: s.utf16.count)  
        return regex.stringByReplacingMatches(in: s, options: [], range: range, withTemplate: " ")  
    }  
}

지금까지 접근성 엘리먼트, 롤(Role), 스테이트(State)의 중요성과 UIKit 및 SwiftUI에서의 초점 관리 최적화에 대해 살펴보았습니다. 접근성은 단순한 가이드 준수를 넘어, 모든 사용자가 앱의 가치를 동등하게 경험하도록 돕는 필수적인 과정입니다. 이번 글과 공유드린 코드가 여러분의 iOS 접근성 구현에 실질적인 도움이 되기를 바랍니다.

감사합니다.

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