4/8/2026

個人開発アプリ『ユメハシ』の技術スタックと、v2.1.0 までに解決した5つの実装課題

FlutterDartDriftRiverpodFirebase個人開発AI駆動開発パフォーマンス

はじめに

ユメハシ(YumeHashi) は、夢を目標に、目標をタスクに分解して行動に変える個人開発の Flutter Web アプリです。2026 年 4 月に旧名「ユメログ」から改名し、現在 v2.1.0 まで運用しています。

この記事では、アプリそのものの紹介ではなく 技術的な裏側 にフォーカスします。

  • 採用した技術スタックとその選定理由
  • サーバーレス × 無料枠 0 円運用のインフラ構成
  • 運用を続ける中で直面した 5 つの実装課題と、その解決策

個人開発で「コスト 0 円」「サーバーレス」「Flutter Web」のどれか 1 つでも検討している方の参考になれば嬉しいです。


1. 全体アーキテクチャ

[ユーザー (ブラウザ)]
      │ HTTPS

[GitHub Pages] ──── Flutter Web ビルド成果物(静的ホスティング)

      ├── [Firebase Auth]      匿名認証 → メール連携
      ├── [Firestore]          JSON 全量同期(3秒デバウンス + SHA-256 差分検出)
      ├── [Google Apps Script] Stripe 決済プロキシ / フィードバック受付
      │         └── [Stripe]   月額 200 円サブスクリプション
      ├── [GitHub Gist API]    リモート設定(招待コード・ユーザー設定)
      └── [ブラウザ内 SQLite]  Drift ORM + drift_worker.js(WASM)

ポイントは データの主記憶はブラウザ内 SQLite にあることです。Firestore は「全量 JSON のクラウドバックアップ」として利用しており、読み書きの大部分はローカルで完結します。これにより Firestore の無料枠(書き込み 20,000 回/日)を DAU 3,000 人規模まで消費しません。

技術スタック一覧

レイヤ技術バージョン選定理由
UI フレームワークFlutter3.27+単一コードベースで Web / Windows 対応、宣言的 UI
言語Dart^3.11強い型、null safety、pub.dev エコシステム
状態管理Riverpod^2.5コンパイル時依存解決、テスト容易性
ローカル DBDrift (SQLite)^2.18型安全なクエリビルダ、WASM で Web 対応
ルーティングgo_router^14.0宣言的 URL、Web の履歴対応
グラフfl_chart^0.68星座ダッシュボード・統計で活躍
認証firebase_auth_web^5.12匿名 → メール連携のシームレス移行
同期cloud_firestore_web^4.xJSON 一括 put/get で十分
決済Stripe (via Apps Script)クライアントに秘密鍵を持たせない

インフラのコスト試算

運用 1 年経過時点で 月額コスト 0 円 を維持できています。

サービス無料枠現在の使用量コスト発生閾値
GitHub Pages1 GB 帯域/月~100 MB超大規模アクセスのみ
Firebase Auth50,000 回/月数百回同上
Firestore 書き込み20,000 回/日数百回DAU 3,000 人
Firestore ストレージ1 GB数 MB同上
Google Apps ScriptURL Fetch 20,000 回/日数十回個人利用では実質無制限
GitHub Actions2,000 分/月200〜300 分超大規模のみ

「唯一のコスト変動要因は Firestore」という設計が、無料枠運用の鍵になっています。


2. なぜこの技術スタックにしたのか

なぜ Flutter Web か — React + Next.js ではなく

「個人開発で未経験技術を 3 週間で習得する」制約の中で、以下を評価しました。

候補メリットデメリット
Flutter Webマルチプラットフォーム展開可、宣言的 UI、Dart が静的型付きで堅いWeb の初回ロードが重い(CanvasKit 5.5 MB)
React + Next.jsWeb に最適化、SSR 対応モバイルは React Native が別途必要
PWA (HTML/JS)軽量、学習コスト低複雑な UI 構築が困難

