
오늘은 Jetpack Compose와 Accompanist Permissions를 활용하여 안드로이드 앱에서 권한 요청 화면을 구성하는 방법에 대해 알아보려고 한다.
특히 안드로이드 권한요청 플로우에서 고려해야 할 “1회 거부”와 “2회 이상 거부”를 구분해 UI와 로직을 다르게 처리하는 방법을 주요하게 알아보려 한다.

기존 View 시스템에서는 Activity나 Fragment에서 requestPermissions()와 onRequestPermissionsResult()를 오가며 직접 처리를 했어야 했다.
그러나 Jetpack Compose는 모든 UI를 Composable로 작성하므로, Activity/Fragment의 권한 콜백에 직접 접근하기가 불편합니다.
Accompanist Permissions는 이러한 문제를 해결하기 위해 나온 라이브러리 이다.
rememberMultiplePermissionsState(...) 혹은 rememberPermissionState(...) API를 제공하여,
UI 상태와 권한 요청 상태를 간단하게 연동할 수 있다.
// build.gradle
dependencies {
implementation "com.google.accompanist:accompanist-permissions:<version>"
}
아래 클래스는 권한 정보를 쉽게 표시하기 위한 데이터 구조입니다.
data class PermissionInfo(
val permission: String, // 실제 Manifest.permission.XXX
@StringRes val titleRes: Int, // 권한 이름(리소스)
@StringRes val descriptionRes: Int, // 권한 설명(리소스)
val isRequired: Boolean, // 필수 권한 여부
val imageVector: ImageVector // 아이콘
)

안드로이드 버전이나 상황에 따라 필요한 권한이 다를 수 있다. 따라서 다음과 같이 OS 버전별로 필요한 권한 리스트를 생성한다.
@Composable
fun rememberPermissionList(): List<PermissionInfo> {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
listOf(
PermissionInfo(
permission = Manifest.permission.BLUETOOTH_SCAN,
titleRes = R.string.bluetooth_scan_title,
descriptionRes = R.string.bluetooth_scan_description,
isRequired = true,
imageVector = Icons.Default.Bluetooth
)
)
} else {
listOf(
PermissionInfo(
permission = Manifest.permission.ACCESS_FINE_LOCATION,
titleRes = R.string.location_title,
descriptionRes = R.string.location_description,
isRequired = true,
imageVector = Icons.Default.LocationOn
)
)
}
}
PermissionRequestScreen은 여러 권한을 한 번에 확인하고, 사용자가 권한을 승인/거부했을 때 UI 상태를 업데이트하거나 “앱 설정” 이동을 안내하는 핵심 화면이다.
@Composable
fun PermissionRequestScreen(
onAllRequiredPermissionsGranted: () -> Unit,
trackingManager: TrackingManager,
mainViewModel: MainViewModel
) {
// 코루틴 스코프
val coroutineScope = rememberCoroutineScope()
// 필요한 권한 목록
val permissionList = rememberPermissionList()
// Accompanist Permissions: 여러 권한 상태를 한꺼번에 관리
val permissionsState = rememberMultiplePermissionsState(
permissions = permissionList.map { it.permission }
)
// 권한 승인/거부 상태 변화 감지
LaunchedEffect(permissionsState.allPermissionsGranted) {
if (permissionsState.allPermissionsGranted) {
// 모든 권한 승인됨
onAllRequiredPermissionsGranted()
} else if (trackingManager.isFirstLaunchNotified()) {
// 최초 권한 요청 화면 진입 + 처음 거부됨
mainViewModel.saveFirstLunch()
}
}
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
Text(text = "권한 상태", style = MaterialTheme.typography.h6)
// 권한 목록 렌더링
LazyColumn(modifier = Modifier.weight(1f)) {
items(permissionList) { permissionInfo ->
PermissionItem(
permissionInfo = permissionInfo,
permissionState = permissionsState.permissions.first {
it.permission == permissionInfo.permission
}
)
}
}
// 하단 영역: 거부 상황에 따라 다른 UI 표시
when {
// 1회 거부 → shouldShowRationale == true
permissionsState.shouldShowRationale -> {
Button(onClick = { permissionsState.launchMultiplePermissionRequest() }) {
Text(text = "권한 재요청하기")
}
}
// 2회 이상 거부 → shouldShowRationale == false
permissionsState.permissions.any { !it.status.isGranted && !it.status.shouldShowRationale } -> {
PermanentlyDeniedSection(
onOpenSettings = { openAppSettings() }
)
}
}
}
}
1) rememberMultiplePermissionsState(...)
Accompanist Permissions가 제공하는 API로, 여러 권한의 승인 상태와 재요청 필요 여부(shouldShowRationale)를 한꺼번에 추적합니다.
2) trackingManager, mainViewModel.saveFirstLunch() 의 필요성
단순히 shouldShowRationale 플래그만 봐서는 “첫 거부”와 “여러 번 거부”를 완벽히 구분하기 어렵다.
실제 기기에 따라 shouldShowRationale가 한 번 거부만으로 false가 뜨기도 하고, “권한을 원치 않습니다”로 영구 거부할 수도 있다.
따라서 TrackingManager와 ViewModel에서 “첫 실행 여부”를 기기에 저장해두면, 1회 거부 후 saveFirstLunch()를 통해 “첫 실행”을 false로 저장 → 반복 거부 시나리오 판단에 활용 가능하다.

