모바일 앱 접근성 — 스크린 리더와 터치 접근성 완전 가이드

접근성

접근성VoiceOverTalkBack스크린 리더FlutterReact Native

이 글은 누구를 위한 것인가

  • 앱 접근성 검수를 받아야 하는 팀
  • VoiceOver나 TalkBack을 켰을 때 앱이 제대로 작동하지 않는 문제를 해결하려는 개발자
  • WCAG 모바일 가이드라인을 구현해야 하는 엔지니어

접근성이 중요한 이유

WHO에 따르면 전 세계 인구의 약 15%가 어떤 형태로든 장애를 가지고 있다. 시각 장애인은 스크린 리더(iOS: VoiceOver, Android: TalkBack)로 앱을 사용한다. 한국에서도 장애인 차별 금지법에 따라 일정 규모 이상의 앱은 접근성 기준을 충족해야 한다.

접근성은 장애인만을 위한 것이 아니다. 밝은 야외에서 화면이 잘 안 보이는 상황, 한 손을 사용할 수 없는 상황, 고령 사용자 모두가 접근성 개선의 수혜자다.


1. 스크린 리더 지원: 시맨틱 레이블링

스크린 리더는 화면의 텍스트를 읽어주고, 이미지나 아이콘 등 비텍스트 요소는 개발자가 의미를 제공해야 한다.

Flutter: Semantics 위젯

// 아이콘 버튼에 레이블 추가
Semantics(
  label: '좋아요',
  hint: '더블 탭으로 좋아요를 표시합니다',
  button: true,
  child: IconButton(
    icon: Icon(Icons.favorite_border),
    onPressed: onLike,
  ),
),

// 이미지에 대체 텍스트
Semantics(
  label: '나이키 에어맥스 270 흰색 운동화',
  image: true,
  child: Image.network(productImageUrl),
),

// 장식용 이미지는 스크린 리더에서 제외
ExcludeSemantics(
  child: Image.asset('assets/decorative_background.png'),
),

// 여러 위젯을 하나의 시맨틱 단위로 묶기
Semantics(
  label: '상품명: 나이키 에어맥스. 가격: 139,000원. 재고 있음',
  child: Column(
    children: [
      Text('나이키 에어맥스'),
      Text('139,000원'),
      Text('재고 있음'),
    ],
  ),
),

React Native: accessibility Props

// 아이콘 버튼
<TouchableOpacity
  accessible={true}
  accessibilityLabel="장바구니에 추가"
  accessibilityHint="상품을 장바구니에 추가합니다"
  accessibilityRole="button"
  onPress={addToCart}
>
  <Icon name="cart-plus" />
</TouchableOpacity>

// 이미지
<Image
  source={{ uri: productImage }}
  accessible={true}
  accessibilityLabel="나이키 에어맥스 270 흰색 운동화 측면 사진"
  accessibilityRole="image"
/>

// 장식용 요소 숨기기
<Image
  source={decorativeImage}
  accessible={false}
  importantForAccessibility="no-hide-descendants"
/>

// 상태를 전달하는 컴포넌트
<TouchableOpacity
  accessibilityLabel={`${productName}, ${isFavorite ? '즐겨찾기 등록됨' : '즐겨찾기 해제됨'}`}
  accessibilityState={{ selected: isFavorite }}
  onPress={toggleFavorite}
>
  <Icon name={isFavorite ? 'heart' : 'heart-outline'} />
</TouchableOpacity>

2. 포커스 관리

스크린 리더 사용자는 포커스 순서로 앱을 탐색한다. 논리적이지 않은 포커스 순서는 혼란을 준다.

포커스 순서

UI에서 시각적으로 왼쪽 위에서 오른쪽 아래로 읽히는 순서가 포커스 순서여야 한다. 복잡한 레이아웃에서 이 순서가 깨지는 경우:

Flutter:

// FocusTraversalGroup으로 포커스 순서 그룹화
FocusTraversalGroup(
  policy: OrderedTraversalPolicy(),
  child: Column(
    children: [
      FocusTraversalOrder(
        order: NumericFocusOrder(1),
        child: ProductTitle(),
      ),
      FocusTraversalOrder(
        order: NumericFocusOrder(2),
        child: ProductPrice(),
      ),
      FocusTraversalOrder(
        order: NumericFocusOrder(3),
        child: AddToCartButton(),
      ),
    ],
  ),
),

화면 전환 시 포커스 이동

