1. 서론
Android 6.0(마시멜로) 이전 버전에서는 애플리케이션이 AndroidManifest.xml에 권한을 선언하기만 하면 즉시 권한이 부여되었습니다. 사용자는 설치할 것인지 말지만 결정할 수 있었지요. Android 6.0부터는 런타임 권한(Runtime Permission) 시스템이 도입되어 사용자가 직접 권한을 허용하거나 거부할 수 있게 되었습니다.
캘린더, 카메라, 연락처, 위치정보, 마이크, 전화, 문자메시지, 저장소, 신체 센서 같은 민감한 권한은 런타임에 동적으로 확인해야 합니다. 이러한 권한 없이 보호된 기능에 접근하면 null이 반환되거나 예외가 발생할 수 있으며, 적절히 처리하지 않으면 앱이 갑자기 종료되는 문제가 생길 수 있습니다. 따라서 Android 6.0 이상을 타겟으로 하는 앱은 반드시 런타임 권한 처리 코드를 구현해야 합니다.
2. 권한의 분류
Android 시스템에서 권한은 위험도에 따라 다음과 같이 분류됩니다:
- normal: 저위험 권한으로 시스템, 사용자 또는 다른 앱에 영향을 주지 않습니다. 설치 시 자동으로 부여됩니다.
- dangerous: 고위험 권한으로 사용자의 개인정보와 직접적으로 관련됩니다. 반드시 사용자의 동적인同意가 필요합니다.
- signature: 앱의 디지털 서명이 권한을 선언한 앱의 서명과 동일한 경우에만 부여됩니다.
- signatureOrSystem: 동일한 서명을 가진 앱이나 system 파티션의 앱에만 부여됩니다(현재는 privileged로 대체됨).
- privileged: signatureOrSystem과 동일합니다.
Android 6.0 이전에는 normal과 dangerous 권한에 차이가 없었습니다. AndroidManifest.xml에 선언만 하면 모든 권한이 자동으로 부여되었지요. 그러나 Android 6.0부터는 25개의 dangerous 권한이 9개의 그룹으로 나뉘며, 앱이 직접 사용자에게 권한을 요청해야 합니다. 요청하지 않으면 기본적으로 거부 상태가 됩니다. 같은 그룹 내에서 하나의 권한이 허용되면 다른 권한들도 자동으로 허용됩니다.
| 권한 그룹 | 포함된 권한 |
|---|---|
android.permission-group.CALENDAR |
- android.permission.READ_CALENDAR - android.permission.WRITE_CALENDAR |
android.permission-group.CAMERA |
- android.permission.CAMERA |
android.permission-group.CONTACTS |
- android.permission.READ_CONTACTS - android.permission.WRITE_CONTACTS - android.permission.GET_ACCOUNTS |
android.permission-group.LOCATION |
- android.permission.ACCESS_FINE_LOCATION - android.permission.ACCESS_COARSE_LOCATION |
android.permission-group.MICROPHONE |
- android.permission.RECORD_AUDIO |
android.permission-group.PHONE |
- android.permission.READ_PHONE_STATE - android.permission.CALL_PHONE - android.permission.READ_CALL_LOG - android.permission.WRITE_CALL_LOG - com.android.voicemail.permission.ADD_VOICEMAIL - android.permission.USE_SIP - android.permission.PROCESS_OUTGOING_CALLS |
android.permission-group.SENSORS |
- android.permission.BODY_SENSORS |
android.permission-group.SMS |
- android.permission.SEND_SMS - android.permission.RECEIVE_SMS - android.permission.READ_SMS - android.permission.RECEIVE_WAP_PUSH - android.permission.RECEIVE_MMS - android.permission.READ_CELL_BROADCASTS |
android.permission-group.STORAGE |
- android.permission.READ_EXTERNAL_STORAGE - android.permission.WRITE_EXTERNAL_STORAGE |
3. 권한 요청 대화상자의 동작 방식
권한 요청 시 시스템 대화상자가 표시됩니다. 첫 번째 요청에서는 단순한 허용/거부 옵션만 나타납니다. 사용자가 첫 번째에서 "거부"를 선택하면, 두 번째 요청 시 "다시 묻지 않음" 체크박스가 포함된 대화상자가 표시됩니다.
사용자가 "허용"을 선택하면 이후에는 해당 권한 요청 시 대화상자가 다시 표시되지 않고 자동으로 허용됩니다. "다시 묻지 않음"을 체크하지 않고 "거부"를 선택하면, 사용자가 명시적으로 허용하거나 "다시 묻지 않음"을 체크할 때까지每次권한 요청마다 대화상자가 표시됩니다.
사용자는 언제든지 설정 > 애플리케이션에서 개별 앱의 권한을 수정할 수 있습니다. 여기서 변경된 권한은 앱의 권한 요청 대화상자 표시 여부에 영향을 주지 않습니다.
targetSdkVersion이 23 미만인 앱은 기존 방식(설치 시 모든 권한 자동 부여)을 유지합니다. 23 이상부터는 dangerous 권한에 대해 런타임 요청 코드를 구현해야 합니다.
4. 런타임 권한 관련 API
Android 6.0은 런타임 권한 관리를 위한 새로운 API들을 제공합니다:
Context 클래스
checkSelfPermission() 메서드로 현재 앱의 권한 상태를 확인할 수 있습니다. 반환값이 0(PERMISSION_GRANTED)이면 권한이 허용된 상태이고, 1(PERMISSION_DENIED)이면 거부된 상태입니다:
public int checkSelfPermission(String permission) {
if (permission == null) {
throw new IllegalArgumentException("permission cannot be null");
}
return checkPermission(permission, Process.myPid(), Process.myUid());
}
Activity 클래스
shouldShowRequestPermissionRationale()는 권한 요청 전에 사용자에게 설명이 필요한지 판단합니다:
- 첫 번째 요청 시: false 반환
- 사용자가 거부한 후 두 번째 요청: true 반환(권한 필요성 설명 가능)
- "다시 묻지 않음"을 선택하고 거부 시: false 반환(이후 대화상자 표시 안됨)
- 권한 허용 후: false 반환
- 사용자가 거부했지만 "다시 묻지 않음" 미선택: true 반환
public boolean shouldShowRequestPermissionRationale(@NonNull String permission) {
return getPackageManager().shouldShowRequestPermissionRationale(permission);
}
requestPermissions()는 권한 요청을 시작합니다. 내부적으로 startActivityForResult를 호출하며 여러 권한을 한 번에 요청할 수 있습니다:
public final void requestPermissions(@NonNull String[] permissions, int requestCode) {
if (mHasCurrentPermissionsRequest) {
Log.w(TAG, "Can request only one set of permissions at a time");
onRequestPermissionsResult(requestCode, new String[0], new int[0]);
return;
}
Intent intent = getPackageManager().buildRequestPermissionsIntent(permissions);
startActivityForResult(REQUEST_PERMISSIONS_WHO_PREFIX, intent, requestCode, null);
mHasCurrentPermissionsRequest = true;
}
권한 요청 결과는 onRequestPermissionsResult() 콜백에서 처리합니다:
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions,
@NonNull int[] grantResults) {
// 결과 처리 로직 구현
}
Fragment 클래스
Fragment에서도 Activity와 동일한 API(shouldShowRequestPermissionRationale, requestPermissions, onRequestPermissionsResult)를 제공합니다.
하위 호환성을 위한 support-v4 라이브러리
older Android 버전과의 호환성을 위해 Android Support Library v4에서 동일한 기능을 제공합니다:
- ContextCompat.checkSelfPermission()
- ActivityCompat.requestPermissions()
- ActivityCompat.OnRequestPermissionsResultCallback
- ActivityCompat.shouldShowRequestPermissionRationale()
- FragmentCompat.requestPermissions()
- FragmentCompat.OnRequestPermissionsResultCallback
- FragmentCompat.shouldShowRequestPermissionRationale()
5. 앱 적용 방법
targetSdkVersion이 23 이상인 경우, dangerous 권한을 사용할 때는 항상 권한 상태 확인, 요청, 결과 처리의 3단계를 구현해야 합니다. support-v4 라이브러리를 사용하는 것이 좋습니다.
1단계: 권한 확인
int permissionStatus = ActivityCompat.checkSelfPermission(this,
Manifest.permission.CAMERA);
2단계: 권한 요청
if (permissionStatus == PackageManager.PERMISSION_GRANTED) {
// 권한이 이미 허용됨 - 카메라 기능 실행
Snackbar.make(containerLayout,
"카메라 권한이 허용되었습니다.",
Snackbar.LENGTH_SHORT).show();
initializeCamera();
} else {
// 권한이 없음 - 요청 필요
requestCameraPermission();
}
private void requestCameraPermission() {
if (ActivityCompat.shouldShowRequestPermissionRationale(this,
Manifest.permission.CAMERA)) {
// 권한이 거부된 상태로 사용자에게 설명이 필요한 경우
Snackbar.make(containerLayout,
"카메라 미리보기를 위해 접근 권한이 필요합니다.",
Snackbar.LENGTH_INDEFINITE).setAction("확인", new View.OnClickListener() {
@Override
public void onClick(View view) {
ActivityCompat.requestPermissions(MainActivity.this,
new String[]{Manifest.permission.CAMERA},
CAMERA_PERMISSION_CODE);
}
}).show();
} else {
// 첫 번째 요청인 경우
Snackbar.make(containerLayout,
"카메라 권한을 요청합니다.",
Snackbar.LENGTH_SHORT).show();
ActivityCompat.requestPermissions(this,
new String[]{Manifest.permission.CAMERA},
CAMERA_PERMISSION_CODE);
}
}
한 번에 여러 권한을 요청할 수도 있습니다.
3단계: 권한 요청 결과 처리
@Override
public void onRequestPermissionsResult(int requestCode, String[] permissions,
int[] grantResults) {
if (requestCode == CAMERA_PERMISSION_CODE) {
if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
// 권한 허용됨
Snackbar.make(containerLayout, "카메라 권한이 허용되었습니다. 미리보기를 시작합니다.",
Snackbar.LENGTH_SHORT).show();
initializeCamera();
} else {
// 권한 거부됨
Snackbar.make(containerLayout, "카메라 권한이 거부되었습니다.",
Snackbar.LENGTH_SHORT).show();
}
}
}
실전 예제: 전화 걸기 기능
package com.example.utility.dialer;
import android.Manifest;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.net.Uri;
import android.os.Bundle;
import android.support.annotation.NonNull;
import android.support.v4.app.ActivityCompat;
import android.support.v4.content.ContextCompat;
import android.support.v7.app.AppCompatActivity;
import android.view.View;
import android.widget.EditText;
import android.widget.Toast;
public class PhoneDialerActivity extends AppCompatActivity {
private static final int PERMISSION_CALL_REQUEST = 100;
private EditText phoneInput;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_phone_dialer);
phoneInput = findViewById(R.id.input_phone_number);
}
public void handleDialButton(View view) {
if (view.getId() == R.id.btn_dial) {
String phoneNumber = phoneInput.getText().toString().trim();
if (phoneNumber.isEmpty()) {
Toast.makeText(this, "전화번호를 입력하세요", Toast.LENGTH_SHORT).show();
return;
}
if (ContextCompat.checkSelfPermission(this, Manifest.permission.CALL_PHONE)
!= PackageManager.PERMISSION_GRANTED) {
// 권한이 없는 경우 요청
if (ActivityCompat.shouldShowRequestPermissionRationale(this,
Manifest.permission.CALL_PHONE)) {
// 사용자에게 설명 제공 가능
}
ActivityCompat.requestPermissions(this,
new String[]{Manifest.permission.CALL_PHONE},
PERMISSION_CALL_REQUEST);
} else {
// 권한이 이미 있는 경우
makePhoneCall(phoneNumber);
}
}
}
@Override
public void onRequestPermissionsResult(int requestCode,
@NonNull String[] permissions, @NonNull int[] grantResults) {
if (requestCode == PERMISSION_CALL_REQUEST) {
if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
String phoneNumber = phoneInput.getText().toString().trim();
makePhoneCall(phoneNumber);
} else {
Toast.makeText(this, "권한이 거부되었습니다", Toast.LENGTH_SHORT).show();
}
}
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
}
private void makePhoneCall(String number) {
Intent dialIntent = new Intent(Intent.ACTION_CALL);
Uri phoneUri = Uri.parse("tel:" + number);
dialIntent.setData(phoneUri);
if (ActivityCompat.checkSelfPermission(this, Manifest.permission.CALL_PHONE)
!= PackageManager.PERMISSION_GRANTED) {
return;
}
startActivity(dialIntent);
}
}
이 구현을 따르면 Android 6.0 이상에서 런타임 권한을 정상적으로 처리할 수 있으며, 권한이 없을 때 발생하는 앱 충돌을 방지할 수 있습니다.