안드로이드 백그라운드 타이머 구현 및 Foreground Service 알림 설계 정리

타이머 앱을 만들고 운영하다 보면 백그라운드 처리 때문에 골치 아픈 일이 많습니다. 화면이 켜져 있을 때는 잘 돌다가도, 사용자가 홈 버튼을 누르고 다른 앱을 보거나 화면을 꺼버리면 타이머가 멈추거나 종료 알림이 안 오는 현상이 발생하곤 합니다.

처음에는 무조건 Foreground Service로 다 돌리면 해결될 줄 알았는데, 실제 서비스를 운영해 보니 배터리 소모나 구글 플레이 정책 측면에서 고려할 게 한두 가지가 아니었습니다. 자꾸 까먹어서 나중에도 바로 보고 적용할 수 있도록 실무 기준으로 정리해 둡니다.

백그라운드 타이머 운영 시 자주 겪는 문제

보통 처음 구현할 때 아래와 같은 지점에서 막히거나 실수를 많이 합니다.

  • 화면이 꺼지면 타이머가 같이 멈춰버리는 현상

  • 앱을 백그라운드로 보낸 뒤 타이머 종료 알림이 먹통이 되는 상황

  • 모든 카운트다운을 무조건 Foreground Service로 처리해서 배터리를 낭비하는 구조

  • 상단 알림창에 일시정지, 재개, 종료 같은 제어 버튼이 없는 불편함

  • 알림 채널 중요도를 너무 높여서 사용자에게 과도한 피로감을 주는 경우

  • PendingIntent 나 로그에 사용자 ID나 민감한 데이터를 그대로 남기는 보안 실수

백그라운드 타이머를 작업할 때는 "코드를 백그라운드에서 계속 실행한다"가 아니라, "사용자가 인지할 수 있는 지속된 작업과 종료 알림을 조합한다"로 접근하는 것이 운영상 훨씬 안전합니다.

핵심 개념: 계산은 상태로, 표시는 알림으로 분리

타이머의 남은 시간은 매초 줄어드는 루프 코드가 아니라, 시작 기준 시각과 현재 시각의 차이로 계산하는 것이 정석입니다. Foreground Service는 시간을 계산하는 주체가 아니라, 백그라운드에서도 사용자에게 "타이머가 돌고 있다"는 것을 보여주고 제어할 수 있게 돕는 창구일 뿐입니다.

저의 경우 아래와 같이 역할을 나누어 구조를 잡았습니다.

구성 요소역할주의할 점
TimerSession시작 시각, 전체 시간, 일시정지 상태 저장데이터가 유실되지 않도록 관리하는 핵심 상태 모델
ViewModel화면이 보일 때 UI 갱신 담당백그라운드 보장 용도가 아님
Foreground Service사용자가 인지해야 하는 지속 타이머 표시시스템에 의해 종료되지 않도록 지속 알림 필요
Notification남은 시간과 제어 액션(버튼) 표시과도한 중요도(헤드업 등)는 피해야 함
AlarmManager정확한 타이머 종료 시점 알림 예약Android 버전별 정확한 알람 권한 정책 확인 필요

이렇게 구조를 쪼개놓으면 서비스가 시스템에 의해 강제 종료되었다가 다시 살아나도, 저장된 TimerSession을 읽어서 남은 시간을 오차 없이 다시 계산할 수 있습니다.

Foreground Service가 정말 필요한 상황인가?

운영하다 보면 모든 타이머에 서비스를 붙일 필요는 없다는 것을 깨닫게 됩니다. 사용자가 백그라운드 상태에서도 실시간 진행 상태를 계속 봐야 하는지, 아니면 종료될 때 알림만 받으면 되는지 기준을 세워야 합니다.

상황Foreground Service 필요성이유
장시간 타이머 진행 상태를 계속 노출해야 할 때높음사용자가 인지해야 하는 지속 작업입니다.
운동/공부/시험처럼 중단되면 안 되는 흐름높음백그라운드 제어(일시정지 등)가 필수적입니다.
10초 이하의 임시 카운트다운낮음화면 UI 내에서 처리해도 충분합니다.
단순 애니메이션 카운트다운낮음백그라운드 지속 작업이 아닙니다.
종료 시점 알림만 필요한 긴 타이머중간AlarmManager와의 조합을 검토하는 게 낫습니다.
서버 동기화용 타이머낮음~중간WorkManager가 더 적합할 수 있습니다.

