본문 바로가기
Project/Ear Alarm

타이머 서비스 분석 및 알람 로직 개선

by oldogz 2025. 3. 31.

https://github.com/p-chanmin/EarAlarmApp

 

GitHub - p-chanmin/EarAlarmApp: 🔔 이어폰에서 울리는 타이머 알람 앱

🔔 이어폰에서 울리는 타이머 알람 앱. Contribute to p-chanmin/EarAlarmApp development by creating an account on GitHub.

github.com

 

기존의 Ear Alarm 앱에서는 타이머 알람이 울리기까지 얼마나 시간이 남았는지 표시해주는 UI가 없었습니다.

 

사용자가 타이머를 설정하면, 알람이 울리는 시간만 UI 표시되고 있는 상황이었습니다.

 

타이머를 동작시키면, 타이머가 종료될 때까지 얼마나 남았는지 지속적으로 확인할 수 있는 UI를 추가하기로 하였고, 그 과정에 대해서 정리하였습니다.

 

기존 방식

AlarmManager를 기본적으로 사용하여 타이머가 동작하도록 구현했었습니다.

 

사용자가 타이머를 설정하면 현재시간에서 해당 시간을 더해 AlarmManager에 등록시켜 특정 시간에 알람이 동작하는 방식입니다.

 

또한, 앱을 종료하고 재실행 했을 때, 타이머의 정보를 표시하기 위해 SharedPreferences를 사용하여 설정된 알람 정보를 저장했습니다.

alarmTextmin.text = String.format(getString(R.string.alarmTextmin), sleepMinTime.toString())
alarmTextendtime.text = String.format(getString(R.string.endtimetext), endtime)
prefs.setString("state", "alarm_on")
prefs.setString("alarmTextmin", alarmTextmin.text.toString())
prefs.setString("alarmTextendtime", alarmTextendtime.text.toString())

 

과거에 작성한 코드를 보면

알람 데이터를 저장하는 것이 아닌, 알람 정보의 text를 저장하고 있었습니다.

 

문제점

기존의 방식을 개선하는 과정에서 예측 가능한 여러가지 문제가 있었습니다.

  1. 종료 시간 정보를 가져오기 까다롭다. 현재 UI에 표시할 text를 String으로 저장하고 있기 때문에,  앱을 유지한 상태라면 몰라도 앱을 재실행 했을 경우에는 시간 형식의 데이터를 계산하기 위해 복잡한 과정을 거쳐야 한다.
  2. 종료 시간 정보가 없기 때문에, 남은 시간을 UI 표시하는 것 역시 복잡한 과정이 필요하다.
  3. 기기가 재부팅 되었을 경우, AlarmManager에 등록된 알람이 사라져 유지되지 않는다.

 

비슷한 서비스 분석

플레이스토어에 출시된 비슷한 서비스의 어플리케이션을 사용해보고, 각각의 앱들은 사용자에게 어떤 경험을 제공하는지 분석해보았습니다.

각각의 앱들은 모두 남은 시간을 UI에 표시해주는 기능을 포함하는 서비스입니다.

 

특히 백그라운드 기능이 포함된 서비스는 백그라운드 서비스를 강제로 종료했을 때의 동작을 주의깊게 살펴보았습니다.

 

1. 삼성 시계

- 삼성 시계의 타이머 기능은 앱 재실행, 기기 재부팅 상황에서도 계속 동작한다.

- 백그라운드 서비스가 있으며, 백그라운드 서비스를 강제 종료했을 때도 계속 동작한다.

- 백그라운드 서비스는 Notification으로 남은 시간을 표시하고, 일시정지와 정지 기능을 제공한다.

2. 구글 시계

- 구글 시계의 타이머 기능은 앱 재실행, 기기 재부팅 상황에서도 계속 동작한다.

- 백그라운드 서비스는 없다.

3. Hybrid Stopwatch & Timer

- Hybrid Stopwatch & Timer는 앱 재실행, 기기 재부팅 상황에서도 계속 동작한다.

- 백그라운드 서비스가 있으며, 백그라운드 서비스를 강제 종료했을 때도 계속 동작한다.

- 백그라운드 서비스는 Notification으로 남은 시간을 표시하는 기능만 제공한다.

4. 타이머 플러스

- 타이머 플러스는 앱 재실행, 기기 재부팅 상황에서도 계속 동작한다.

- 백그라운드 서비스는 없다.

5. Engross

- Engross는 앱 재실행 상황에서 계속 동작한다.

- 백그라운드 서비스가 있으며, Notification으로 남은 시간을 표시하는 기능만 제공한다.

- 백그라운드 서비스를 강제 종료 했을 경우, 타이머가 00:00으로 초기화되며 멈춘다.

6. 비주얼 타이머

- 비주얼 타이머는 앱 재실행 상황에서 계속 동작한다.

- 백그라운드 서비스가 있으며, Notification으로 남은 시간을 표시하고, 일시정지와 정지 기능을 제공한다.

- 백그라운드 서비스를 강제 종료 했을 경우, 앱 재실행 시 타이머가 다시 처음부터 동작하여 시작된다.

 

각각 표로 정리하면 다음과 같습니다

서비스 앱 재실행 동작 기기 재부팅 동작 백그라운드 서비스 유무 백그라운드 서비스 강제 종료 시 동작 비고
삼성 시계 O O O O  
구글 시계 O O X -  
Hybrid Stopwatch & Timer O O O O  
타이머 플러스 O O X -  
Engross O X O X  
비주얼 타이머 O X O X 앱 재실행 시, 처음부터 다시 시작

 

