Tack TODO 기술 개발 회고 #Flutter

프로젝트 소개

Tack TODO는 처음부터 꽤 욕심을 내고 시작한 프로젝트다.

할 일 목록만 보여주는 TODO 앱을 만들 생각은 없었다. 시간에 맞춰 해야 하는 작업이 있고, 정해진 시간에 알람이 와야 하고, 완료하면 코인을 주고 하지 않으면 패널티를 주는 구조를 생각했다.

일반적인 Event TODO도 있고 startAt, endAt을 가지는 Time TODO도 만들었다. 여기에 Daily 슬롯과 Calendar 슬롯을 추가했다. Daily는 매주 특정 요일에 실제 TODO를 배출하고, Calendar는 특정 날짜에 TODO를 생성한다.

Time TODO의 상태도 단순한 done 하나로 끝내지 않았다.

대기 상태에서 시작하면 inProgress가 되고, 정상적으로 끝내면 done, 시간이 지나도록 처리하지 않으면 failed가 된다. 시작 시간 전후로 30분의 착수 윈도우도 두었다. 착수에 성공하면 착수 코인을 먼저 주고, 정상적으로 끝냈을 때 완료 코인을 추가로 지급하는 방식이다.

이 정도까지 만들고 나니 앱의 중심이 TODO 화면이 아니라 시간 스케줄링 쪽으로 넘어가기 시작했다.

Flutter로 화면을 만드는 것 자체는 별 문제가 없었다.

문제는 앱이 꺼진 다음이었다.

Flutter 백그라운드만으로는 부족했다

처음에는 Flutter의 workmanager와 백그라운드 콜백을 이용하려고 했다.

구조 자체는 단순했다.

Flutter가 데이터를 가지고 있고 -> 백그라운드 작업이 실행되고 -> 알람과 위젯을 갱신하는 방식이다.

개발할 때는 이게 가장 자연스러워 보인다.

그런데 실제 Android 기기에서 테스트하니 문제가 바로 보였다.

Doze Mode가 들어가거나 앱 프로세스가 종료된 상태에서는 백그라운드 작업이 원하는 시간에 실행되지 않았다. 알람이 몇 분씩 밀리는 경우도 있었고, 위젯 데이터가 갱신되지 않는 경우도 있었다.

Time TODO는 시간이 핵심인데 알람이 늦게 울리면 시스템 자체가 의미가 없어졌다.

그래서 이 부분은 Flutter로 계속 해결하려 하지 않았다.

Android Kotlin 쪽에 별도의 네이티브 엔진을 만들었다.

AlarmNativeSync, NativeAlarmReceiver, ForwardAlarmForegroundService, MidnightSettlementReceiver, 각종 AppWidget Provider 등을 네이티브에서 직접 관리하도록 구조를 바꿨다.

여기서 조금 더 들어갔다.

Flutter가 살아 있을 때는 매번 네이티브에서 Firestore를 다시 읽을 필요가 없었다. Flutter가 가지고 있는 상태를 AlarmScheduleSnapshot 형태로 넘겨주면 된다.

반대로 Flutter 프로세스가 죽은 상태에서는 네이티브에서 직접 Firebase SDK를 사용하도록 했다.

그래서 최종적으로는 두 경로가 생겼다.

Flutter 실행 중 -> Snapshot 전달 -> Native

Flutter 종료 -> Native Firebase SDK -> Firestore

Flutter와 Android를 완전히 분리한 것도 아니고, 전부 Flutter에 맡긴 것도 아니다. 상황에 따라 더 적합한 쪽을 사용하도록 했다.

알람 하나 띄우는 데 생각보다 많은 코드가 필요했다

Time TODO 알람은 일반적인 로컬 알림과 조금 달랐다.

화면이 꺼져 있어도 동작해야 했고, 잠금 화면에서도 사용자가 바로 작업을 확인할 수 있어야 했다. 다른 앱을 보고 있는 상태도 고려했다.

Android 버전이 올라가면서 이 부분이 더 까다로워졌다.

백그라운드에서 Activity를 마음대로 실행할 수 없었고, Exact Alarm도 별도의 권한 문제가 있었다. Android 14에서는 Foreground Service 정책까지 신경 써야 했다.

그래서 알람이 발생하면 바로 Activity를 띄우는 식으로 처리하지 않았다.

먼저 NativeAlarmReceiver가 호출되고 현재 화면과 앱 상태를 확인한다.