새 화면이 열릴 때 포커스가 적절한 위치로 이동해야 한다.

// React Native
import { AccessibilityInfo, findNodeHandle } from 'react-native';

const ModalContent = () => {
  const titleRef = useRef(null);

  useEffect(() => {
    // 모달이 열리면 제목으로 포커스 이동
    const timeout = setTimeout(() => {
      const node = findNodeHandle(titleRef.current);
      if (node) {
        AccessibilityInfo.setAccessibilityFocus(node);
      }
    }, 300);  // 애니메이션 완료 후

    return () => clearTimeout(timeout);
  }, []);

  return (
    <View>
      <Text ref={titleRef} accessibilityRole="header">
        상품 상세 정보
      </Text>
      {/* ... */}
    </View>
  );
};

3. 터치 타겟 크기

손가락으로 터치할 수 있는 최소 크기 기준:

  • WCAG 2.5.5: 44×44 CSS 픽셀 (권장)
  • Apple HIG: 44×44 포인트
  • Material Design: 48×48dp

시각적으로 작은 요소도 터치 타겟은 크게 만들 수 있다.

// Flutter: 시각적 크기와 터치 타겟 분리
GestureDetector(
  behavior: HitTestBehavior.opaque,
  onTap: onTap,
  child: Container(
    width: 44,
    height: 44,
    alignment: Alignment.center,
    child: Icon(Icons.close, size: 20),  // 아이콘은 20, 타겟은 44
  ),
),
// React Native: hitSlop으로 터치 영역 확장
<TouchableOpacity
  hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }}
  onPress={onClose}
  style={{ width: 20, height: 20 }}
>
  <Icon name="close" size={20} />
</TouchableOpacity>

4. 색상 대비

시각 장애나 색약이 있는 사용자를 위해 충분한 색상 대비가 필요하다.

WCAG AA 기준:

  • 일반 텍스트: 4.5:1 이상
  • 큰 텍스트 (18pt 이상 또는 굵은 14pt): 3:1 이상
  • UI 컴포넌트, 그래픽: 3:1 이상

색상만으로 정보를 전달하지 않는다:

// 나쁜 예: 빨강=에러, 초록=성공을 색상만으로 구분
Text(
  message,
  style: TextStyle(color: isError ? Colors.red : Colors.green),
),

// 좋은 예: 아이콘 + 색상 조합
Row(
  children: [
    Icon(
      isError ? Icons.error : Icons.check_circle,
      color: isError ? Colors.red : Colors.green,
      semanticLabel: isError ? '오류' : '성공',
    ),
    Text(message),
  ],
),

5. 접근성 테스트

VoiceOver (iOS) 테스트

  1. 설정 → 손쉬운 사용 → VoiceOver 켜기
  2. 또는 사이드 버튼 3번 탭 (접근성 단축키 설정 시)
  3. 화면을 탐색하며 포커스 순서와 레이블이 올바른지 확인
  4. Simulator에서도 테스트 가능 (단축키: Cmd+F5)

TalkBack (Android) 테스트

  1. 설정 → 접근성 → TalkBack 켜기
  2. 또는 볼륨 양쪽 3초 동시 누르기
  3. Android Studio Accessibility Scanner 플러그인으로 자동 검사

자동화 도구

# Flutter: flutter_test의 accessibility 검사
testWidgets('접근성 검사', (WidgetTester tester) async {
  await tester.pumpWidget(MyApp());

  // 접근성 가이드라인 위반 검사
  await expectLater(
    tester,
    meetsGuideline(androidTapTargetGuideline),  // 터치 타겟 크기
  );
  await expectLater(
    tester,
    meetsGuideline(labeledTapTargetGuideline),  // 레이블 누락
  );
  await expectLater(
    tester,
    meetsGuideline(textContrastGuideline),  // 색상 대비
  );
});

맺으며

접근성은 나중에 추가하는 기능이 아니라 처음부터 설계에 포함되어야 한다. 이미 완성된 앱에 접근성을 추가하는 것은 몇 배의 비용이 든다.

시작점은 두 가지다: 모든 인터랙티브 요소에 레이블 추가터치 타겟 크기 44×44 확인. 이 두 가지만 해도 스크린 리더 사용자의 기본 탐색 경험이 크게 개선된다. 직접 VoiceOver를 켜고 자신의 앱을 사용해보는 것이 어떤 문서보다 빠른 학습법이다.