← Projects
Project status: Completed · server shut down 🏆 Excellence Award, 2025 Tourism Data Utilization Competition (Korea Tourism Organization, 2025.11)

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

Java 21 · Spring Boot 3.5 MySQL 8 (FULLTEXT ngram) Python · FastAPI · Pydantic OpenAI API (recommendation server) Flutter AWS S3 · CloudFront Spring Security · JWT JPA Docker Compose Kotlin (2026)
Five HeatTrip app screens — Explore, emotion input (PAD sliders), For You analysis result, recommended destinations, Diary
Final build from the competition period — ONE store 1.0.0(3), 2025-10

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

AreaMeTeammate ATeammate B
PlanningTeam lead (planning, coordination)Full-stack developmentFull-stack development
ServerPlace collection and enrichment (tour, kakao), explore and search (explore), recommendation (curation), media (S3), auth (OAuth, most of user), diary v2 API, deployment configSchedule CRUD, diary (journey) entity and v1 APIBookmarks and collections, profile edit and account deletion, in-app feedback
Rec. serverAll of the Python (FastAPI) server——
AppFor You v1 and v2, most of Explore, auth, records, media, release buildsPart of the diaryBookmarks and profile, part of the diary screens, part of Explore, design fixes
ReleaseDockerfile and compose (2025-09-12), ONE store registration (I am the seller)——

System architecture

HeatTrip system architecture. The Flutter app calls the Spring server over REST, and for place details the app calls the Tourism Organization API directly. Spring uses MySQL, S3 with CloudFront, the Tourism Organization collection API and Kakao Local, and recommendations go through the Python recommendation server to OpenAI. Gold is mine, grey is Teammates A and B, teal is 2026 solo additions, and the OAuth2 providers are dashed because they exist only on the server.
2025 team setup and 2026 solo additions (teal) — backend 47d161f/034c3dc, app 5313bc7

Open full size ↗

  • 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

Sequence of one recommendation (v2). The app sends emotion values and goal keywords to Spring, and Spring passes only the emotion values and keywords — no places — to the Python recommendation server. The recommendation server asks OpenAI with a closed list of 95 categories and returns validated category labels. Spring maps the labels to cat3 codes, reads candidates from MySQL, sorts them with its scoring formula and returns them to the app. A note in the figure: pad and energy are integers in the server DTO, so truncated values are used in steps 2 and 10.
One recommendation (v2, store 1.0.0(3)) — code lines at 034c3dc

Open full size ↗

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

Place data collection order and overview ERD. Collection runs Tour API area list → clean and truncate → batch save to places, then branches into Kakao link and coordinate backfill, and CAT3 trait snapshot → search_text (2026). Below are the main tables of the place catalog (places, place_traits, place_trait_snapshots, place_features, place_kakao_links), emotion categories, emotion feedback, and users and content, with solid lines for foreign keys and dashed lines for logical keys without a foreign key.
How place data is collected, and the main tables — counts from the local DB on 2026-10-01

Open full size ↗

  • 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 fill search_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 contentid and cat3 without foreign keys. The schema is managed by Hibernate ddl-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. contentid as 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.

AreaWhat 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
SearchEXPLAIN 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, observabilityThe 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 summarySome 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.

Limitations (if I fixed them)

LimitationImpactIf I fixed it
The server DTO takes emotion values (PAD, energy) as integersDecimal 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 intactSwitch 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 fallbackWhen the recommendation server or OpenAI is slow, request threads stay tied up and the recommendation failsA 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-autoQueries appear that read or sort a large table wholeReturn 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 recordsThere is no test to show a change is safeStart 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 appMerge 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.