Foreground Service는 시스템 자원과 상단 알림 영역을 상시 차지하기 때문에, 사용자가 "이 앱이 왜 백그라운드에서 계속 돌고 있는지" 납득할 수 있는 기능에만 쓰는 것이 좋습니다.

Manifest 및 권한 설정

Foreground Service를 돌리기 위해 Manifest에 선언해야 하는 기본 형태입니다. Android 버전에 따라 foregroundServiceType을 명시해야 심사를 통과할 수 있습니다.

XML
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
    <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
    <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

    <application>
        <service
            android:name=".timer.TimerForegroundService"
            android:exported="false"
            android:foregroundServiceType="specialUse" /> 
            </application>
</manifest>

주의: 빌드만 통과하려고 임의의 serviceType을 넣으면 구글 플레이 스토어 출시 심사 때 거절 사유가 됩니다. 앱의 실질적인 기능과 일치하는 타입을 검토해서 넣으셔야 합니다. 또한 외부 앱에서 내 서비스를 임의로 호출하지 못하도록 android:exported="false" 처리는 필수입니다.

서비스 상태 모델 및 인텐트 제어

서비스는 Intent의 action을 통해 시작, 일시정지, 재개, 종료 명령을 받도록 설계합니다. 액션 문자열은 앱 내부에서만 사용하도록 패키지명을 조합해 정의합니다.

Kotlin
object TimerServiceActions {
    const val START = "com.example.timer.START"
    const val PAUSE = "com.example.timer.PAUSE"
    const val RESUME = "com.example.timer.RESUME"
    const val STOP = "com.example.timer.STOP"
}

class TimerForegroundService : Service() {
    private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)

    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        when (intent?.action) {
            TimerServiceActions.START -> startTimer()
            TimerServiceActions.PAUSE -> pauseTimer()
            TimerServiceActions.RESUME -> resumeTimer()
            TimerServiceActions.STOP -> stopTimer()
        }
        return START_STICKY // 시스템에 의해 죽어도 다시 살려달라고 설정
    }

    private fun startTimer() {
        // 저장소에서 현재 세션을 읽어와 알림을 올리고 타이머 시작
        startForeground(NOTIFICATION_ID, buildNotification())
        startNotificationTicker()
    }

    private fun pauseTimer() {
        // 세션 상태를 PAUSED로 갱신하고 알림 UI 업데이트
        updateNotification()
    }

    private fun resumeTimer() {
        // 누적 일시정지 시간을 계산해서 반영 후 알림 UI 업데이트
        updateNotification()
    }

    private fun stopTimer() {
        scope.cancel()
        stopForeground(STOP_FOREGROUND_REMOVE)
        stopSelf()
    }

    override fun onBind(intent: Intent?): IBinder? = null

    override fun onDestroy() {
        scope.cancel()
        super.onDestroy()
    }

    companion object {
        private const val NOTIFICATION_ID = 1001
    }
}

알림 채널(Notification Channel) 분리 설계

진행 중인 타이머 알림과 종료 알림은 성격이 완전히 다릅니다. 진행 중 알림은 상단에 계속 떠 있는 게 목적이고, 종료 알림은 사용자의 주의를 확 끌어야 합니다. 따라서 하나의 채널로 퉁치지 말고 채널을 분리해야 운영하기 편합니다.

채널 예시 이름중요도 기준 (Importance)설명
"타이머 진행 상태"IMPORTANCE_LOW 또는 DEFAULT상단바에 지속 표시되되, 매초 소리나 진동이 울리면 안 되므로 낮게 설정합니다.
"타이머 종료 알림"IMPORTANCE_HIGH소리와 진동, 혹은 헤드업 알림으로 사용자에게 완료를 명확히 알립니다.
"타이머 기록/통계"IMPORTANCE_LOW완료 후 기록 완료 알림 등 피로도가 낮은 항목입니다.

진행 알림 채널의 중요도를 너무 높여서 만들면, 사용자가 시끄럽거나 불편해서 아예 앱의 알림 채널 전체를 차단해 버리는 역효과가 납니다.

상단 알림 액션(Button) 구현 시 주의점

사용자가 앱을 열지 않고도 알림창 내에서 일시정지나 종료를 누를 수 있게 PendingIntent 액션을 달아줍니다.