@Composable
fun PermissionItem(
permissionInfo: PermissionInfo,
permissionState: PermissionState
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Icon(
imageVector = permissionInfo.imageVector,
contentDescription = null
)
Spacer(modifier = Modifier.width(8.dp))
Column {
Text(text = stringResource(id = permissionInfo.titleRes))
Text(text = stringResource(id = permissionInfo.descriptionRes))
// 권한 승인 여부
if (permissionState.status.isGranted) {
Text(text = "허용됨", color = MaterialTheme.colorScheme.primary)
} else {
// 권한이 거부된 상태
if (permissionState.status.shouldShowRationale) {
// 1회 거부
Text(
text = "권한을 다시 확인해주세요",
color = MaterialTheme.colorScheme.error
)
} else {
// 2회 이상 거부
Text(
text = "거부됨",
color = MaterialTheme.colorScheme.error
)
}
}
}
}
}
승인됨: “허용됨” 안내 표시
1) PermanentlyDeniedSection - 영구 거부 UI
@Composable
fun PermanentlyDeniedSection(onOpenSettings: () -> Unit) {
Column(
modifier = Modifier
.fillMaxWidth()
.background(Color(0xFFFFCDD2)) // 연한 빨간색 배경
.padding(16.dp)
) {
Text(
text = "권한이 영구적으로 거부되었습니다.",
color = Color.Red,
style = MaterialTheme.typography.subtitle1
)
Text(
text = "앱 설정에서 직접 권한을 허용해주세요.",
style = MaterialTheme.typography.body2
)
Spacer(modifier = Modifier.height(8.dp))
Button(onClick = onOpenSettings) {
Text(text = "설정으로 이동")
}
}
}
2) openAppSettings - 설정 화면 이동 함수
fun openAppSettings() {
try {
val context = LocalContext.current
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.fromParts("package", context.packageName, null)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
} catch (e: ActivityNotFoundException) {
e.printStackTrace()
}
}

Jetpack Compose 환경에서 Accompanist Permissions를 사용하면,
여러 권한을 손쉽게 추적하고 UI 반응을 자연스럽게 작성할 수 있다.
각 정책에 따라 1회 거부와 2회 이상 거부 상황을 분리하면, 사용자가 왜 권한이 필요한지 충분히 안내하고,필요 시 앱 설정으로 이동을 유도해 재허용할 수 있게 가능하다 trackingManager나 mainViewModel 등을 이용해 “첫 실행” 여부를 저장해두면,시스템 팝업만 봐서는 알기 어려운 사용자의 권한 거부 이력까지 구분할 수 있어 더욱 견고하게 안내가 가능하다.
참고 문서
Accompanist Permissions