서비스를 개발하다 보면 로컬 푸시 알림을 구현해야 할 때가 있습니다.
특히, 특정 날짜 및 시간에 정확하게 알림을 보내기 위해서는
서버를 통한 알림 (예: FCM) 이 아닌 클라이언트 자체에서 처리해야 합니다.
이번 글에서는 AlarmManager
를 통한 알림 처리에 대해 알아보겠습니다.
AlarmManager
AlarmManager
는 안드로이드 프레임워크에서 제공하는 API입니다.
안드로이드에는 백그라운드 작업을 처리할 수 있는 API가 몇 가지 있는데,
그 중 AlarmManager
는 날짜 및 시간에 기반한 작업을 처리할 수 있습니다.
AlarmManager
에는 알람을 설정할 수 있는 메소드들이 있습니다.
set
setExact
setExactAndAllowWhileIdle
setAlarmClock
setRepeating
setInexactRepeating
이 중 대부분의 메소드는 부정확한 알람을 설정합니다.
그 이유는 공식 문서에 나와있습니다.
정확한 알람을 보장하지 않는 이유는 배터리 소모를 최적화하기 위해서입니다.
특히 사용자가 기기를 한동안 사용하지 않으면 Doze 모드가 활성화되는데,
OS 레벨에서 배터리 관리를 위해 정확한 알람을 보장하지 않게 됩니다.
또한 정확한 알람을 보장한다는 setExactAndAllowWhileIdle
을 사용해도
테스트해보면 설정한 시간에 딱 맞게 알람이 오지 않는다는 것을 알 수 있습니다.
setAlarmClock
그렇다면 개발 중인 서비스에서 정확한 알람을 보장하려면 어떻게 해야 할까요?
바로 setAlarmClock
을 사용하면 됩니다.
하지만 문서에 나와 있듯이 정확한 알람 예약은 리소스에 영향을 미칠 수 있기 때문에,
캘린더, 알람 앱 등 정확성을 보장해야 하는 경우에만 사용하는 것이 좋겠습니다.
제가 진행 중인 보따리 프로젝트에서는 정확한 알람,
또 특정 요일마다 반복되는 알람이 필요했기 때문에 setAlarmClock
을 사용했습니다.
이제부터 어떤 방식으로 구현했는지 알아보겠습니다.
@RequiresPermission(Manifest.permission.SCHEDULE_EXACT_ALARM)
public void setAlarmClock(@NonNull AlarmClockInfo info, @NonNull PendingIntent operation) {
setImpl(RTC_WAKEUP, info.getTriggerTime(), WINDOW_EXACT, 0, 0, operation, null, null, (Handler) null, null, info);
}
먼저 setAlarmClock
의 구현을 살펴보면, @RequiresPermission
어노테이션이 있습니다.
정확한 알람을 설정하기 위해서는 SCHEDULE_EXACT_ALARM
권한을 추가해야 합니다.
또한 알림창을 띄우는 데에 필요한 POST_NOTIFICATIONS
권한과,
알림이 울릴 때 진동을 울려야 하기 때문에 VIBRATE
권한도 추가하겠습니다.
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.VIBRATE" />
추가로 SCHEDULE_EXACT_ALARM
권한은 특별 권한에 해당됩니다.
앱을 최초 실행할 때 사용자에게 권한을 요청하고,
권한을 거절당할 시 해당 기능을 사용하려 할 때 권한을 재요청하는 플로우가 필요합니다.
(전체 플로우는 공식 문서를 읽어보는 것을 추천합니다.)
SCHEDULE_EXACT_ALARM
권한의 경우 AlarmManager
의 메소드로 처리할 수 있습니다.
fun hasExactAlarmPermission(context: Context): Boolean =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val alarmManager = context.getSystemService(AlarmManager::class.java)
alarmManager.canScheduleExactAlarms()
} else {
true
}
권한 처리가 끝났다면, 이제 AlarmManager
를 통한 알람 설정에 대해 알아보겠습니다.
먼저 setAlarmClock
메소드를 살펴보자면,
public void setAlarmClock(
@NonNull AlarmClockInfo info,
@NonNull PendingIntent operation,
) {
setImpl(RTC_WAKEUP, info.getTriggerTime(), WINDOW_EXACT, 0, 0, operation, null, null, (Handler) null, null, info);
}
AlarmClockInfo
와 PendingIntent
를 인자로 받는 것을 알 수 있습니다.
public AlarmClockInfo(
long triggerTime,
PendingIntent showIntent,
) {
mTriggerTime = triggerTime;
mShowIntent = showIntent;
}
AlarmClockInfo
를 생성할 때도 PendingIntent
를 전달해야 하는데,
여기에 전달하는 PendingIntent
는 해당 알람의 세부 정보를 표시하거나 편집하는 데 사용되는 인텐트입니다.
일정 버전 이상부터는 현재 설정된 알람의 정보를 퀵 패널에서 확인할 수 있습니다.
위의 알람 탭을 클릭하면 해당 PendingIntent
가 실행됩니다.
고로 setAlarmClock
과 AlarmClockInfo
에는 각각 다른 PendingIntent
를 전달해야 합니다.
다시 AlarmClockInfo
로 돌아가면,
public AlarmClockInfo(
long triggerTime,
PendingIntent showIntent,
) {
mTriggerTime = triggerTime;
mShowIntent = showIntent;
}
triggerTime
에는 알람이 실행될 날짜 및 시간을 전달해야 하는데,
제 경우에는 LocalDateTime
을 변환해서 전달했습니다.
private fun LocalDateTime.toTimeMillis(): Long =
this
.atZone(ZoneId.systemDefault())
.toInstant()
.toEpochMilli()
참고로, 안드로이드에는 여러 유형의 알람 타입이 존재합니다.
디바이스가 부팅된 이후 일정 시간이 지났을 때 울리는 타입,
실제 날짜 및 시간에 맞추어 울리는 타입 등이 있습니다.
하지만 setAlarmClock
을 사용할 때는 실제 날짜 및 시간을 변환하기 때문에,
크게 신경쓰지 않아도 됩니다.
이제 다시 setAlarmClock
으로 돌아가봅시다.
public void setAlarmClock(
@NonNull AlarmClockInfo info,
@NonNull PendingIntent operation,
) {
setImpl(RTC_WAKEUP, info.getTriggerTime(), WINDOW_EXACT, 0, 0, operation, null, null, (Handler) null, null, info);
}
AlarmClockInfo
를 생성했다면 이제 PendingIntent
를 전달해야 합니다.
여기에 들어가는 PendingIntent
는 AlarmClockInfo
와는 달리,
BroadcastReceiver
를 실행시키기 위한 PendingIntent
를 전달해야 합니다.
이는 PendingIntent.getBroadcast
로 생성할 수 있습니다.
requestCode
: 요청 코드, 식별자 역할intent
: 실제로 브로드캐스트 되는 Intent
flags
: PendingIntent
에 대한 플래그각 알람 별로 requestCode
는 고유해야 하며,
알람이 브로드캐스트 될 때 Intent
에 데이터를 담으면 BroadcastReceiver
에서 해당 데이터를 받아서 특정 동작을 할 수 있습니다.
플래그는 특정 알람의 PendingIntent
에 대한 플래그인데,
되도록이면 FLAG_UPDATE_CURRENT
혹은 FLAG_IMMUTABLE
을 사용하는 것을 권장합니다.
현재 PendingIntent
를 변경할 수 있으면 동작을 쉽게 예측할 수 없기 때문입니다.
관련 메소드를 대부분 살펴보았으니, 구현 과정을 알아보겠습니다.
먼저 AlarmManager
를 이용해 알람을 설정하는 클래스를 선언했습니다.
object AlarmScheduler {
private val manager: AlarmManager =
ApplicationContextProvider.applicationContext.getSystemService(AlarmManager::class.java)
}
우선 AlarmClockInfo
를 생성해야 하는데,
위에서 봤듯이 알람이 울릴 시간과 알람 편집 PendingIntent
가 필요합니다.
private fun createEditPendingIntent(notification: NotificationUiModel): PendingIntent {
val intent =
PersonalBottariEditActivity.newIntent(
context,
notification.id,
false,
)
return PendingIntent.getActivity(
context,
notification.id.toInt(),
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
}
PendingIntent
가 전달될 때 실행될 Intent
를 정의하고,
getActivity
로 PendingIntent
를 생성합니다.
제 경우에는 알람 편집 화면으로 이동할 수 있도록 했습니다.
다음은 알람을 클릭했을 때 전달될 PendingIntent
를 생성하겠습니다.
private fun createPendingIntent(notification: NotificationUiModel): PendingIntent =
PendingIntent.getBroadcast(
context,
notification.id.toInt(),
AlarmReceiver.newIntent(context, notification),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
알람이 실행되면 BroadcastReceiver
가 알 수 있어야 하기 때문에,
getBroadcast
로 PendingIntent
를 생성합니다.
이제 AlarmClockInfo
를 생성하고, 알람을 설정합니다.
private fun scheduleAlarmInternal(
notification: NotificationUiModel,
triggerTime: Long,
) {
val editPendingIntent = createEditPendingIntent(notification)
val pendingIntent = createPendingIntent(notification)
val alarmClockInfo = AlarmClockInfo(triggerTime, editPendingIntent)
manager.setAlarmClock(alarmClockInfo, pendingIntent)
}
알람 설정은 되었습니다.
반복 알람 로직을 구성하기 전에, 브로드캐스트를 처리할 클래스를 선언하겠습니다.
class AlarmReceiver : BroadcastReceiver() {
private val notificationHelper: NotificationHelper by lazy { NotificationHelper() }
override fun onReceive(
context: Context?,
intent: Intent,
) {
val notification = intent.getParcelableCompat<NotificationUiModel>(EXTRA_NOTIFICATION)
notificationHelper.sendPersonalNotification(notification.id, notification.title)
scheduler.scheduleNextAlarm(notification)
BottariLogger.ui(
UiEventType.NOTIFICATION_CREATE,
mapOf("notification_id" to notification.id, "time" to LocalDateTime.now().toString()),
)
}
companion object {
private const val EXTRA_NOTIFICATION = "EXTRA_NOTIFICATION"
fun newIntent(
context: Context,
notification: NotificationUiModel,
): Intent =
Intent(
context,
AlarmReceiver::class.java,
).apply {
putExtra(EXTRA_NOTIFICATION, notification)
}
}
}
BroadcastReceiver
를 상속받은 클래스입니다.
알람이 실행되면, getBroadcast
로 생성된 PendingIntent
에 의해 onReceive
가 실행됩니다.
onReceive
에서는 사용자에게 보여줄 알림창을 생성하고, 다음 알람을 다시 예약하고 있습니다.
이번 글에서는 AlarmManager
에 대한 설명이 주 목적이기 때문에 Notification
에 대해서는 다루지 않겠습니다.
이제 다시 AlarmScheduler
로 돌아가 반복 알람 로직에 대해 알아보겠습니다.
fun scheduleNextAlarm(notification: NotificationUiModel) {
val alarm = notification.alarm
if (alarm.type == AlarmTypeUiModel.NON_REPEAT) return
val triggerTime = getNextTriggerTime(notification = notification)
scheduleAlarmInternal(notification, triggerTime)
}
AlarmReceiver
의 onReceive
에서 호출하는 메소드입니다.
알람을 설정할 때 전달한 객체를 해당 메소드에 전달하여 다음 알람을 설정합니다.
NotificationUiModel
에는 알람이 반복될 요일과 시간 정보가 저장되어 있습니다.
private fun getNextTriggerTime(
today: LocalDate = LocalDate.now(),
nowTime: LocalTime = LocalTime.now(),
notification: NotificationUiModel,
): Long {
val alarm = notification.alarm
val availableDays = getAvailableDays(alarm.repeatDays)
if (availableDays.contains(today.dayOfWeek) && nowTime.isBefore(alarm.time)) {
return LocalDateTime.of(today, alarm.time).toTimeMillis()
}
val triggerDate =
availableDays
.map { dayOfWeek ->
val daysUntil =
(dayOfWeek.value - today.dayOfWeek.value + DAYS_IN_WEEK) % DAYS_IN_WEEK
val adjustedDaysUntil = if (daysUntil == 0) DAYS_IN_WEEK else daysUntil
today.plusDays(adjustedDaysUntil.toLong())
}.minByOrNull { it.toEpochDay() }
return LocalDateTime.of(triggerDate, alarm.time).toTimeMillis()
}
해당 로직을 차례대로 살펴보겠습니다.
1. 알람이 울릴 수 있는 요일을 탐색한다. (getAvailableDays
)
2. 알람이 울릴 수 있는 요일에 오늘이 포함되어 있고, 알람이 울릴 시간이 현재 시간 이후면 오늘 날짜 및 알람 시간을 변환하여 반환한다.
3. 알람이 울릴 수 있는 요일 목록과 오늘을 비교하여 가장 가까운 요일의 날짜를 구한다.
4. 날짜 및 알람 시간을 변환하여 반환한다.
로직이 조금 복잡한데, 이해할 수 있도록 예시를 들어보겠습니다.
만약 알람이 울릴 수 있는 요일 리스트에 [월, 수, 금]
이 있고, 알람이 울리는 시간이 오후 3시라고 가정하겠습니다.
9월 29일 월요일 오후 12시에 알람을 설정했다면, 첫 번째 조건문과 부합하여 즉시 월요일 오후 3시로 알람이 설정됩니다.
오후 3시에 알람이 울리면 AlarmReceiver
의 onReceive
가 실행되고,
AlarmScheduler
의 scheduleNextAlarm
이 실행됩니다.
요일 리스트에 월요일이 존재하지만 시간이 지났기 때문에 (nowTime.isBefore
이 false
),
요일 리스트에서 가장 가까운 날짜를 탐색합니다.
가장 가까운 요일이 수요일이므로, 오늘 날짜에 월요일과 수요일의 차이만큼 날짜를 더하고 알람이 울릴 날짜 및 시간을 설정합니다. (10월 1일 오후 3시)
이런 과정을 거쳐 반복 알람을 설정하게 됩니다.
알람이 제대로 등록되었는지 확인하기 위한 방법은 2가지가 있습니다.
adb shell
터미널에 다음과 같은 명령어를 입력하면 됩니다.
adb shell dumpsys alarm | findStr "패키지명"
Mac의 경우 findStr
대신 grep
을 사용하면 됩니다.
명령어를 입력하면 다음과 같은 결과가 나옵니다.
알람에 대한 주요 정보를 차례대로 해석해보겠습니다.
RTC_WAKEUP
: 알람 타입RTC
: Real Time Clock, 실제 시간 기준으로 동작WAKEUP
: 기기가 Doze 모드에 있어도 기기를 강제로 깨움origWhen 1759298400000
: 알람이 트리거될 날짜 및 시간whenElapsed
: 기기가 부팅된 이후 알람이 울릴 시간까지의 경과 시간tag=...AlarmReceiver
: 알람이 울렸을 때 실행될 컴포넌트operation
: 알람이 울렸을 때 실행될 동작Next wake from idle
: 기기가 절전 모드에서 깨어나는 다음 이벤트해당 알람을 설정한 날짜 및 시간은 9월 29일 16시 이후이고,
알람은 월, 수, 금 15시에 울리도록 설정했습니다.
알람이 트리거될 날짜 및 시간을 변환하면 10월 1일 15시가 나오므로,
의도한 대로 알람이 설정되었음을 알 수 있습니다.
안드로이드 스튜디오의 App Inspection을 실행하면,
백그라운드 작업을 볼 수 있는 Background Task Inspector가 있습니다.
여기서는 설정된 알람의 정보도 확인할 수 있고,
콜스택까지 확인할 수 있어서 더욱 유용합니다.
알람을 취소하려면 동일하게 AlarmManager
를 이용하면 됩니다.
fun cancelAlarm(notification: NotificationUiModel) {
val pendingIntent = createPendingIntent(notification)
manager.cancel(pendingIntent)
}
알람을 등록할 때 생성했던 것과 동일한 PendingIntent
를 생성하고,
cancel
메소드에 전달하면 됩니다.
내부적으로 PendingIntent.equals()
로 비교하기 때문에
반드시 동일한 내용을 가진 PendingIntent
를 전달해야 합니다.
(요청 코드, Intent
, 플래그 등)
이렇게 AlarmManager
를 이용해 정확한 반복 알람을 구현했습니다.
프레임워크에서 정확한 반복 알람을 보장하고 있지 않기 때문에,
해당 기능을 구현할 때 정말 많은 고민을 했던 부분입니다.
저와 같은 기능을 구현해야 하는 분들에게 이 글이 도움이 되었으면 하며,
질문이 있거나 개선할 수 있는 부분이 있다면 댓글로 알려주시길 바랍니다.
좋은 글 감사합니다~