Flutter 튜토리얼 37편: 접근성 구현
요약
핵심 요지
- 문제 정의: 시각, 청각, 운동 장애가 있는 사용자가 앱을 사용할 수 없으면 법적, 도덕적 문제가 발생하고 사용자 기반이 제한된다.
- 핵심 주장: Flutter는 기본 위젯에서 접근성을 자동 지원하고,
Semantics1 위젯으로 커스텀 접근성 정보를 추가할 수 있으며,AccessibilityGuideline2 API로 테스트할 수 있다. - 주요 근거: WCAG 2 표준은 4.5<1>1> 색상 대비, 48x48dp 터치 타겟 크기를 권장하고, UN 장애인권리협약은 정보 시스템 접근성을 요구한다.
- 실무 기준: 스크린 리더로 테스트하고, 대비율과 터치 타겟 크기를 확인하며, Semantics 위젯으로 명확한 레이블을 제공한다.
문서가 설명하는 범위
- 접근성 규정과 표준(WCAG, ADA, EAA)
- UI 디자인과 스타일링(폰트 크기, 대비율, 터치 타겟)
- Semantics 위젯을 활용한 스크린 리더 지원
- TalkBack, VoiceOver로 접근성 테스트
- AccessibilityGuideline API로 자동화 테스트
읽는 시간: 18분 | 난이도: 중급
참고 자료
- Accessibility - 접근성 소개
- UI design & styling - UI 디자인 가이드
- Assistive technologies - 보조 기술 지원
- Accessibility testing - 접근성 테스트
문제 상황
앱을 출시했는데 시각 장애가 있는 사용자로부터 “스크린 리더가 버튼을 읽어주지 않는다”는 피드백을 받았습니다. 아이콘만 있는 버튼에 레이블이 없어서 스크린 리더가 “버튼”이라고만 읽어줍니다. 저시력 사용자는 텍스트 색상이 배경과 비슷해서 읽기 어렵다고 합니다.
// 접근성 문제가 있는 코드IconButton( icon: const Icon(Icons.delete), onPressed: () => deleteItem(), // 레이블 없음 - 스크린 리더가 "버튼"이라고만 읽음)
Container( color: Colors.grey.shade300, child: Text( '중요한 정보', style: TextStyle(color: Colors.grey.shade500), // 대비율 낮음 ),)문제는 다음과 같습니다.
- 스크린 리더가 UI 요소를 제대로 설명하지 못한다.
- 색상 대비가 낮아 저시력 사용자가 읽기 어렵다.
- 터치 타겟이 너무 작아 운동 장애가 있는 사용자가 탭하기 어렵다.
- 시스템 폰트 크기 설정을 무시하고 고정 크기를 사용한다.
해결 방법
Flutter는 접근성을 기본으로 지원합니다.
Material과 Cupertino 위젯은 자동으로 접근성 정보를 생성하고, Semantics 위젯으로 커스텀 정보를 추가할 수 있습니다.
챕터 1: 접근성 규정 이해하기
Why
NOTE접근성은 도덕적 의무이자 법적 요구사항입니다. UN 장애인권리협약은 정보 시스템 접근성을 요구하고, 많은 국가에서 이를 법으로 강제합니다.
미국의 ADA(Americans with Disabilities Act), EU의 EAA(European Accessibility Act) 등 법률을 위반하면 법적 문제가 발생할 수 있습니다.
What
NOTE주요 접근성 표준
표준 설명 WCAG 2 Web Content Accessibility Guidelines - 국제 표준 EN 301 549 EU ICT 제품/서비스 접근성 표준 VPAT Voluntary Product Accessibility Template - 자체 평가 도구 주요 법률
법률 지역 요구사항 ADA 미국 공공 시설 차별 금지 Section 508 미국 연방 기관 ICT WCAG 준수 EAA EU 공공/민간 서비스 접근성
Watch out
WARNING앱 출시 전 접근성 체크리스트
- 모든 인터랙티브 요소가 동작해야 합니다. 빈 콜백 대신 SnackBar라도 표시하세요.
- 스크린 리더가 모든 컨트롤을 설명할 수 있어야 합니다.
- 색상 대비율이 4.5<1>1> 이상이어야 합니다.
- 터치 타겟이 48x48dp 이상이어야 합니다.
- 중요한 동작은 실행 취소가 가능해야 합니다.
- 색맹 모드와 흑백 모드에서도 사용 가능해야 합니다.
- 큰 폰트 설정에서도 레이아웃이 유지되어야 합니다.
결론: 접근성 표준을 이해하고 앱 출시 전에 체크리스트를 확인해야 합니다.
챕터 2: 큰 폰트와 텍스트 크기 지원
Why
NOTE사용자는 시스템 설정에서 폰트 크기를 조정할 수 있습니다. 저시력 사용자는 더 큰 폰트가 필요하고, Flutter는 이 설정을 자동으로 존중합니다.
하지만 개발자가 레이아웃을 테스트하지 않으면 큰 폰트에서 UI가 깨질 수 있습니다.
What
NOTEFlutter의 Text 위젯은 시스템 폰트 크기 설정을 자동으로 반영합니다.
MediaQuery.textScalerOf(context)3로 현재 텍스트 배율을 확인할 수 있습니다.
설정 Android 경로 iOS 경로 폰트 크기 설정 > 디스플레이 > 글꼴 크기 설정 > 손쉬운 사용 > 디스플레이 및 텍스트 크기
How
TIP레이아웃이 큰 폰트에서도 유지되도록 설계
class AccessibleLayout extends StatelessWidget {const AccessibleLayout({super.key});@overrideWidget build(BuildContext context) {// 텍스트 배율 확인final textScaler = MediaQuery.textScalerOf(context);return Scaffold(appBar: AppBar(title: const Text('Accessible App')),body: SingleChildScrollView(// 큰 폰트에서 오버플로우 방지를 위해 스크롤 가능하게child: Padding(padding: const EdgeInsets.all(16),child: Column(crossAxisAlignment: CrossAxisAlignment.start,children: [// Flexible 위젯으로 텍스트가 줄바꿈되도록Text('이 텍스트는 시스템 폰트 크기 설정을 따릅니다.',style: Theme.of(context).textTheme.bodyLarge,),const SizedBox(height: 16),// Wrap으로 버튼들이 줄바꿈되도록Wrap(spacing: 8,runSpacing: 8,children: [ElevatedButton(onPressed: () {},child: const Text('버튼 1'),),ElevatedButton(onPressed: () {},child: const Text('버튼 2'),),],),],),),),);}}텍스트 배율 제한 (필요한 경우에만)
// 특정 위젯에서 텍스트 크기 제한이 필요한 경우Text('최대 1.5배까지만 확대',textScaler: MediaQuery.textScalerOf(context).clamp(maxScaleFactor: 1.5,),)
Watch out
WARNING고정 높이를 사용하면 텍스트가 잘립니다
// ❌ 고정 높이 - 큰 폰트에서 텍스트 잘림Container(height: 50,child: Text('긴 텍스트...'),)// ✅ 최소 높이 또는 콘텐츠 기반 높이Container(constraints: BoxConstraints(minHeight: 50),child: Text('긴 텍스트...'),)가장 큰 폰트 설정으로 앱을 테스트하세요.
결론: 시스템 폰트 크기 설정을 존중하고, 레이아웃이 유연하게 조정되도록 설계합니다.
챕터 3: 색상 대비율 확보
Why
NOTE색상 대비가 낮으면 저시력 사용자와 색맹 사용자가 콘텐츠를 읽기 어렵습니다. 직사광선 아래나 밝기가 낮은 화면에서도 대비가 중요합니다.
WCAG는 텍스트 대비율 기준을 제시합니다.
What
NOTEWCAG 권장 대비율
텍스트 크기 최소 대비율 작은 텍스트 (18pt 미만, 14pt 미만 볼드) 4.5<1>1> 큰 텍스트 (18pt 이상, 14pt 이상 볼드) 3.0<1>1> 대비율은 전경색과 배경색의 휘도 비율입니다. 흰색 배경에 검은색 텍스트는 21<1의>1의> 최대 대비율을 가집니다.
How
TIP접근 가능한 색상 사용
class AccessibleColors extends StatelessWidget {const AccessibleColors({super.key});@overrideWidget build(BuildContext context) {return Column(children: [// ✅ 좋은 대비 - 4.5:1 이상Container(color: Colors.white,padding: const EdgeInsets.all(16),child: const Text('읽기 쉬운 텍스트',style: TextStyle(color: Colors.black87, // 높은 대비fontSize: 16,),),),// ✅ 큰 텍스트는 3:1 이상Container(color: Colors.blue.shade900,padding: const EdgeInsets.all(16),child: const Text('헤드라인',style: TextStyle(color: Colors.white,fontSize: 24,fontWeight: FontWeight.bold,),),),// ✅ 비활성화 요소는 대비율 예외const ElevatedButton(onPressed: null, // 비활성화child: Text('비활성화 버튼'),),],);}}테마에서 접근 가능한 색상 정의
ThemeData(colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue,// Material 3은 접근 가능한 색상을 자동 생성),textTheme: const TextTheme(bodyLarge: TextStyle(color: Colors.black87, // 높은 대비),),)
Watch out
WARNING색상만으로 정보를 전달하지 마세요
// ❌ 색상만으로 상태 표시 - 색맹 사용자 문제Container(color: isError ? Colors.red : Colors.green,)// ✅ 색상 + 아이콘/텍스트로 상태 표시Container(color: isError ? Colors.red : Colors.green,child: Row(children: [Icon(isError ? Icons.error : Icons.check),Text(isError ? '오류' : '성공'),],),)색맹 시뮬레이터로 앱을 테스트하세요.
결론: WCAG 대비율 기준을 따르고, 색상 외에도 아이콘이나 텍스트로 정보를 전달합니다.
챕터 4: 터치 타겟 크기
Why
NOTE터치 타겟이 너무 작으면 운동 장애가 있는 사용자나 손 떨림이 있는 사용자가 탭하기 어렵습니다. 손가락으로 정확히 작은 영역을 탭하는 것은 모든 사용자에게 어렵습니다.
What
NOTE플랫폼별 터치 타겟 권장 크기
플랫폼 최소 크기 Android 48x48 dp iOS 44x44 pt WCAG 44x44 CSS 픽셀 Material 위젯(IconButton, ListTile 등)은 기본적으로 이 크기를 만족합니다.
How
TIP적절한 터치 타겟 크기 확보
class AccessibleTouchTargets extends StatelessWidget {const AccessibleTouchTargets({super.key});@overrideWidget build(BuildContext context) {return Column(children: [// ✅ Material 버튼은 기본적으로 48x48 이상IconButton(icon: const Icon(Icons.add),onPressed: () {},),// ✅ 작은 아이콘이지만 터치 영역은 충분히 큼IconButton(iconSize: 20, // 작은 아이콘padding: const EdgeInsets.all(14), // 터치 영역 확보icon: const Icon(Icons.close),onPressed: () {},),// ✅ 커스텀 터치 타겟GestureDetector(onTap: () {},child: Container(width: 48,height: 48,alignment: Alignment.center,child: const Icon(Icons.star, size: 24),),),// ✅ InkWell로 터치 영역 확장InkWell(onTap: () {},child: const Padding(padding: EdgeInsets.all(12),child: Text('탭하세요'),),),],);}}ListTile은 기본적으로 접근 가능
// ListTile은 기본 높이가 56dp로 충분한 터치 타겟ListTile(leading: const Icon(Icons.settings),title: const Text('설정'),onTap: () {},)
Watch out
WARNING터치 타겟이 너무 가까우면 실수로 다른 버튼을 탭합니다
// ❌ 버튼들이 너무 가까움Row(children: [IconButton(icon: Icon(Icons.edit), onPressed: () {}),IconButton(icon: Icon(Icons.delete), onPressed: () {}),],)// ✅ 충분한 간격 확보Row(children: [IconButton(icon: Icon(Icons.edit), onPressed: () {}),const SizedBox(width: 8), // 간격 추가IconButton(icon: Icon(Icons.delete), onPressed: () {}),],)
결론: 모든 터치 타겟은 최소 48x48dp 이상이어야 하고, 서로 충분한 간격을 유지해야 합니다.
챕터 5: Semantics 위젯으로 스크린 리더 지원
Why
NOTE스크린 리더(TalkBack, VoiceOver)는 UI 요소의 의미를 음성으로 읽어줍니다. Material 위젯은 자동으로 접근성 정보를 제공하지만, 커스텀 위젯은 직접 정보를 추가해야 합니다.
아이콘만 있는 버튼은 스크린 리더가 “버튼”이라고만 읽어서 사용자가 기능을 알 수 없습니다.
What
NOTEFlutter는 위젯 트리에서 자동으로 접근성 트리(Semantics Tree)를 생성합니다.
Semantics위젯으로 커스텀 접근성 정보를 추가할 수 있습니다.
속성 설명 label스크린 리더가 읽어주는 텍스트 hint추가 설명 (예: “두 번 탭하여 활성화”) button버튼임을 표시 image이미지임을 표시 excludeSemantics하위 위젯의 Semantics 무시
How
TIPIconButton에 레이블 추가
// 방법 1: tooltip 사용 (권장)IconButton(icon: const Icon(Icons.delete),tooltip: '항목 삭제', // 스크린 리더가 읽음onPressed: () => deleteItem(),)// 방법 2: Semantics 위젯 사용Semantics(label: '항목 삭제',button: true,child: IconButton(icon: const Icon(Icons.delete),onPressed: () => deleteItem(),),)이미지에 설명 추가
// 장식용 이미지 - 스크린 리더가 무시Semantics(image: true,label: '', // 빈 레이블로 장식용 표시child: Image.asset('assets/decoration.png'),)// 의미 있는 이미지 - 설명 제공Semantics(image: true,label: '로그인 성공을 나타내는 체크 아이콘',child: Image.asset('assets/success.png'),)복잡한 커스텀 위젯
class AccessibleRatingWidget extends StatelessWidget {const AccessibleRatingWidget({super.key,required this.rating,required this.onChanged,});final int rating;final ValueChanged<int> onChanged;@overrideWidget build(BuildContext context) {return Semantics(label: '평점 $rating점',hint: '좌우로 스와이프하여 변경',value: '$rating / 5',slider: true,onIncrease: () => onChanged((rating + 1).clamp(1, 5)),onDecrease: () => onChanged((rating - 1).clamp(1, 5)),child: Row(mainAxisSize: MainAxisSize.min,children: List.generate(5, (index) {return GestureDetector(onTap: () => onChanged(index + 1),child: Icon(index < rating ? Icons.star : Icons.star_border,color: Colors.amber,),);}),),);}}그룹화된 요소
// 여러 요소를 하나의 Semantics로 그룹화Semantics(label: '사용자 정보: 홍길동, 서울시',child: Row(children: [// 개별 Text의 Semantics 제외ExcludeSemantics(child: Text('홍길동')),ExcludeSemantics(child: Text('서울시')),],),)
Watch out
WARNINGTextSpan.locale로 다국어 음성 지원
앱에 다국어 텍스트가 있으면 스크린 리더가 올바른 발음으로 읽도록 locale을 지정하세요.
RichText(text: TextSpan(children: [TextSpan(text: '안녕하세요, ',locale: const Locale('ko'),),TextSpan(text: 'Hello!',locale: const Locale('en'),),],),)
결론: Semantics 위젯과 tooltip으로 스크린 리더가 모든 UI 요소를 명확하게 설명하도록 합니다.
챕터 6: 스크린 리더로 테스트하기
Why
NOTE실제 스크린 리더로 테스트해야 사용자 경험을 확인할 수 있습니다. 개발자가 직접 스크린 리더를 사용해보면 접근성 문제를 더 잘 이해할 수 있습니다.
What
NOTE플랫폼별 스크린 리더
플랫폼 스크린 리더 활성화 방법 Android TalkBack 설정 > 손쉬운 사용 > TalkBack iOS VoiceOver 설정 > 손쉬운 사용 > VoiceOver 모빌리티 지원 도구
플랫폼 도구 설명 Android Switch Access 외부 스위치로 조작 Android Voice Access 음성으로 조작 iOS Switch Control 외부 스위치로 조작 iOS Voice Control 음성으로 조작 iOS AssistiveTouch 멀티터치 제스처 대체
How
TIPTalkBack으로 테스트 (Android)
- 설정 > 손쉬운 사용 > TalkBack을 켭니다.
- 화면을 탭하면 TalkBack이 요소를 읽어줍니다.
- 두 번 탭하면 요소를 활성화합니다.
- 좌우로 스와이프하면 다음/이전 요소로 이동합니다.
VoiceOver로 테스트 (iOS)
- 설정 > 손쉬운 사용 > VoiceOver를 켭니다.
- 화면을 탭하면 VoiceOver가 요소를 읽어줍니다.
- 두 번 탭하면 요소를 활성화합니다.
- 좌우로 스와이프하면 다음/이전 요소로 이동합니다.
테스트 체크리스트
- [ ] 모든 버튼에 의미 있는 레이블이 있는가?- [ ] 읽어주는 순서가 논리적인가?- [ ] 화면 전환 시 포커스가 적절한 위치로 이동하는가?- [ ] 에러 메시지가 자동으로 읽히는가?- [ ] 로딩 상태가 알림되는가?
결론: 개발 중에 TalkBack과 VoiceOver로 정기적으로 테스트하세요.
챕터 7: AccessibilityGuideline API로 자동화 테스트
Why
NOTE수동 테스트만으로는 모든 접근성 문제를 찾기 어렵습니다. 자동화 테스트를 추가하면 회귀를 방지하고 일관된 접근성을 유지할 수 있습니다.
What
NOTEFlutter의
AccessibilityGuidelineAPI는 다음을 자동으로 테스트합니다.
가이드라인 설명 androidTapTargetGuidelineAndroid 탭 타겟 최소 48x48dp iOSTapTargetGuidelineiOS 탭 타겟 최소 44x44pt labeledTapTargetGuideline탭 가능한 요소에 레이블 있음 textContrastGuideline텍스트 대비율 3<1>1> 이상
How
TIP접근성 테스트 작성
test/accessibility_test.dart import 'package:flutter_test/flutter_test.dart';import 'package:your_app/main.dart';void main() {testWidgets('앱이 접근성 가이드라인을 따른다', (tester) async {// Semantics 활성화final SemanticsHandle handle = tester.ensureSemantics();// 테스트할 위젯 렌더링await tester.pumpWidget(const MyApp());// Android 탭 타겟 크기 검사 (48x48dp)await expectLater(tester,meetsGuideline(androidTapTargetGuideline),);// iOS 탭 타겟 크기 검사 (44x44pt)await expectLater(tester,meetsGuideline(iOSTapTargetGuideline),);// 레이블 검사await expectLater(tester,meetsGuideline(labeledTapTargetGuideline),);// 텍스트 대비율 검사 (3:1 이상)await expectLater(tester,meetsGuideline(textContrastGuideline),);// 정리handle.dispose();});}특정 화면 테스트
testWidgets('로그인 화면 접근성', (tester) async {final handle = tester.ensureSemantics();await tester.pumpWidget(const MaterialApp(home: LoginScreen()),);// 모든 가이드라인 검사await expectLater(tester, meetsGuideline(androidTapTargetGuideline));await expectLater(tester, meetsGuideline(labeledTapTargetGuideline));await expectLater(tester, meetsGuideline(textContrastGuideline));handle.dispose();});테스트 실행
Terminal window flutter test test/accessibility_test.dart
Watch out
WARNINGAccessibilityGuideline은 모든 문제를 찾지 못합니다
자동화 테스트는 기본적인 검사만 수행합니다. 다음은 여전히 수동 테스트가 필요합니다.
- 레이블이 의미 있는지 (있기만 한 것 vs 이해할 수 있는 것)
- 읽는 순서가 논리적인지
- 포커스 이동이 자연스러운지
- 컨텍스트 변경이 예측 가능한지
자동화 테스트와 수동 스크린 리더 테스트를 함께 사용하세요.
결론: AccessibilityGuideline API로 기본 접근성 검사를 자동화하고, 수동 테스트로 보완합니다.
한계
- 자동화 테스트는 레이블의 품질을 검사하지 못합니다. “버튼 1”보다 “장바구니에 추가”가 더 좋지만, 둘 다 테스트를 통과합니다.
- 색상 대비 테스트는 정적 색상만 검사합니다. 이미지 위의 텍스트는 수동으로 확인해야 합니다.
- 웹 접근성은 별도 문서에서 다룹니다. ARIA 속성과 키보드 탐색은 추가 구현이 필요합니다.
Footnotes
공유
이 글이 도움이 되었다면 다른 사람과 공유해주세요!