HeatTrip
An emotion-centered mobile travel service that recommends destinations based on the user's emotional state and connects itineraries, emotion diaries and retrospectives. I staged the LLM recommendation for cost/failure isolation and designed search, pagination and observability myself.
Role
Team Lead · Backend-focused
Period
2025 · Korea Tourism Data Utilization Competition (team of 3)
Award
Korea Tourism Data Competition
2025
0 re-calls
On repeated same-condition requests
cat3Filter reuse → no LLM call
Offset + Cursor
Split pagination strategy
createdtime + contentid composite key
Role breakdown
As team lead, I designed and built the emotion-based recommendation logic, the LLM recommender integration, place search & pagination, operational observability, and the auth/security structure. Below is not “I added features” but what problem I solved, on what basis, and how.
System architecture
A Flutter client talks to the Spring Boot backend over REST, and the backend communicates with MySQL, S3/CloudFront and external APIs. The LLM recommendation — expensive and failure-prone — is split into a Python server and connected via a Port/Adapter.
Contribution 1 — Staging the LLM recommendation to isolate cost & failure
I wanted to recommend places with an LLM, but the simplest approach (sending user input + all place data straight to the LLM) would require feeding the tens of thousands of places from the Korea Tourism API as input tokens — impossible due to context limits and cost.
Keep the LLM’s natural-language recommendation ability, but build a structure that is feasible at a realistic cost.
I staged the recommendation. The LLM only recommends a first-pass category (cat3), while detailed ranking is handled by a Spring-side algorithm — separating responsibilities.
- Convert the LLM’s category label into the Tour API’s internal
cat3code - Filter actual places by
cat3, then rank on the backend by combining emotion features · popularity · distance scores - The frontend reuses the initial
cat3result as acat3Filter→ on repeated requests with the same condition, no LLM call is made — only ranking runs
Personalized recommendations while the staged split reduced response latency, failure propagation and cost risk together. Next improvements I’m considering: Redis caching (same-input LLM output) and a VectorDB-based RAG approach.
Contribution 2 — Splitting two pagination patterns
The place list had two different usage patterns: regular page browsing and mobile infinite scroll. Using offset alone would degrade performance and cause duplicate/missing rows in infinite scroll as data grows.
I split the strategy. Regular lists use Offset Pagination, while infinite scroll uses Cursor Pagination with createdtime + contentid as a composite sort key. The cursor is Base64-encoded, and I fetch size + 1 rows to decide whether a next page exists.
Infinite scroll gets stable next-page fetching, and even with identical createdtime values, the contentid tiebreaker fixes the order, preventing duplicates/omissions.
Contribution 3 — A read model + FULLTEXT for search
In the raw Tour API data, place names, categories and descriptions were spread across multiple tables — joining them on every search inflated query cost and complexity.
I denormalized place name, category name and description text into a search_text column and applied MySQL FULLTEXT ngram search. I also added place_trait_snapshots for fast lookup of cat3-based descriptions/tags.
Separating the source storage from a search/display read model made the search API’s responsibility clear.
Contribution 4 — Building a quality & observability foundation
External APIs, the LLM server, image uploads and auth were all failure-prone, and the recommendation/auth flows were core paths prone to regression on change. I needed fast detection during development and root-cause tracing in production.
- Unit tests for recommendation orchestration, LLM-response DTO deserialization, and the auth service
- JaCoCo coverage reports + Qodana wired into GitHub Actions for static-analysis visibility
- Collected controller latency & exceptions via AOP; errors are deduplicated by fingerprint then sent to Slack
- Sensitive-data masking + a
correlation idfor both log traceability and security
Core flows (recommendation · LLM DTO · auth) are guarded against regression by tests, and production errors can be noticed and traced faster via Slack alerts and correlation id. Qodana/JaCoCo are still at the visibility stage, but they’re a starting point for tightening quality gates around core packages.
I’m documenting what I learned refactoring this project from Java to Kotlin as a series on my blog (Korean).