버튼을 삭제했습니다 — Flutter AI 기능을 UI 트리거에서 시간별 Cron 배치로 마이그레이션

발행: (2026년 4월 18일 AM 09:20 GMT+9)
8 분 소요
원문: Dev.to

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 기반 기능의 인지 지연 시간을 줄이고 싶은 경우

원래 설계 및 문제점

초기 설계는 간단했습니다:

  1. 사용자가 “AI 예측 생성” 버튼을 누름
  2. Supabase Edge Function 호출
  3. OpenAI/Gemini API가 예측을 생성
  4. 결과가 표시

프로덕션에서 실행한 후, 세 가지 문제가 나타났습니다.

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 값을 읽어 일반 오류 메시지 대신 카운트다운 타이머를 표시할 수 있습니다.

지표

MetricBeforeAfter
예측 표시 지연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

0 조회
Back to Blog

관련 글

더 보기 »

지구의 날을 위한 활력

제가 만든 History는 브라우저에 달력 날짜별로 저장됩니다; 각 섹션 옆의 사진은 실제 번들된 이미지입니다. 선택적인 Gemini API route는 따뜻한 코치를 추가할 수 있습니다.