Flutter와 Riverpod으로 '나만의 타바타 타이머' 앱 개발기 (feat. 상태 머신 구현)
목차
쓰던 타이머 앱이 다 마음에 안 들었다
개발자로서 하루 종일 의자에 앉아 있다 보니 체력 저하를 절감했습니다. 짧은 시간에 최대 효율을 낼 수 있는 '타바타 운동(Tabata Workout)'을 시작하려 했는데, 기존 앱들은 광고가 너무 많거나 제가 원하는 커스텀 설정(세트 수, 라운드 수 디테일)이 부족했습니다.
"그럼 내가 직접 만들어보자!"
단순한 타이머 같지만, 운동-휴식-준비 시간이 유기적으로 돌아가는 로직을 직접 구현해보고 싶었고, 무엇보다 **Flutter의 최신 생태계(Riverpod, GoRouter)**를 실전 프로젝트에 녹여내고 싶었습니다. 이 포스팅에서는 단순한 카운트다운을 넘어, 복잡한 운동 사이클을 어떻게 '상태 머신(State Machine)' 형태로 구현했는지 중점적으로 다뤄보겠습니다.
무엇을 만들었나
사용자는 이 앱을 통해 자신만의 운동 루틴을 정밀하게 설정하고 실행할 수 있습니다.
- 커스텀 워크아웃 설정: 운동 시간, 휴식 시간, 라운드(Round), 세트(Set) 수를 자유롭게 조절합니다.
- 직관적인 타이머 UI: 현재 상태(Ready, Exercise, Rest)에 따라 배경색이 직관적으로 변하여(초록, 빨강, 파랑), 화면을 멀리서 힐끗 봐도 현재 상태를 알 수 있습니다.
- 음성/오디오 코칭: 운동 시작과 종료, 남은 시간(3-2-1)을 TTS(Text-to-Speech)와 효과음으로 알려주어 화면을 보지 않고도 운동에 집중할 수 있습니다.
- 운동 기록 저장: 완료된 운동 기록은 SQLite에 저장되어 언제든 운동량을 확인할 수 있습니다.
무엇으로 만들었나
- Flutter & Dart: 하나의 코드베이스로 Android와 iOS를 동시에 대응하기 위해 선택했습니다. 특히 UI 렌더링 성능이 중요했기에 네이티브에 가까운 퍼포먼스를 보여주는 Flutter가 제격이었습니다.
- Riverpod: 상태 관리의 '끝판왕'이라 불리는 Riverpod을 도입했습니다.
ChangeNotifier보다 보일러플레이트가 적고, 컴파일 타임에 안전하게 Provider를 관리할 수 있어TabataSettings같은 전역 설정 관리에 매우 효율적이었습니다. - SQLite (sqflite): 사용자의 운동 기록은 기기에 영구적으로 남아야 합니다. 가벼우면서도 신뢰성 높은 로컬 DB인 SQLite를 사용하여
TabataRecord를 구조적으로 저장했습니다. - WakelockPlus: 운동 앱의 필수 기능인 '화면 꺼짐 방지'를 위해 사용했습니다.
사이클을 상태 머신으로 정리하기
가장 챌린징했던 부분은 단순히 시간을 줄이는 것이 아니라, **[준비 -> 운동 -> 휴식]**으로 이어지는 사이클을 세트(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)하도록 만들었습니다.
휴식 0초라는 예외
이 구조로 정리하고 나니 까다로웠던 케이스가 자연스럽게 풀렸습니다. 휴식 시간을 0초로 설정한 경우입니다.
타바타 원본 프로토콜은 20초 운동 / 10초 휴식이지만, 사용자가 휴식을 0으로 두고 연속 운동을 하고 싶어 할 수 있습니다. 이때 Rest 상태로 전이했다가 0초라서 바로 빠져나오면, 그 한 틱 동안 배경색이 파랑으로 깜빡이고 TTS가 "Rest"를 외쳤다가 곧바로 "Start Round 2"를 외칩니다. 실제로 그렇게 동작해서 테스트하다 웃었습니다.
그래서 Rest 상태에 진입하기 전에 휴식 시간을 먼저 확인하고, 0이면 아예 그 상태를 건너뛰도록 했습니다.
case 'Exercise':
if (currentRound < round) {
if (tabataSettings.restTime.inSeconds > 0) {
phase = 'Rest'; // 정상 경로
TtsManager().speak("Rest");
} else {
currentRound++; // 휴식 0초면 Rest를 건너뛴다
phase = 'Exercise';
TtsManager().speak("Start Round $currentRound");
}
}상태 머신에서 "지속 시간이 0인 상태"는 상태가 아니라 전이라는 걸 여기서 배웠습니다. 머무를 시간이 없는 상태를 만들면 진입과 탈출이 같은 틱에 일어나면서 부수효과(소리, 색, 음성)만 남습니다.
1초씩 빼는 타이머는 정확하지 않다
여기까지 만들고 나서 뒤늦게 알아챈 문제가 있습니다. 이 타이머는 시간이 밀립니다.
원인은 위 코드의 이 부분입니다.
timer = Timer.periodic(Duration(seconds: 1), (timer) {
if (currentSecond > 0) {
currentSecond--; // 콜백이 불릴 때마다 1을 뺀다
}
});Timer.periodic은 정확히 1000ms마다 콜백을 부른다고 보장하지 않습니다. 최소 1000ms가 지난 뒤 이벤트 루프가 한가해지면 부릅니다. 그리고 이 앱은 콜백 안에서 setState로 화면 전체를 다시 그리고, 전이 시점에는 오디오 재생과 TTS까지 겁니다. 매 틱이 정확히 1000ms일 수 없습니다.
한 틱에 몇 ms씩 밀리는 건 눈에 안 보입니다. 문제는 이게 누적된다는 것입니다. 20초 운동 8라운드를 2세트 하면 400틱이 넘고, 틱당 5ms만 밀려도 2초가 사라집니다. 스톱워치를 옆에 두고 재보면 앱의 "총 운동 시간"과 실제 흐른 시간이 다릅니다.
제대로 하려면 카운터를 빼는 게 아니라 끝나는 시각을 기억해 두고 매번 현재 시각과의 차이를 다시 계산해야 합니다.
// 이렇게 했어야 했다
DateTime phaseEndsAt = DateTime.now().add(totalPhaseDuration);
timer = Timer.periodic(Duration(milliseconds: 200), (timer) {
final remaining = phaseEndsAt.difference(DateTime.now());
currentSecond = remaining.inSeconds.clamp(0, totalPhaseDuration.inSeconds);
// 누적하지 않으니 콜백이 밀려도 표시값은 항상 옳다
});이렇게 하면 콜백이 몇 번 밀리거나 건너뛰어도 화면에 뜨는 숫자는 언제나 맞습니다. 경과 시간을 누적하지 말고 매번 다시 계산한다는 건 타이머를 만들 때마다 나오는 원칙인데, 정작 타이머 앱을 만들면서 지키지 못했습니다.
일시정지도 같은 문제를 안고 있습니다. _togglePause는 재개할 때 startTimer()를 다시 불러서 새 Timer.periodic을 만듭니다. 멈추기 직전에 흘러가던 0.7초 같은 조각은 그냥 사라집니다. 끝나는 시각 방식이었다면 일시정지 시점에 남은 시간을 저장했다가 재개할 때 DateTime.now()에 더하면 그만입니다.
화면이 꺼지면 운동도 멈춘다
더 큰 문제는 백그라운드입니다.
운동 중에 화면이 꺼지지 않도록 WakelockPlus를 켜 뒀고, 화면을 나갈 때 반드시 끄도록 dispose에서 정리했습니다.
@override
void dispose() {
timer?.cancel();
WakelockPlus.disable();
super.dispose();
}이건 잘 동작합니다. 문제는 사용자가 운동 중에 홈 버튼을 누르거나 전화를 받는 경우입니다. 앱이 백그라운드로 내려가면 Dart의 이벤트 루프가 멈추고 Timer.periodic도 함께 멈춥니다. 돌아오면 타이머가 멈춰 있던 그 자리에서 다시 시작합니다. 그동안 흐른 시간은 사라지고, 20초 운동이 40초가 됩니다.
여기서도 끝나는 시각을 기억하는 방식이 답의 절반입니다. 복귀 시점에 DateTime.now()와 비교하면 몇 초가 지났는지 알 수 있으니 화면은 바로 맞출 수 있습니다. 다만 그 사이에 지나간 라운드의 소리와 음성 안내는 되살릴 수 없습니다. 백그라운드에서 3라운드가 지나갔다면 "Start Round 2"와 "Start Round 3"은 이미 놓친 겁니다.
이걸 제대로 하려면 결국 OS의 백그라운드 실행이나 로컬 알림 스케줄링으로 가야 합니다. 각 전이 시점의 알림을 미리 예약해 두는 방식입니다. 운동 앱에서 이건 선택이 아니라 필수 기능인데, 지금 버전은 "앱을 켜 두고 있어야 정확하다"는 전제 위에 서 있습니다.
1초마다 화면 전체를 다시 그리는 것에 대해
타이머 콜백을 다시 보면 이렇게 돼 있습니다.
timer = Timer.periodic(Duration(seconds: 1), (timer) {
setState(() {
// 남은 초 감소, 상태 전이, 소리, TTS가 전부 이 안에
});
});setState가 콜백 전체를 감싸고 있습니다. 즉 1초마다 이 페이지의 위젯 트리 전체가 다시 빌드됩니다. 남은 초 숫자 하나만 바뀌는데도요.
Flutter는 리빌드가 저렴한 편이고 이 화면은 위젯이 많지 않아서 실제로 프레임이 떨어지지는 않았습니다. 다만 원칙적으로는 바뀌는 부분만 다시 그리는 게 맞습니다. ValueNotifier와 ValueListenableBuilder로 남은 초만 감싸거나, 원형 진행 인디케이터를 AnimatedBuilder로 분리하면 리빌드 범위가 숫자 주변으로 줄어듭니다.
이걸 안 한 이유는 솔직히 문제가 안 보였기 때문입니다. 저사양 기기에서 발열이나 배터리 이슈가 보고됐다면 바로 손댔을 텐데, 그런 신호가 없으니 우선순위에서 밀렸습니다. 사이드 프로젝트에서 최적화의 기준은 대체로 이렇게 정해집니다. 나쁜 습관은 아니지만, 나중에 위젯이 늘어나면 어디서 느려졌는지 찾기 어려워지는 지점이기도 합니다.
음성 안내도 같은 콜백 안에 있습니다.
void _checkVoiceCoach() {
if (currentSecond <= 3 && currentSecond > 0) {
TtsManager().speak(currentSecond.toString());
}
}남은 시간이 3초 이하일 때 숫자를 읽어 줍니다. 여기서 > 0 조건이 중요합니다. 이게 없으면 0에서도 "0"을 읽는데, 0이 되는 순간은 이미 다음 상태로 넘어가면서 "Start Round 2" 같은 안내가 나가는 시점입니다. 두 음성이 겹치면 둘 다 못 알아듣습니다. 운동 중에는 화면을 안 보고 소리만 듣기 때문에, 소리가 겹치는 건 화면이 깨지는 것보다 치명적입니다.
광고를 운동 중에 띄우지 않기
이 앱을 만든 계기 중 하나가 "기존 앱들은 광고가 너무 많다"였으니, 광고 배치에는 스스로 지킬 선이 있었습니다.
전면 광고는 타이머 화면에 들어오는 순간 미리 불러 둡니다.
@override
void initState() {
super.initState();
WakelockPlus.enable();
AdHelper.loadInterstitialAd(); // 미리 로드만 해 둔다
startTimer();
}loadInterstitialAd()는 불러오기만 하고 띄우지 않습니다. 실제 노출은 운동이 전부 끝난 뒤 결과 화면으로 넘어갈 때입니다. 미리 로드해 두는 이유는 그 순간 네트워크를 기다리면 화면 전환이 멈칫하기 때문입니다. 20초 운동을 하는 동안 광고는 조용히 준비만 되고 있습니다.
운동 중에 광고를 띄우는 선택지도 있었습니다. 세트 사이 휴식 시간은 어차피 쉬는 시간이니 거기에 넣으면 노출이 크게 늘어납니다. 그런데 타바타의 휴식은 10초입니다. 10초 쉬는 사람에게 광고를 보여주는 건 쉬지 말라는 뜻입니다. 그리고 그 순간 소리가 겹치면 다음 라운드 시작 음성을 놓칩니다.
내가 쓰기 싫어서 만든 앱에 내가 싫어하던 걸 넣지 않는 것, 그 정도가 개인 개발자가 지킬 수 있는 최소한이라고 생각했습니다.
덧붙이면, 운동이 끝난 뒤에 광고를 띄우는 것도 무조건 좋은 건 아닙니다. 20분을 채우고 "Workout Complete" 음성을 들은 직후는 성취감이 가장 큰 순간인데, 그 위에 전면 광고를 덮는 셈이니까요. 지금은 결과 화면을 먼저 보여주고 거기서 넘어갈 때 띄우도록 한 단계 미뤄 뒀습니다. 완주의 여운을 광고가 먹지 않도록 하는 정도가 타협점이었습니다.
정리
상태 머신으로 사이클을 정리한 부분은 지금도 만족합니다. 라운드와 세트가 섞인 분기를 switch 하나로 눌러 놓으니, 나중에 "휴식 0초" 같은 예외를 넣을 때도 건드릴 곳이 한 군데뿐이었습니다.
반대로 타이머의 정확성은 처음부터 다시 짜야 합니다. 정리하면 이렇습니다.
- 경과를 누적하지 말고 목표 시각과의 차이를 매번 다시 계산할 것. 카운터를 빼는 방식은 콜백이 밀리는 만큼 그대로 오차가 된다.
- 지속 시간이 0인 상태는 상태로 만들지 말 것. 진입과 탈출이 같은 틱에 일어나면 부수효과만 남는다.
- 앱이 안 보이는 동안의 시간도 시간이다. 화면 복구는 시각 계산으로 되지만, 놓친 소리와 음성은 별도로 설계해야 한다.
재밌는 건, 이 첫 번째 교훈을 나중에 웹으로 실시간 급여 계산기를 만들 때 다시 만났다는 점입니다. 거기서는 처음부터 매 프레임 now - start로 다시 계산하도록 짰습니다. 배운 걸 다음 프로젝트에서 써먹는 게 사이드 프로젝트를 계속하는 이유 중 하나인 것 같습니다.
앱은 Google Play에 있습니다.