val mustLaunchApp = !appVisible || !screenOn
val warmProcessAlive = bridgeAttached
val warmPausedScreenOn = warmProcessAlive && !appVisible && screenOn

여기서 현재 화면이 켜져 있는지, 앱이 실제로 보이는지, Flutter 브릿지가 살아있는지를 보고 이후 동작을 나눈다.

필요하면 PARTIAL_WAKE_LOCK을 확보하고, 콜드 스타트라면 ForwardAlarmForegroundService를 거쳐 전면 알람 화면을 올린다.

Activity에는 setShowWhenLocked(true), setTurnScreenOn(true)도 적용했다.

알람 화면만 띄우고 끝낸 것도 아니다.

소리와 진동도 네이티브에서 직접 제어했다. 기기마다 알림 설정이 다르고, 시스템 알림만 믿었을 때 원하는 동작이 나오지 않는 경우가 있었기 때문이다.

이 작업을 하면서 알게 된 건 Flutter 플러그인을 사용하는 것과 실제 OS 동작을 제어하는 것은 꽤 다른 문제라는 점이었다.

알람을 취소했는데 다시 살아나는 문제

이게 실제로 꽤 귀찮았다.

사용자가 알람 화면에서 Skip을 누른다.

알람은 취소된다.

그런데 잠시 뒤 백그라운드 동기화가 돈다.

Firestore나 SharedPreferences에 남아있는 이전 상태를 보고 스케줄러가 다시 알람을 등록한다.

취소한 알람이 부활한다.

처음에는 스케줄 취소 부분을 고치면 될 것 같았는데 그렇지 않았다. 여러 작업이 서로 다른 타이밍에 실행되고 있었기 때문이다.

그래서 AlarmFiredRegistryTodoAlarmDismiss를 만들었다.

알람마다 fireKey를 만들고 로컬에 발송 및 취소 상태를 기록했다.

이제 백그라운드 동기화가 과거 데이터를 읽더라도 이미 처리된 알람이면 다시 예약하지 않는다.

여기서 또 하나 문제가 있었다.

Time TODO가 종료 시간을 지나면 자동으로 failed 처리된다.

그런데 사용자가 완료 알람 화면에서 완료 버튼을 누르는 순간에 백그라운드 로직이 먼저 실행될 수 있다.

한쪽에서는 완료.

다른 쪽에서는 실패.

이런 상황이다.

그래서 완료 알람이 처리 중인 동안에는 overdue fail을 잠시 미루도록 deferOverdueFailForTodo()를 넣었다.

bool deferOverdueFailForTodo(Todo todo, DateTime now) {
  if (todo.type != TodoType.time || todo.isTimeTodoResolved) {
    return false;
  }

  if (_guardsCompleteAlarm(todo.id)) {
    return true;
  }

  if (_nativeCompleteAlarmInFlight) {
    return true;
  }

  return false;
}

결과적으로 알람 시스템에서 단순히 “몇 시에 알람을 울린다”보다 중요한 부분은 이미 처리된 이벤트를 다시 처리하지 않는 것이었다.

Firestore Snapshot 때문에 체크가 되돌아가는 문제

Firestore의 실시간 스트림도 처음에는 단순하게 사용했다.

snapshots()에서 데이터가 들어오면 Provider의 리스트를 갱신하면 된다고 생각했다.

그런데 TODO를 빠르게 체크하거나 삭제하면서 문제가 발생했다.

사용자가 체크한다.

로컬 리스트를 변경한다.

Firestore에 write한다.

그런데 서버 Snapshot이 늦게 들어온다.

그 Snapshot이 사용자가 변경하기 전 상태라면 화면이 다시 이전 상태로 돌아간다.

이건 사용자가 보기에는 그냥 “체크했는데 다시 풀리는 버그”다.

실제로는 비동기 작업의 도착 순서 문제였다.

그래서 TodoProvider 내부에 쓰기 큐를 만들었다.

_writeTail을 사용해서 비동기 write를 직렬화했고 _queuedWriteCounts로 현재 서버에 반영되지 않은 작업을 추적했다.

사용자 입력이 들어오면 서버 응답을 기다리지 않고 _todos부터 변경한다.

그 다음 notifyListeners()를 호출한다.

화면은 바로 바뀐다.

이후 Firestore Snapshot이 들어왔을 때 해당 ID에 pending write가 있으면 Snapshot으로 덮어쓰지 않는다.

final hasPendingWrite =
    (_queuedWriteCounts[incoming.id] ?? 0) > 0;

