Android 정확한 타이머 앱 구현하기 (CountDownTimer의 한계와 기준 시각 설계)
시험 타이머, 운동 타이머, 집중 타이머 같은 앱을 개발할 때 초보자가 가장 많이 하는 실수가 있습니다. 바로 화면에 보이는 카운트다운 숫자를 그대로 '타이머의 진짜 상태'라고 믿고 설계하는 것입니다.
저의 경우에도 처음에 CountDownTimer나 코루틴 delay(1000)를 사용해서 1초마다 숫자를 단순히 줄이는 방식으로 구현했었습니다. 하지만 이렇게 하면 앱이 백그라운드로 가거나, 화면이 회전되거나, 시스템에 의해 프로세스가 종료되었을 때 시간이 완전히 틀어지는 문제가 발생하더라고요.
타이머 앱을 안정적으로 운영하려면 "1초마다 숫자를 줄이는 방식"이 아니라 "언제 시작했고 언제 끝나야 하는지" 기준 시각을 저장하는 방식으로 접근해야 합니다. 나중에 제가 다시 개발할 때 참고하기 위해 핵심 설계 구조와 구현 코드를 정리해 둡니다.
1. 타이머 구현 시 자주 겪는 문제들
단순히 UI의 Tick 값만 믿고 개발하면 아래와 같은 실무 문제에 부딪히게 됩니다.
CountDownTimer가 돌던 중 화면을 회전하면 Activity가 재생성되면서 타이머가 처음부터 다시 시작됨사용자가 홈 버튼을 눌러 앱이 백그라운드로 간 동안 코루틴이나 타이머가 멈춰서 시간이 안 감
기기 시스템 시각을 사용자가 수동으로 바꾸거나 해외 로밍 등으로 네트워크 시간이 동기화되면 남은 시간이 비정상적으로 계산됨
일시정지(Pause)와 재개(Resume)를 반복할 때 오차가 누적됨
결론부터 말씀드리면, 타이머는 단순한 UI 카운트다운 인터페이스가 아니라 '상태 머신'과 '시간 계산 로직'의 결합으로 바라보고 설계해야 합니다.
2. 핵심 개념: '틱(Tick)'이 아니라 '기준 시각' 저장하기
타이머의 남은 시간은 저장된 고정 값이 아니라 "현재 시점 기준으로 매번 계산하는 결과"여야 합니다. 이를 위해 필요한 핵심 데이터 모델은 다음과 같습니다.
| 항목 | 의미 | 저장 필요성 |
| durationMillis | 전체 타이머 설정 시간 (예: 25분) | 필수 |
| startedAtRealtime | 타이머를 시작한 기준 시각 | 필수 |
| pausedAtRealtime | 일시정지 버튼을 누른 시각 | 일시정지 기능 구현 시 필수 |
| accumulatedPausedMillis | 일시정지되어 흐르지 않은 누적 시간 | 일시정지 기능 구현 시 필수 |
| state | READY / RUNNING / PAUSED / FINISHED | 필수 |
| remainingMillis | 현재 기준 계산된 남은 시간 | 저장하지 않고 실시간 계산 권장 |
SystemClock.elapsedRealtime()을 쓰는 이유
기준 시각을 잡을 때 System.currentTimeMillis()를 쓰면 안 됩니다. 사용자가 기기 설정을 바꾸면 타이머가 망가지기 때문입니다. 기기 부팅 후 경과 시간을 나타내는 SystemClock.elapsedRealtime()을 사용하는 것이 안전합니다.
| 기준 시각 API | 장점 | 주의할 점 |
| System.currentTimeMillis() | 실제 날짜/시간 기록에 적합 | 사용자가 핸드폰 시간 변경 시 영향 받음 |
| SystemClock.elapsedRealtime() | 경과 시간 계산에 절대 안전 | 기기가 재부팅되면 기준값 초기화됨 |
💡 참고: 기기 재부팅 상황까지 방어해야 하는 아주 긴 장기 타이머라면, 처음 시작 시 서버 시각을 받아와 보정하는 로직이나 Wall Clock을 서브로 둔 백업 설계를 검토해야 합니다.
3. 타이머 데이터 모델 및 ViewModel 구현 코드
실무에서 개인정보나 불필요한 로그 없이 바로 쓸 수 있도록 정제한 타이머 세션 모델과 ViewModel 예시 코드입니다.
타이머 세션 모델 (TimerSession.kt)
enum class TimerState {
READY, RUNNING, PAUSED, FINISHED
}
data class TimerSession(
val durationMillis: Long,
val startedAtRealtime: Long?,
val pausedAtRealtime: Long?,
val accumulatedPausedMillis: Long,
val state: TimerState
) {
// 핵심: 매번 호출되는 시점(nowRealtime)을 기준으로 남은 시간을 역산합니다.
fun remainingMillis(nowRealtime: Long): Long {
if (state == TimerState.READY) return durationMillis
if (state == TimerState.FINISHED) return 0L
val startedAt = startedAtRealtime ?: return durationMillis
val effectiveNow = if (state == TimerState.PAUSED) {
pausedAtRealtime ?: nowRealtime
} else {
nowRealtime
}
val elapsed = effectiveNow - startedAt - accumulatedPausedMillis
return (durationMillis - elapsed).coerceAtLeast(0L)
}
}
ViewModel 구현 (TimerViewModel.kt)
UI는 단지 이 ViewModel이 던져주는 계산된 상태를 1초마다 받아서 그리기만 하면 됩니다. CountDownTimer 대신 코루틴 티커를 활용한 예시입니다.
import android.os.SystemClock
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
class TimerViewModel : ViewModel() {
private val _uiState = MutableStateFlow(TimerUiState())
val uiState: StateFlow<TimerUiState> = _uiState.asStateFlow()
private var timerJob: Job? = null
// 25분 타이머 세션 초기화
private var session = TimerSession(
durationMillis = 25 * 60 * 1000L,
startedAtRealtime = null,
pausedAtRealtime = null,
accumulatedPausedMillis = 0L,
state = TimerState.READY
)
fun start() {
if (session.state == TimerState.RUNNING) return
val now = SystemClock.elapsedRealtime()
session = session.copy(
startedAtRealtime = now,
state = TimerState.RUNNING
)
startTicker()
}
fun pause() {
if (session.state != TimerState.RUNNING) return
session = session.copy(
pausedAtRealtime = SystemClock.elapsedRealtime(),
state = TimerState.PAUSED
)
timerJob?.cancel()
updateUi()
}
fun resume() {
if (session.state != TimerState.PAUSED) return
val now = SystemClock.elapsedRealtime()
val pausedAt = session.pausedAtRealtime ?: now
session = session.copy(
accumulatedPausedMillis = session.accumulatedPausedMillis + (now - pausedAt),
pausedAtRealtime = null,
state = TimerState.RUNNING
)
startTicker()
}
private fun startTicker() {
timerJob?.cancel()
timerJob = viewModelScope.launch {
while (isActive) {
updateUi()
delay(1000L) // UI 갱신용 주기일 뿐, 이 숫자가 타이머 정확도에 영향을 주지 않음
}
}
}
private fun updateUi() {
val remaining = session.remainingMillis(SystemClock.elapsedRealtime())
_uiState.value = TimerUiState(
remainingMillis = remaining,
state = session.state
)
if (remaining == 0L && session.state == TimerState.RUNNING) {
session = session.copy(state = TimerState.FINISHED)
timerJob?.cancel()
}
}
override fun onCleared() {
super.onCleared()
timerJob?.cancel()
}
}
data class TimerUiState(
val remainingMillis: Long = 0L,
val state: TimerState = TimerState.READY
)
4. 생명주기(Lifecycle) 대응 및 데이터 저장소 활용법
운영 시에는 앱이 시스템에 의해 강제 종료될 때를 대비해 가벼운 영속성 저장소에 데이터를 적재해 둬야 합니다. 데이터 성격에 따라 아래와 같이 분리해서 관리하시는 것을 추천합니다.
상태 저장 위치 선택 기준
SavedStateHandle: 화면 회전이나 일시적인 프로세스 죽음 이후 UI 복구용 최소 데이터 저장에 적합합니다.
DataStore (Preferences): 현재 활성화된 타이머의 세션 정보(
startedAtRealtime,durationMillis등)를 가볍게 유지하기 가장 좋습니다.Room DB: 시험 기록, 공부 완료 통계 데이터 등 영구 보존 및 복잡한 쿼리가 필요한 영역에 사용합니다.
앱 상태별 처리 요약
화면 회전: ViewModel이 살아있으므로 그대로 유지되지만, 프로세스 재생성을 대비해
SavedStateHandle에 세션 값을 연동합니다.백그라운드 진입: 코루틴 티커는 멈추거나 시스템에 의해 취소될 수 있습니다. 어차피 사용자가 앱으로 돌아오는 시점(onStart / onResume)에 현재
elapsedRealtime으로 남은 시간을 재계산하므로 흐트러지지 않습니다.
5. 백그라운드 종료 알림 처리 시 주의점
앱이 백그라운드로 들어가면 언제든 OS에 의해 죽을 수 있기 때문에, 타이머 종료 시점에 정확히 푸시 알림이나 벨소리를 울려주려면 내부 타이머 루프에만 의존하면 안 됩니다.
정확한 초 단위 알림이 필요할 때:
AlarmManager.setExactAndAllowWhileIdle()을 활용해 종료 예정 시점(startedAtRealtime + durationMillis + accumulatedPausedMillis)을 시스템 알람으로 예약해야 합니다.사용자가 진행 상황을 실시간 인지해야 할 때: 운동 타이머처럼 백그라운드에서도 진행 상황을 계속 보여줘야 한다면 Foreground Service를 띄우고 알림 창에 상주시켜야 OS의 백그라운드 제한 정책을 피할 수 있습니다.
6. 구현 후 필수 체크리스트
타이머 기능을 배포하기 전에 아래 케이스를 무조건 테스트해 보셔야 마켓에서 대량의 크래시나 버그 리포트를 막을 수 있습니다.
타이머 구동 중 화면을 가로/세로로 빠르게 전환해도 시간이 이어지는가?
타이머를 켜두고 다른 앱을 5분간 쓰다 돌아왔을 때, 시간이 멈춰있지 않고 정상 시점을 가리키는가?
타이머 진행 중 스마트폰 [설정] -> [날짜 및 시간]에서 시간을 임의로 변경해도 타이머가 왜곡되지 않는가?
일시정지 후 10분 뒤에 재개(Resume)했을 때 오차 없이 일시정지했던 시점부터 정상 동작하는가?
(운영 관점) 기기 로그(Logcat)나 로컬 DB 예제 코드에 사용자의 테스트 데이터나 개인 식별 정보가 무차별적으로 남지 않도록 마스킹 처리했는가?
요약
UI 카운트다운 컴포넌트는 그저 보여주기식 도구일 뿐입니다. 타이머의 진짜 상태는 "기준 시각 데이터 모델"이라는 점을 명심하고 설계하시면 백그라운드나 생명주기 변화에도 끄떡없는 탄탄한 앱을 만드실 수 있습니다. 처음 설계할 때 공을 조금만 들이면 나중에 유지보수할 때 정말 편해집니다.
댓글
댓글 쓰기