[Android] 시험 및 학습 타이머 앱 백그라운드 시간 멈춤과 세션 복구 구현하기
운영하다 보면 타이머 기능이 필요한 앱을 만들 때가 있습니다. 저의 경우 수험생들을 위한 시험 타이머 앱을 사이드 프로젝트로 개발했었는데, 사용자가 일시정지를 누르거나 앱을 잠깐 나갔다 돌아올 때 시간이 어긋나는 문제가 자꾸 발생하더군요.
처음에는 단순하게 남은 시간(remainingSeconds)을 변수나 로컬 저장소에 매초 저장하면 될 줄 알았는데, 화면이 꺼지거나 시스템이 프로세스를 강제 종료하면 타이머가 초기화되거나 실제 흐른 시간과 맞지 않는 버그가 생겼습니다.
이래저래 고민하다가 정리한 안정적인 타이머 세션 복구 구조를 기록해 둡니다. 나중에 저도 자꾸 까먹을 때마다 다시 보려고 정리하는 목적도 있습니다.
내가 겪은 문제와 헷갈린 지점
타이머 앱을 단순하게 만들면 아래와 같은 상황에서 무조건 버그가 터집니다.
앱을 백그라운드로 보낸 동안 시간이 멈춘 것처럼 보이는 현상
화면 회전(Configuration Change)이나 시스템에 의한 프로세스 재생성 시 타이머 초기화
사용자가 직접 누른 일시정지와 앱이 중단된(백그라운드) 상태를 구분하지 못함
강제 종료 후 재실행 시 이전 세션을 이어갈지 새로 시작할지 명확한 기준이 없음
남은 시간을 매초 디스크에 저장하여 배터리와 저장소에 불필요한 부담을 줌
핵심은 타이머를 매초 화면을 갱신하는 '초 단위 카운터'로 보면 안 된다는 점입니다. 운영 관점에서는 타이머를 "시작 시각, 누적 진행 시간, 일시정지 상태를 가진 하나의 세션(Session)"으로 관리해야 안전합니다.
해결 방법: 남은 시간이 아닌 '세션 데이터' 저장하기
매초 남은 시간을 직접 저장하는 방식은 비효율적이고 부정확합니다. 대신 시간을 언제든 역산해서 얻을 수 있는 '기준값'들을 세션 모델로 묶어서 상태가 바뀔 때만 디스크에 저장하는 것이 좋습니다.
제가 정의해서 사용한 세션 데이터 구조입니다.
| 저장 항목 | 설명 | 필요한 이유 |
| sessionId | 현재 시험 또는 타이머 세션 ID | 이전 세션과 새 세션을 명확히 구분 |
| durationMs | 전체 제한 시간 (밀리초) | 남은 시간 계산의 기본 기준값 |
| startedAtMs | 실제 타이머를 시작한 시스템 시각 | 앱이 중단된 동안 흐른 시간을 계산하기 위함 |
| pausedAtMs | 일시정지를 누른 시각 (Null 허용) | 사용자가 의도적으로 멈춘 상태를 구분 |
| accumulatedPauseMs | 누적 일시정지 시간 | 실제 진행 시간 계산에서 제외할 총 일시정지 시간 |
| status | IDLE, RUNNING, PAUSED, FINISHED | 복구 시 화면 UI 및 버튼 상태 결정 |
| updatedAtMs | 마지막으로 데이터가 저장된 시각 | 너무 오래된 세션을 폐기하는 기준으로 활용 |
이렇게 기준값들을 들고 있으면, 앱이 백그라운드에 가 있거나 완전히 꺼져 있어도 [현재 시각 - 시작 시각 - 누적 일시정지 시간]을 계산해서 정확한 남은 시간을 바로 뽑아낼 수 있습니다.
타이머 상태 설계 기준
상태는 군더더기 없이 딱 네 가지로만 나눕니다. 여기서 중요한 점은 앱이 백그라운드로 이동했다고 해서 무조건 PAUSED 상태로 바꾸면 안 된다는 점입니다. 사용자가 직접 멈춘 것과 앱이 가려진 것은 엄연히 다릅니다.
IDLE: 아직 시작 전 상태 (시작 버튼 표시)
RUNNING: 시간 흐르는 중 (일시정지 / 종료 버튼 표시)
PAUSED: 사용자가 직접 일시정지함 (이어서 진행 / 종료 버튼 표시)
FINISHED: 시간 만료 또는 사용자가 완전 종료 (결과 확인 / 새 세션 버튼 표시)
💡 운영 팁 (앱 정책 정하기)
실제 시험용 타이머라면 앱을 나가도 시간은 계속 흘러야 실전 같습니다. 반면 단순 학습용(뽀모도로 등) 타이머라면 백그라운드로 갈 때 자동 일시정지 처리를 해주는 것이 맞습니다. 이 정책을 개발 전에 명확히 정해두어야 사용자가 버그로 느끼지 않습니다.
핵심 구현 코드 (Kotlin)
실제 안드로이드 프로젝트에서 활용한 계산 로직 예시 코드입니다. Room이나 DataStore 등 본인 프로젝트 구조에 맞는 저장소를 선택해 연동하시면 됩니다.
data class TimerSession(
val sessionId: String,
val durationMs: Long,
val startedAtMs: Long,
val pausedAtMs: Long? = null,
val accumulatedPauseMs: Long = 0L,
val status: TimerStatus,
val updatedAtMs: Long,
)
enum class TimerStatus {
IDLE, RUNNING, PAUSED, FINISHED
}
class TimerSessionCalculator {
// 현재 시각(nowMs)을 기준으로 정확한 남은 시간을 역산
fun remainingMs(session: TimerSession, nowMs: Long): Long {
if (session.status == TimerStatus.FINISHED) return 0L
val effectiveNow = when (session.status) {
TimerStatus.PAUSED -> session.pausedAtMs ?: nowMs
else -> nowMs
}
val elapsedMs = effectiveNow - session.startedAtMs - session.accumulatedPauseMs
return (session.durationMs - elapsedMs).coerceAtLeast(0L)
}
// 일시정지 처리
fun pause(session: TimerSession, nowMs: Long): TimerSession {
if (session.status != TimerStatus.RUNNING) return session
return session.copy(
status = TimerStatus.PAUSED,
pausedAtMs = nowMs,
updatedAtMs = nowMs
)
}
// 이어서 진행 처리 (멈춰있던 시간만큼 누적 일시정지 시간에 더해줌)
fun resume(session: TimerSession, nowMs: Long): TimerSession {
if (session.status != TimerStatus.PAUSED) return session
val pausedAt = session.pausedAtMs ?: nowMs
val pauseDuration = nowMs - pausedAt
return session.copy(
status = TimerStatus.RUNNING,
pausedAtMs = null,
accumulatedPauseMs = session.accumulatedPauseMs + pauseDuration,
updatedAtMs = nowMs
)
}
// 시간 만료 여부 체크
fun finishIfExpired(session: TimerSession, nowMs: Long): TimerSession {
if (remainingMs(session, nowMs) > 0L) return session
return session.copy(
status = TimerStatus.FINISHED,
updatedAtMs = nowMs
)
}
}
코드를 보시면 남은 시간을 직접 저장하는 곳이 없습니다. 매번 저장하지 않고 세션 구조체와 현재 시스템 시각(nowMs)만 넘겨주면 알아서 남은 시간이 계산되므로, 앱이 죽었다 깨어나도 오차 없이 완벽하게 복구됩니다.
상황별 저장소 선택 기준
현재 진행 중인 세션을 어디에 백업할지는 상황에 따라 선택하시면 됩니다. 저의 경우 보통 이렇게 기준을 잡습니다.
ViewModel만 사용: 구현은 단순하지만 프로세스 종료 시 복구 불가능. 화면 회전 정도만 대응할 때 사용.
SavedStateHandle: 액티비티가 시스템에 의해 기습적으로 재생성되는 짧은 화면 상태 보존에 적합.
DataStore (추천): 구조가 단순하고 비동기 저장이 지원되어, 현재 진행 중인 단일 세션 1개 저장용으로 딱 좋음.
Room: 완료된 시험 기록 데이터베이스 저장, 통계, 히스토리 관리 등 관계형 데이터 처리가 필요할 때 사용.
💡 실무 추천 구조: 현재 실시간으로 진행 중인 세션 정보는 DataStore에 가볍게 저장하고, 타이머가 최종 완료된 기록 데이터는 Room에 덤프를 뜨는 방식을 쓰면 구조가 아주 깔끔해집니다.
복구 UX 및 실무 체크리스트
앱을 다시 열었을 때 무조건 이전 세션을 강제로 이어 붙이면 사용자가 당황할 수 있습니다. 상황에 맞춰 적절한 안내와 UX 처리를 곁들여야 앱 완성도가 올라갑니다.
시간이 아직 남은 경우: 바로 이어서 진행할 수 있는 화면 표시
앱을 완전히 종료한 사이에 제한 시간이 지나간 경우: 곧바로 종료 상태로 전환 후 결과 화면 제공
일시정지 상태로 저장되어 있던 경우: 이어서 진행할지, 아니면 세션을 종료할지 선택 팝업 제공
마지막 저장 시각이 너무 오래된 경우: 유효하지 않은 세션으로 판단하고 자동 폐기 후 새 세션 유도
초보자가 자주 하는 실수 & 보안 체크리스트
[ ]
CountDownTimer의 리스너 내부 콜백 값만 믿고 세션 복구 로직을 짜지 않았는지 확인[ ] 디스크 Write를 매초 발생시켜 배터리와 하드웨어 수명을 갉아먹고 있지 않은지 체크
[ ] 화면 회전, 화면 꺼짐, 강제 종료 시나리오를 각각 누락 없이 테스트했는지 확인
[ ] 시험 기록이나 유저 개인정보를 로그(Logcat)나 예제 데이터에 그대로 노출하지 않는지 검토
[ ] 사용자가 임의로 기기 시스템 시각을 바꾸는 상황에 대비해 별도의 검증 기준(UptimeMillis 등)을 둘지 고민
마무리하며
타이머 앱은 겉보기엔 UI 갱신만 해주면 될 것처럼 만만해 보이지만, 백그라운드 제약과 기기 프로세스 종료 정책을 고려하기 시작하면 설계가 까다로워집니다.
핵심은 숫자를 매초 세는 게 아니라 "변하지 않는 기준 시각과 세션 상태"를 정확하게 보존하는 것입니다. 타이머 버그로 골치 아프셨던 분들은 세션 모델 방식으로 구조를 한번 변경해 보시길 권합니다.
댓글
댓글 쓰기