if (hasPendingWrite) {
  merged.add(existingLocal);
} else {
  merged.add(incoming);
}

이 방식으로 Optimistic UI와 Firestore 실시간 동기화를 같이 사용할 수 있었다.

위젯은 만들고 나서 최적화했다

홈 화면 위젯도 처음부터 5개였다.

가장 가까운 Time TODO를 보여주는 NextTimeTodoWidget, TODO 목록을 보여주는 TodoListWidget, 달력을 보여주는 CalendarWidget, 당일 코인 목표를 보여주는 GoalWidget, 패널티를 보여주는 PenaltyWidget이다.

처음에는 데이터가 변경되면 위젯을 바로 갱신했다.

당연히 동작은 한다.

그런데 TODO를 연속으로 수정하거나 순서를 드래그해서 변경하면 위젯 갱신도 계속 발생한다.

SharedPreferences에 데이터를 다시 쓰고 AppWidgetManager.updateAppWidget도 호출한다.

이게 쌓이면 불필요한 I/O가 많아진다.

그래서 _fingerprint를 만들어 현재 위젯에 필요한 데이터가 실제로 변경됐는지 확인했다.

그리고 변경이 있더라도 바로 갱신하지 않고 2초 동안 기다렸다가 한 번에 Flush하도록 DebouncedHomeWidgetSync를 만들었다.

결과적으로 연속된 변경을 묶어서 처리할 수 있었다.

Windows는 같은 알람을 그대로 가져가지 않았다

Windows 버전에서는 모바일과 같은 전면 알람을 사용하지 않았다.

PC에서 작업하고 있는데 화면 전체를 강제로 가져오는 것은 오히려 방해가 될 가능성이 높았다.

그래서 Windows에서는 Toast Notification을 사용하고 액션 버튼을 붙였다.

그런데 여기에서도 Flutter 플러그인만으로는 원하는 수준의 동작이 나오지 않았다.

패키징되지 않은 상태에서 앱 이름이나 Activation 처리가 제대로 되지 않았고 알림 취소도 문제가 있었다.

그래서 C++로 Win32 Shell API를 직접 사용해서 Start Menu Shortcut을 만들고 AUMID를 연결했다.

MSIX 설정에서는 Toast Activation을 위한 COM CLSID도 등록했다.

결과적으로 Windows에서는

Toast -> 착수 / 완료 / 스킵

형태의 알림으로 처리했다.

Android와 Windows에서 같은 기능을 제공하지만 실제 구현과 UX는 다르게 가져간 셈이다.

반복 일정의 시간 충돌

Daily Time TODO와 Calendar Time TODO를 같이 사용하면서 또 다른 문제가 나왔다.

예를 들어 매주 월요일 10시부터 11시까지 Daily 일정이 있고, 특정 월요일에 Calendar 일정이 같은 시간에 들어올 수 있다.

날짜 하나만 비교해서는 안 된다.

반복 요일을 비교해야 한다.

그래서 _sharedWeekdays로 반복 요일의 교집합을 구하고 _clockOnReferenceDay를 통해 기준 날짜에 시간을 투영해서 겹치는 구간을 검사했다.

일정을 추가하거나 수정할 때 같은 검사를 수행하고, 충돌하면 TimeTodoOverlapDialog를 보여주도록 했다.

이쪽은 처음부터 완벽하게 설계했다기보다는 기능이 늘어나면서 실제 문제가 생겼고, 그때 시간 모델을 다시 정리한 부분에 가깝다.

자정에 앱이 꺼져 있으면 어떻게 할 것인가

패널티 정산도 비슷했다.

전날 완료하지 않은 Event는 개수에 따라 패널티를 차감하고, 실패한 Time TODO는 소요 시간에 따라 패널티를 계산한다.

문제는 자정에 앱이 실행되고 있다는 보장이 없다는 것이다.

그래서 Android Native에 MidnightSettlementReceiver를 만들었다.

AlarmManagerCompat.setExactAndAllowWhileIdle을 사용해서 자정에 실행되도록 예약했다.

그런데 이것만으로 끝내면 안 된다.

휴대폰을 며칠 동안 사용하지 않을 수도 있기 때문이다.

그래서 마지막 정산 날짜인 lastSettledDateKey를 저장하고, 앱이 다시 실행됐을 때 마지막 정산일 이후 누락된 날짜를 찾아서 순차적으로 정산하도록 했다.

자정 정산 -> 정상 실행

앱 미실행 -> 누락 날짜 확인 -> 순차 정산

이렇게 보완했다.