Kotlin
private fun serviceActionPendingIntent(action: String, requestCode: Int): PendingIntent {
    val intent = Intent(this, TimerForegroundService::class.java).apply {
        this.action = action
    }
    return PendingIntent.getService(
        this,
        requestCode,
        intent,
        PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, // Android 12 이상 필수
    )
}

보안 실무 팁: PendingIntent 의 extras에 사용자 ID, 시험명, 공부 기록 같은 민감 데이터를 담아서 넘기지 마세요. 인텐트 데이터가 꼬이거나 보안상 취약점이 될 수 있습니다. 인텐트에는 오직 action만 담아 쏘고, 서비스 내부에서 현재 활성화된 세션 데이터를 로컬 저장소(DataStore, DB 등)에서 직접 조회해 처리하는 편이 가장 안전합니다.

배터리를 고려한 알림 갱신 주기

알림창의 남은 시간을 1초마다 무조건 갱신(notify())하면 눈으로 보기엔 정확해 보이지만, 백그라운드 갱신 량이 많아져 배터리 소모와 시스템 부하가 커집니다.

  • 화면이 켜져서 앱 UI가 보일 때: ViewModel 루프를 통해 1초 단위로 촘촘하게 갱신합니다.

  • 백그라운드로 갔을 때: 굳이 초 단위로 알림을 계속 때릴 필요가 없다면 10초~30초 단위 또는 분 단위로 느슨하게 알림 UI를 갱신하고, 정확한 종료 시점은 AlarmManager가 깨워주도록 설계하는 조합이 베스트입니다.

Android 13 이상 알림 권한(POST_NOTIFICATIONS) 대응

Android 13부터는 알림 권한이 필수입니다. 권한이 거부되었을 때 서비스가 아예 안 돌거나 터지는 일은 없어야 하므로 내부 상태 처리를 꼼꼼하게 해줘야 합니다.

  • 권한 허용 시: 백그라운드 진행 알림과 종료 알림을 정상 표시합니다.

  • 권한 거부 시: Foreground Service는 정상 동작하지만 알림만 노출되지 않는 상태가 됩니다. 사용자가 종료 시점을 모를 수 있으므로, 앱 내부 UI를 통해 "백그라운드 알림을 받으려면 권한 허용이 필요하다"는 안내 팝업을 띄워주는 스텝이 필요합니다.

  • 권한 요청 타이밍: 앱을 처음 켜자마자 권한부터 요구하면 거부당하기 쉽습니다. 사용자가 타이머를 세팅하고 '시작' 버튼을 누르는 순간에 기능 맥락을 설명하며 권한을 요청하는 것이 수락 확률을 높이는 방법입니다.

실무 배포 전 체크리스트

  • [ ] 타이머 시간 계산 로직이 루프 코드가 아닌 '시작 시각 대비 현재 시각 차이' 구조인가?

  • [ ] 서비스 컴포넌트에 android:exported="false"를 명시했는가?

  • [ ] 진행 채널(LOW)과 종료 채널(HIGH)의 중요도를 분리했는가?

  • [ ] PendingIntent 생성 시 FLAG_IMMUTABLE을 적용했는가?

  • [ ] 알림 내부 extras 데이터에 사용자 고유 정보나 민감한 값을 제외했는가?

  • [ ] 알림에서 PAUSE, RESUME, STOP을 눌렀을 때 로컬 저장소 세션 상태도 동기화되는가?

  • [ ] 타겟 SDK 버전에 맞는 foregroundServiceType 권한 심사 기준을 확인했는가?

타이머 앱의 백그라운드 핵심은 "서비스를 안 죽이고 계속 돌리기"가 아니라 "서비스 상태는 언제든 죽을 수 있음을 인지하고 세션을 안전하게 저장하며, 알림은 사용자 제어 창구로만 활용하기"입니다. 이 원칙만 지켜도 OS 버전이 올라가면서 백그라운드 제약이 강해져도 유연하게 대응할 수 있습니다.

댓글

이 블로그의 인기 게시물

안드로이드 화면 꺼짐 방지 FLAG_KEEP_SCREEN_ON 및 WakeLock 적용 방법

관광 앱 다국어 데이터 모델 및 검색 색인 설계 (자꾸 까먹어서 정리)