← Projects
🏆 Excellence Award, Korea Tourism Data Utilization Competition (2025)

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)

Java 21 Spring Boot 3.5 Spring Security JPA MySQL 8 OAuth2 / JWT OpenAI API AWS S3 / CloudFront Docker

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

Backend (auth·search·recommendation·diary) 85%
Infra · Deploy (full FE/BE) 100%
ML / AI (crawling·LLM·prompting) 100%
Frontend (home·search·recommend·diary) 65%

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.

image slot Drop a real HeatTrip app screenshot (e.g. heat.png) here — upload it to public/img/ and set the cover path.
App screens · demo

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.

Flutter App Mobile Client Spring Boot 3.5 · Java 21 REST API · Spring Security · JPA Explore Curation · Rec Bookmark Schedule Media Auth · OAuth2 Cross-cutting JWT Rate Limit AOP correlation id Slack alerts Port / Adapter · Docker Compose MySQL 8 Read Model · FULLTEXT S3 · CloudFront Image CDN Tour · Kakao API Tourism / Location Google·Kakao·Naver OAuth2 Provider Python LLM Recommender OpenAI · /recommend REST Port / Adapter
HeatTrip backend architecture

Contribution 1 — Staging the LLM recommendation to isolate cost & failure

! Problem

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.

Goal

Keep the LLM’s natural-language recommendation ability, but build a structure that is feasible at a realistic cost.

Solution

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 cat3 code
  • Filter actual places by cat3, then rank on the backend by combining emotion features · popularity · distance scores
  • The frontend reuses the initial cat3 result as a cat3Filteron repeated requests with the same condition, no LLM call is made — only ranking runs
Result

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

! Problem

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.

Solution

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.

Result

Infinite scroll gets stable next-page fetching, and even with identical createdtime values, the contentid tiebreaker fixes the order, preventing duplicates/omissions.

! Problem

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.

Solution

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.

Result

Separating the source storage from a search/display read model made the search API’s responsibility clear.

Contribution 4 — Building a quality & observability foundation

! Problem

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.

Solution
  • 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 id for both log traceability and security
Result

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).