Flutter 튜토리얼 38편: 웹 접근성
Flutter 웹 접근성
Flutter 웹 애플리케이션에서 접근성을 구현하는 방법을 알아봅니다. Semantics 트리가 HTML DOM으로 어떻게 변환되는지, 그리고 스크린 리더가 이를 어떻게 해석하는지 살펴보겠습니다.
웹 접근성의 특수성
Why - 왜 웹에서는 별도의 접근성 처리가 필요한가요?
Flutter는 모든 UI를 단일 캔버스(Canvas)에 렌더링합니다. 네이티브 앱에서는 운영체제가 제공하는 접근성 API와 직접 통신하지만, 웹에서는 브라우저가 HTML DOM 구조를 기반으로 접근성 정보를 해석합니다.
캔버스에 그려진 픽셀만으로는 스크린 리더가 UI의 의미와 구조를 파악할 수 없습니다. 따라서 Flutter 웹은 내부 Semantics 트리를 접근 가능한 HTML DOM 구조로 변환하는 특별한 레이어가 필요합니다.
What - Flutter 웹 접근성이란 무엇인가요?
Flutter 웹 접근성은 Semantics 트리의 정보를 HTML 요소와 ARIA(Accessible Rich Internet Applications) 속성으로 변환하여 스크린 리더가 이해할 수 있게 만드는 시스템입니다.
주요 특징:
- 성능 최적화를 위한 선택적 활성화: 기본적으로 비활성화 상태
- Semantics → HTML DOM 변환: 위젯의 의미 정보를 HTML 구조로 매핑
- ARIA 역할 지원: SemanticsRole을 통한 웹 표준 역할 정의
How - 어떻게 동작하나요?
Flutter Widget Tree ↓Semantics Tree (의미 정보) ↓HTML DOM + ARIA 속성 ↓브라우저 접근성 API ↓스크린 리더Watch out - 주의사항
Flutter 웹 접근성은 기본적으로 비활성화되어 있습니다. 성능상의 이유로 명시적으로 활성화해야 합니다.
웹 접근성 활성화
Why - 왜 명시적으로 활성화해야 하나요?
접근성 DOM을 생성하고 유지하는 것은 추가적인 메모리와 처리 비용이 발생합니다. 모든 사용자에게 이 비용을 부담시키기보다, 실제로 필요한 사용자만 활성화할 수 있도록 선택적으로 제공합니다.
What - 활성화 방법은 무엇인가요?
두 가지 방법으로 접근성 모드를 활성화할 수 있습니다.
방법 1: 숨겨진 버튼 (사용자 활성화)
Flutter 웹 앱에는 aria-label="Enable accessibility" 속성을 가진 보이지 않는 버튼이 있습니다.
스크린 리더 사용자가 이 버튼을 누르면 접근성 모드가 활성화됩니다.
방법 2: 코드에서 직접 활성화
import 'package:flutter/foundation.dart';import 'package:flutter/semantics.dart';
void main() { runApp(const MyApp());
if (kIsWeb) { SemanticsBinding.instance.ensureSemantics(); }}How - 각 방법의 차이점은 무엇인가요?
| 방법 | 활성화 시점 | 사용 사례 |
|---|---|---|
| 숨겨진 버튼 | 사용자 요청 시 | 일반 사용자를 위한 성능 유지 |
| 코드 활성화 | 앱 시작 시 | 접근성이 필수인 서비스 |
Watch out - 주의사항
kIsWeb 상수는 package:flutter/foundation.dart에서 가져옵니다.
웹이 아닌 플랫폼에서 ensureSemantics()를 호출해도 문제없지만, 조건문으로 감싸는 것이 의도를 명확히 합니다.
// kIsWeb 사용 시 foundation.dart 임포트 필요import 'package:flutter/foundation.dart';SemanticsRole을 통한 역할 정의
Why - 왜 역할 정의가 중요한가요?
시맨틱 역할(Semantic Role)은 UI 요소의 목적을 정의합니다. 스크린 리더는 이 역할 정보를 바탕으로 사용자에게 요소의 유형과 기능을 알려줍니다.
예를 들어 역할이 정의되지 않은 커스텀 버튼은 스크린 리더에서 단순히 “텍스트”로 읽힐 수 있습니다. 하지만 버튼 역할이 정의되면 “버튼, 제출하기”처럼 명확하게 안내됩니다.
What - SemanticsRole이란 무엇인가요?
SemanticsRole은 Flutter에서 제공하는 열거형으로, 위젯에 특정 역할을 할당합니다.
웹에서 렌더링될 때 이 역할은 해당하는 ARIA 역할로 변환됩니다.
주요 SemanticsRole 값:
| SemanticsRole | ARIA 역할 | 설명 |
|---|---|---|
button | button | 클릭 가능한 버튼 |
link | link | 하이퍼링크 |
heading | heading | 제목 |
list | list | 목록 컨테이너 |
listItem | listitem | 목록 항목 |
tab | tab | 탭 |
tabBar | tablist | 탭 목록 |
table | table | 테이블 |
slider | slider | 슬라이더 |
How - SemanticsRole을 어떻게 사용하나요?
표준 위젯 사용 (자동 역할 할당)
Flutter의 표준 위젯들은 적절한 시맨틱 정보를 자동으로 포함합니다.
// 자동으로 button 역할이 할당됨ElevatedButton( onPressed: () => print('클릭'), child: Text('제출'),)
// 자동으로 tablist, tab 역할이 할당됨TabBar( tabs: [ Tab(text: '홈'), Tab(text: '설정'), ],)커스텀 위젯에 역할 명시
import 'package:flutter/material.dart';import 'package:flutter/semantics.dart';
class CustomButton extends StatelessWidget { final VoidCallback onTap; final Widget child;
const CustomButton({ required this.onTap, required this.child, super.key, });
@override Widget build(BuildContext context) { return Semantics( role: SemanticsRole.button, button: true, child: GestureDetector( onTap: onTap, child: child, ), ); }}목록 구조 정의
class MyCustomListWidget extends StatelessWidget { const MyCustomListWidget({super.key});
@override Widget build(BuildContext context) { return Semantics( role: SemanticsRole.list, explicitChildNodes: true, child: Column( children: <Widget>[ Semantics( role: SemanticsRole.listItem, child: const Padding( padding: EdgeInsets.all(8.0), child: Text('첫 번째 항목'), ), ), Semantics( role: SemanticsRole.listItem, child: const Padding( padding: EdgeInsets.all(8.0), child: Text('두 번째 항목'), ), ), ], ), ); }}Watch out - 주의사항
explicitChildNodes: true를 설정하면 부모 Semantics가 자식의 시맨틱 정보를 병합하지 않습니다.
목록처럼 개별 항목이 독립적인 의미를 가져야 할 때 사용합니다.
역할별 적용 가이드
버튼과 링크
// 버튼 역할Semantics( role: SemanticsRole.button, button: true, label: '제출하기', child: CustomButtonWidget(),)
// 링크 역할Semantics( role: SemanticsRole.link, link: true, label: '자세히 보기', child: GestureDetector( onTap: () => launchUrl(uri), child: Text('자세히 보기'), ),)제목 구조
Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Semantics( role: SemanticsRole.heading, header: true, child: Text( '주요 기능', style: Theme.of(context).textTheme.headlineMedium, ), ), SizedBox(height: 16), Text('기능에 대한 상세 설명...'), ],)테이블 구조
Semantics( role: SemanticsRole.table, child: Table( children: [ TableRow( children: [ Semantics( role: SemanticsRole.cell, child: Text('이름'), ), Semantics( role: SemanticsRole.cell, child: Text('나이'), ), ], ), ], ),)접근성 테스트
브라우저 개발자 도구 활용
- Chrome DevTools에서 Accessibility 탭을 엽니다
- 요소를 선택하여 ARIA 속성이 올바르게 매핑되었는지 확인합니다
- Accessibility Tree에서 전체 구조를 검토합니다
스크린 리더 테스트
| 운영체제 | 스크린 리더 | 단축키 |
|---|---|---|
| Windows | NVDA | Ctrl + Alt + N |
| macOS | VoiceOver | Cmd + F5 |
| ChromeOS | ChromeVox | Ctrl + Alt + Z |
자동화된 접근성 검사
import 'package:flutter_test/flutter_test.dart';
void main() { testWidgets('접근성 가이드라인 준수', (WidgetTester tester) async { await tester.pumpWidget(MyApp());
// 시맨틱 정보 확인 final semantics = tester.getSemantics(find.byType(CustomButton)); expect(semantics.hasFlag(SemanticsFlag.isButton), isTrue); });}실전 예제: 접근성을 고려한 카드 컴포넌트
class AccessibleCard extends StatelessWidget { final String title; final String description; final VoidCallback onTap; final String? imageUrl;
const AccessibleCard({ required this.title, required this.description, required this.onTap, this.imageUrl, super.key, });
@override Widget build(BuildContext context) { return Semantics( role: SemanticsRole.button, button: true, label: '$title. $description', child: Card( child: InkWell( onTap: onTap, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (imageUrl != null) Semantics( image: true, label: '$title 이미지', excludeSemantics: true, child: Image.network(imageUrl!), ), Padding( padding: const EdgeInsets.all(16.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // 제목은 부모 Semantics의 label에 포함되므로 제외 ExcludeSemantics( child: Text( title, style: Theme.of(context).textTheme.titleLarge, ), ), const SizedBox(height: 8), ExcludeSemantics( child: Text(description), ), ], ), ), ], ), ), ), ); }}이 예제에서는:
- 전체 카드를 하나의 버튼으로 인식하도록 설정
- 이미지에 대체 텍스트 제공
- 중복 읽기를 방지하기 위해
ExcludeSemantics사용 - 제목과 설명을 결합한 의미 있는 label 제공
마무리
Flutter 웹 접근성의 핵심 개념을 정리하면:
- 활성화 필요: 성능상 기본 비활성화, 명시적 활성화 필요
- Semantics → ARIA 변환: Flutter의 시맨틱 정보가 웹 표준으로 변환
- SemanticsRole 활용: 커스텀 위젯에 적절한 역할 부여
- 표준 위젯 우선: 가능하면 표준 위젯 사용으로 자동 접근성 확보
- 테스트 필수: 실제 스크린 리더로 검증
공유
이 글이 도움이 되었다면 다른 사람과 공유해주세요!