테스트는 정상적인 경우보다 경계 조건을 많이 봤다

시간 관련 로직은 직접 몇 번 실행해보는 것만으로는 부족했다.

time_todo_overlap_test.dart에서는 반복 요일이 겹치는 경우와 단일 날짜의 시간 범위를 검사했고, 1~2시와 2~3시처럼 경계가 맞닿는 경우도 따로 확인했다.

daily_penalty_test.dart에서는 미완료 Event 개수와 Time TODO 소요 시간에 따른 패널티 계산을 확인했다.

time_todo_scheduling_test.dart에서는 임박한 TODO의 필터링과 정렬을 확인했다.

Provider 쪽은 MemoryTodoRepository를 이용해서 실제 Firestore 없이 테스트했다.

빠르게 토글하고 삭제한 뒤 Snapshot이 늦게 들어오는 상황을 만들어서 Optimistic UI 상태가 유지되는지 확인했다.

Android 쪽은 권한 관련 테스트도 별도로 했다.

배터리 최적화 예외, Android 13의 알림 권한, Android 12의 Exact Alarm 권한, 전체 화면 알람 권한이 없는 상황까지 확인하고 각각 fallback을 두었다.

개발하면서 구조에 대한 생각이 조금 바뀌었다

처음에는 Flutter를 사용하니까 최대한 Flutter로 끝내는 게 좋다고 생각했다.

개발 속도만 보면 맞는 이야기다.

하지만 이번 프로젝트에서는 그게 항상 정답은 아니었다.

UI는 Flutter가 편하다.

Provider도 충분히 사용할 만했다.

Firestore도 데이터를 관리하기 좋았다.

반대로 AlarmManager나 AppWidget처럼 Android OS와 직접 붙는 부분까지 Flutter에 맡기면 문제가 생겼다.

Windows도 마찬가지였다. Toast와 Shell API는 결국 Windows가 제공하는 기능이고, 필요한 수준까지 제어하려면 Win32를 직접 만지는 게 빠른 경우가 있었다.

그래서 최종적으로는 역할을 나눴다.

Flutter -> UI / 상태 관리 / 공통 도메인

Firebase -> 인증 / 데이터 저장 / 실시간 동기화

Android Kotlin -> 알람 / 백그라운드 / 위젯 / 자정 정산

Windows C++ -> Shell / Toast / 패키징 연동

코드가 하나로 통일되지는 않았다.

대신 각 플랫폼에서 문제가 생겼을 때 어디를 봐야 하는지는 명확해졌다.

마무리

Tack TODO를 만들면서 가장 많이 건드린 부분은 화면이 아니었다.

시간이었다.

시간이 들어가면서 상태가 생겼고, 상태가 생기면서 비동기 처리가 필요해졌다. 비동기 처리가 들어가니 race condition이 생겼고, 앱이 종료될 수 있으니 Flutter 밖에서 데이터를 처리해야 했다.

그 과정에서 처음에는 단순했던 TODO가 상당히 복잡한 구조가 됐다.

특히 기억에 남는 것은 알람 문제다.

처음에는 “정해진 시간에 알림을 보내면 되는 것 아닌가”라고 생각했는데 실제로는 그렇지 않았다.

앱이 죽어 있을 수도 있고, 화면이 꺼져 있을 수도 있고, 잠금 화면일 수도 있다. 이미 취소한 알람이 다시 예약될 수도 있고, 알람이 뜬 순간 자동 실패 처리와 사용자 완료 처리가 충돌할 수도 있다.

Firestore도 마찬가지였다.

실시간 스트림을 붙였다고 데이터 정합성이 자동으로 해결되는 게 아니었다. 로컬에서 발생한 사용자 입력과 서버에서 돌아오는 Snapshot의 순서를 직접 관리해야 했다.

결국 이번 프로젝트에서 가장 크게 얻은 건 특정 Flutter 기술 하나가 아니다.

시간과 비동기가 들어간 시스템에서는 정상적인 흐름만 구현해서는 끝나지 않는다는 것.

앱이 종료됐을 때,

알람이 이미 처리됐을 때,

서버 응답이 늦게 왔을 때,

자정 작업이 실행되지 않았을 때,

권한이 없을 때,

이런 상황을 계속 생각해야 했다.

그런 예외 상황을 하나씩 처리하다 보니 처음 만들었던 구조도 몇 번 바뀌었다.

지금 생각하면 그게 오히려 이 프로젝트에서 가장 의미 있었던 개발 과정이었다.