우아하게(?) react native 권한 관리하기

dalbodre·2022년 3월 8일
9

study

목록 보기
4/6
post-thumbnail

Android, iOS의 사용자 권한

개발자가 사용자의 위치, 카메라, 사진, 파일 등의 데이터에 접근하기 위해서는 해당 접근에 대한 사용자 권한 승인이 필수적이다. 기기에 상관없이 모든 권한들이 통일되어있다면 더욱 좋았겠지만 애플과 안드로이드는 각자만의 권한 처리 방식을 사용한다. 특히 iOS 13버전/android API30에서 권한과 관련된 여러 업데이트들이 존재하는데, 몇가지 중요한 점들만 살펴본다면 아래와 같다.

  1. iOS
  • 사용자가 한번이라도 권한 요청을 거절하는 경우, 앱에서 권한 재요청이 불가하다.
    • (사용자가 설정 페이지로 이동하도록 하여 권한 승인을 자연스럽게 유도하자!)
  • iOS 13버전에서 위치 정보 권한에 대한 always allow 유저 플로우가 변경되었다.
    • 처음 사용자에게 위치 권한을 요청하는 창에는 '앱 사용 중에만 허용', '한번 허용', '승인하지 않음' 세 가지 선택지만 존재한다.
    • 사용자가 '앱 사용 중에만 허용' 옵션을 선택하고 앱을 지속적으로 사용한다면, 시스템이 "Allow ~~~ to also access your location even when you are not using the app?"이라고 물어본다. 해당 권한 요청 시스템 모달에는 'Keep Only While Using' 옵션과 'Change to Always Allow' 옵션이 포함된다.
    • 항상 허용 옵션을 통해 위치 권한을 승인했더라도, 주기적으로 (3일에 한번씩) 추적된 위치정보를 맵으로 보여주면서 위치 권한을 유지할 것인지 묻는 모달이 뜬다.
  • iOS 13버전에서 일회성 권한 승인 옵션 추가
    • single session에서만 권한을 grant한다. 사용자가 앱을 relaunch할 때마다 시스템 모달이 다시 뜨게된다.
  1. Android
  • 사용자가 권한을 거절하여도, 서비스에서 런타임 권한 설정을 재요청할 수 있다.
    • 단, 사용자가 한번이라도 이미 권한 승인 요청을 거절한 이후에는 '다시 묻지 않기' 옵션이 추가된다. (이를 선택하지 않으면 계속해서 권한 재요청 가능)
  • 백그라운드, 포어그라운드 위치 정보 접근 권한이 나뉘어있다.
    • 앱이 실행되고 있을 때에만 접근 가능한 포어그라운드 위치 정보(ACCESS_FINE_LOCATION 및 ACCESS_COARSE_LOCATION)와 백그라운드에 있을 때에도 접근 가능한 ACCESS_BACKGROUND_LOCAITON이 구별된다.
    • ACCESS_COARSE_LOCAITON은 네트워크만을 이용하여 단말기 위치를 구분하고, ACCESS_FINE_LOCATION은 GPS와 네트워크를 함께 사용하여 더욱 정확한 위치 정보를 제공한다.
    • 개발자가 FINE LOCATION에 대한 권한을 요청하더라도, 시스템은 사용자가 COARSE LOCATION에 대한 정보만을 제공할 수 있도록 하고 있다. 따라서 manifest에서 두가지 권한을 모두 요청하거나 덜 민감한 정보인 COARSE LOCAITON 권한만을 요청해야 한다. (아래의 그림 참고. ACCESS_FINE_LOCATION 권한만 요청하는 경우 Android 31-API12-부터는 무시된다)
  • Android 30(API11)에서 일회성 권한 승인 옵션 추가
    • 단 iOS와는 다르게 권한 유지가 일정 시간동안 유지되어, 앱을 재실행해도 권한이 승인되어있는 경우가 발생하기도 한다.
    • 오랜 시간동안 앱을 사용하지 않은 경우, 시스템이 강제로 앱의 (민감한) 런타임 권한을 삭제한다.
    • iOS와 비슷하게 항상 허용 옵션이 기본 시스템 권한 모달에 옵션으로 포함되어있지 않다. 항상 허용 옵션이 필요한 권한의 경우에만 사용자가 설정페이지로 이동할 수 있는 링크가 시스템모달 본분에 포함된다.

