HeatTrip
A mobile travel service that recommends places from Korea Tourism Organization data based on emotional input (mood chips, PAD sliders, energy, sociability, trip goals) and links them to schedules and a diary. As team lead of a three-person team for the 2025 Tourism Data contest, I handled planning, the server's place collection, explore, recommendation, media and auth, the Python recommendation server, the app's recommendation and explore screens, and the deployment and ONE store release. From March to June 2026 I reworked the server on my own. As of 2026-10 the server is not running.
Role
Team Lead (team of 3) — planning, backend (place collection, explore, recommendation, media, auth), Python recommendation server, app recommendation & explore screens, deployment and ONE store release
Period
2025.07–11 competition (team Hit다Heat, 3 people) · 2026.03–06 solo refactoring
~50,000
Tourism Organization places collected
50,098 rows in the local DB (2026-10-01)
95
Categories the LLM chooses from
a closed list · no places in the prompt
Teammate A built the schedule API and diary v1, and Teammate B built bookmarks and the profile. The app was registered on ONE store as 1.0.0 on 2025-09-15 and re-uploaded as 1.0.0(3) on 2025-10-23.
Team and scope
This covers the 2025 team period. The server is split by package and the app by feature.
| Area | Me | Teammate A | Teammate B |
|---|---|---|---|
| Planning | Team lead (planning, coordination) | Full-stack development | Full-stack development |
| Server | Place collection and enrichment (tour, kakao), explore and search (explore), recommendation (curation), media (S3), auth (OAuth, most of user), diary v2 API, deployment config | Schedule CRUD, diary (journey) entity and v1 API | Bookmarks and collections, profile edit and account deletion, in-app feedback |
| Rec. server | All of the Python (FastAPI) server | — | — |
| App | For You v1 and v2, most of Explore, auth, records, media, release builds | Part of the diary | Bookmarks and profile, part of the diary screens, part of Explore, design fixes |
| Release | Dockerfile and compose (2025-09-12), ONE store registration (I am the seller) | — | — |
System architecture
- The server collects the place data; for the ‘Overview’ on a place’s detail page, the app calls the Tourism Organization API directly.
- The recommendation LLM sits in a separate Python server, and Spring turns its result into a ranked list of places. The cost is one more synchronous hop on the request path, with a Tomcat thread waiting during it.
Recommendation: what the LLM picks and what Spring does
No places go into the prompt. The LLM chooses from 95 category names and returns its picks in two groups. Because the output is a closed list, Spring maps the labels to cat3 codes with a category dictionary held in memory (place_traits, 95 rows) and filters places by those codes. The cost: labels missing from the dictionary are dropped, and an empty mapping makes it read every place (findAll()).
Spring’s scoring formula, not the LLM, decides the order. The final score is 0.6×trait match + 0.4×popularity. When the app sends a location, a distance score is blended in with a weight of 0.2. The constants were set by hand, and there is no record of validating them against data.
The recommendation server validates and repairs the LLM’s output format. It takes the response in JSON mode and validates it with Pydantic; on failure it re-asks, then repairs the comfort letter, then normalizes. Identical inputs are cached for five minutes. The cache lives in process memory, so it assumes a single instance.
The contest submission (submitted 2025-09-19) is v1 (2025-09-10, b6fac1c), which has no LLM. v1 scored every place and grouped the results into 18 emotion categories (72 mappings). v2, which uses the LLM, came in on 2025-10-18 (58da1b9) and shipped in store build 1.0.0(3). Both versions use the same ScoringService.
Place data: collection and model
- Collection. The server pulls the Tourism Organization area list (
areaBasedList2), cleans and truncates it (Place.sanitizeAndTruncate) and saves it in batches. After saving, the work splits in two: one job enriches links and coordinates through Kakao Local (run once behind a property flag), and the other builds CAT3 trait snapshots, which since 2026 also fillsearch_text. The scheduler is commented out, so collection is run by hand, and no run history is kept. - Scale. There are 50,098 places (38,871 with an image). The category dictionary has 95 rows, and there are 18 emotion categories (72 mappings).
- Keys. The place-side tables are joined by
contentidandcat3without foreign keys. The schema is managed by Hibernateddl-auto(no migration tool), and the FULLTEXT index is created with hand-written SQL. - Infinite scroll. A
(createdtime, contentid)cursor travels as a Base64URL token, and the query reads size+1 rows to tell whether a next page exists.contentidas the second key prevents duplicates and gaps among rows with the same timestamp. The list screen keeps offset paging. The cost: the repo has no DDL for a composite index to back the cursor. - Media. The DB stores only the S3 key, and the URL is built at runtime from the CloudFront domain and the key. What a file belongs to is recorded as
ref_type/ref_id(not a foreign key), and deletion is soft.
2026 solo refactoring
From 2026-03-19 to 06-18 I reworked the server alone, with a public repository and operation in mind.
| Area | What changed (commit) | Cost |
|---|---|---|
| Preparing for public operation | /api/curation/** requires authentication, fewer paths open without it, per-path rate limits (in-memory fixed window), gitleaks secret scanning (934caca) | The already released 1.0.0(3) sends no token, so its For You screen now fails with a 302. Backward compatibility was not checked |
| Search | EXPLAIN ANALYZE showed the count query doing a full scan while repeating a correlated subquery. The searchable text was denormalized into a single search_text column, and search switched to a FULLTEXT (ngram) index with MATCH … AGAINST … IN BOOLEAN MODE (0a72056) | Results differ from LIKE (single characters, special characters). Results are ordered by contentid, not relevance |
| Recommendation call split, tests, observability | The recommendation-server call moved behind a Kotlin port/adapter (12d351c, 2026-04-19), with Kotlin DTOs and unit tests. Request-logging and error-alert AOP, a correlation id and masking. Errors are deduplicated by fingerprint and sent to Slack, optionally with a Spring AI summary | Some search and LLM DTO tests still pass when the change is reverted. The observability code has no tests and no record of an alert actually being sent |
For search, the count took about 755 ms before and the list about 4.9 ms after (one measurement for ‘카페’, “cafe”), and the two numbers come from different queries. Search had been split behind a port/adapter in the 2025-09 team period (df3044f), so the switch only touched the storage side.
The app also moved to Today · Explore · [Record] · Feed · Me tabs and added an emotion check-in and a feed, but it was never released. The screens below were captured on 2026-10-01 against a local backend with demo data.

Today · emotion check-in

Explore · search list

Feed · my records

Feed · together
Limitations (if I fixed them)
| Limitation | Impact | If I fixed it |
|---|---|---|
| The server DTO takes emotion values (PAD, energy) as integers | Decimal input mostly becomes 0, so emotion traits barely affect ranking or the LLM input. Only the mood chip, the goal keywords and sociability reach the LLM intact | Switch to real-number types and add an app–server contract test |
Recommendation-server call: the 180 s timeout applies to every retry, 4xx responses are retried too, a Tomcat thread waits on .block(), and there is no fallback | When the recommendation server or OpenAI is slow, request threads stay tied up and the recommendation fails | A short timeout, no retry on 4xx, and a rule-based (v1) fallback response |
An empty mapping reads every place (findAll()), there is no DDL for the composite index behind the cursor, and the schema is ddl-auto | Queries appear that read or sort a large table whole | Return an empty result for an empty mapping, and manage indexes and schema through migrations |
| Tests do not catch regressions (they pass on revert), and the observability code has no tests or send records | There is no test to show a change is safe | Start with tests that fail on revert |
The public app’s main does not compile (store build is 5313bc7; recovery PR #9 is open) | The linked main cannot rebuild the released app | Merge PR #9 |
The backend and app code are public; the Python recommendation server is private. As of 2026-10 the server is not running, and only the ONE store listing record remains. Cleanup commits in the repos from 2026-09-30 on (README, config classes and the like) were written by an AI coding agent and are marked with commit trailers. The 2026-06 attempt to redesign the app is not in the public repos; that work later continued as Pawloop.