Android/Kotlin

[Android/Kotlin] Jetpack Compose 환경에서 런타임 권한 요청 구조 개선하기 - 1

양심고백 2026. 5. 28. 17:07
반응형
SMALL

키어로(Kiero) 프로젝트 스프린트를 진행하면서 다음과 같은 요구사항을 마주했다. 

마이페이지 일부 화면

 

마이페이지의 토글 버튼을 통해 앱 알림 수신 여부를 설정하는 기능이었다. 

알림이 꺼져있는 상태에서 클릭하여 켜려고 시도하면 권한 유도 모달 창이 나타나며,
'설정으로 이동' 버튼을 클릭 시 디바이스 OS 앱 알림 설정 화면으로 이동하는 요구사항이다.

 

1. 문제 상황

처음에는 알림 권한이 켜져 있는지 확인하기 위해 Context 확장 함수를 하나 만들었다.

fun Context.isNotificationEnabled(): Boolean {
    val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
    return manager.areNotificationsEnabled()
}

 

 

그리고 화면(Screen) 단에서는 다이얼로그를 띄우고, 수동으로 인텐트를 만들어 설정 화면으로 보냈다.

if (state.showNotificationDialog) {
    KieroDialog(
        // ...
        confirmAction = KieroConfirmAction(
            text = "설정으로 이동",
            onClick = {
                onNotificationDialogDismiss()
                context.startActivity(
                    Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
                        putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
                    }
                )
            }
        )
    )
}

 

뷰모델에서도 토글 상태를 직접 비교하며 다이얼로그 노출 상태를 관리했다.
기능 자체는 의도대로 잘 동작했다.

 

 

문제가 있었다. 현재 코드가 화면이 하나일 때는 괜찮다.
하지만 알림, 카메라 등 권한이 필요한 다른 화면들이 존재하는데
현재 구조라면 똑같은 코드가 여러 화면에 복붙되어 재사용성을 해치는 문제가 있다.
그래서 권한 요청 흐름 전체를 하나의 레이어로 묶어 UI에서는 호출만 하면 되는 구조로 수정하였다.

 

 

2. 해결 과정

프로젝트에는 이미 카메라 등의 권한을 체크하기 위해 만들어둔 PermissionChecker 객체가 있었고,

설정으로 보내는 Intent 역시 이미 존재했었다!!
나 역시 이걸 확장해서 이용하여 알림 권한 요청 로직을 공통 레이어로 통합하기로 했다.


1단계: PermissionChecker로 로직 통합

기존에 파편화되어 있던 Context.isNotificationEnabled() 확장 함수를 지우고,

공통 권한 관리 객체인 PermissionChecker에 알림(POST_NOTIFICATIONS) 체크 로직을 합쳤다.

object PermissionChecker {
    fun isGranted(context: Context, type: PermissionType): Boolean {
        if (type == PermissionType.POST_NOTIFICATIONS) {
            val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
            return manager.areNotificationsEnabled()
        }

        val permission = type.manifestPermission ?: return true
        return ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
    }
}

이렇게 하면 추후 알림 관련 정책이 변경되더라도 오직 이 파일 한 곳만 수정하면 된다.

ViewModel이나 UI 컴포저블에서는 OS 버전 분기를 신경 쓸 필요 없이 이 함수만 호출하면 되어 가독성과 응집도가 확 올라갔다.

 

 

2단계: 공통 권한 요청기(rememberPermissionRequester) 적용

설정 화면으로 보내는 Intent와 다이얼로그 처리 역시, 이미 프로젝트 내에 구축되어 있던 rememberPermissionRequester 모듈과 context.navigateToSettings()를 활용해 화면 단의 복잡한 코드를 날려버렸다.

private fun Context.getSettingsIntent(type: PermissionType): Intent {
    val packageUri = Uri.fromParts("package", packageName, null)
        
        
    // 일반 런타임 권한 (카메라, 위치 등) -> 앱 상세 설정
    return when (type) {
        // 알림 권한 -> 알림 전용 설정
        PermissionType.POST_NOTIFICATIONS -> {
            Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
                putExtra(Settings.EXTRA_APP_PACKAGE, packageName)
            }
        }
    }.apply {
        addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
    }
}

/**
 * Intent 반환 없이 바로 설정 화면으로 이동하고 싶을 때 사용하는 편의성 함수
 */
fun Context.navigateToSettings(type: PermissionType) {
    startActivity(getSettingsIntent(type))
}
// 권한 요청 객체 생성
val notificationPermissionRequester = rememberPermissionRequester(
    type = PermissionType.POST_NOTIFICATIONS,
    deniedCount = 0,
    onGranted = { 
        viewModel.onNotificationToggle(true)
    },
    onDenied = {
        viewModel.onNotificationToggle(false)
    },
    onPermanentlyDenied = {
        // 영구 거부 시 설정 안내 다이얼로그 띄우기
        viewModel.showNotificationDialog(true)
    },
    onCountIncrease = { /* 권한 거부 카운트 증가 로직 */ }
)

 

 

