Zero Gravity Backend

Spring Boot REST API for emotion logging and AI analysis — Reduced token usage by 97% through Time Bucket sampling, architected cloud infrastructure from scratch.

RoleBackend Developer
Timeline2025.10 - Present
TeamSolo
SkillsSpring Boot, Java, MySQL, Docker, Terraform
Overview

In a Nutshell

A Spring Boot REST API that powers Zero Gravity's emotion logging and AI analysis. The infrastructure and deployment pipeline were built on a free-tier cloud, and the AI features were designed within budget constraints.

  • 5 domains, 15 endpoints — designed auth, emotion logging, statistics, and AI analysis into a single API.
  • Built infrastructure with OCI + Terraform 6 modules, and achieved Zero-Downtime deployment with a Build-first strategy.
  • Reduced Gemini API payload by 97% through Time Bucket sampling, keeping the AI feature within the free-tier budget.
Architecture

From team project to production.

A Spring Boot backend that started as a team project was taken to production. The project originally had only basic CRUD APIs. Authentication, infrastructure, a deployment pipeline, and AI analysis were all added on top.

  • Restructured the layered architecture into a domain-based structure.
  • Implemented NextAuth OAuth to JWT authentication.
  • Built infrastructure on OCI and implemented Zero-Downtime deployment.
  • Added Gemini API-based emotion analysis.

API Endpoints

DomainEndpointsDescription
AuthPOST /auth/verify, /auth/refreshOAuth token verification, JWT issuance and refresh
UserGET, DELETE /users/me, PUT /users/consent, POST /users/logoutProfile, account deletion, consent management, logout
EmotionPOST, GET, PUT /emotions/recordsCreate, read, and update emotion records
ChartGET /chart/level, count, reasonEmotion statistics by period (level, frequency, reason)
AIPOST /ai/emotion-predictions, GET /period-analysesEmotion prediction, period analysis

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

LayerTechnologyRole
IaCTerraform (6 modules)VCN · Compute · LB · Certificate · Storage · Monitoring
CloudOCI Ampere A1ARM64 instance
ProxyNginxReverse proxy · Rate Limit · Domain-based routing
ContainerDocker ComposeFrontend · Backend · MySQL isolation
SSLLet's EncryptACME DNS-01 via Route53 · Auto-renewal
CI/CDGitHub ActionsSSH + Docker Build → Health Check → Auto-Rollback
AI Token Optimization

Sending everything would blow the budget in a month.

The goal was to build a feature that analyzes emotion records via the Gemini API. Sending a full year of records meant ~55K input tokens per request. That was not sustainable on the project budget.

Reducing data would hurt analysis quality, but dropping the feature was not an option since it was core functionality.

The answer was already in the existing data.

If sending everything was off the table, the solution was to pick the most representative record from each time period. The question was how to define "representative."

The Chart API's per-period average levels and per-bucket top reasons served as sampling criteria. For each bucket, the single record closest to "that month's average level, containing the top reason" was selected.

// Reuse Chart API average levels as the sampling baseline
ChartLevelResponse levelChart = chartService.getEmotionLevelChart(
    userId, period, startDate, timezone);
 
// Send only representative records to Gemini instead of the full set
List<EmotionRecord> representativeRecords = selectRepresentativeRecords(
    emotionRecords, levelChart, period, startDate, timezone
);

One bucket, one representative record.

The period was divided into equal time units (buckets), and the single most representative record was selected from each. Simply picking the top 12 globally could cluster them in a particular month, so splitting evenly by bucket was the better approach.

PeriodBucket UnitSamplesReduction
YearMonth12365 → 12 (97%)
MonthWeek4~30 → 4 (87%)

365 Emotion Records

Time Bucket Split 12 Monthly Buckets

Chart API Avg Level per Period

Scoring Level 60% + Reason 40%

Per-Bucket Aggregation Top Reason

Select 1 Representative per Bucket

12 Representative Records → Send to Gemini

Each bucket's representative was selected by a weighted score of emotion level 60% + reason match 40%. Daily records, written as a full-day reflection, received a 1.5x weight multiplier.

private double calculateMatchScore(
    EmotionRecord record, Double targetLevel, String topReason
) {
    // 1.5x weight for Daily records
    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);
}

Ties were broken by longest diary first, most reasons, then most recent.

Example: January bucket in a Year analysis (avg emotion level: 4.5, top reason: "Work")

RecordEmotionTypeReasonsCalculationScore
ALevel 3DailyWork, Family3x1.5=4.5 (exact match) + has Work1.0 ✅
BLevel 5MomentHealth5x1.0=5.0 (close) + no Work0.57
CLevel 3MomentWork3x1.0=3.0 (gap) + has Work0.90

AI analysis results were cached for 24 hours, and the cache was invalidated for the relevant period whenever emotion records changed.

97% payload reduction, $0.002 per request

MetricBefore (Full Payload)After (Sampled)
Annual analysis payload365 records12 records (97% reduction)
Input tokens per request~55K tokens~2.4K tokens
Cost per request$0.017$0.002

Constraints shaped the strategy. Instead of dropping the feature, the problem was solved by reusing existing data in a new context.

arrow_downwardExplore Furtherarrow_downward