react-native-permissions, react native에서 사용자 권한 관리하기

다행히 react native에서도 권한 요청 및 확인을 할 수 있도록 하는 react-native-permissions라는 라이브러리가 존재한다. docs가 매우 자세하게 나와있기는 하지만, 영어보다 한국어가 편한 나 포함 사람들을 위해 사용방법을 코드예시와 함께 정리해보았다.

1. 라이브러리 설치

yarn add react-native-permissions 명령어를 통해 설치한다. (만약 react native 버전이 63 미만이라면 linking 설정이 추가로 필요하다. 이건 내가 안쓰니까 패스)

2. 안드로이드 manifest 권한 설정

android/apps/src/main/AndroidManifest.xml에 사용할 권한을 아래와 같이 추가합니다. 앞서 말했듯이 ACCESS_FINE_LOCATION을 사용하기 위해서는 ACCESS_COARCE_LOCATION이 필수로 필요하다는 것을 잊지 않도록 한다.

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
  package="com.myawesomeapp">

  <!-- 🚨 Keep only the permissions used in your app 🚨 -->

  <uses-permission android:name="android.permission.ACCEPT_HANDOVER" />
  <uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
  <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
  <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
  <uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION" />
  <uses-permission android:name="android.permission.ACTIVITY_RECOGNITION" />
  <uses-permission android:name="android.permission.ANSWER_PHONE_CALLS" />
  <uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
  <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
  <uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
  <uses-permission android:name="android.permission.BODY_SENSORS" />
  <uses-permission android:name="android.permission.CALL_PHONE" />
  <uses-permission android:name="android.permission.CAMERA" />
  <uses-permission android:name="android.permission.GET_ACCOUNTS" />
  <uses-permission android:name="android.permission.PROCESS_OUTGOING_CALLS" />
  <uses-permission android:name="android.permission.READ_CALENDAR" />
  <uses-permission android:name="android.permission.READ_CALL_LOG" />
  <uses-permission android:name="android.permission.READ_CONTACTS" />
  <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
  <uses-permission android:name="android.permission.READ_PHONE_NUMBERS" />
  <uses-permission android:name="android.permission.READ_PHONE_STATE" />
  <uses-permission android:name="android.permission.READ_SMS" />
  <uses-permission android:name="android.permission.RECEIVE_MMS" />
  <uses-permission android:name="android.permission.RECEIVE_SMS" />
  <uses-permission android:name="android.permission.RECEIVE_WAP_PUSH" />
  <uses-permission android:name="android.permission.RECORD_AUDIO" />
  <uses-permission android:name="android.permission.SEND_SMS" />
  <uses-permission android:name="android.permission.USE_SIP" />
  <uses-permission android:name="android.permission.WRITE_CALENDAR" />
  <uses-permission android:name="android.permission.WRITE_CALL_LOG" />
  <uses-permission android:name="android.permission.WRITE_CONTACTS" />
  <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
  <uses-permission android:name="com.android.voicemail.permission.ADD_VOICEMAIL" />

  <!-- … -->

</manifest>

3. iOS 권한 설정

처음에는 iOS의 권한 handler가 아무것도 설치되어있지 않다. ios/Podfile에 아래와 같이 추가하려는 권한에 대한 permission handler을 추가하고 ios 폴더에서 pod install명령어를 실행한다.