ユメハシではスケジュールのタイムライン表示、星座ダッシュボード、ドラッグ並び替えなど リッチな UI を多用します。さらに将来的な Windows デスクトップ展開も視野に入れていたため、Flutter Web を選びました。初回ロードの重さは後述のクリティカルパス最適化で緩和しています。

なぜ Drift SQLite(Firestore 直読みではない)か

Firestore を主記憶にすると以下の問題が発生します。

  • 読み取り料金が DAU に比例して発生する(1 ユーザーがタスク一覧を開くだけで数十回の read)
  • オフライン動作が弱い
  • 複雑な JOIN・集計(実施率計算・連続記録判定)がクライアント側で非効率

そこで ブラウザ内 SQLite をマスタFirestore をバックアップ という役割分担にしました。同期は SyncManager が 3 秒デバウンス + SHA-256 ハッシュ比較で実施し、変更がない場合は書き込みゼロです。

なぜ Apps Script を Stripe のプロキシにするか

  • Stripe の API キーをクライアントに持たせられない
  • Cloud Functions / Lambda を使うと最低料金や常駐コストがかかる
  • Google Apps Script は URL Fetch 20,000 回/日 の無料枠があり、個人開発の Stripe 検証用途なら十分

Apps Script を CORS プロキシ兼シークレット金庫として利用し、「Checkout Session 作成」「契約状態検証」「Customer Portal URL 取得」を代行させます。ただしこの設計には代償があり、これが後述の課題 #1(起動時の遅延)の主因になります。


3. v2.1.0 までに解決した 5 つの実装課題

運用を続ける中で実際に直面した課題と、その解決策を紹介します。いずれもユーザーの不満や計測結果からの逆算で修正したもので、机上の空論ではありません。

課題 #1: 初回アクセスの体感速度が遅い

症状: 開発者ツールで計測したところ、キャッシュ温存状態でも First Paint まで 3.4 秒、コールドスタート推定 6〜10 秒。

原因: HAR ファイルをダンプして集計した結果、以下の 直列依存チェーン が判明しました。

+0   ms  HTML 取得
+84  ms  flutter_bootstrap.js
+227 ms  canvaskit.wasm (5.5 MB、cache hit でも 453ms)
+242 ms  main.dart.js (4.1 MB)
+640 ms  Flutter 起動完了、フォント取得
+842 ms  Firebase JS 取得
+1032 ms identitytoolkit/lookup (匿名認証)
+1096 ms getProjectConfig (755ms)
+1339 ms script.google.com/exec (1.3s) ← Apps Script の Stripe 検証
+2680 ms script.googleusercontent.com (489ms) ← Apps Script のリダイレクト先
+3416 ms 全データ同期完了

最大のブロッカーは Apps Script 呼び出しの 1.8 秒 でした。Google のサーバーレスランタイムはコールドスタートが遅く、さらに 302 リダイレクトで往復するため、単発でも 1.3〜1.8 秒かかります。

解決策: 3 段階で対応しました。

(1) キャッシュされたプレミアム状態を runApp() 前に同期適用

lib/services/startup_premium_sync.dart を新設しました。

void applyCachedPremiumState(SharedPreferences prefs) {
  final stripeService = StripeService(prefs);
  if (stripeService.isSubscriptionActive) {
    setSubscriptionPremium(enabled: true);
  }
  if (stripeService.isTrialActive) {
    setTrialPremium(enabled: true);
  }
}

ポイントは 「有効化のみ」を行い、「無効化」はサーバー検証に委ねる こと。これによって、別端末で契約したユーザーを誤って非プレミアム扱いする事故を防ぎます。

(2) 外部通信を addPostFrameCallback 経由で遅延実行

runApp(...);