3단계: UI 컴포저블에 권한 요청기 연결

이제 복잡한 다이얼로그 노출 로직과 Intent 코드를 걷어내고,

토글 스위치에 방금 만든 공통 권한 요청기만 깔끔하게 연결해 주었다.

KidMySpaceScreen(
    isChecked = state.isNotificationChecked,
    onCheckedChange = { isChecked ->
        if (isChecked) {
            // 알림을 켤 때는 공통 권한 요청 프로세스 태우기
            notificationPermissionRequester()
        } else {
            // 알림을 끌 때는 UI 상태 변경 처리
            viewModel.onNotificationToggle(false)
        }
    }
)

화면(UI) 단에서 지저분한 인텐트 코드가 전부 사라졌다.

컴포저블은 오직 '상태에 따라 토글을 그리고, 클릭 시 요청기를 호출한다'는 본연의 역할에만 충실해졌다.

 

 

4단계: 설정 복귀 시 동기화 대응

사용자가 다이얼로그를 통해 시스템 설정에 들어간 뒤, 알림을 켜고(혹은 끄고) 앱으로 다시 돌아오는 상황을 대비해야 했다. LifecycleEventEffect를 사용해 ON_RESUME 시점에 실제 기기 권한 상태를 다시 읽어오도록 처리하여 싱크를 맞췄다.

LifecycleEventEffect(Lifecycle.Event.ON_RESUME) {
    val isEnabled = PermissionChecker.isGranted(context, PermissionType.POST_NOTIFICATIONS)
    viewModel.onNotificationToggle(isEnabled)
}

 

 

3. 마무리... 인 줄 알았으나 (2편 예고)

OS 시스템 권한 상태와 앱의 비즈니스(UI) 상태를 철저히 분리함으로써,

권한 요청 로직이 공통 모듈로 깔끔하게 캡슐화되었고 뷰(View)는 화면을 그리는 본연의 역할에만 충실하게 되었다.

 

"이제 다 끝났다!" 싶었는데, 작업을 하던 중 내 머릿속을 떠나지 않는 근본적인 의문이 하나 있었다.

 

"우리 서버에는 알림 상태를 조회하고 업데이트하는 API가 따로 존재하는데... 이게 왜 있어야 하는 거지?"

 

그냥 기기의 설정(OS 권한)에 맞춰서 앱 화면을 업데이트하면 되지 않나?

왜 이걸 굳이 서버에서 따로 관리하려고 하는 걸까? 이러면 서버와 기기 내 설정이 서로 달라져서 동기화 문제가 생기지는 않을까?

 

이 의문을 품고 기획 화면 설계서를 다시 꼼꼼히 읽어보았고, 그제야 내가 놓쳤던 진짜 요구사항이 눈에 들어왔다.

디바이스 OS의 알림 권한 상태에 따라 동작이 미묘하게 달라져야 했던 것이다.

  • OS 알림 권한 허용 상태: 토글 ON 선택 시 즉시 알림 활성화 처리
  • OS 알림 권한 거부 상태: 토글 ON 선택 시 권한 활성화 유도 팝업 노출 ➔ '설정으로 이동' 버튼 클릭 시 OS 앱 알림 설정 화면으로 이동

아차 싶었다. 내가 처음에 구현한 방식은 토글 상태가 오직 기기의 알림 권한 유무에만 의존하도록 묶여 있었다.

그렇기 때문에 만약 사용자가 이미 OS 알림 권한을 허용해 둔 상태에서 앱 내 알림만 껐다가 다시 켜려고 하면, 즉시 활성화가 되지 않고 권한 구조의 특성상 무조건 설정 유도 팝업이 노출되거나 꼬여버리게 된다.


이와 관련한 수정 반영 내용은 다음 편에서 다루어 보겠다..

 

 

 

 

GitHub - Team-Kiero/Kiero-Android: 우리 안드 영양 간식 🍼

우리 안드 영양 간식 🍼. Contribute to Team-Kiero/Kiero-Android development by creating an account on GitHub.

github.com

 

 

런타임 권한 요청  |  Privacy  |  Android Developers

이 문서에서는 Android 애플리케이션에서 런타임 권한을 요청하는 방법을 개발자에게 안내하고, 워크플로를 자세히 설명하며, 사용자 환경 원칙을 설명하고, 일회성 권한 및 자동 재설정 기능을

developer.android.com

 

반응형
LIST