target 'YourAwesomeProject' do

  # …

  permissions_path = '../node_modules/react-native-permissions/ios'

  pod 'Permission-AppTrackingTransparency', :path => "#{permissions_path}/AppTrackingTransparency"
  pod 'Permission-BluetoothPeripheral', :path => "#{permissions_path}/BluetoothPeripheral"
  pod 'Permission-Calendars', :path => "#{permissions_path}/Calendars"
  pod 'Permission-Camera', :path => "#{permissions_path}/Camera"
  pod 'Permission-Contacts', :path => "#{permissions_path}/Contacts"
  pod 'Permission-FaceID', :path => "#{permissions_path}/FaceID"
  pod 'Permission-LocationAccuracy', :path => "#{permissions_path}/LocationAccuracy"
  pod 'Permission-LocationAlways', :path => "#{permissions_path}/LocationAlways"
  pod 'Permission-LocationWhenInUse', :path => "#{permissions_path}/LocationWhenInUse"
  pod 'Permission-MediaLibrary', :path => "#{permissions_path}/MediaLibrary"
  pod 'Permission-Microphone', :path => "#{permissions_path}/Microphone"
  pod 'Permission-Motion', :path => "#{permissions_path}/Motion"
  pod 'Permission-Notifications', :path => "#{permissions_path}/Notifications"
  pod 'Permission-PhotoLibrary', :path => "#{permissions_path}/PhotoLibrary"
  pod 'Permission-PhotoLibraryAddOnly', :path => "#{permissions_path}/PhotoLibraryAddOnly"
  pod 'Permission-Reminders', :path => "#{permissions_path}/Reminders"
  pod 'Permission-Siri', :path => "#{permissions_path}/Siri"
  pod 'Permission-SpeechRecognition', :path => "#{permissions_path}/SpeechRecognition"
  pod 'Permission-StoreKit', :path => "#{permissions_path}/StoreKit"

end

이후 ios/프로젝트이름/Info.plist에 권한에 대한 설명을 추가한다. 해당 string은 시스템 권한 설정 모달의 메시지로서 사용된다. 복붙하다가 카메라 권한 요청 모달에 갤러리 접근 메시지가 뜨는 불상사가 없도록 하자. XCode에서 직접 설정도 가능하다.

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>

  <!-- 🚨 Keep only the permissions used in your app 🚨 -->

  <key>NSAppleMusicUsageDescription</key>
  <string>YOUR TEXT</string>
  <key>NSBluetoothAlwaysUsageDescription</key>
  <string>YOUR TEXT</string>
  <key>NSBluetoothPeripheralUsageDescription</key>
  <string>YOUR TEXT</string>
  <key>NSCalendarsUsageDescription</key>
  <string>YOUR TEXT</string>
  <key>NSCameraUsageDescription</key>
  <string>YOUR TEXT</string>
  <key>NSContactsUsageDescription</key>
  <string>YOUR TEXT</string>
  <key>NSFaceIDUsageDescription</key>
  <string>YOUR TEXT</string>
  <key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
  <string>YOUR TEXT</string>
  <key>NSLocationAlwaysUsageDescription</key>
  <string>YOUR TEXT</string>
  <key>NSLocationTemporaryUsageDescriptionDictionary</key>
  <dict>
    <key>YOUR-PURPOSE-KEY</key>
    <string>YOUR TEXT</string>
  </dict>
  <key>NSLocationWhenInUseUsageDescription</key>
  <string>YOUR TEXT</string>
  <key>NSMicrophoneUsageDescription</key>
  <string>YOUR TEXT</string>
  <key>NSMotionUsageDescription</key>
  <string>YOUR TEXT</string>
  <key>NSPhotoLibraryUsageDescription</key>
  <string>YOUR TEXT</string>
  <key>NSPhotoLibraryAddUsageDescription</key>
  <string>YOUR TEXT</string>
  <key>NSRemindersUsageDescription</key>
  <string>YOUR TEXT</string>
  <key>NSSpeechRecognitionUsageDescription</key>
  <string>YOUR TEXT</string>
  <key>NSSiriUsageDescription</key>
  <string>YOUR TEXT</string>
  <key>NSUserTrackingUsageDescription</key>
  <string>YOUR TEXT</string>

  <!-- … -->