WidgetsBinding.instance.addPostFrameCallback((_) {
  _initRemoteConfigAsync(prefs, container);
  _initInviteCodeAsync(prefs);
  _verifySubscriptionAsync(prefs);
  _initAnonymousAuth();
  _runDataRetentionCleanup(container);
});

Stripe 検証・Firebase Auth・リモート設定の取得を初回フレーム描画の に回すことで、First Paint がネットワーク競合の影響を受けなくなります。

(3) index.html に preconnect / preload ヒントを追加

<link rel="preconnect" href="https://www.gstatic.com" crossorigin>
<link rel="preconnect" href="https://firestore.googleapis.com">
<link rel="preconnect" href="https://identitytoolkit.googleapis.com">
<link rel="dns-prefetch" href="https://script.google.com">
<link rel="preload" href="main.dart.js" as="script">

ブラウザへの取得指示のみで動作への影響はゼロ。コールドスタートで 1〜2 秒の短縮を見込めます。

結果: プレミアムユーザーが起動直後に有料機能へアクセスした際の「一瞬ロックされる」問題が完全に解消しました。


課題 #2: 受信ボックスのデータ肥大化

懸念: 運用 1 年で蓄積したタスク・通知が増え続けると、描画パフォーマンスの劣化と Firestore 書き込み量の増加が起こります。特に受信ボックス通知は、リリース告知を毎回追加する運用だったため、既読通知がメモリを圧迫していました。

解決策: 「既読かつ作成から 30 日経過した通知は物理削除」するポリシーを導入しました。

// NotificationDao に追加
Future<int> deleteReadNotificationsOlderThan(DateTime threshold) {
  return (delete(notifications)
        ..where((t) => t.isRead.equals(true))
        ..where((t) => t.createdAt.isSmallerThanValue(threshold)))
      .go();
}

未読通知は期間にかかわらず保護 する設計が重要です。ユーザーが見逃した重要な情報を消してしまうと不信感を招きます。

課題 #3: 完了タスクのデータ増大

懸念: タスクのステータスが completed になった後もレコードは残り続け、スケジュール画面に表示され続けます。10 ヶ月運用した実機テストでは、タスク一覧の描画が体感で重くなり始めました。

解決策: 二段構えで対応しました。

(a) 完了タスクをデフォルト非表示にするフィルタ

// ganttTasksProvider
if (!showCompleted) {
  tasks = tasks
      .where((t) => t.status != TaskStatus.completed && t.progress < 100)
      .toList();
}

メニューから切り替え可能で、SharedPreferences で永続化します。デフォルトで非表示 にすることで、タスク数が増えても描画パフォーマンスが劣化しない設計です。

(b) 30 日経過した完了タスクの物理削除

// TaskDao に追加
Future<int> deleteCompletedOlderThan(DateTime threshold) {
  return (delete(tasks)
        ..where((t) => t.status.equals('completed'))
        ..where((t) => t.updatedAt.isSmallerThanValue(threshold)))
      .go();
}

ユーザーへの事前告知が必須 なので、FAQ に「残したいデータは事前にエクスポートを」という項目を追加しました。DataRetentionService が起動時に 1 回だけ実行します。

課題 #4: 受信ボックスで重複告知が発生する

症状: announcements.json から旧エントリを削除したにもかかわらず、既存ユーザーの受信ボックスには古い通知が残り続けていました。

原因: syncSystemNotificationsFromJson新規追加のみ を行い、JSON から削除されたエントリを DB から除去する処理がなかったためです。

// 修正前: 新規のみ insert
if (await _notificationDao.existsByDedupKey(dedupKey)) continue;
// ...insert

解決策:announcements.json を受信ボックスの 唯一のソース として扱う」ように仕様変更しました。

// 修正後: JSON にない dedup_key を持つシステム通知を DB から削除
final jsonDedupKeys = items
    .map((e) => e['dedup_key']?.toString() ?? '')
    .where((k) => k.isNotEmpty)
    .toSet();

