Flutter와 Riverpod으로 '나만의 타바타 타이머' 앱 개발기 (feat. 상태 머신 구현)
1. 들어가며: 왜 이 서비스를 만들었나?
개발자로서 하루 종일 의자에 앉아 있다 보니 체력 저하를 절감했습니다. 짧은 시간에 최대 효율을 낼 수 있는 '타바타 운동(Tabata Workout)'을 시작하려 했는데, 기존 앱들은 광고가 너무 많거나 제가 원하는 커스텀 설정(세트 수, 라운드 수 디테일)이 부족했습니다.
"그럼 내가 직접 만들어보자!"
단순한 타이머 같지만, 운동-휴식-준비 시간이 유기적으로 돌아가는 로직을 직접 구현해보고 싶었고, 무엇보다 **Flutter의 최신 생태계(Riverpod, GoRouter)**를 실전 프로젝트에 녹여내고 싶었습니다. 이 포스팅에서는 단순한 카운트다운을 넘어, 복잡한 운동 사이클을 어떻게 '상태 머신(State Machine)' 형태로 구현했는지 중점적으로 다뤄보겠습니다.
2. 핵심 기능과 UX
사용자는 이 앱을 통해 자신만의 운동 루틴을 정밀하게 설정하고 실행할 수 있습니다.
- 커스텀 워크아웃 설정: 운동 시간, 휴식 시간, 라운드(Round), 세트(Set) 수를 자유롭게 조절합니다.
- 직관적인 타이머 UI: 현재 상태(Ready, Exercise, Rest)에 따라 배경색이 직관적으로 변하여(초록, 빨강, 파랑), 화면을 멀리서 힐끗 봐도 현재 상태를 알 수 있습니다.
- 음성/오디오 코칭: 운동 시작과 종료, 남은 시간(3-2-1)을 TTS(Text-to-Speech)와 효과음으로 알려주어 화면을 보지 않고도 운동에 집중할 수 있습니다.
- 운동 기록 저장: 완료된 운동 기록은 SQLite에 저장되어 언제든 운동량을 확인할 수 있습니다.
3. 기술 스택 선정 이유 (Tech Stack)
- Flutter & Dart: 하나의 코드베이스로 Android와 iOS를 동시에 대응하기 위해 선택했습니다. 특히 UI 렌더링 성능이 중요했기에 네이티브에 가까운 퍼포먼스를 보여주는 Flutter가 제격이었습니다.
- Riverpod: 상태 관리의 '끝판왕'이라 불리는 Riverpod을 도입했습니다.
ChangeNotifier보다 보일러플레이트가 적고, 컴파일 타임에 안전하게 Provider를 관리할 수 있어TabataSettings같은 전역 설정 관리에 매우 효율적이었습니다. - SQLite (sqflite): 사용자의 운동 기록은 기기에 영구적으로 남아야 합니다. 가벼우면서도 신뢰성 높은 로컬 DB인 SQLite를 사용하여
TabataRecord를 구조적으로 저장했습니다. - WakelockPlus: 운동 앱의 필수 기능인 '화면 꺼짐 방지'를 위해 사용했습니다.
4. 개발 과정의 챌린지 (Deep Dive)
Timer Loop와 상태 머신(State Machine) 설계
가장 챌린징했던 부분은 단순히 시간을 줄이는 것이 아니라, **[준비 -> 운동 -> 휴식]**으로 이어지는 사이클을 세트(Set)와 라운드(Round) 개념과 섞어 끊김 없이 순환시키는 로직이었습니다.
초기에는 상태 관리가 꼬여서 라운드가 건너뛰어지거나 종료 시점이 애매해지는 문제가 있었습니다. 이를 해결하기 위해 타이머 내부 로직을 명확한 상태 머신(State Machine) 형태로 재설계했습니다.
// 타이머 로직의 핵심 구조 (간소화된 코드)
timer = Timer.periodic(Duration(seconds: 1), (timer) {
if (currentSecond > 0) {
currentSecond--; // 단순히 초만 줄임
_checkVoiceCoach(); // 3-2-1 카운트다운 체크
} else {
// 시간이 0이 되었을 때, 현재 Phase에 따라 다음 상태 결정
switch (phase) {
case 'Ready':
_enterPhase('Exercise');
break;
case 'Exercise':
if (currentRound < targetRound) {
// 라운드가 남았다면 휴식으로
_enterPhase('Rest');
} else if (currentSet < targetSet) {
// 라운드는 끝났지만 세트가 남았다면 다음 세트 시작
_enterPhase('Exercise');
_nextSet();
} else {
// 모든 운동 종료
_finishWorkout();
}
break;
case 'Rest':
// 휴식 끝, 다음 라운드 운동 시작
_enterPhase('Exercise');
_nextRound();
break;
}
}
});
위 코드처럼 Timer.periodic 내부에서 1초마다 currentSecond를 감소시키되, 시간이 0이 되는 순간(Edge Trigger)에만 Switch-Case 문을 통해 다음 상태로 전이(Transition)하도록 만들었습니다.
덕분에 '휴식 시간 없이 바로 다음 라운드 진행'이나 '세트 간 전환' 같은 예외 케이스도 깔끔하게 분기 처리할 수 있었고, 각 상태 전이 시점에 맞춰 **TTS 안내("Rest", "Start Round 2")**나 배경색 변경 애니메이션을 정확한 타이밍에 실행할 수 있게 되었습니다.
5. 마치며
이번 프로젝트를 통해 Flutter의 Animation과 Timer를 정밀하게 다루는 법을 익혔습니다. 특히 운동 앱 특성상 사용자가 화면을 보지 않을 때도 정확한 피드백(소리, 진동)을 주는 것이 UX의 핵심임을 깨달았습니다.
향후 업데이트에서는 백그라운드에서도 타이머가 멈추지 않고 돌아가도록 Isolate나 OS 백그라운드 서비스를 연동하는 기능을 추가할 예정입니다. 여러분도 건강한 코딩 생활을 위해 한 번 써보시길 추천합니다!
👉 앱 구경가기