</dict>
</plist>

4. 실제 권한 check, request 코드 작성하기

react-native-permissions의 권한 체크/요청에 따른 결과는 아래 표를 따른다.

Return valueNotes
RESULTS.UNAVAILABLEThis feature is not available (on this device / in this context)
RESULTS.DENIEDThe permission has not been requested / is denied but requestable
RESULTS.GRANTEDThe permission is granted
RESULTS.LIMITEDThe permission is granted but with limitations
RESULTS.BLOCKEDThe permission is denied and not requestable anymore

권한 체크/요청 플로우는 아래와 같다.

  1. 권한 체크 요청
  2. 해당 feature가 기기에서 사용 가능한지 확인 (불가하면 UNAVAILABLE)
  3. 해당 feature가 기기에서 요청 가능한지 확인 (불가하면 GRANTED, LIMITED,BLOCKED)
  4. DENIED 상태를 확인한 후, 권한 승인 요청
  5. 권한 승인 시 GRANTED, iOS에서 거절 시 BLOCKED, Android에서 거절 시 DENIED, Android에서 '다시 보지 않기' 옵션 선택 시 BLOCKED

해당 플로우를 따라 아래와 같이 코드로 작성할 수 있다. check 결과가 DENIED일 때에 request를 실행했으며, 그 결과가 GRANTED가 아닐 경우 LIMITED, BLOCKED와 같이 취급하도록 하였다. (LIMITED의 경우 권한 종류에 따라 달라지는데, 해당 프로젝트에서는 앱 사용 중에만 위치 정보에 접근하기 때문에 따로 케이스를 분리하지는 않았다)

let requested: PermissionStatus;
const checked = await check(needPermission);
switch (checked) {
  case RESULTS.UNAVAILABLE:
    return handlePermissionError(
      strings.PERMISSION_UNAVAILABLE,
      essential
    );
  case RESULTS.GRANTED:
    return handlePermissionSuccess();
  case RESULTS.DENIED:
    requested = await request(needPermission);
    if (requested === RESULTS.GRANTED) {
      return handlePermissionSuccess();
    }
  case RESULTS.LIMITED:
  case RESULTS.BLOCKED:
  default:
    return handlePermissionError(strings.PERMISSION_BLOCKED, essential);
}

우아하게(?) 권한 관리하기


iOSAndroid 모두 권한 요청 시도가 있을 때에는, 위의 그림과 같이 사용자에게 해당 권한이 왜 필요하고 어떻게 사용되는지 전달하는 것을 권유하고있다. 그러나 모든 개별 권한에 맞춤형 페이지를 만드는 것도 어려웠고, 실제 그 기능을 사용할 때에 (보통 한 두개의) 권한에 대한 설명 메시지를 간략하게 보여주는 것 만으로도 충분히 사용자에게 권한 요청에 대한 이유를 설명할 수 있을 것이라고 생각했다. 아래는 dot slash dash라는 어플인데, 이런 방식으로 앞에 시스템 권한 요청 모달이 뜨면 뒤쪽에 해당 권한에 대한 설명이 보이도록 위의 함수를 업데이트했다.

react native 앱의 최상단에 모달을 삽입하고 해당 모달을 관리하는 mobx store을 사용하여, 앞에서 짰던 권한요청 함수가 실행될 때에 open 여부와 message를 변경하는 getPermission 함수를 작성하였다. 함수는 permission 이름을 받아, 기기가 iOS인지 Android인지를 확인하고 그에 맞는 권한을 체크/요청한다. 권한이 승인된 경우에는 인자로 받은 onSuccess 함수를 실행하고, 권한이 거절되면 (essential 여부에 따라 설정페이지로 이동하며) onFailed 함수를 실행하도록 구성하였다.

