단식 트래커를 만들면서 가장 먼저 부딪힌 문제는 이거였다. 사용자가 단식 시작·종료 버튼 누르는 걸 깜빡하면, 그 순간 기록이 통째로 깨진다. 대부분의 타이머 앱이 “시작 버튼을 눌렀는가”를 상태의 근거로 삼기 때문이다. 회식·야근으로 창이 틀어지는 한국 사용자에게 이건 치명적이었다. 나는 이걸 뒤집어, 버튼을 놓쳐도 기록이 살아있게 설계했다.
상태를 저장하지 않고, 이벤트에서 ‘도출’한다
핵심은 “현재 상태(단식 중/식사 중)를 DB에 저장하지 않는다”는 것이다. 저장하는 건 이벤트 로그뿐이다. 이벤트는 두 종류다.
type=0단식 시작,type=1식사 시작 — 각각 발생 시각(eatenAt)을 가진다.
현재 페이즈는 가장 최근 이벤트에서 계산한다(derivePhase()). 분기는 네 가지다.
- 이벤트가 없다 → idle
- 최신이 식사 시작 → eating
- 최신이 단식 시작인데
지금 < 시작+목표시간→ fasting - 목표시간을 넘겼다 → completed(목표 달성)
식사창 길이는 계산으로 나온다: 24 − 목표시간(16시간 단식이면 8시간 식사창). 상태를 따로 들고 있지 않으니 상태가 어긋날 일 자체가 없다. 스냅샷도 페이즈로 게이팅해서, 단식 스냅샷은 fasting/completed에서만, 식사 스냅샷은 eating에서만 계산된다.
버튼은 ‘상태 전환’이 아니라 ‘기록 추가’다
덕분에 버튼의 의미가 바뀐다. 누르는 건 스위치가 아니라 “이 시각에 이 이벤트가 있었다”는 기록을 추가하는 행위다. 그래서 시간 입력이 핵심 UI가 된다. 시작 버튼을 누르면 시각 입력창이 뜨고, 기본값은 “지금”이되 이렇게 보정한다.
- 늦게 눌렀으면 과거로 조정 → 기록이 실제와 맞는다.
- 미래 시각은 전날로 보정(자정 넘어 입력하는 경우 대응).
- 이전 이벤트보다 앞선 시각(역전)은 거부 → 로그 순서가 깨지지 않는다.
깜빡하고 아예 안 눌러도, 다음 이벤트를 넣는 순간 페이즈가 재계산돼 복구된다.
설계 원칙: 예상이 기본값이고, 기록은 수정 대상이다. 사용자에게 완벽한 실시간 입력을 요구하지 않는다.
UX에도 같은 철학: ‘실패’ 대신 ‘조정’
식사창에서 단식으로 돌아올 때 “이번 식사창은 N시간이었어요”라고만 알려주고, 목표보다 길어도 “실패”라고 찍지 않는다. 스트릭도 all-or-nothing이 아니라 주간 목표(주 N일)로 뒀다. 회식으로 하루 틀어져도 “실패”로 이탈하지 않게 하는, 해외 앱이 굳이 만들 이유 없는 한국 특화 장치다. 개입(“지금 배고파요”)도 단식 중에만 뜬다 — 맥락에 맞는 순간에만 개입한다.
왜 이 구조가 이식 가능한가
거창한 이벤트 소싱을 쓴 건 아니다. “상태를 저장하지 말고 이벤트에서 도출하라”는 한 줄이 전부다. 하지만 이 패턴은 기록이 깨지기 쉬운 부류의 앱(습관·운동·복약 트래커 등) 전체에 그대로 옮길 수 있다. 사용자의 성실한 입력에 상태를 의존하는 순간 앱은 깨지기 시작한다. 도출 가능한 것은 저장하지 말고 계산하라 — 이게 이 앱에서 가장 크게 남은 설계 교훈이었다.
알림도 같은 이벤트에서 나온다
이 구조의 이점은 화면에만 있지 않다. 지속 알림(단식 남은 시간·식사창 종료)도 같은 이벤트 로그에서 도출한 페이즈를 그대로 쓴다. 단식 중이면 목표 달성 예정 시각을, 식사 시간이면 식사창 종료를 알린다. 상태를 여러 곳에 중복 저장했다면 화면·알림·DB가 어긋났을 텐데, “이벤트에서 도출”이라는 단일 원천 덕분에 모든 표시가 자동으로 일관된다. 페이즈가 바뀌면 화면도 알림도 한 번에 따라온다. 진실을 한 곳(이벤트 로그)에 두면, 파생되는 모든 게 저절로 맞는다.