Zero Gravity バックエンド

感情記録とAI分析のためのSpring Boot REST API — Time Bucketサンプリングでトークン97%削減、クラウドインフラを自ら構築。

RoleBackend Developer
Timeline2025.10 - Present
Teamソロ
SkillsSpring Boot, Java, MySQL, Docker, Terraform
概要

一言で

感情記録とAI分析を提供するZero GravityのSpring Boot REST APIです。 フリーティアクラウド上にインフラからデプロイまで自ら構築し、コスト制約の中でAI機能を設計しました。

  • 5つのドメイン、15個のエンドポイント — 認証、感情記録、統計、AI分析を一つのAPIとして設計しました。
  • OCI + Terraform 6モジュールでインフラを自ら構築し、Build-first戦略でZero-Downtimeデプロイを実現しました。
  • Time BucketサンプリングでGemini API送信量を97%削減し、AI機能をコスト上限内で運用しました。
全体構造

チームプロジェクトからプロダクションまで。

チームプロジェクトとして始めたSpring Bootバックエンドをプロダクションサービスに転換しました。 基本的なCRUD APIのみ存在していたプロジェクトに認証、インフラ、デプロイパイプラインを構築し、AI分析機能を追加しました。

  • レイヤードアーキテクチャをドメイン別構造に再構成しました。
  • NextAuth OAuth → JWT認証体系を実装しました。
  • OCI上にインフラを自ら構築し、Zero-Downtimeデプロイを実現しました。
  • Gemini APIベースの感情分析機能を追加しました。

API Endpoints

DomainEndpointsDescription
AuthPOST /auth/verify, /auth/refreshOAuthトークン検証 → JWT発行、更新
UserGET, DELETE /users/me, PUT /users/consent, POST /users/logoutプロフィール照会、アカウント削除、同意管理、ログアウト
EmotionPOST, GET, PUT /emotions/records感情記録の作成・照会・修正
ChartGET /chart/level, count, reason期間別感情統計(レベル、頻度、理由)
AIPOST /ai/emotion-predictions, GET /period-analyses感情予測、期間分析

Infrastructure

OCI Cloud — Terraform 6 Modules

Ampere A1 ARM64 — 4 OCPU · 24GB

Backend (docker-compose)

Frontend (docker-compose)

SSH + Docker

AWS Route53 zerogv.com · api.zerogv.com

GitHub Actions CI/CD · Zero-Downtime

Load Balancer HTTPS · TLS 1.3

Nginx Reverse Proxy · Rate Limit

Next.js 15 :3000

Spring Boot 3.2 :8080

MySQL 8.0 :3306

レイヤー技術役割
IaCTerraform (6 modules)VCN · Compute · LB · Certificate · Storage · Monitoring
CloudOCI Ampere A1ARM64インスタンス
ProxyNginxリバースプロキシ · Rate Limit · ドメイン別ルーティング
ContainerDocker ComposeFrontend · Backend · MySQL隔離
SSLLet's EncryptACME DNS-01 via Route53 · 自動更新
CI/CDGitHub ActionsSSH + Docker Build → Health Check → Auto-Rollback
AIトークン最適化

全部送ると一ヶ月も持ちません。

Gemini APIで感情記録を分析する機能を実装しようとしました。 1年分の記録を全て送るとリクエストあたりのInputトークンが~55Kに達しました。プロジェクト予算では賄えませんでした。

データを減らすと分析品質が下がり、機能自体を諦めるにはコア機能でした。

既存のデータから答えを見つけました。

全てを送れないなら、各期間を代表する記録だけを選んで送ればよかったのです。 問題は「代表」をどの基準で選ぶかでした。

チャートAPIの期間別平均レベルとバケット別最頻理由をサンプリング基準として使用しました。 「この月の平均レベルに最も近く、最頻理由を含む」記録1件を各バケットから選別しました。

// チャートAPIの平均レベルをサンプリング基準として再活用
ChartLevelResponse levelChart = chartService.getEmotionLevelChart(
    userId, period, startDate, timezone);
 
// 全記録の代わりに代表記録のみGeminiに送信
List<EmotionRecord> representativeRecords = selectRepresentativeRecords(
    emotionRecords, levelChart, period, startDate, timezone
);

バケット一つ、代表記録一つ。

期間を均等な時間単位(バケット)に分割し、各バケットから最も代表的な記録1件を選択しました。 上位12件をそのまま抽出すると特定の月に集中する可能性があるため、バケット単位で均等に分割しました。

Periodバケット単位サンプル数削減率
Year月 (MONTH)12件365 → 12 (97%)
Month週 (WEEK)4件~30 → 4 (87%)

感情記録365件

Time Bucket分割 月別12個のバケット

チャートAPI 期間別平均レベル

スコアリング レベル60% + 理由40%

バケット別集計 最頻理由

バケットあたり代表1件選別

代表記録12件 → Gemini送信

感情レベル60% + 理由マッチング40%の加重スコアで各バケットの代表記録を選別しました。 Daily記録は一日全体を振り返って書いた記録のため、1.5倍の重みを適用しました。

private double calculateMatchScore(
    EmotionRecord record, Double targetLevel, String topReason
) {
    // Daily記録に1.5倍の重み
    double recordLevel = record.getEmotionId() *
        (record.getEmotionRecordType() == EmotionRecord.Type.DAILY ? 1.5 : 1.0);
    double levelScore = 1.0 - (Math.abs(recordLevel - targetLevel) / 9.0);
 
    double reasonScore = record.getEmotionReasons().contains(topReason) ? 1.0 : 0.0;
 
    return (levelScore * 0.6) + (reasonScore * 0.4);
}

同点の場合は日記が長い順、理由が多い順、最新順でタイブレークしました。

例: Year分析の1月バケット (平均感情レベル: 4.5、最頻理由: "Work")

記録感情タイプ理由計算スコア
ALevel 3DailyWork, Family3×1.5=4.5 (一致) + Work含む1.0 ✅
BLevel 5MomentHealth5×1.0=5.0 (近似) + Workなし0.57
CLevel 3MomentWork3×1.0=3.0 (差異) + Work含む0.90

AI分析結果は24時間キャッシュし、感情記録が変更されると該当期間のキャッシュを無効化しました。

送信量97%削減、リクエストあたり$0.002

指標Before(全件送信)After(サンプリング)
年間分析送信量365件12件 (97%削減)
リクエストあたりInputトークン~55K tokens~2.4K tokens
リクエストあたりコスト$0.017$0.002

制約が戦略を生みました。 機能を諦める代わりに、既存のデータを新しい文脈で再活用する発想で問題を解決しました。

arrow_downwardさらに詳しく見るarrow_downward