버튼을 삭제했습니다 — Flutter AI 기능을 UI 트리거에서 시간별 Cron 배치로 마이그레이션
Source: Dev.to
위 링크에 포함된 글의 내용을 번역하려면 해당 텍스트를 제공해 주세요. 텍스트를 주시면 그대로 한국어로 번역해 드리겠습니다.
소개
앱에서 “Run AI Prediction” 버튼을 제거했습니다. 이는 한 걸음 뒤로 물러서는 것처럼 보일 수 있지만, 실제로는 이번 주에 내린 최고의 아키텍처 결정 중 하나였습니다.
AI 추론을 UI‑트리거 호출에서 자동화된 시간별 크론 작업(GitHub Actions)으로 옮김으로써 저는:
- 모든 사용자 대기 시간 제거
- API‑쿼터 낭비를 약 90% 절감
- 나머지 앱 전반에 걸친 연쇄적인 429 오류를 방지하는 회로 차단기 추가
이 글에서는 “Jibun Kabushiki Kaisha”(자기 회사) 라는 개인 프로젝트, Flutter Web + Supabase 생활 관리 앱을 사례 연구로 무엇을, 왜, 어떻게 했는지 다룹니다.
다음에 해당한다면 유용합니다:
- Supabase Edge Functions + AI API와 함께 Flutter 앱을 개발하는 경우
- OpenAI, Anthropic, 또는 Gemini에서 발생하는 속도 제한(429 오류)을 경험한 경우
- AI 기반 기능의 인지 지연 시간을 줄이고 싶은 경우
원래 설계 및 문제점
초기 설계는 간단했습니다:
- 사용자가 “AI 예측 생성” 버튼을 누름
- Supabase Edge Function 호출
- OpenAI/Gemini API가 예측을 생성
- 결과가 표시
프로덕션에서 실행한 후, 세 가지 문제가 나타났습니다.
1. 높은 지연 시간 및 할당량 낭비
- AI 예측에 3–8 초가 소요됩니다. 사용자는 로딩 스피너를 바라보며 기다려야 하는데, 이는 경주 직전 결과를 확인하려는 말 경주 예측 앱에서는 매우 고통스럽습니다.
- 버튼을 빠르게 여러 번 누르면 중복 요청이 발생해 할당량이 낭비됩니다.
2. 연쇄적인 429 오류
피크 시간대에 OpenAI/Anthropic/Gemini 할당량이 소진되면, 429 오류가 모든 AI 의존 기능에 연쇄적으로 발생했습니다:
ai-assistant: 400 → 429 → 429 → 429 (연쇄 실패)
회로 차단기가 없었기 때문에 AI 어시스턴트 화면 전체가 충돌했습니다.
3. 빈 상태에서의 이탈
사용자가 AI가 아직 실행되지 않은 상태에서 예측 페이지를 열면 빈 화면을 보게 됩니다. 분석 결과, **~30 %**의 신규 사용자가 이 시점에서 이탈하는 것으로 나타났습니다.
Solution: Hourly Cron Batch & Circuit Breaker
Run predictions hourly
문제 1과 3에 대한 해결책은 간단했습니다: AI 예측을 매시간 자동으로 실행하여 사용자가 앱을 열 때 언제든지 예측 결과가 준비되어 있도록 하는 것입니다.
# .github/workflows/horse-racing-update.yml
on:
schedule:
- cron: '0 * * * *' # Every hour at :00
워크플로는 fetch_horse_racing.py를 실행하고 AI 예측을 생성한 뒤, Supabase horse_races 테이블에 UPSERT합니다. 사용자가 앱을 열면 데이터가 이미 존재하므로 대기 시간이 0입니다.
UI simplification
- “Run AI Prediction” 버튼을 제거했습니다.
- AppBar에 있던 뇌 아이콘을 정보 툴팁과 레이스 번호 배지로 교체했습니다.
// Before
ElevatedButton(
onPressed: _runAiPredictions, // DELETED
child: const Text('Run AI Prediction Now'),
),
// After — race number badge (no button needed)
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: AppTheme.orange,
borderRadius: BorderRadius.circular(12),
),
child: Text('${race.raceNumber}R',
style: const TextStyle(color: Colors.white, fontSize: 11)),
),
Empty‑state message도 업데이트했습니다:
- Before: “Tap the brain icon to generate AI predictions”
- After: “Predictions are auto‑generated by hourly batch (cron)”
또한 horse_races 테이블에 race_number 컬럼을 추가했습니다(race_id_ext의 마지막 두 자리에서 마이그레이션). 이제 각 레이스 카드에 “1R / 2R / …” 배지가 명확히 표시됩니다.
Circuit‑breaker for other AI features
다른 AI 기능(예: ai-assistant)은 여전히 UI‑트리거 방식이지만, 이제 쿨‑다운 가드를 사용해 연속적인 429 오류가 발생하지 않도록 합니다.
// lib/services/ai_service.dart
class AiQuotaGuard {
static DateTime? _cooldownUntil;
static const _cooldownDuration = Duration(seconds: 60);
static bool get isInCooldown {
if (_cooldownUntil == null) return false;
return DateTime.now().isBefore(_cooldownUntil!);
}
static Duration get remainingCooldown {
if (_cooldownUntil == null) return Duration.zero;
final r = _cooldownUntil!.difference(DateTime.now());
return r.isNegative ? Duration.zero : r;
}
static void triggerCooldown() =>
_cooldownUntil = DateTime.now().add(_cooldownDuration);
static bool isQuotaError(dynamic error) {
final msg = error.toString().toLowerCase();
return msg.contains('429') ||
msg.contains('quota') ||
msg.contains('rate limit') ||
msg.contains('too many requests');
}
}
Usage in the AI call wrapper
Future callAiAssistant(String prompt) async {
if (AiQuotaGuard.isInCooldown) {
final secs = AiQuotaGuard.remainingCooldown.inSeconds;
throw AIServiceException(
errorType: 'RATE_LIMIT',
message: 'AI service is temporarily limited. Retry in ${secs}s.',
retryAfter: secs,
);
}
try {
return await _invokeFunction('ai-assistant', {'prompt': prompt});
} catch (e) {
if (AiQuotaGuard.isQuotaError(e)) {
AiQuotaGuard.triggerCooldown();
rethrow;
}
rethrow;
}
}
UI에서는 retryAfter 값을 읽어 일반 오류 메시지 대신 카운트다운 타이머를 표시할 수 있습니다.
지표
| Metric | Before | After |
|---|---|---|
| 예측 표시 지연 | 3–8 초 | 0 초 (미리 생성된) |
| 피크 시 중복 API 호출 | 40–60 요청/시간 | 0 (요청 배치만) |
| 연쇄 429 오류 | 3–5 월당 | 0 (회로 차단기) |
| 빈 상태 포기 | ~30 % | < 5 % |
요약
-
버튼으로 AI 트리거는 자연스러운 첫 구현이지만, 실제 운영에서는 비용이 발생한다:
- 사용자 대기 시간 → 추론을 배치로 이동하고 결과를 미리 생성
- 쿼터 낭비 → cron으로 호출을 의도적으로 스케줄링
- 연쇄 실패 → 쿨다운 윈도우가 있는 회로 차단기 추가
-
이러한 패턴은 Flutter 전용이 아니다; AI API를 통합하는 모든 앱에 적용된다.
-
버튼을 제거한 것은 기능 삭제가 아니라 UI 레이어에서 자동화 레이어로 책임을 이동한 것으로, 앱을 더 견고하고 사용자 친화적으로 만든다.
공개적으로 빌드하기
Jibun Kabushiki Kaisha – Flutter Web + Supabase 생활 관리 앱
태그: FlutterWeb Supabase buildinpublic AI CircuitBreaker GitHubActions