삼성 시계, 구글 시계, Hybrid Stopwatch & Timer, 타이머 플러스는 백그라운드 서비스의 유무와 상관 없이, 앱 재실행, 기기 재부팅 상황에서도 정상동작 하므로 AlarmManger와 데이터를 local에 저장하여 타이머를 구현했을 것으로 추측했습니다.

 

특히, 기기 재부팅 상황에서는 BroadcastReceiver에서 BOOT_COMPLETED를 수신받는 방법으로 타이머 기능을 유지했을 것으로 추측하였습니다.

 

백그라운드 서비스가 있는 삼성 시계, Hybrid Stopwatch & Timer는 서비스에서 타이머 기능을 수행한다기 보다는 남은 시간을 Notification이나 pip로 보여주기 위해 사용했을 것으로 추측하였습니다.

 

Engross, 비주얼 타이머는 앱 재실행 시에는 동작하지만, 기기 재부팅 상황에서는 타이머가 동작하지 않고,

백그라운드 서비스를 강제로 종료했을 때, 타이머의 기능이 제대로 동작하지 않는 것을 통해 서비스에서 타이머 로직이 수행되는 것으로 추측하였습니다.

 

특히, 비주얼 타이머의 경우 백그라운드 서비스를 강제로 종료한 후 앱을 재실행 시켰을 때 타이머가 처음부터 다시 실행되는 것으로 보아 Workmanager의 retry기능이 수행된 것으로 추측하였습니다.

 

좋은 사용자 경험 제공하기

남은 시간을 UI에 표시하기 위해 시작한 고민에서 출발하여

타이머 로직을 어떻게 수정하고, 구현할 지에 대해서 고민하여 다양한 예상 시나리오를 생각하였습니다.

 

최종적으로 개선 목표는 다음과 같습니다.

 

1. 사용자가 남은 시간을 실시간으로 확인할 수 있을 것

2. 앱 재실행, 기기 재부팅 상황에서도 타이머가 유지되며, 남은 시간을 확인할 수 있을 것.

3. 백그라운드 서비스를 사용할 경우, 강제 종료 시에도 타이머 기능이 유지 될 것

 

구현

좋은 사용자 경험을 제공하기 위해 기존의 AlarmManger와 local에 데이터를 저장하는 방법을 수정하였습니다.

 

SharedPreferences를 DataStore로 마이그레이션하며, 기존의 알람 종료 text를 String으로 저장하던 것을 시간 형식으로 저장하도록 변경하였습니다.

class DataStoreHelper @Inject constructor(
    private val dataStore: DataStore<Preferences>, private val gson: Gson
) {

	...

    val alarmInfo: Flow<TimerAlarmInfo?> = dataStore.data.map {
            gson.fromJson(it[TIMER_ALARM_INFO], TimerAlarmInfo::class.java)
        }.distinctUntilChanged()
        
    ...
    
    suspend fun storeTimerAlarmInfo(timerAlarmInfo: TimerAlarmInfo) {
        dataStore.edit {
            it[TIMER_ALARM_INFO] = gson.toJson(timerAlarmInfo, TimerAlarmInfo::class.java)
        }
    }
    
    ...
    
}

 

AlarmInfo가 저장되어 있고, 앱이 실행되어 있을 경우에만 ViewModel에서 남은 시간을 측정하여 UI에 갱신하도록 합니다.

private fun measuringTimer() {
    viewModelScope.launch {
        while (_measuringUiState.value.isMeasuring) {
            _measuringUiState.update {
                it.copy(
                    isMeasuring = it.progress < 100,
                    progress = getTimerProgressFromNow(it.startTime, it.endTime),
                    leftTime = it.endTime.getRemainingTimeFromNow()
                )
            }
            delay(100)
        }
    }
}

 

또한, 기기가 재부팅 되었을 경우, 저장된 AlarmInfo를 기반으로 다시 AlarmManager에 등록하여 타이머를 유지하였습니다.

@AndroidEntryPoint
class AlarmReceiver : BroadcastReceiver() {

	...
    
    override fun onReceive(context: Context, intent: Intent) {
        when (intent.action) {
            Intent.ACTION_BOOT_COMPLETED -> {
                CoroutineScope(Dispatchers.IO).launch {
                    dataStoreHelper.alarmInfo.first()?.let {
                        if (ZonedDateTime.parse(it.endTime) > ZonedDateTime.now()) {
                            alarmHelper.setTimerAlarm(it)
                        } else {
                            dataStoreHelper.deleteTimerAlarmInfo()
                        }
                    }
                }
            }
            
            ...
            
        }
    }
}

 

여기까지 개선하며 남은 시간을 UI에 효율적으로 표시하고, 기기 재부팅 상황에서도 타이머를 유지하도록 하였습니다.

타이머 로직을 백그라운드 서비스에서 구현하지 않고, 기존의 AlarmManger와 local 데이터 저장을 개선하였기 때문에 서비스를 추가한다고 해도 타이머 로직과는 분리될 것으로 예상합니다.

 

이후에 Notification으로 남은 시간이나 정지, 일시정지 기능을 구현할 때도 빠르게 구현할 수 있을 것으로 예상하며, 백그라운드 서비스의 강제 종료 등의 상황에서도 문제 되지 않을 것으로 예상하였습니다.