await _notificationDao.deleteByTypeWhereDedupKeyNotIn(
  NotificationType.system.value,
  jsonDedupKeys,
);

NotificationType.system のみを対象にすることで、リマインダー・実績通知などの dedup_key を持たない別種類の通知 は影響を受けません。単純に見えますが、「どの範囲を削除するか」の線引きで小さなリグレッションを起こしやすい箇所です。

課題 #5: CI のバージョンチェックがレース条件で誤検知する

症状: PR マージ後に CI が赤 X を出す。

原因: CI の version-check ジョブが origin/mainpubspec.yaml と PR ブランチの pubspec.yaml を比較する設計になっていました。GitHub Actions のキュー遅延で CI 実行が merge 後になると、origin/main はすでにマージ済みの内容になっており、両者の version が一致して「バージョンが更新されていない」と誤判定します。

00:58:06 PR #3 作成
00:58:20 PR #3 マージ完了 (14 秒後)
00:58:23 Deploy 成功 ✅
00:59:36 CI (version-check) がやっと開始(キュー待ち)
01:03:23 CI 失敗 ❌ ← main と PR が両方 2.0.0

解決策: 「PR の HEAD SHA がすでに origin/main に含まれている場合は version-check をスキップする」ようにしました。

# .github/workflows/test.yml
- name: Check version update
  run: |
    if git merge-base --is-ancestor HEAD origin/main 2>/dev/null; then
      echo "PR is already merged, skipping version check"
      exit 0
    fi
    # 通常のバージョンチェック...

git merge-base --is-ancestor で「現在のコミットが main の祖先かどうか」を判定できます。マージ後であれば HEAD は必ず main の祖先になるので、ここで早期リターンします。


4. AI 駆動開発の実績

ユメハシは Claude Code による AI 駆動開発 で構築しました。主要な数字をまとめます。

項目数値
初回リリースまでの期間3 週間(未経験の Flutter/Dart から)
現在のバージョンv2.1.0
テスト件数847 件(単体 + Widget + 統合)
ソースコード行数約 30,000 行(lib/ + test/)
コミット数160+
ドキュメントREQUIREMENTS / SPECIFICATION / DESIGN / OPERATIONS / INFRASTRUCTURE の 5 点セット + lessons-learned

AI 駆動開発の肝は「AI に丸投げしない」ことです。具体的には以下のルールを守っています。

  • CLAUDE.md に運用ガイドを集約 — レベル最適化で 150 行以内に維持
  • Skills / Hooks / Agents の使い分け — 繰り返し作業は Skills、品質ゲートは Hooks、並行レビューは Agents
  • テスト必須 — ソースコード変更はテスト追加・修正とセットでしかコミットしない
  • ドキュメント同時更新 — 変更は必ず該当する docs/ ファイルに反映する

5. まとめと今後

この記事では以下を紹介しました。

  1. アーキテクチャ — Flutter Web + Drift + Riverpod + Firebase + Apps Script のサーバーレス構成
  2. コスト — 月額 0 円を維持する無料枠設計
  3. 技術選定の理由 — Flutter Web / Drift SQLite / Apps Script プロキシ
  4. 実装課題と解決 — 起動速度、データ肥大化、重複告知、CI レース条件
  5. AI 駆動開発の数字 — 3 週間で初回リリース、847 テスト、v2.1.0 まで

個人開発は「動くものを作る」ところがゴールになりがちですが、運用を続ける中で見つかる課題 のほうがよほど多く、よほど学びが深いと感じています。今後も v2.2.0 以降で新機能を追加しつつ、運用で見えた課題を解決していく予定です。

アプリは https://teppei19980914.github.io/YumeHashi/ で試せます(体験版あり)。リポジトリは GitHub で公開しています。

同じような構成で個人開発している方、これから始めようとしている方の参考になれば嬉しいです。

Work with me

I'm available for full-stack development and technical consultations.

Get in touch