const androidPermissions: PermissionsPerOS = {
    location: PERMISSIONS.ANDROID.ACCESS_FINE_LOCATION,
    camera: PERMISSIONS.ANDROID.CAMERA,
    photo: PERMISSIONS.ANDROID.READ_EXTERNAL_STORAGE,
};
const iosPermissions: PermissionsPerOS = {
    location: PERMISSIONS.IOS.LOCATION_WHEN_IN_USE,
    camera: PERMISSIONS.IOS.CAMERA,
    photo: PERMISSIONS.IOS.PHOTO_LIBRARY,
};
const permissionsPerOS =
    Platform.OS === strings.PLATFORM_IOS ? iosPermissions : androidPermissions;

const getPermission = async (
    permission: PossiblePermission,
    onSuccess?: () => void,
    onFailed?: () => void,
    essential = false
): Promise<boolean> => {
    const needPermission = permissionsPerOS[permission];
    permissionModalStore.setMessage(PERMISSION_REQUEST_MESSAGE[permission]);
    permissionModalStore.setOpen(true);

    const handlePermissionSuccess = () => {
        if (onSuccess) onSuccess();
        permissionModalStore.setOpen(false);
        permissionModalStore.setMessage('');
        return true;
    };

    const handlePermissionError = (message: string, openSetting = false) => {
        if (openSetting) goToSettings(message);
        if (onFailed) onFailed();
        permissionModalStore.setOpen(false);
        permissionModalStore.setMessage('');
        return false;
    };

    let requested: PermissionStatus;
    const checked = await check(needPermission);
    switch (checked) {
        case RESULTS.UNAVAILABLE:
            return handlePermissionError(
                strings.PERMISSION_UNAVAILABLE,
                essential
            );
        case RESULTS.GRANTED:
            return handlePermissionSuccess();
        case RESULTS.DENIED:
            requested = await request(needPermission);
            if (requested === RESULTS.GRANTED) {
                return handlePermissionSuccess();
            }
        case RESULTS.LIMITED:
        case RESULTS.BLOCKED:
        default:
            return handlePermissionError(strings.PERMISSION_BLOCKED, essential);
    }
};

여러 권한 한번에 요청하기

앱을 사용하다가 사진을 첨부하거나, 근처 매장을 찾는 등의 경우에는 한 두개의 권한에만 승인 요청이 발생한다. 그러나 앱을 처음 깔거나, 앱을 실행할 때마다(앱에 따라서는..좋지 않은 플로우기는 하다) 앱에서 필요한 설정들을 설명하고 한번에 승인을 요청하기도 한다. 이러한 권한 요청들을 리스트로 받아, 리스트 순서대로 권한을 요청하는 getPermissions 함수를 작성해보자. Promise들을 순차적으로 실행 보장하기 위해 재귀적인 방법을 쓰기도 하지만 아래 함수에서는 reduce와 async/await를 사용하였다.

const getPermissions = async (
    permissions: PossiblePermission[],
    onSuccess?: () => void,
    onFailed?: () => void,
    essential = false
): Promise<void> => {
    const permissionsResult = permissions.reduce(
        async (previousPermission, currentPermission) => {
            const previousPermissionResult = await previousPermission;
            const currentPermissionResult = await getPermission(
                currentPermission
            );
            previousPermissionResult.push(currentPermissionResult);
            return previousPermissionResult;
        },
        Promise.resolve<boolean[]>([])
    );

    permissionsResult.then((result) => {
        if (result.every(Boolean) && onSuccess) onSuccess();
        if (!result.every(Boolean) && onFailed) {
            if (essential) goToSettings(strings.PERMISSION_BLOCKED);
            onFailed();
        }
    });
};

아래는 실제로 getPermissions(['location', 'camera', 'photo']) 함수를 실행했을 때 확인할 수 있는 결과이다.

profile
휘뚜루마뚜루

2개의 댓글

comment-user-thumbnail
2022년 6월 19일

이제는 안드로이드에 do not ask again도 안뜨던데요

답글 달기
comment-user-thumbnail
2023년 10월 18일

코드가 아름답네요 :) 잘 읽고 갑니다.

답글 달기