앱에서는 다양한 권한이 필요하다. 알림, 위치, 카메라, 마이크, 사진 라이브러리 등. 각 권한은 플랫폼별로 다르게 처리해야 한다.
앱에서 사용하는 권한을 필수와 선택으로 구분했다. 사실 이건 좀 말장난 같긴 하다. 필수인 권한을 허용하지 않았을때 기능을 막아버리면 ios에서 리젝당한다. 유저에게 선택권을 줘야한다나. 맞말이긴 한데... 그 권한이 있어야 그 기능을 에러없이 쓸 수 있는건데..ㅠ 그래서 앱 개발자는 어쩔 수 없이 소극적으로 '이 권한은 필수예요! 허용해주세요!'라고 계속 어필할 수밖에 없다...
그리고 꼭 필요한 권한만 명시해야지 앞으로 할 것 같다고 미리 권한을 넣어 두면 또 리젝당한다. 필요한 것만 넣자.
:
필수 권한:
선택 권한:
앱 최초 실행 시 권한 요청 페이지를 표시한다. 사용자가 권한을 거부하면 설정으로 이동하도록 안내한다.
ios의 경우 한번 사용자가 권한을 거부하면 단순 거부가 아니라 '영구 거부'가 되어버리기 때문에, 이 상태에 대한 대응도 추가로 해야한다.
class PermissionPage extends ConsumerStatefulWidget {
const PermissionPage({super.key});
ConsumerState<PermissionPage> createState() => _PermissionPageState();
}
class _PermissionPageState extends ConsumerState<PermissionPage>
with WidgetsBindingObserver {
static const String _tag = '[PermissionPage]';
static const String title = '원활한 서비스 이용을 위해 \n일부 권한이 필요해요';
bool _hasShownInitialDialog = false;
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
}
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
// 앱이 포그라운드로 돌아올 때 권한 상태를 다시 확인
if (_hasShownInitialDialog) {
getPermissionState().then((allGranted) {
if (allGranted && mounted) {
navigateBasedOnToken();
}
});
}
}
}
}
앱이 포그라운드로 돌아올 때 권한 상태를 다시 확인한다. 사용자가 설정에서 권한을 허용하고 돌아왔을 때 바뀐 상태가 반영되어있지 않으면 오해를 일으킬 수 있기 때문이다. 그냥 봤을때 버그처럼 보이기도 하고.
Future<bool> getPermissionState() async {
try {
return await Permission.notification.isGranted;
} catch (e, stackTrace) {
await ref.read(crashlyticsServiceProvider).recordError(
e,
stackTrace,
reason: '$_tag 권한 상태 확인 실패',
);
debugPrint('권한 상태 확인 실패: $e');
return false;
}
}
Future<void> requestPermissions() async {
try {
if (await getPermissionState()) {
await navigateBasedOnToken();
return;
}
_hasShownInitialDialog = true;
if (!context.mounted || !mounted) return;
return showPermissionDialog(
context,
() async {
try {
if (Platform.isAndroid) {
await _handleAndroidPermission();
} else if (Platform.isIOS) {
await _handleIOSPermission();
}
} catch (e, stackTrace) {
await ref.read(crashlyticsServiceProvider).recordError(
e,
stackTrace,
reason: '$_tag 권한 요청 실패',
information: [
'플랫폼: ${Platform.operatingSystem}',
'플랫폼 버전: ${Platform.operatingSystemVersion}',
],
);
debugPrint('권한 요청 실패: $e');
}
},
);
} catch (e, stackTrace) {
// 에러 처리
}
}
Android 13 이상에서는 알림 권한을 명시적으로 요청해야 한다. Android 12 이하에서는 자동으로 허용된다.
Future<void> _handleAndroidPermission() async {
try {
final deviceInfo = DeviceInfoPlugin();
final androidInfo = await deviceInfo.androidInfo;
final sdkInt = androidInfo.version.sdkInt;
if (sdkInt >= 33) {
// Android 13 이상: 알림 권한 상태 확인
var status = await Permission.notification.status;
if (status.isPermanentlyDenied) {
// 영구 거부: 설정 이동 안내
if (!context.mounted || !mounted) return;
showSettingRedirectModal(context, permissionName: '알림');
} else {
// 권한 요청 가능
await Permission.notification.request();
await navigateBasedOnToken();
}
} else {
// Android 12 이하: 권한 요청 없이 바로 다음 단계
await navigateBasedOnToken();
}
} catch (e, stackTrace) {
await ref.read(crashlyticsServiceProvider).recordError(
e,
stackTrace,
reason: '$_tag Android 권한 처리 실패',
information: [
'Android SDK: ${(await DeviceInfoPlugin().androidInfo).version.sdkInt}',
],
);
debugPrint('Android 권한 처리 실패: $e');
}
}
Android에서는 AndroidManifest.xml에 필요한 권한을 선언해야 한다.
<!-- 인터넷 -->
<uses-permission android:name="android.permission.INTERNET"/>
<!-- 미디어 파일 읽기 (Android 13+) -->
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
<uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION" />
<!-- 알림 -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<uses-permission android:name="android.permission.VIBRATE"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<!-- 카메라 및 오디오 -->
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<!-- 위치 정보 -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<!-- 캘린더 -->
<uses-permission android:name="android.permission.READ_CALENDAR" />
<uses-permission android:name="android.permission.WRITE_CALENDAR" />
<!-- 전화 -->
<uses-permission android:name="android.permission.CALL_PHONE" />
iOS에서는 알림 권한을 요청하고, '영구 거부'된 경우 설정으로 이동하도록 안내한다.
Future<void> _handleIOSPermission() async {
try {
var status = await Permission.notification.status;
if (status.isPermanentlyDenied) {
if (!context.mounted || !mounted) return;
showSettingRedirectModal(context, permissionName: '알림');
} else {
await Permission.notification.request();
await navigateBasedOnToken();
}
} catch (e, stackTrace) {
await ref.read(crashlyticsServiceProvider).recordError(
e,
stackTrace,
reason: '$_tag iOS 권한 처리 실패',
);
debugPrint('iOS 권한 처리 실패: $e');
}
}
iOS에서는 Info.plist에 각 권한에 대한 사용 목적을 설명해야 한다. 이 목적을 대충 쓰면 역시 리젝당한다. 그치만 조금이라도 정성을 보이면 통과되는 듯 함. 그치만 사용자가 권한을 요청받을 때 이 설명이 표시되니 대충 쓰지 말고 예쁘게 잘 안내하자.
<!-- 알림 -->
<key>NSLocalNotificationUsageDescription</key>
<string>진료 예약 알림, 처방전 도착 알림, 비대면 진료 시작 알림 등 중요한 의료 관련 정보를 놓치지 않기 위해 알림 권한이 필요합니다. 알림을 허용하지 않으면 진료 관련 중요한 정보를 놓칠 수 있습니다.</string>
<!-- 위치 -->
<key>NSLocationWhenInUseUsageDescription</key>
<string>접수 가능한 병원을 찾고, 진료 접수 시 위치 기반 서비스를 제공하기 위해 위치 권한이 필요합니다.</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>접수 가능한 병원을 찾고, 진료 접수 시 위치 기반 서비스를 제공하기 위해 위치 권한이 필요합니다.</string>
<!-- 카메라 -->
<key>NSCameraUsageDescription</key>
<string>카메라 사용을 위해 권한이 필요합니다.</string>
<!-- 마이크 -->
<key>NSMicrophoneUsageDescription</key>
<string>비대면 진료를 하기 위해 음성 사용을 위해 권한이 필요합니다.</string>
<!-- 사진 라이브러리 -->
<key>NSPhotoLibraryUsageDescription</key>
<string>진단 및 치료를 위해 필요한 의료 이미지를 업로드하고 공유하기 위해 사진 라이브러리 접근이 필요합니다. 예를 들어, X-ray 이미지나 상처 사진을 의사와 공유할 수 있습니다.</string>
<!-- 캘린더 -->
<key>NSCalendarsUsageDescription</key>
<string>캘린더에 예약 및 접수 일정을 추가하기 위해 권한이 필요합니다.</string>
<key>NSCalendarsFullAccessUsageDescription</key>
<string>캘린더에 예약 및 접수 일정을 추가하기 위해 권한이 필요합니다.</string>
우리 앱의 경우 진료 접수 시 혹은 회사 근처에서 출퇴근 버튼을 누르기 위해서 사용함. 위치 기반 서비스를 제공하기 위해 위치 권한이 필요하다. 위치 권한은 런타임에 요청한다.
class LocationService implements ILocationService {
Future<bool> checkPermission() async {
try {
final status = await Permission.locationWhenInUse.status;
final statusAlways = await Permission.locationAlways.status;
return status.isGranted || statusAlways.isGranted;
} catch (e, stackTrace) {
debugPrint('위치 권한 확인 실패: $e');
return false;
}
}
Future<LocationPermission> requestPermission() async {
try {
final permission = await Geolocator.requestPermission();
debugPrint('위치 권한 요청 결과: $permission');
return permission;
} catch (e, stackTrace) {
debugPrint('위치 권한 요청 실패: $e');
return LocationPermission.denied;
}
}
}
위치 권한이 없으면 사용자에게 안내하고 권한을 요청한다.
Future<void> _checkPermissionAndStartTracking() async {
final locationService = ref.read(locationServiceProvider);
final hasPermission = await locationService.checkPermission();
if (context.mounted && mounted && !hasPermission) {
showPermissionDialog(context);
return;
}
// 위치 추적 시작
locationService.startLocationUpdates((position) {
// 위치 업데이트 처리
});
}
사용자가 설정에서 권한 상태를 확인하고 변경할 수 있도록 권한 설정 페이지를 구현했다.
(keepAlive: true)
class Setting extends _$Setting {
Future<SettingState> build() async {
await checkPermissions();
return SettingState.init();
}
Future<void> checkPermissions() async {
try {
final notificationGranted = await Permission.notification.isGranted;
final locationGranted = await Permission.location.isGranted;
final calendarGranted = await Permission.calendarFullAccess.isGranted;
state = AsyncData(SettingState(
notificationGranted: notificationGranted,
locationGranted: locationGranted,
calendarGranted: calendarGranted,
));
} catch (e, stackTrace) {
// 에러 처리
}
}
}
사용자가 권한을 변경하려면 시스템 설정으로 이동해야 한다. type을 주지 않으면 기본 시스템 설정으로 이동된다. type을 파라미터로 넘기면 특정 권한의 설정으로 이동되니 유저 경험을 위해 명시해 두는 게 좋다.
Future<void> _handlePermissionToggle(
Permission permission,
bool? currentValue,
) async {
try {
state = state.copyWith(status: SettingStatus.loading);
switch (permission) {
case Permission.notification:
await AppSettings.openAppSettings(type: AppSettingsType.notification);
break;
case Permission.location:
case Permission.locationWhenInUse:
await AppSettings.openAppSettings(type: AppSettingsType.location);
break;
default:
await AppSettings.openAppSettings();
}
await _refreshPermissions();
state = state.copyWith(status: SettingStatus.success);
} catch (e, stackTrace) {
// 에러 처리
}
}
permission_handler는 Flutter에서 다양한 권한을 요청하고 상태를 확인하는 데 사용하는 패키지.
dependencies:
permission_handler: ^11.3.1
// 권한 상태 확인
final status = await Permission.notification.status;
// 권한 상태 종류
// - isGranted: 권한이 허용됨
// - isDenied: 권한이 거부됨
// - isPermanentlyDenied: 권한이 영구 거부됨 (설정에서만 변경 가능)
// - isRestricted: 권한이 제한됨 (iOS의 경우)
// - isLimited: 권한이 제한적으로 허용됨 (iOS 14+)
// 단일 권한 요청
final status = await Permission.notification.request();
// 여러 권한 동시 요청
final statuses = await [
Permission.notification,
Permission.location,
Permission.camera,
].request();
// 알림
Permission.notification
// 위치
Permission.location
Permission.locationWhenInUse
Permission.locationAlways
// 미디어
Permission.camera
Permission.microphone
Permission.photos
Permission.videos
Permission.audio
// 저장소
Permission.storage
Permission.manageExternalStorage
// 캘린더
Permission.calendar
Permission.calendarFullAccess
// 기타
Permission.phone
Permission.sms
Permission.contacts
// 알림 권한 요청
Future<void> requestNotificationPermission() async {
final status = await Permission.notification.status;
if (status.isGranted) {
// 이미 권한이 허용됨
return;
}
if (status.isPermanentlyDenied) {
// 영구 거부: 설정으로 이동 안내
await AppSettings.openAppSettings(type: AppSettingsType.notification);
return;
}
// 권한 요청
final result = await Permission.notification.request();
if (result.isGranted) {
// 권한 허용됨
debugPrint('알림 권한이 허용되었습니다.');
} else if (result.isDenied) {
// 권한 거부됨
debugPrint('알림 권한이 거부되었습니다.');
}
}
Android:
AndroidManifest.xml에 필요한 권한을 선언해야 함iOS:
Info.plist에 권한 사용 목적 설명(UsageDescription)이 필요app_settings는 시스템 설정 화면으로 이동하는 기능을 제공하는 패키지임.
dependencies:
app_settings: ^5.1.1
// 앱의 일반 설정 화면 열기
await AppSettings.openAppSettings();
// 알림 설정
await AppSettings.openAppSettings(type: AppSettingsType.notification);
// 위치 설정
await AppSettings.openAppSettings(type: AppSettingsType.location);
// 앱 정보 설정
await AppSettings.openAppSettings(type: AppSettingsType.settings);
enum AppSettingsType {
wifi, // WiFi 설정
location, // 위치 설정
bluetooth, // 블루투스 설정
notification, // 알림 설정
settings, // 앱 설정
security, // 보안 설정
accessibility, // 접근성 설정
airplaneMode, // 비행기 모드 설정
cellular, // 셀룰러 설정
dataRoaming, // 데이터 로밍 설정
date, // 날짜/시간 설정
display, // 디스플레이 설정
internalStorage, // 내부 저장소 설정
memoryCard, // 메모리 카드 설정
sound, // 사운드 설정
batteryOptimization, // 배터리 최적화 설정
}
// 권한이 영구 거부된 경우 설정으로 이동
Future<void> openPermissionSettings(String permissionName) async {
try {
// 권한 종류에 따라 다른 설정 화면으로 이동
switch (permissionName) {
case '알림':
await AppSettings.openAppSettings(type: AppSettingsType.notification);
break;
case '위치':
await AppSettings.openAppSettings(type: AppSettingsType.location);
break;
default:
await AppSettings.openAppSettings();
}
} catch (e) {
debugPrint('설정 화면 열기 실패: $e');
// 일반 설정 화면으로 대체
await AppSettings.openAppSettings();
}
}
Android:
iOS:
권한 관리에서 두 패키지를 함께 사용하는 일반적인 패턴:
class PermissionManager {
// 권한 요청 및 처리
Future<bool> requestPermission(Permission permission) async {
// 1. 현재 권한 상태 확인
var status = await permission.status;
// 2. 이미 허용된 경우
if (status.isGranted) {
return true;
}
// 3. 영구 거부된 경우: 설정으로 이동 안내
if (status.isPermanentlyDenied) {
await _showSettingDialog(permission);
return false;
}
// 4. 권한 요청
status = await permission.request();
if (status.isGranted) {
return true;
} else if (status.isPermanentlyDenied) {
// 요청 후에도 영구 거부된 경우
await _showSettingDialog(permission);
return false;
}
return false;
}
// 설정으로 이동 안내
Future<void> _showSettingDialog(Permission permission) async {
final context = NavigationService.context;
if (context == null) return;
final permissionName = _getPermissionName(permission);
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text('권한 필요'),
content: Text('$permissionName 권한이 필요합니다.\n설정에서 권한을 허용해주세요.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text('취소'),
),
TextButton(
onPressed: () async {
Navigator.pop(context);
await _openAppSettings(permission);
},
child: Text('설정으로 이동'),
),
],
),
);
}
// 권한 종류에 따라 적절한 설정 화면 열기
Future<void> _openAppSettings(Permission permission) async {
if (permission == Permission.notification) {
await AppSettings.openAppSettings(type: AppSettingsType.notification);
} else if (permission == Permission.location ||
permission == Permission.locationWhenInUse) {
await AppSettings.openAppSettings(type: AppSettingsType.location);
} else {
await AppSettings.openAppSettings();
}
}
String _getPermissionName(Permission permission) {
if (permission == Permission.notification) return '알림';
if (permission == Permission.location) return '위치';
if (permission == Permission.camera) return '카메라';
if (permission == Permission.microphone) return '마이크';
if (permission == Permission.photos) return '사진';
if (permission == Permission.calendar) return '캘린더';
return '권한';
}
}
앞서 설명한 내용들을 실제 구현할 때 주의해야 할 점들을 정리했다.
사용자가 권한을 '영구 거부'한 경우, 설정으로 이동하도록 안내해야 한다. 해당 상태에 대해 대응하지 않으면 아무 동작도 하지 않는다. (자세한 구현은 섹션 7.3 참고)
각 권한의 사용 목적을 명확하게 설명해야 한다. 특히 iOS의 경우 Info.plist에 작성한 설명이 사용자에게 표시되므로 신중하게 작성해야 한다. (자세한 내용은 섹션 4.2 참고)
Android와 iOS에서 권한 처리 방식이 다르므로 플랫폼별로 분기 처리해야 한다:
앱이 포그라운드로 돌아올 때 권한 상태를 다시 확인해야 한다. 사용자가 설정에서 권한을 변경했을 수 있기 때문이다. (구현 방법은 섹션 2.1, 2.2 참고)
권한 요청 실패 시 사용자에게 적절한 안내를 제공하고, 에러를 로깅해야 한다. (에러 처리 예시는 섹션 2.3, 3.1, 4.1 참고)
증상: Android 13 이상에서 알림이 표시되지 않음
원인: Android 13부터 알림 권한을 명시적으로 요청해야 함
해결: Android SDK 버전을 확인하고, 33 이상이면 알림 권한을 요청
증상: 사용자가 권한을 영구 거부한 후, 다시 요청해도 다이얼로그가 표시되지 않음
원인: 영구 거부된 권한은 앱에서 직접 요청할 수 없음
해결: 설정으로 이동하도록 안내하는 모달 표시
증상: 사용자가 설정에서 권한을 허용하고 돌아와도 앱에서 인식하지 못함
원인: 앱 생명주기 변경 시 권한 상태를 다시 확인하지 않음
해결: WidgetsBindingObserver를 사용해서 앱이 포그라운드로 돌아올 때 권한 상태 확인
권한 관리는 사용자 경험에 직접적인 영향을 미친다. 권한이 없으면 기능이 제대로 실행되지 않거나 아예 동작하지 않기 때문.
특히:
permission_handler와 app_settings 패키지를 적절히 활용