From 9ee01c80cf2d874f5b862bf27640a294a016a7e1 Mon Sep 17 00:00:00 2001 From: ysb21 Date: Thu, 27 Aug 2026 23:28:35 +0900 Subject: [PATCH] =?UTF-8?q?docs:=20README=C2=B7=EA=B0=9C=EB=B0=9C=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D=EC=9D=84=201=EC=9D=B8=EC=B9=AD=20=EC=84=9C=EC=88=A0?= =?UTF-8?q?=20=EB=AC=B8=EC=B2=B4=EB=A1=9C=20=EB=8B=A4=EC=8B=9C=20=EC=94=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 담백한 격식체(~습니다) 1인칭으로 통일하고, 문제·원인·해결·결과 흐름으로 서술. 라벨식 구성과 불필요한 강조·기호를 정리. 표·코드·수치·기술명은 그대로 유지. Co-Authored-By: Claude Opus 4.8 --- DEVELOPMENT_JOURNEY.md | 449 ++++++++++------------------------------- README.md | 81 +++----- 2 files changed, 143 insertions(+), 387 deletions(-) diff --git a/DEVELOPMENT_JOURNEY.md b/DEVELOPMENT_JOURNEY.md index 09fbcd3..8a331ac 100644 --- a/DEVELOPMENT_JOURNEY.md +++ b/DEVELOPMENT_JOURNEY.md @@ -1,13 +1,10 @@ -# WTGMate 개발 기록 (Living Document) +# WTGMate 개발 기록 -> **이 문서는 코드와 함께 git으로 관리되는 "살아있는" 개발 기록입니다.** -> 기능이 발전할 때마다 갱신합니다. 고민한 것 · 선택 · 그 이유 · 어려웠던 점을 남겨, -> 나중에 포트폴리오/면접 자료로 바로 쓸 수 있게 하는 것이 목적입니다. -> 개발의 **출발점(최초 설계)은 Part 0**, 이후의 변화 과정은 **Part B**에 정리했습니다. +이 문서는 코드와 함께 git으로 관리하는 개발 기록입니다. 기능을 만들 때마다 무엇을 고민했고 왜 그렇게 정했는지, 어떤 문제를 만나 어떻게 풀었는지를 남겼습니다. 나중에 포트폴리오나 면접에서 바로 쓸 수 있게 하는 것이 목적입니다. 처음 설계는 Part 0에, 이후 바꿔 나간 과정은 Part B에 정리했습니다. -- **프로젝트**: WTGMate — Smart Route Planner (자연어 일정 → 장소 추출·검증 → 경로 최적화·비교) -- **스택**: React + Vite (front) / FastAPI + Python (back) / OR-Tools, Kakao Maps·Mobility·Local, 로컬 파인튜닝 LLM(Ollama) -- **레포**: `todo-route-planner` (front/back 모노 구조) +- 프로젝트: WTGMate. 자연어 일정에서 장소를 뽑아 확인하고, 경로를 최적화·비교하는 웹앱. +- 스택: React + Vite(프론트), FastAPI + Python(백엔드), OR-Tools, Kakao Maps·Mobility·Local, 로컬 파인튜닝 LLM(Ollama). +- 저장소: github.com/ysb2152/WTGMate (프론트/백엔드 모노 구조). --- @@ -15,415 +12,191 @@ | 시점 | 구분 | 한 일 | 관련 | |---|---|---|---| -| 2026-08-27 | deploy | **실제 라이브 배포 성공** — https://wtg-mate.vercel.app (Vercel 프론트 + Cloudflare Tunnel로 개발 PC의 백엔드/Ollama 노출). 지도·추출·약속·지오코딩·경로 라이브 확인 | B-11 | -| 2026-08-27 | deploy | 실제 라이브 데모를 Cloudflare Tunnel+Vercel(비용 0)로 결정·검증(quick tunnel로 백엔드 HTTPS 노출·/api/health 200). 런북·실행 스크립트 추가 | `deploy/CLOUDFLARE.md` | -| 2026-08-27 | chore | AWS 배포 산출물 완비(EC2에 올리면 바로 기동): backend Dockerfile + docker-compose(ollama+model-loader+backend) + nginx + Modelfile 커밋, GitHub Actions로 Docker 이미지 빌드 CI 추가, DEPLOY.md 구체 런북화 | `chore/deploy-artifacts` / `DEPLOY.md` | -| 2026-08-27 | chore | 배포 Phase 1(코드 준비): requirements.txt 생성, 프론트 API주소·백엔드 CORS를 env화, .env.example 2종 추가 | `chore/deploy-phase1` / `DEPLOY.md` | -| 2026-08-27 | fix | 실사용 브라우저 검증 중 발견: Kakao 자동차 API가 실패해도 200으로 result_code만 주는 짧은 구간에서 `summary` KeyError → result_code 확인해 명확히 폴백 | `fix/kakao-car-resultcode` | -| 2026-08-27 | perf/test | 배포용 정확도·성능 측정(최적화 최적성 PASS·LLM 추출 6/6·추정행렬 regret 평균 0.5분) 후 OR-Tools time_limit 5s→2s, 검증 하네스 경로 하드코딩 제거 | `chore/perf-timelimit-harness` | -| 2026-08-27 | chore | 배포 전 전체 점검 후 정리: 죽은 코드·미사용 import 제거, 낡은 주석(도보/대중교통 실경로) 현행화, eslint 설정 보완 | `chore/cleanup` | -| 2026-08-27 | feat | 대중교통 실제 경로(ODsay `searchPubTransPathT`) 연동 — 실제 시간·거리·정류장 경로, 키 없으면 추정 폴백 | `feature/transit-real-route` | -| 2026-08-27 | perf | 실 경로 API 호출을 O(n²)→n-1로 축소(순서 최적화는 추정 행렬, 최종 경로만 실측) + leg 캐시(LRU 512·자동차/대중교통 TTL 600s) | `feature/walk-real-route` | -| 2026-08-27 | feat | 도보 실제 인도 경로(Tmap 보행자 경로안내 API) 연동 — 실제 거리·시간·경로 좌표, 키 없으면 추정 폴백 | `feature/walk-real-route` | -| 2026-08-26 | fix/test | `clamp_priority(0)`가 falsy로 3이 되던 버그 수정 + 헬퍼 유닛테스트 12개 추가 | PR #7 | -| 2026-08-26 | feat | 출발시각 미입력 시 약속 기준 '추천 출발시각' 역산 | PR #6 (`feature/car-real-route`) | -| 2026-08-26 | feat | 자동차 실제 도로 경로를 지도에 표시(Kakao Mobility 좌표열 활용) | PR #6 (`feature/car-real-route`) | -| 2026-08-26 | feat | 출발시각 '현재시간 기준' 체크박스 + 미선택 시 시간계산 생략(약속 순서는 유지) | PR #6 (`feature/car-real-route`) | -| 2026-08-26 | test | 경로 계산 전수 검증 하네스(90건·정합성 168/168) + 이동수단 재최적화 버그 발견·수정 | PR #5 / `docs/verification/` | -| 2026-08-26 | feat | LLM 약속시각 자동추출(재파인튜닝) + 프론트 UX(자동채움·AI뱃지·약속시각 드롭다운·현재시각) | PR #5 | -| ~2026-08-25 | feat | 시간 제약 스케줄링(출발시각·체류시간·약속시각·도착시각·위반경고) | PR #4 | -| ~2026-08-25 | refactor | Gemini → 로컬 파인튜닝 모델(Ollama) 전환 + 세 경로 모드 목적함수 실제 차별화 | PR #3 | -| 이전 | init | 초기 구현 및 포트폴리오 초안(원본 Word 문서) | — | - -> 원본 Word 문서 대비 크게 달라진 3가지: **① 모델을 Gemini에서 로컬 파인튜닝 모델로 교체**, -> **② "AI 추천 = 우선순위" 동일 비용식 문제를 실제로 해결**, **③ 시간창(약속시각) 제약을 구현**. +| 2026-08-27 | deploy | 실제 라이브 배포 성공(https://wtg-mate.vercel.app). Vercel 프론트 + Cloudflare Tunnel로 개발 PC의 백엔드/Ollama 노출. 지도·추출·약속·지오코딩·경로 라이브 확인 | B-11 | +| 2026-08-27 | test | solver·스케줄·캐시 단위 테스트 추가 + CI(백엔드 테스트/프론트 빌드) | PR #20 | +| 2026-08-27 | deploy | AWS 배포 산출물 완비(Dockerfile·docker-compose·nginx·Modelfile) + Docker 빌드 CI. Cloudflare Tunnel + Vercel 런북 | B-11 | +| 2026-08-27 | chore | 배포 준비 코드화: requirements.txt, 프론트 API주소·백엔드 CORS를 env로, .env.example 추가 | PR #12 | +| 2026-08-27 | fix | Kakao 자동차 API가 실패해도 200으로 result_code만 주는 짧은 구간에서 summary KeyError 나던 것을 result_code 확인으로 정리 | PR #11 | +| 2026-08-27 | perf/test | 배포용 정확도·성능 측정(최적화 최적성 통과, LLM 추출 6/6, 추정행렬 regret 평균 0.5분) 후 OR-Tools time_limit 5s를 2s로 | PR #10 | +| 2026-08-27 | feat | 대중교통 실제 경로(ODsay `searchPubTransPathT`) 연동, 키 없으면 추정 폴백 | B-10 | +| 2026-08-27 | perf | 실 경로 API 호출을 O(n²)에서 n-1로 축소(순서 최적화는 추정, 최종 경로만 실측) + leg 캐시 | B-9 | +| 2026-08-27 | feat | 도보 실제 인도 경로(Tmap 보행자 경로안내 API) 연동, 키 없으면 추정 폴백 | B-9 | +| 2026-08-26 | fix/test | `clamp_priority(0)`이 falsy로 3이 되던 버그 수정 + 헬퍼 유닛테스트 12개 | PR #7 | +| 2026-08-26 | feat | 출발시각 미입력 시 약속 기준 추천 출발시각 역산 | B-8 | +| 2026-08-26 | feat | 자동차 실제 도로 경로를 지도에 표시(Kakao Mobility 좌표열) | B-6 | +| 2026-08-26 | feat | 출발시각 현재시간 기준 체크박스 + 미선택 시 시간계산 생략 | B-7 | +| 2026-08-26 | test | 경로 계산 전수 검증 하네스 + 이동수단 재최적화 버그 발견·수정 | B-5 | +| 2026-08-26 | feat | LLM 약속시각 자동추출(재파인튜닝) + 프론트 UX | B-4 | +| ~2026-08-25 | feat | 시간 제약 스케줄링(출발시각·체류시간·약속시각·도착시각·위반경고) | B-3 | +| ~2026-08-25 | refactor | Gemini에서 로컬 파인튜닝 모델(Ollama)로 전환 + 세 경로 모드 목적함수 차별화 | B-1, B-2 | +| 이전 | init | 초기 구현 및 포트폴리오 초안 | Part 0 | + +초안 대비 크게 바꾼 것은 세 가지입니다. 모델을 Gemini에서 로컬 파인튜닝 모델로 교체했고, AI 추천과 우선순위가 사실상 같은 결과를 내던 문제를 실제로 해결했으며, 약속 시각 같은 시간 제약을 구현했습니다. --- -# Part 0. 프로젝트의 출발점 — 최초 설계 - -> 처음 이 프로젝트를 구상했을 때의 설계와 계획이다. 이후의 모든 변화(Part B)는 여기서 출발한다. -> 이 문서의 핵심은 "무엇을, 왜 바꿨는가"이므로, 그 **기준점**으로 최초 설계를 그대로 남겨둔다. - -## 0-1. 처음의 목표와 형태 -- **취업 포트폴리오용** 프로젝트. 산문형 할일 텍스트에서 장소·우선순위를 추출해 지도에 표시하고, - 우선순위 기반 추천 루트와 예상 소요시간을 계산하는 **하루치 할일 범위의 MVP**. -- 처음엔 **Electron 데스크톱 앱**으로 패키징할 계획이었다(React를 그대로 재사용하려는 선택). - → 이후 웹(**React + Vite**)으로 방향을 굳혔다. - -## 0-2. 처음의 기술 선택 -- **백엔드** FastAPI(`main.py`), **프론트** React(`App.jsx`) + 카카오맵 JS SDK — *지금까지 유지*. -- **장소 추출**: 처음엔 **Gemini API(`gemini-2.5-flash`)** 로 산문 → JSON 배열(name/task/priority/lat/lng/address). - → 비용·쿼터·외부 의존 때문에 **로컬 파인튜닝 모델(Ollama)** 로 교체(→ B-1). -- **지도/경로**: 카카오. 지도·검색·지오코딩은 JS SDK/REST, 자동차 길찾기는 카카오모빌리티 Directions - (REST 키만으로 별도 승인 없이 사용) — *지금까지 유지*. -- **도보/대중교통**: 처음엔 실제 API 엔드포인트를 확정하지 못해, 직선거리에 도로 굴곡 보정(1.2~1.4배)과 - 모드별 평균속도를 적용한 **근사치(`fallback_leg`)** 로 대체했다. → 도보는 이후 **Tmap 실 API**로 교체(→ B-9), - 대중교통은 ODsay로 예정. -- **경로 최적화**: 하루치라 장소가 적어(보통 10개 미만) **완전탐색(순열 brute-force)** 을 기본으로 쓰고, - 목적지가 `MAX_BRUTE_FORCE_STOPS`(9)를 넘으면 OR-Tools 근사로 자동 전환 — *지금까지 유지*. - -## 0-3. 처음부터 명확히 하려던 원칙 — 최적화 모드 3종 -세 모드가 서로 다른 로직으로 **명확히 분리**되어야 한다는 게 초기부터의 핵심 요구였다. (실제로 초기엔 세 -모드가 전부 같은 결과를 내는 버그가 있었다 — 우선순위 페널티가 방문 순서와 무관하게 늘 동일하게 더해지던 -설계 오류. → B-2에서 실제로 해결.) -- **`shortest`**: 우선순위 완전 무시, 순수 이동시간 최소화. -- **`priority`**: 우선순위 그룹 순서를 절대 기준으로 강제(그룹 간 순서 불변), 같은 그룹 내에서만 이동시간 - 최소화. DP(bucket, end_node)로 정확히 계산. -- **`ai`**: 이동시간 + (중요도 × 방문순번) 페널티 합산 최소화. `priority`는 1이 가장 중요·5가 가장 여유라 - 그대로 곱하면 방향이 반대이므로 반드시 **`importance = 6 - priority`** 로 뒤집어 쓴다. 반영 강도는 - `AI_PRIORITY_WEIGHT_SEC`(**처음 300초=5분**)로 조절 → 이후 실측 스윕으로 **100초**로 튜닝(→ B-2). - -## 0-4. 처음부터 지킨 방어적 설계 -- **키 관리**: `.env`에 `KAKAO_REST_API_KEY`·`GEMINI_API_KEY` 저장(`.gitignore` 등록). REST 키는 프론트 - 노출 금지(항상 백엔드에서만 호출), 프론트는 지도 렌더링용 JS 키만 사용. -- **실패 대비**: Gemini 실패(429 등) 시 Mock 데이터로 자동 대체(`is_mock`로 프론트에 안내). → Ollama 전환 - 이후에는 외부 쿼터 의존 자체가 사라졌다(→ B-1). -- **중복 호출 방지**: 같은 방문 순서를 다른 시점에 두 번 부르면 실시간 교통 때문에 숫자가 미세하게 달라지는 - 문제를, 순서 기반 캐시 키(`routeSequenceKey`)로 방지. → 이후 이동수단 재최적화 버그(→ B-5)와 최종 경로 - leg 캐시(→ B-9)로 발전. - -## 0-5. 처음의 알려진 한계 / TODO (당시 기준) -- 도보/대중교통 실 API 미연동(위). / MVP 범위는 하루치 할일만(여러 날 분배는 범위 밖). / 우선순위 커스텀은 - 드래그 방식 UI. +# Part 0. 프로젝트의 출발점 ---- - -# Part A. 프로젝트 개요 · 현재 설계 - -## A-1. 프로젝트가 풀려는 문제 -- 일정을 입력한 뒤 방문 순서를 사용자가 직접 고민해야 하는 불편함 -- 가장 가까운 곳만 잇는 경로는 일정의 **중요도**를 반영하지 못함 -- AI가 뽑은 장소명이 실제 지도상의 장소와 다를 수 있음 -- **AI 추천 / 최단시간 / 내 우선순위** 경로를 서로 비교하기 어려움 -- 경로가 바뀌었는데 거리·시간이 이전 결과와 섞이는 상태 관리 문제 - -## A-2. 사용자 경험 전체 흐름 -1. 출발지 검색·선택 → 2. 이동수단 선택(자동차/도보/대중교통) → 3. 자연어 일정 입력 -→ 4. **로컬 LLM이 장소명·할일·우선순위·약속시각 추출** → 5. Kakao 장소 검색으로 실제 후보 검증 -→ 6. 사용자가 장소 확인·수정 → 7. AI추천/최단시간/내우선순위 경로 각각 계산 -→ 8. Kakao Mobility(자동차) 또는 보정계산(도보/대중교통)으로 거리·시간 산출 → 9. 지도+방문순서 비교 +처음 이 프로젝트를 구상했을 때의 설계와 계획입니다. 이후의 변화는 모두 여기에서 출발했습니다. 이 문서의 목적이 무엇을 왜 바꿨는가를 남기는 것이기 때문에, 기준점으로 최초 설계를 그대로 남겨 둡니다. -## A-3. 세 경로 모드의 목적함수 (핵심 설계 원칙) -초기 구현의 가장 큰 문제는 **세 경로가 모두 이동시간 중심이라 결과가 비슷해지는 것**이었다. -버튼 이름만 다르게 두는 게 아니라, 각 모드가 **실제로 다른 목적함수**를 가져야 한다고 정리했다. +**처음의 목표와 형태.** 취업 포트폴리오로 만든 프로젝트입니다. 문장으로 적은 하루 일정에서 장소와 우선순위를 뽑아 지도에 표시하고, 우선순위를 반영한 추천 경로와 예상 소요 시간을 계산하는, 하루치 일정 범위의 MVP를 목표로 했습니다. 처음에는 React를 그대로 재사용하려고 Electron 데스크톱 앱으로 만들 계획이었지만, 이후 웹(React + Vite)으로 방향을 정했습니다. -| 모드 | 최우선 기준 | 의도 | -|---|---|---| -| 최단시간(shortest) | 이동시간 | 우선순위 완전 무시, 이동시간 최소화 | -| 내 우선순위(priority) | 사용자 우선순위 | 중요한 장소를 반드시 앞쪽에(계층적 최적화) | -| AI 추천(ai) | 우선순위 × 방문순번 + 이동시간 | 일정의 의미와 이동효율의 균형 | +**처음의 기술 선택.** 백엔드는 FastAPI, 프론트는 React와 카카오맵 JS SDK로 잡았고 이 부분은 지금까지 유지하고 있습니다. 장소 추출은 처음에 Gemini API(`gemini-2.5-flash`)로 문장을 JSON 배열(name/task/priority/lat/lng/address)로 바꾸는 방식이었는데, 비용과 쿼터, 외부 의존 때문에 나중에 로컬 파인튜닝 모델로 교체했습니다(B-1). 지도와 경로는 카카오를 썼고, 자동차 길찾기는 카카오모빌리티 Directions를 REST 키만으로 사용했습니다. 도보와 대중교통은 처음에 실제 API 엔드포인트를 확정하지 못해, 직선거리에 도로 굴곡 보정(1.2~1.4배)과 이동수단별 평균 속도를 적용한 근사치(`fallback_leg`)로 대체했습니다. 도보는 이후 Tmap 실 API로 바꿨습니다(B-9). 경로 최적화는 하루치라 장소가 보통 10개 미만이어서 완전탐색(순열)을 기본으로 쓰고, 목적지가 `MAX_BRUTE_FORCE_STOPS`(9)를 넘으면 OR-Tools 근사로 자동 전환하도록 했습니다. -> **원본 문서에서는 "ai와 priority가 사실상 같은 비용식이라 결과가 같다"고 지적**했는데, -> 이 부분은 이후 실제로 해결했다(→ Part B-2). +**처음부터 명확히 하려던 원칙.** 세 최적화 모드가 서로 다른 로직으로 확실히 분리되어야 한다는 것이 초기부터의 요구였습니다. 사실 처음에는 세 모드가 전부 같은 결과를 내는 문제가 있었는데, 우선순위 페널티가 방문 순서와 무관하게 늘 같은 값으로 더해지던 설계 오류였습니다. 이 문제는 B-2에서 실제로 해결했습니다. -## A-4. 장소 추출과 검증의 역할 분리 -자연어를 바로 좌표로 쓰는 건 위험하다("강남역"이라 해도 모델이 준 좌표가 정확하다는 보장 없음). -그래서 역할을 분리한다. +**처음부터 지킨 방어적 설계.** 키는 `.env`에 넣고 `.gitignore`에 등록했으며, REST 키는 프론트에 노출하지 않고 항상 백엔드에서만 호출했습니다. Gemini가 실패(429 등)하면 Mock 데이터로 화면이 멈추지 않게 처리하고 `is_mock`으로 알렸는데, Ollama로 바꾼 뒤에는 외부 쿼터 의존 자체가 없어졌습니다(B-1). 같은 방문 순서를 다른 시점에 두 번 부르면 실시간 교통 때문에 숫자가 미세하게 달라지는 문제는 순서 기반 캐시 키로 막았고, 이 부분은 이후 이동수단 재최적화 버그(B-5)와 최종 경로 leg 캐시(B-9)로 발전했습니다. -| 단계 | 담당 | 역할 | -|---|---|---| -| 1 | 로컬 LLM(구 Gemini) | 자연어에서 장소명·할일·중요도·**약속시각** 추출 | -| 2 | Kakao Local | 실제 지도상의 장소 후보 검색·좌표 확정, 사용자 확인 | - -## A-5. 실패를 고려한 방어적 설계 -- LLM 실패 시 Mock 데이터로 화면이 멈추지 않게 처리 -- Kakao Mobility 실패 시 Haversine 거리 + 이동수단별 보정 속도로 예상값 계산(estimated 표시) -- 좌표 유효성 백엔드 검증, 오류 시 사용자 알림·loading 해제 - -## A-6. 프론트엔드 상태 관리 -경로 결과를 모드별로 독립 저장해 "덮어쓰기/이전 결과 잔존" 문제를 막는다. -- `routeResults.ai / .shortest / .priority`, `activeRoute`, `isPriorityDirty`, `scheduleKey`, `travelMode` -- 일정 변경 → 세 결과 모두 무효화 / 우선순위만 변경 → priority만 무효화 -- 같은 방문순서·같은 이동수단이면 ETA 중복 호출 방지 +**당시의 한계.** 도보·대중교통 실 API 미연동, 하루치 일정만 다루는 범위, 드래그 방식의 우선순위 UI가 처음의 알려진 한계였습니다. --- -# Part B. 개발 진행 로그 — 고민 · 선택 · 이유 · 어려움 - -> 여기부터가 실제로 손을 대며 프로그램을 발전시킨 기록이다. 각 항목은 -> **무엇이 문제였나 → 어떤 선택을 했나 → 왜 그렇게 했나 → 무엇이 어려웠나** 순으로 남긴다. - -## B-1. Gemini → 로컬 파인튜닝 모델(Ollama) 전환 · PR #3 - -**고민.** 초기엔 장소 추출을 Gemini API에 의존했다. 외부 API는 비용·쿼터·네트워크 의존이 있고, -데모를 상시 띄우거나 오프라인에서 돌리기 어렵다. - -**선택.** 소형 오픈모델 **Qwen2.5-3B-Instruct**를 **QLoRA로 파인튜닝**해, GGUF(q4_k_m)로 변환하고 -**Ollama에 `wtgmate-parser`로 등록**해 서빙한다. Gemini는 버리지 않고 **학습 데이터 생성용 교사 모델**로만 남긴다. - -**왜.** -- 추론 비용 0 · 오프라인 · 프라이버시. 태스크가 좁아서(장소 추출 JSON) 소형 모델도 파인튜닝하면 충분. -- **좌표는 LLM을 신뢰하지 않는다**: 모델은 name/task/priority만 뽑고, 좌표·주소는 항상 Kakao로 재조회해 덮어쓴다(특히 소형 모델은 좌표를 거의 항상 틀림). -- 학습을 채팅 형식이 아니라 **alpaca 프롬프트(순수 텍스트 이어쓰기)**로 하고, 추론도 `raw=True`로 같은 형식을 맞춘다. +# Part A. 프로젝트 개요와 현재 설계 -**AI 가중치 튜닝.** AI 모드의 우선순위 지배력 상수 `AI_PRIORITY_WEIGHT_SEC`를 **300 → 100**으로 조정. -실측 스윕 결과 60 미만은 우선순위 과소반영, 100 초과는 사실상 priority 모드와 같아져서, 60~100이 -"중요한 곳 먼저 가되 나머지는 이동시간 최적화"의 균형 구간이었다. +**풀려는 문제.** 일정을 입력한 뒤 방문 순서를 사용자가 직접 고민해야 하는 불편함, 가장 가까운 곳만 잇는 경로가 일정의 중요도를 반영하지 못하는 점, AI가 뽑은 장소명이 실제 지도상의 장소와 다를 수 있는 점, 세 종류의 경로를 서로 비교하기 어려운 점, 경로가 바뀌었는데 거리와 시간이 이전 결과와 섞이는 상태 관리 문제를 풀고자 했습니다. -**어려웠던 점.** -- **학습/추론 프롬프트 형식이 조금만 어긋나도 성능이 급락**한다. `TASK_INSTRUCTION`을 backend와 데이터 생성 스크립트에서 byte 단위로 동일하게 유지해야 했다. -- GGUF 가중치가 1.9GB라 git에서 제외하고 Colab 노트북으로 재생성하는 체계를 만들어야 했다. -- Ollama **콜드 스타트**: 서버 기동 직후 첫 요청은 모델 로딩 때문에 실패→Mock 폴백될 수 있다(예열 후 정상). +**사용자 흐름.** 출발지를 검색해 고르고, 이동수단을 선택하고, 자연어로 일정을 입력하면, 로컬 LLM이 장소명과 할 일, 우선순위, 약속 시각을 뽑습니다. 그다음 Kakao 장소 검색으로 실제 후보를 확인하고, 사용자가 장소를 확인·수정한 뒤, AI 추천·최단시간·내 우선순위 경로를 각각 계산합니다. 거리와 시간은 자동차는 Kakao Mobility로, 도보·대중교통은 Tmap·ODsay로 산출하고, 지도와 방문 순서를 함께 보여 줍니다. -## B-2. "AI 추천 = 우선순위" 문제의 실제 해결 · PR #3 +**세 모드의 목적함수.** 초기 구현에서 가장 큰 문제는 세 경로가 모두 이동시간 중심이라 결과가 비슷해지는 것이었습니다. 버튼 이름만 다르게 두는 것이 아니라 각 모드가 실제로 다른 목적함수를 가져야 한다고 정리했습니다. -**고민.** 원본 포트폴리오 문서가 스스로 지적한 핵심 약점 — ai와 priority가 거의 같은 비용식이라 -방문 순서가 같아지는 문제. 게다가 우선순위를 이동시간에 그냥 더하기만 하면, **순서를 바꿔도 총합이 같아 -"순서가 점수에 반영되지 않는" 버그**가 있었다. - -**선택 ①(AI 모드).** 비용을 `이동시간 + Σ(importance × 방문순번 × 가중치)`로 바꿨다. -- `importance = 6 − priority` 로 방향을 뒤집는다(priority 1=가장 중요 → importance 5). -- **방문 순번(position)을 곱한다** → 중요한 장소를 늦게 방문할수록 페널티가 커진다. 순서 자체가 점수에 반영된다. - -**선택 ②(우선순위 모드).** 단순 penalty가 아니라 **계층적(DP) 최적화**로 구현. -- 우선순위 그룹(1~5)을 **절대 순서로 강제**하고, 같은 그룹 안에서만 이동시간을 최소화한다. -- `dp[(bucket, end_node)] = (cost, path)`로 그룹을 순서대로 확장하며 정확해를 구한다. -- 이로써 원본 문서 11장의 "권장 최종 설계(1순위 우선순위, 2순위 이동시간)"를 **실제 코드로** 구현했다. - -**왜.** 기능 이름("내 우선순위")과 알고리즘의 목적이 일치해야 한다. 사용자가 직접 지정한 중요도를 -시스템이 이동시간 때문에 임의로 뒤집으면 안 된다. - -## B-3. 시간 제약 스케줄링 · PR #4 - -**고민.** 원본 로드맵의 "특정 시간까지 도착해야 하는 장소"(time window)를 실제로 지원하고 싶었다. - -**선택.** 장소별 **약속시각(appointment_time)**·**체류시간(duration_min)**과 **출발 예정 시각(start_time)**을 도입. -- `build_schedule`가 순서를 따라 시간축을 계산: 도착 = 직전 출발 + 이동시간. 일찍 도착하면 약속시각까지 대기, 늦으면 위반으로 기록. -- 위반 순서에는 `APPOINTMENT_VIOLATION_PENALTY_SEC = 10,000,000`(사실상 하드 제약)를 얹어, 약속을 지키는 순서가 항상 우선 선택되게 함. -- `/api/route-eta`가 각 정거장의 도착·출발 시각과 위반 목록을 반환, 프론트가 표시. +| 모드 | 최우선 기준 | 의도 | +|---|---|---| +| 최단시간(shortest) | 이동시간 | 우선순위를 무시하고 이동시간만 최소화 | +| 내 우선순위(priority) | 사용자 우선순위 | 중요한 장소를 반드시 앞쪽에(계층적 최적화) | +| AI 추천(ai) | 우선순위 × 방문순번 + 이동시간 | 일정의 의미와 이동 효율의 균형 | -**왜.** "가장 효율적인 순서"만이 아니라 "약속을 지킬 수 있는 순서"가 실제 사용자 요구다. +**장소 추출과 검증의 분리.** 자연어를 바로 좌표로 쓰는 것은 위험하다고 판단했습니다. "강남역"이라고 해도 모델이 준 좌표가 정확하다는 보장이 없기 때문입니다. 그래서 의미(장소명·할 일·중요도·약속시각) 추출은 로컬 LLM이 맡고, 실제 좌표 확정은 Kakao Local이 맡도록 역할을 나눴습니다. -## B-4. LLM 약속시각 자동추출 + UX · PR #5 +**실패를 고려한 설계.** LLM이 실패하면 Mock 데이터로 화면이 멈추지 않게 하고, Kakao Mobility가 실패하면 Haversine 거리에 이동수단별 보정 속도를 적용한 예상값으로 대체하며 추정치임을 표시합니다. 좌표 유효성은 백엔드에서 검증하고 오류가 나면 사용자에게 알리며 로딩을 해제합니다. -**고민.** PR #4까지는 "3시 미팅"의 약속시각을 **사용자가 수동 입력**해야 했다. 이걸 LLM이 산문에서 자동으로 뽑게 하고 싶었다. +**프론트 상태 관리.** 경로 결과를 모드별로 따로 저장해 이전 결과가 남거나 덮어써지는 문제를 막았습니다. 일정을 바꾸면 세 결과를 모두 무효화하고, 우선순위만 바꾸면 priority 결과만 무효화하며, 같은 방문 순서와 같은 이동수단이면 ETA를 다시 부르지 않습니다. -**선택 · 데이터 전략.** 기존 학습데이터 200개에는 `appointment_time` 라벨이 없었고, 그중 21개는 -input에 시각이 있는데 라벨이 없어 그대로 쓰면 "시각을 무시하도록" 학습된다. 그래서: -- 시각 없는 179개 → `appointment_time: null`로 재활용(정상 negative) -- 시각 있는 21개 → 폐기 -- `gemini-3.5-flash-lite`(운영 모델 쿼터 절약용)로 **시각 포함 예시를 보충** → 총 **375개** -- 목표 비율을 정해 튜닝: 문장 기준 **약 40%(실제 39%)**, 장소 기준 17%가 약속시각 보유. - - 근거: positive가 너무 적으면 추출을 못 배우고, 너무 많으면 시각이 없는데도 아무 시각이나 찍는 과잉추출(hallucination)로 기운다. +--- -**선택 · 정규화.** 모델 출력 시각을 `parse_hhmm`/`format_hhmm`으로 `HH:MM`(24시간제)로 정규화하고, -"오후 3시→15:00", "10시 반→10:30"처럼 오전/오후·반시를 변환. 형식 오류는 버린다. +# Part B. 개발 진행 로그 -**선택 · 프론트 UX.** -- 추출된 약속시각을 입력란에 **자동 채움** + **"✨ AI 자동입력" 뱃지**(사용자가 수정하면 뱃지 내림). -- 약속시각 입력을 출발시각과 동일한 **오전/오후·시·분 드롭다운**으로 통일. +여기부터가 실제로 손을 대며 프로그램을 발전시킨 기록입니다. 각 항목은 무엇이 문제였고 어떤 선택을 했으며 왜 그렇게 했는지, 무엇이 어려웠는지를 남겼습니다. -**어려웠던 점.** 재파인튜닝은 GPU가 필요해 Colab에서만 가능. Colab 노트북의 GGUF 다운로드 셀 경로 버그 -(`wtgmate_parser/` → 실제 `wtgmate_parser_gguf/`)와, Unsloth가 자동 생성하는 채팅형 Modelfile을 -쓰면 raw passthrough 학습과 어긋나 성능이 떨어지는 함정을 피해야 했다(프로젝트 Modelfile 사용). +## B-1. Gemini에서 로컬 파인튜닝 모델(Ollama)로 전환 -## B-5. 경로 계산 전수 검증 + 이동수단 재최적화 버그 발견·수정 +처음에는 장소 추출을 Gemini API에 의존했습니다. 하지만 외부 API는 비용과 쿼터, 네트워크 의존이 있어 데모를 상시 띄우거나 오프라인에서 돌리기 어려웠습니다. 그래서 소형 오픈 모델인 Qwen2.5-3B-Instruct를 QLoRA로 파인튜닝하고, GGUF(q4_k_m)로 변환해 Ollama에 `wtgmate-parser`로 등록해 서빙하기로 했습니다. Gemini는 버리지 않고 학습 데이터 생성용 교사 모델로만 남겼습니다. -**고민.** "정말 세 모드·세 이동수단이 제대로 계산되나?"를 눈으로만 확인하면 놓치는 게 생긴다. +이렇게 한 이유는 추론 비용이 들지 않고 오프라인에서 동작하며, 태스크가 장소 추출 JSON으로 좁아서 소형 모델을 파인튜닝해도 충분하다고 판단했기 때문입니다. 좌표는 여전히 모델을 신뢰하지 않고, 모델은 name/task/priority만 뽑게 한 뒤 좌표와 주소는 항상 Kakao로 다시 조회해 덮어씁니다. 특히 소형 모델은 좌표를 거의 항상 틀리기 때문입니다. 학습은 채팅 형식이 아니라 alpaca 프롬프트(순수 텍스트 이어쓰기)로 했고, 추론도 `raw=True`로 같은 형식을 맞췄습니다. -**선택.** UI가 호출하는 백엔드 API를 그대로 구동하는 **검증 하네스**(`docs/verification/verify_routes.py`)를 작성. -10개 시나리오 × 3 이동수단 × 3 최적화모드 = **90건**을 계산하고, 정합성 **불변식**을 자동 검사: -- (A) 최단모드가 실제로 최소 이동시간인지, (B) 우선순위 모드가 우선순위 순서를 지키는지, -- (C) 구간 합이 총합과 맞는지, (D) 도보가 자동차보다 느린지, (E) 약속 위반이 0인지. -결과: **168/168 통과**. 리포트는 `docs/verification/verification-report.md`. +AI 모드의 우선순위 지배력 상수 `AI_PRIORITY_WEIGHT_SEC`는 300에서 100으로 조정했습니다. 실측으로 값을 바꿔 보니 60보다 작으면 우선순위가 거의 반영되지 않고, 100을 넘으면 사실상 priority 모드와 같아졌습니다. 그 사이 60에서 100이 중요한 곳을 먼저 가되 나머지는 이동시간으로 최적화하는 균형 구간이었습니다. -**발견한 버그(중요).** UI로 대조하다가, **이동수단을 바꾸면 방문 순서를 재최적화하지 않고 -이전 이동수단의 순서에 ETA만 다시 계산**하는 프론트 버그를 찾았다. -- 증상: 잠실역 도보/AI가 UI에선 1.5km(자동차 순서), 실제 도보 최적은 1.0km. -- 원인: `handleTravelModeChange`가 `optimize-route`를 다시 안 부르고 `fetchRealEta`만 호출. 게다가 - `calculateRoute` 캐시 키가 `mode`뿐이라 재클릭해도 stale 순서를 반환. -- 수정: 이동수단 변경 시 캐시를 비우고 활성 모드를 **새 이동수단으로 재최적화**(`calculateRoute`에 `travelModeArg` 추가해 비동기 `setTravelMode` 이전 값 문제 회피). +어려웠던 점은 학습과 추론의 프롬프트 형식이 조금만 어긋나도 성능이 크게 떨어진다는 것이었습니다. `TASK_INSTRUCTION`을 백엔드와 데이터 생성 스크립트에서 byte 단위로 동일하게 유지해야 했습니다. GGUF 가중치가 1.9GB라 git에서 제외하고 Colab 노트북으로 재생성하는 체계도 만들어야 했습니다. Ollama는 서버 기동 직후 첫 요청이 모델 로딩 때문에 실패해 Mock으로 폴백될 수 있는데, 예열 후에는 정상 동작합니다. -**왜 기록하나.** "직접 만든 검증 과정에서 스스로 버그를 찾아 고쳤다"는 것 자체가 좋은 포트폴리오 스토리다. +## B-2. AI 추천과 우선순위가 같은 결과를 내던 문제 해결 -## B-6. 자동차 실제 도로 경로 지도 표시 · `feature/car-real-route` +원본 문서가 스스로 지적한 약점이 있었습니다. ai와 priority가 거의 같은 비용식이라 방문 순서가 같아지는 문제였습니다. 게다가 우선순위를 이동시간에 그냥 더하기만 하면 순서를 바꿔도 총합이 같아, 순서가 점수에 반영되지 않는 버그가 있었습니다. -**고민.** 지도 경로가 방문 지점을 **직선**으로만 이어, 실제 도로/인도를 반영하지 못했다. +AI 모드는 비용을 `이동시간 + Σ(importance × 방문순번 × 가중치)`로 바꿨습니다. `importance = 6 − priority`로 방향을 뒤집어 priority 1이 가장 중요하도록 하고, 방문 순번을 곱해서 중요한 장소를 늦게 방문할수록 페널티가 커지게 했습니다. 순서 자체가 점수에 반영되도록 만든 것입니다. -**선택.** 자동차는 Kakao Mobility 응답에 **이미 도로 좌표열(`sections[].roads[].vertexes`)이 들어온다**. -지금까지 summary(거리·시간)만 쓰고 버렸는데, 이 좌표열을 `[[lat,lng],…]`로 추출(`extract_car_path`)해 -`route-eta`의 각 leg에 `path`로 실어 보낸다. 프론트는 path가 있으면 실제 경로 Polyline, 없으면(도보/대중교통) 직선 폴백. +우선순위 모드는 단순 페널티가 아니라 계층적 최적화로 구현했습니다. 우선순위 그룹을 절대 순서로 강제하고 같은 그룹 안에서만 이동시간을 최소화하며, `dp[(bucket, end_node)] = (cost, path)`로 그룹을 순서대로 확장해 정확해를 구합니다. 이렇게 한 이유는 기능 이름(내 우선순위)과 알고리즘의 목적이 일치해야 한다고 생각했기 때문입니다. 사용자가 직접 지정한 중요도를 시스템이 이동시간 때문에 임의로 뒤집으면 안 된다고 판단했습니다. -**왜.** 자동차 거리·시간은 이미 정확했으므로, "공짜에 가까운" 개선으로 시각적 완성도를 크게 올릴 수 있었다. -행렬 계산 경로에는 `include_path=False`로 좌표 파싱을 생략해 성능 영향도 없앴다. +## B-3. 시간 제약 스케줄링 -**남은 것.** 도보(인도)·대중교통 실제 경로는 별도 API(Tmap 보행자/대중교통, ODsay 등)가 필요해 다음 과제로 둔다. +특정 시간까지 도착해야 하는 장소를 실제로 지원하고 싶었습니다. 그래서 장소별 약속시각(appointment_time)과 체류시간(duration_min), 출발 예정 시각(start_time)을 도입했습니다. `build_schedule`이 순서를 따라 시간축을 계산해서, 도착 시각은 직전 출발에 이동시간을 더해 구하고, 일찍 도착하면 약속시각까지 대기하며, 늦으면 위반으로 기록합니다. 위반한 순서에는 `APPOINTMENT_VIOLATION_PENALTY_SEC = 10,000,000`을 얹어 사실상 하드 제약으로 두어, 약속을 지키는 순서가 항상 먼저 선택되게 했습니다. `/api/route-eta`가 각 정거장의 도착·출발 시각과 위반 목록을 반환하고 프론트가 표시합니다. 가장 효율적인 순서만이 아니라 약속을 지킬 수 있는 순서가 실제 사용자 요구라고 봤기 때문입니다. -## B-7. 출발시각 '현재시간 기준' 체크박스 + 시간계산 생략 +## B-4. LLM 약속시각 자동추출 -**고민.** 출발시각을 안 고르면 **자동으로 현재 PC 시각을 강제**하던 로직 때문에, 저녁에 오후 약속을 -넣으면 "이미 지난 시각"이라 **지각으로 잘못 표시**됐다. 안 고른 것뿐인데 시각이 몰래 적용되는 게 혼란스럽다. +이전까지는 "3시 미팅"의 약속시각을 사용자가 직접 입력해야 했습니다. 이것을 LLM이 문장에서 자동으로 뽑게 하고 싶었습니다. -**선택.** 현재시각 적용을 **명시적 선택(체크박스)**으로 바꿨다. 상태는 셋: -- ① 체크 → 현재 PC 시각 기준(드롭다운 비활성화) -- ② 미체크 + 출발시각 선택 → 그 시각 -- ③ 미체크 + 미선택 → `start_time=null`(도착시각/지각 **계산 안 함**), 단 약속시각이 있으면 **시간순 정렬은 유지** +문제는 데이터였습니다. 기존 학습데이터 200개에는 `appointment_time` 라벨이 없었고, 그중 21개는 입력에 시각이 있는데 라벨이 없어 그대로 쓰면 시각을 무시하도록 학습될 상황이었습니다. 그래서 시각이 없는 179개는 `appointment_time: null`로 재활용하고, 시각이 있는 21개는 폐기했으며, `gemini-3.5-flash-lite`로 시각 포함 예시를 보충해 총 375개로 늘렸습니다. 비율도 정해서 튜닝했는데, 문장 기준으로 약 40%, 장소 기준으로 17%가 약속시각을 갖도록 했습니다. positive가 너무 적으면 추출을 배우지 못하고, 너무 많으면 시각이 없는데도 아무 시각이나 찍는 과잉추출로 기울기 때문입니다. -**③의 "시간순 정렬"(선택 이유).** 출발시각을 몰라 도착 계산은 못 해도, 약속시각을 굳이 입력했다면 -"이른 약속 → 늦은 약속" 순서는 의미가 있다. 그래서 백엔드에 `chronological_penalty_sec`를 추가해, -약속시각 있는 장소들끼리의 역순 쌍(inversion)에만 페널티를 준다(약속 0~1개면 무영향). +모델이 출력한 시각은 `parse_hhmm`과 `format_hhmm`으로 24시간제 `HH:MM`으로 정규화하고, "오후 3시"를 15:00으로, "10시 반"을 10:30으로 바꾸며 형식 오류는 버립니다. 프론트에서는 추출된 약속시각을 입력란에 자동으로 채우고 AI 자동입력 뱃지를 붙였으며(사용자가 수정하면 뱃지를 내림), 약속시각 입력을 출발시각과 같은 오전/오후·시·분 드롭다운으로 통일했습니다. -**왜.** "모르는 걸 아는 척하지 않는다" — 시스템이 임의로 현재시각을 밀어넣기보다, 사용자가 정보를 줄 때만 시간 계산을 한다. +어려웠던 점은 재파인튜닝이 GPU가 필요해 Colab에서만 가능했다는 것입니다. Colab 노트북의 GGUF 다운로드 셀 경로 버그와, Unsloth가 자동 생성하는 채팅형 Modelfile을 쓰면 raw passthrough 학습과 어긋나 성능이 떨어지는 함정을 피해야 했습니다. 프로젝트 자체의 Modelfile을 써서 해결했습니다. -## B-8. 출발시각 역산 — "몇 시에 나가야 하나?" · `feature/car-real-route` +## B-5. 경로 계산 전수 검증과 이동수단 재최적화 버그 -**고민.** B-7에서 ③(출발시각 미선택)일 때는 시간 계산을 안 하기로 했다. 하지만 약속시각을 입력했다면, -"그 약속을 지키려면 **몇 시에 나가야 하는지**"를 알려주는 게 진짜 도움이 된다. 이게 사용자와 합의한 2단계. +세 모드와 세 이동수단이 제대로 계산되는지를 눈으로만 확인하면 놓치는 것이 생긴다고 생각했습니다. 그래서 UI가 호출하는 백엔드 API를 그대로 구동하는 검증 하네스(`docs/verification/verify_routes.py`)를 만들었습니다. 10개 시나리오와 3 이동수단, 3 최적화 모드를 조합해 90건을 계산하고, 최단모드가 실제로 최소 이동시간인지, 우선순위 모드가 순서를 지키는지, 구간 합이 총합과 맞는지, 도보가 자동차보다 느린지, 약속 위반이 0인지 같은 불변식을 자동 검사했습니다. 결과는 168개 검사 전부 통과였고 리포트는 `docs/verification/verification-report.md`에 남겼습니다. -**선택 · 알고리즘.** 확정된 방문 순서에서 **약속을 모두 지킬 수 있는 '가장 늦은 출발 시각' D\***를 계산한다. -- 핵심 관찰: 출발이 늦어질수록 도착도 늦거나 같아지고(약속 대기는 지연만 시킴), 따라서 **약속 위반 개수는 - 출발 시각에 대해 단조 증가**한다. -- 그래서 "위반이 D=0에서의 최소치와 같은 최대 D"를 **이진탐색**으로 찾는다(`recommended_departure`). - - 모든 약속을 지킬 수 있으면(최소 위반 0) → 그 시각(`feasible=True`)이 "이때까지 나가면 안전"의 마지노선. - - 불가능하면(약속들이 서로 너무 빡빡) → 위반을 최소로 유지하는 가장 늦은 출발 시각(`feasible=False`). +검증하며 UI로 대조하다가 중요한 버그를 찾았습니다. 이동수단을 바꾸면 방문 순서를 재최적화하지 않고 이전 이동수단의 순서에 ETA만 다시 계산하는 프론트 버그였습니다. 잠실역 도보/AI가 UI에서는 자동차 순서 기준으로 1.5km인데 실제 도보 최적은 1.0km였습니다. 원인은 `handleTravelModeChange`가 `optimize-route`를 다시 부르지 않고 `fetchRealEta`만 호출했고, `calculateRoute` 캐시 키가 `mode`뿐이라 다시 눌러도 이전 순서를 반환한 것이었습니다. 이동수단을 바꿀 때 캐시를 비우고 새 이동수단으로 재최적화하도록 고쳤습니다. 직접 만든 검증 과정에서 스스로 버그를 찾아 고쳤다는 점에서 기록해 둘 만하다고 생각했습니다. -**선택 · 구조.** `route-eta`를 정리해, leg 이동시간으로 `time_matrix`를 만든 뒤 기존 `build_schedule`로 -시간축을 계산하도록 통일했다(도착/출발/종료·위반 계산의 중복 로직 제거). ③+약속이면 D\*를 유효 출발시각으로 -써서 스케줄까지 만들어 `recommended_start_time`과 함께 반환한다. +## B-6. 자동차 실제 도로 경로 표시 -**선택 · UX.** 결과 패널에 "🕒 추천 출발시각 13:50 → 종료 17:30"과 안내를 보여주고, -**"이 시각으로 설정"** 버튼으로 그 시각을 출발시각 드롭다운에 채워 확정할 수 있게 했다(③ → ②). +지도 경로가 방문 지점을 직선으로만 이어서 실제 도로를 반영하지 못했습니다. 자동차는 Kakao Mobility 응답에 이미 도로 좌표열(`sections[].roads[].vertexes`)이 들어오는데, 그동안 summary(거리·시간)만 쓰고 버리고 있었습니다. 이 좌표열을 `[[lat,lng], …]`로 추출해(`extract_car_path`) `route-eta`의 각 leg에 `path`로 실어 보냈습니다. 프론트는 path가 있으면 실제 경로 Polyline으로, 없으면 직선으로 그립니다. 자동차 거리와 시간은 이미 정확했기 때문에 거의 비용을 들이지 않고 시각적 완성도를 크게 올릴 수 있었습니다. 행렬 계산 경로에는 `include_path=False`로 좌표 파싱을 생략해 성능 영향도 없앴습니다. 도보와 대중교통 실제 경로는 별도 API가 필요해 다음 과제로 두었습니다. -**어려웠던 점 · 한계.** 약속이 여러 개면 **가장 빡빡한(binding) 약속 하나**가 D\*를 결정하고, 나머지는 -일찍 도착해 대기하거나(여유) 못 지킬 수도 있다(빡빡). "모든 약속을 항상 완벽히 만족하는 출발시각"이 -존재하지 않을 수 있으므로, 그 경우 위반 최소 기준으로 best-effort 추천하고 이를 UI로 알린다. +## B-7. 출발시각 현재시간 기준 체크박스 -*예: 서울역 출발, 광화문(14:00)·명동(17:00) → 추천 13:50(광화문 14:00 정각 도착이 binding, 명동은 일찍 도착해 대기).* +출발시각을 안 고르면 자동으로 현재 PC 시각을 강제하던 로직 때문에, 저녁에 오후 약속을 넣으면 이미 지난 시각이라 지각으로 잘못 표시됐습니다. 고르지 않은 것뿐인데 시각이 몰래 적용되는 것이 혼란스러웠습니다. 그래서 현재시각 적용을 명시적 선택(체크박스)으로 바꿨습니다. 상태는 셋으로, 체크하면 현재 PC 시각을 쓰고, 미체크에 출발시각을 고르면 그 시각을 쓰며, 미체크에 미선택이면 `start_time`을 null로 두어 도착시각과 지각을 계산하지 않습니다. -## B-9. 도보 실제 경로(Tmap) + 무료 쿼터를 고려한 호출 구조 · `feature/walk-real-route` +미선택이어도 약속시각을 굳이 입력했다면 이른 약속에서 늦은 약속으로 가는 순서는 의미가 있다고 봤습니다. 그래서 백엔드에 `chronological_penalty_sec`를 추가해, 약속시각이 있는 장소들끼리의 역순 쌍에만 페널티를 줍니다(약속이 0~1개면 영향이 없습니다). 시스템이 임의로 현재시각을 밀어넣기보다 사용자가 정보를 줄 때만 시간 계산을 하도록 한 것입니다. -**고민 1 — 도보 실경로.** 자동차는 실제 도로를 그리는데(B-6) 도보·대중교통은 여전히 직선+보정 추정이었다. -도보만이라도 **실제 인도 경로/거리/시간**을 쓰고 싶었다. +## B-8. 출발시각 역산 -**선택.** Tmap(SK) **보행자 경로안내 API**(`POST /tmap/routes/pedestrian`)를 연동했다. 응답은 GeoJSON이라 -첫 Point의 `totalDistance/totalTime`으로 거리·시간을, LineString 좌표열을 `[[lat,lng],…]`로 뒤집어 지도 경로로 -쓴다. 자동차와 **동일한 leg `path` 구조**로 실어, 프론트는 이동수단과 무관하게 그대로 그린다. 키가 없으면 -기존 추정(`fallback_leg`)으로 **안전 폴백**하고, `estimated` 플래그로 "추정치" 여부를 UI에 표시한다. +B-7에서 출발시각을 안 고르면 시간 계산을 안 하기로 했지만, 약속시각을 입력했다면 그 약속을 지키려면 몇 시에 나가야 하는지를 알려 주는 것이 진짜 도움이 된다고 생각했습니다. 그래서 확정된 방문 순서에서 약속을 모두 지킬 수 있는 가장 늦은 출발 시각을 계산했습니다. -**어려웠던 점 — 인증.** 상품 상세 페이지엔 키가 없어 헤맸다. Tmap은 **상품 구독 후 My Page의 App에 붙는 appKey**를 -요청 헤더에 넣는 방식(정적 헤더, OAuth 아님)이라, 구독을 안 하면 게이트웨이가 `403 INVALID_API_KEY`로 막는다. -서버측 호출이라 도메인 제한이 없는 것도 Kakao JS 키와 다른 점. +핵심 관찰은 출발이 늦어질수록 도착도 늦거나 같아지고(약속 대기는 지연만 시키므로), 따라서 약속 위반 개수가 출발 시각에 대해 단조 증가한다는 것이었습니다. 그래서 위반이 D=0에서의 최소치와 같아지는 가장 늦은 출발 시각을 이진탐색으로 찾았습니다(`recommended_departure`). 모든 약속을 지킬 수 있으면 그 시각이 이때까지 나가면 안전한 마지노선이 되고, 지킬 수 없으면 위반을 최소로 유지하는 가장 늦은 시각을 알려 줍니다. -**고민 2 — 무료 쿼터가 너무 적다.** Tmap 대중교통은 **일 10회**, 도보도 유한하다. 그런데 순서 최적화는 -**n×n 거리 행렬**을 만드느라 실 API를 `n(n-1)`회 부른다 — 4곳이면 15회로, 대중교통은 **데모 1회도 완주 불가**. +구조도 정리해서, leg 이동시간으로 `time_matrix`를 만든 뒤 기존 `build_schedule`로 시간축을 계산하도록 통일했습니다(도착·출발·위반 계산의 중복 로직을 없앴습니다). 결과 패널에는 추천 출발시각과 종료 시각을 보여 주고, 그 시각을 출발시각 드롭다운에 채워 확정하는 버튼을 뒀습니다. -**선택 · 호출 구조 재설계(핵심).** "실 API의 가치가 어디에 있나"를 다시 봤다. -- **방문 순서 최적화**에는 정확한 실측이 굳이 필요 없다(2~5곳이면 직선 추정만으로도 순서가 실제와 거의 동일). -- 실측이 진짜 필요한 곳은 **① 지도에 그릴 최종 경로**와 **② 사용자에게 보여줄 정확한 총거리/시간**뿐이고, - 이 둘은 모두 **확정된 최종 경로**에서만 쓰인다. +한계도 있습니다. 약속이 여러 개면 가장 빡빡한 약속 하나가 추천 시각을 결정하고 나머지는 일찍 도착해 대기하거나 못 지킬 수도 있습니다. 모든 약속을 완벽히 만족하는 출발시각이 존재하지 않을 수 있어, 그 경우 위반을 최소로 하는 시각을 추천하고 이를 UI로 알립니다. 예를 들어 서울역에서 출발해 광화문(14:00)과 명동(17:00)을 갈 때는 광화문 정각 도착이 기준이 되어 13:50을 추천하고, 명동은 일찍 도착해 대기합니다. -그래서 **거리 행렬은 추정치로만**(`build_time_distance_matrix`가 실 API 미호출), **실 API는 최종 경로 -(`route-eta`)의 `n-1`개 구간에만** 쓰도록 바꿨다. 결과적으로 최적화 1회당 실 호출이 -**3→1(2곳)·8→2(3곳)·24→4(5곳)** 로 줄었다. +## B-9. 도보 실제 경로와 무료 쿼터를 고려한 호출 구조 -**선택 · 캐시.** 여기에 **leg 캐시**(`get_final_leg`)를 얹어, 같은 좌표쌍+이동수단의 실측 결과를 재사용한다. -이동수단 토글이나 동일 장소 재최적화 시 **추가 호출 0**. 상시 가동을 대비해 **LRU 크기상한(512)** 과, -실시간성이 있는 **자동차·대중교통엔 TTL(600s)** 을 뒀다(도보는 거리 불변이라 무기한). **실좌표열(path)을 받은 -실측만 캐시**하고, 실패·키없음으로 폴백된 결과는 캐시하지 않아 이후 실측 기회를 막지 않는다. +자동차는 실제 도로를 그리는데 도보와 대중교통은 여전히 직선 추정이었습니다. 도보만이라도 실제 인도 경로와 거리, 시간을 쓰고 싶어서 Tmap 보행자 경로안내 API를 연동했습니다. 응답이 GeoJSON이라 첫 Point의 `totalDistance`와 `totalTime`으로 거리·시간을 얻고, LineString 좌표열을 `[[lat,lng], …]`로 뒤집어 지도 경로로 씁니다. 자동차와 같은 leg `path` 구조로 실어서 프론트는 이동수단과 무관하게 그대로 그립니다. 키가 없으면 기존 추정으로 안전하게 폴백하고, `estimated` 플래그로 추정치 여부를 UI에 표시합니다. -**왜.** "정확도가 필요한 지점"과 "대충 해도 되는 지점"을 분리한 전형적 트레이드오프다. 데모 UX(그려지는 경로, -보이는 숫자)는 실측 그대로 유지하면서, 희소한 무료 쿼터를 태우는 O(n²) 내부 계산만 추정으로 돌렸다. +인증에서 헤맸습니다. 상품 상세 페이지에는 키가 없었고, Tmap은 상품을 구독한 뒤 My Page의 App에 붙는 appKey를 요청 헤더에 넣는 방식이라, 구독하지 않으면 게이트웨이가 `403 INVALID_API_KEY`로 막았습니다. 서버측 호출이라 도메인 제한이 없다는 점도 Kakao JS 키와 달랐습니다. -**남은 것.** 대중교통 실경로는 Tmap 10회/일로는 부족해 ODsay로 붙였다(→ B-10). -배포 시엔 `.env`가 아닌 **서버 환경변수로 API 키 주입**, 공개 데모라면 방문자수만큼 곱해지는 쿼터를 -위 캐시·추정 구조와 레이트리밋으로 방어한다. +더 큰 고민은 무료 쿼터가 너무 적다는 것이었습니다. Tmap 대중교통은 하루 10회, 도보도 유한한데, 순서 최적화는 n×n 거리 행렬을 만드느라 실 API를 `n(n-1)`회 부릅니다. 4곳이면 15회라 대중교통은 데모를 한 번도 완주할 수 없었습니다. 그래서 실 API의 가치가 어디에 있는지를 다시 봤습니다. 방문 순서 최적화에는 정확한 실측이 굳이 필요 없고(2~5곳이면 직선 추정만으로도 순서가 실제와 거의 같습니다), 실측이 진짜 필요한 곳은 지도에 그릴 최종 경로와 사용자에게 보여 줄 정확한 총거리·시간뿐이며, 이 둘은 모두 확정된 최종 경로에서만 쓰입니다. 그래서 거리 행렬은 추정치로만 계산하고(`build_time_distance_matrix`가 실 API를 부르지 않음), 실 API는 최종 경로(`route-eta`)의 `n-1`개 구간에만 쓰도록 바꿨습니다. 그 결과 최적화 한 번당 실 호출이 2곳이면 3회에서 1회로, 3곳이면 8회에서 2회로, 5곳이면 24회에서 4회로 줄었습니다. -## B-10. 대중교통 실제 경로(ODsay) + "엔드포인트 이름" 삽질 · `feature/transit-real-route` +여기에 leg 캐시(`get_final_leg`)를 얹어 같은 좌표쌍과 이동수단의 실측 결과를 재사용했습니다. 이동수단을 토글하거나 같은 장소를 다시 최적화해도 추가 호출이 없습니다. 상시 가동을 대비해 크기 상한(LRU 512)을 두고, 실시간성이 있는 자동차와 대중교통에는 TTL(600초)을 뒀습니다(도보는 거리가 변하지 않아 무기한). 실제 좌표열을 받은 실측만 캐시하고, 실패하거나 키가 없어 폴백된 결과는 캐시하지 않아 이후 실측 기회를 막지 않도록 했습니다. 정확도가 필요한 지점과 대충 해도 되는 지점을 분리한 트레이드오프입니다. 데모에서 보이는 경로와 숫자는 실측 그대로 유지하면서, 희소한 무료 쿼터를 태우던 O(n²) 내부 계산만 추정으로 돌린 것입니다. -**고민.** 남은 마지막 이동수단인 대중교통을 실경로로. Tmap 대중교통은 **무료 10회/일**이라 데모조차 어렵다. +## B-10. 대중교통 실제 경로(ODsay)와 엔드포인트 이름 삽질 -**선택.** **ODsay 대중교통 길찾기 API**를 골랐다. 응답 `result.path[0]`에서 `info.totalTime`(분)으로 시간, -`subPath[].distance` 합으로 거리, 각 subPath의 `passStopList.stations[]`(x=경도, y=위도)를 이어 실제 경로 -폴리라인을 만든다. 도보(Tmap)·자동차와 **동일한 leg `path` 구조**로 실어 프론트는 그대로 그린다. -- **쿼터 절약 선택**: ODsay는 정확한 도로 선형을 얻으려면 `loadLane`를 추가 호출해야 하는데, 그러면 leg당 2회가 - 된다. 데모엔 **역/정류장 좌표를 잇는 폴리라인으로 충분**하다고 보고 `loadLane`를 생략해 **leg당 1회**로 묶었다. -- 키가 없거나 실패(예: 출발-도착이 너무 가까움 → 도보 권장)하면 기존 추정으로 **안전 폴백**. +남은 마지막 이동수단인 대중교통을 실제 경로로 그리고 싶었습니다. Tmap 대중교통은 무료 10회/일이라 데모조차 어려워서 ODsay 대중교통 길찾기 API를 골랐습니다. 응답 `result.path[0]`에서 `info.totalTime`으로 시간을, `subPath[].distance` 합으로 거리를 얻고, 각 subPath의 `passStopList.stations[]`(x=경도, y=위도)를 이어 실제 경로 폴리라인을 만듭니다. 도보·자동차와 같은 leg `path` 구조로 실어서 프론트는 그대로 그립니다. ODsay는 정확한 도로 선형을 얻으려면 `loadLane`를 추가로 불러야 하는데 그러면 leg당 2회가 되므로, 데모에는 역과 정류장 좌표를 잇는 폴리라인으로 충분하다고 보고 `loadLane`를 생략해 leg당 1회로 묶었습니다. 키가 없거나 실패하면 기존 추정으로 폴백합니다. -**어려웠던 점 · 두 번의 함정.** -1. **인증 방식 확인**: ODsay는 서버 호출용 **'서버' 키 + 공인 IP 등록**이 필요하다(브라우저용 URI 키는 Referer로 - 막혀 서버에선 못 씀). 로컬 개발기 egress IP를 등록해야 했다. -2. **엔드포인트 이름**: 처음엔 흔히 알려진 `searchPubPathT`로 붙였는데 계속 `code 500 "페이지를 찾을 수 없습니다"`가 - 떴다. IP·키·인코딩을 다 의심했지만, **apiKey 없이/일부러 틀린 주소로 호출해도 똑같은 메시지**가 나오는 걸 보고 - "이 메시지는 범용 에러구나"를 깨달았다. 공식 레퍼런스로 확인하니 정확한 이름은 **`searchPubTransPathT`**('Trans' - 포함)였다. "page not found"가 말 그대로 **없는 엔드포인트**를 가리킨 것. +두 번 헤맸습니다. 하나는 인증인데, ODsay는 서버 호출용 '서버' 키와 공인 IP 등록이 필요했습니다. 브라우저용 URI 키는 Referer로 막혀 서버에서는 쓸 수 없어서, 로컬 개발기의 egress IP를 등록해야 했습니다. 다른 하나는 엔드포인트 이름이었습니다. 흔히 알려진 `searchPubPathT`로 붙였는데 계속 `code 500 "페이지를 찾을 수 없습니다"`가 떴습니다. IP와 키, 인코딩을 다 의심했지만, apiKey 없이 부르거나 일부러 틀린 주소로 불러도 같은 메시지가 나오는 것을 보고 이 메시지가 범용 에러라는 것을 알았습니다. 공식 레퍼런스로 확인하니 정확한 이름은 `searchPubTransPathT`('Trans' 포함)였습니다. "page not found"가 말 그대로 없는 엔드포인트를 가리킨 것이었습니다. -**왜 이 접근이 맞았나.** 무료 30/일이라는 극한 쿼터에서도, 이미 만들어 둔 **행렬=추정 / 최종경로만 실측 + 캐시** -(B-9) 구조 덕분에 최적화 1회당 대중교통 호출이 `n-1`회로 유지된다(2곳 1회, 5곳 4회). 새 이동수단을 붙일 때 -호출 구조를 다시 손댈 필요가 없었다. +이미 만들어 둔 행렬은 추정, 최종 경로만 실측, 캐시 구조(B-9) 덕분에, 무료 30회/일이라는 극한 쿼터에서도 최적화 한 번당 대중교통 호출이 `n-1`회로 유지됩니다(2곳 1회, 5곳 4회). 새 이동수단을 붙일 때 호출 구조를 다시 손댈 필요가 없었습니다. 서울역에서 강남역까지 실제로 불러 보니 33분에 15.2km, subPath 5개(도보와 지하철 혼합), 경로 좌표 13점이 나왔고, `route-eta`에서 `estimated=False`와 2차 호출 캐시 히트를 확인했습니다. ODsay 무료는 30회/일이라 개인 데모용이고, 공개 라이브 데모는 유료 플랜이거나 대중교통을 추정으로 두는 선택이 필요합니다. -**검증.** 서울역→강남역 실 ODsay 200(33분/15.2km, subPath 5개=도보·지하철 혼합, 경로 좌표 13점), `route-eta`에서 -`estimated=False`·2차 호출 캐시 히트. 오프라인으로 파싱·폴백·에러흡수도 확인. +## B-11. 배포: AWS는 준비만 하고 라이브는 Cloudflare Tunnel로 -**남은 것 · 한계.** ODsay 무료는 **30회/일**이라 개인 데모용이다. 공개 라이브 데모는 유료 플랜이거나 대중교통을 -추정으로 두는 선택이 필요하다. 배포 시 `ODSAY_API_KEY`는 서버 환경변수로 주입하고, 서버 공인 IP를 ODsay에 등록해야 한다. +라이브: https://wtg-mate.vercel.app -## B-11. 배포 — AWS "준비 완료"와 Cloudflare Tunnel 라이브의 분리 · 🔴 라이브: https://wtg-mate.vercel.app +포트폴리오를 실제 접속 가능한 URL로 올리고 싶었지만, 이 앱의 백엔드는 로컬 파인튜닝 3B 모델(Ollama)을 씁니다. 이 모델이 RAM 2~3GB를 요구해 무료나 저가 호스팅에는 들어가지 않고, AWS로 가면 t3.medium 상시 가동으로 월 30달러 정도가 듭니다. -**고민.** 포트폴리오를 실제 접속 가능한 URL로 올리고 싶은데, 이 앱의 백엔드는 **로컬 파인튜닝 3B 모델(Ollama)** 을 -쓴다. 이게 RAM 2~3GB를 요구해, 무료/저가 호스팅에는 안 들어가고 AWS로 가면 t3.medium 상시 가동 **월 ~$30** 이다. +그래서 두 갈래로 나눴습니다. 먼저 AWS는 프로비저닝만 하면 바로 뜨는 상태로 완비만 했습니다. 실제로 과금하며 운영하는 대신 배포 산출물을 전부 갖췄습니다. `backend/Dockerfile`과 `docker-compose.yml`(ollama, 부팅 시 `ollama create`를 자동으로 하는 model-loader, backend 세 서비스), `deploy/nginx-wtgmate.conf`(정적 프론트와 `/api` 프록시, certbot), 로컬 Ollama에서 복구한 `backend/finetune/Modelfile`을 만들었고, GitHub Actions로 백엔드 이미지를 실제로 빌드해 언제나 빌드 가능하다는 것을 CI로 보증했습니다. -**선택 1 — AWS는 "올리면 바로 뜨는" 상태로 완비만 한다.** 실제 과금 운영 대신, 배포 산출물을 전부 갖춰 -"프로비저닝만 하면 기동"인 상태로 만들었다. `backend/Dockerfile` + `docker-compose.yml`(ollama + -`model-loader`(부팅 시 `ollama create` 자동) + backend 3서비스) + `deploy/nginx-wtgmate.conf`(정적 프론트 + -`/api` 프록시 + certbot) + 로컬 Ollama에서 복구한 `backend/finetune/Modelfile`. 나아가 **GitHub Actions로 백엔드 -이미지를 실제 빌드**(`docker-build` 워크플로)해 "언제나 빌드 가능"을 CI로 보증했다(레포에 초록 체크). +실제 라이브는 비용이 들지 않는 Cloudflare Tunnel과 Vercel로 운영했습니다. 백엔드와 Ollama가 이미 개발 PC에서 잘 돌고, 모든 API 키(Kakao·Tmap·ODsay)가 그 PC의 IP로 이미 인증되어 있다는 점을 활용했습니다. EC2에 모델을 옮기는 대신, 개발 PC의 FastAPI(8000)를 Cloudflare Tunnel로 HTTPS 노출하고 정적 프론트만 Vercel에 올렸습니다. -**선택 2 — 실제 라이브는 비용 0의 Cloudflare Tunnel + Vercel.** 핵심 통찰: **백엔드/Ollama가 이미 개발 PC에서 -잘 돌고, 모든 API 키(Kakao·Tmap·ODsay)가 그 PC의 IP로 이미 인증**된다. 그래서 EC2에 모델을 옮기는 대신, -개발 PC의 FastAPI(:8000)를 **Cloudflare Tunnel**로 HTTPS 노출하고, 정적 프론트만 **Vercel**(무료·HTTPS)에 올렸다. ``` 브라우저 ─https─▶ Vercel(프론트) ─/api─https─▶ Cloudflare Tunnel ─▶ 개발 PC: FastAPI + Ollama ``` -배포를 위해 코드도 손봤다(Phase 1): 프론트 API 주소를 `VITE_API_BASE_URL` env로, 백엔드 CORS를 `ALLOWED_ORIGINS` -env로 빼고(자격증명 미사용이라 `allow_credentials=False`), `requirements.txt`·`.env.example`을 추가했다. - -**왜 이 분리가 맞나.** "AWS 배포를 설계·완비할 수 있다"는 역량 증명(산출물+Docker CI)과 "실제로 접속되는 데모"를 -**둘 다** 얻으면서, 상시 과금은 피했다. 트레이드오프는 명확하다 — Cloudflare quick tunnel URL은 재시작마다 바뀌고, -데모는 PC를 켜둔 동안만 라이브다(면접 전 `deploy/run-demo.ps1` 실행). -**어려웠던 점 · Kakao "JavaScript SDK 도메인" 함정.** Vercel 배포 후 프론트·API는 다 되는데 **지도만 404**로 안 -떴다. 키는 로컬과 동일(정상), 백엔드 파싱·지오코딩도 라이브로 동작하는데 지도만 죽었다. 원인은 Kakao 콘솔에서 -**일반 "Web 플랫폼 사이트 도메인"과 별개로 "JavaScript SDK 도메인"을 따로** 등록해야 한다는 점(둘이 다른 설정). -후자에 `https://wtg-mate.vercel.app`을 넣으니 해결됐다. 또 Vercel은 `VITE_` 접두 변수를 Secret으로 저장하려다 -막는데, 이 값들은 원래 브라우저 공개(도메인으로 보호)라 **Config 타입**으로 저장해야 빌드에 주입된다. +배포를 위해 코드도 손봤습니다. 프론트 API 주소를 `VITE_API_BASE_URL` env로, 백엔드 CORS를 `ALLOWED_ORIGINS` env로 빼고(자격증명을 쓰지 않아 `allow_credentials=False`), `requirements.txt`와 `.env.example`을 추가했습니다. 이렇게 나누니 AWS 배포를 설계·완비할 수 있다는 것과 실제로 접속되는 데모를 둘 다 얻으면서 상시 과금은 피할 수 있었습니다. 다만 Cloudflare quick tunnel URL은 재시작마다 바뀌고, 데모는 PC를 켜 둔 동안만 라이브라는 한계가 있습니다. -**검증.** 라이브 사이트에서 자연어 일정 → 장소 4개 추출(약속시각 포함) → 지도 렌더·마커·경로, Kakao 후보검색까지 -전 과정 동작 확인. 백엔드 3키(kakao/tmap/odsay) 모두 `configured`. +배포하며 겪은 함정은 Kakao의 JavaScript SDK 도메인이었습니다. Vercel에 올린 뒤 프론트와 API는 다 되는데 지도만 404로 뜨지 않았습니다. 키는 로컬과 같고 백엔드 파싱과 지오코딩도 라이브로 동작하는데 지도만 죽었습니다. 원인은 Kakao 콘솔에서 일반 Web 플랫폼 사이트 도메인과 별개로 JavaScript SDK 도메인을 따로 등록해야 한다는 점이었습니다(둘이 다른 설정입니다). 후자에 `https://wtg-mate.vercel.app`을 넣으니 해결됐습니다. Vercel이 `VITE_` 접두 변수를 Secret으로 저장하려다 막는 것도 있었는데, 이 값들은 원래 브라우저에 공개되고 도메인으로 보호되므로 Config 타입으로 저장해야 빌드에 주입됩니다. 라이브 사이트에서 자연어 일정으로 장소 4개를 추출하고 지도와 마커, 경로, Kakao 후보 검색까지 동작하는 것을 확인했습니다. --- -# Part C. 배운 것 · 면접 스토리 · 로드맵 +# Part C. 배운 것과 로드맵 -## C-1. 개발 관점의 변화 -- 기능을 추가하기 전에 그 기능의 **목적함수**를 먼저 정의해야 한다. -- AI를 쓴다고 AI의 모든 출력을 신뢰하면 안 된다(좌표는 지도 API로 검증). -- 외부 API는 성공뿐 아니라 **실패·fallback**까지 설계해야 한다. -- 여러 상태가 얽힌 React 화면은 데이터의 **생명주기와 무효화 조건**을 명확히 해야 한다. -- 사용자가 지정한 값은 시스템이 임의로 재해석하지 않는다(기능 이름 = 알고리즘 목적). -- **직접 검증 하네스를 만들면** 눈으로 못 보던 버그(이동수단 재최적화 누락)를 잡을 수 있다. +## C-1. 개발하며 바뀐 관점 -## C-2. 면접에서 쓸 핵심 스토리 -- **Q. 가장 어려웠던 문제는?** 경로 최적화 자체보다 *서로 다른 목적의 경로를 어떻게 분리 설계할지*였다. - 초기엔 세 모드가 비슷한 비용으로 계산돼 결과가 같아졌다. 각 모드의 목적을 명확히 정의하고, - 우선순위 모드는 계층적 최적화로, AI 모드는 순번 가중 페널티로 실제로 다르게 만들었다. -- **Q. 왜 생성형 AI 결과를 바로 안 쓰고 Kakao로 검증?** 모델은 의미(장소명)를 뽑지만 실제 좌표를 보장하지 않는다. 그래서 의미 분석은 LLM, 지도 데이터는 Kakao로 역할을 분리했다. -- **Q. 왜 Gemini에서 로컬 모델로?** 비용·쿼터·외부 의존을 없애고 오프라인 데모가 가능하도록, 좁은 태스크를 소형 모델 파인튜닝으로 대체했다. -- **Q. 경로 결과가 같은데 버그 아닌가?** 방문 순서가 같으면 거리·시간이 같은 게 정상. 그래서 *먼저 순서를 비교하고*, 순서가 다른데 수치가 같을 때 ETA/캐시를 의심하도록 디버깅 기준을 세웠다. +기능을 추가하기 전에 그 기능의 목적함수를 먼저 정의해야 한다는 것을 배웠습니다. AI를 쓴다고 모든 출력을 신뢰하면 안 되고(좌표는 지도 API로 검증했습니다), 외부 API는 성공뿐 아니라 실패와 폴백까지 설계해야 합니다. 여러 상태가 얽힌 React 화면은 데이터의 생명주기와 무효화 조건을 명확히 해야 하고, 사용자가 지정한 값은 시스템이 임의로 재해석하면 안 됩니다. 직접 검증 하네스를 만들면 눈으로는 못 보던 버그도 잡을 수 있다는 것도 확인했습니다. + +## C-2. 면접에서 이야기할 핵심 + +가장 어려웠던 문제는 경로 최적화 자체보다 서로 다른 목적의 경로를 어떻게 분리해 설계할지였습니다. 초기에는 세 모드가 비슷한 비용으로 계산돼 결과가 같아졌는데, 각 모드의 목적을 명확히 정의하고 우선순위 모드는 계층적 최적화로, AI 모드는 순번 가중 페널티로 실제로 다르게 만들었습니다. 생성형 AI 결과를 바로 쓰지 않고 Kakao로 검증한 이유는 모델이 장소의 의미는 뽑아도 실제 좌표를 보장하지는 않기 때문입니다. 그래서 의미 분석은 LLM, 지도 데이터는 Kakao로 역할을 나눴습니다. Gemini에서 로컬 모델로 바꾼 것은 비용과 쿼터, 외부 의존을 없애고 오프라인 데모가 가능하도록 좁은 태스크를 소형 모델 파인튜닝으로 대체한 것입니다. 경로 결과가 같아 보여도 방문 순서가 같으면 거리와 시간이 같은 것이 정상이라, 먼저 순서를 비교하고 순서가 다른데 수치가 같을 때 ETA나 캐시를 의심하도록 디버깅 기준을 세웠습니다. ## C-3. 앞으로의 로드맵 -- [x] 내 우선순위: 계층적 최적화로 우선순위 최우선 보장 — **완료(B-2)** -- [x] 시간 약속: time window 제약 모델링 — **완료(B-3)** / LLM 자동추출 — **완료(B-4)** -- [x] AI 추천: 우선순위와 구별되는 별도 점수 — **부분 완료(B-2, 순번 가중)**. 향후 일정 의미·시간제약을 더 반영 가능 -- [x] **출발시각 역산**: 약속시각이 있으면 "몇 시에 나가야 하는지"를 역산해 추천 출발시각 표시 — **완료(B-8)** -- [x] **도보 실제 경로 API**(Tmap 보행자) 연동 — **완료(B-9)** -- [x] **대중교통 실제 경로 API**(ODsay) 연동 — **완료(B-10)** / 무료 30회/일이라 개인 데모용, 공개 배포는 유료/추정 선택 필요 + +- [x] 내 우선순위 계층적 최적화 (B-2) +- [x] 시간 약속 제약 모델링 (B-3) 및 LLM 자동추출 (B-4) +- [x] AI 추천을 우선순위와 구별되는 별도 점수로 (B-2) +- [x] 출발시각 역산 (B-8) +- [x] 도보 실제 경로 API 연동 (B-9) +- [x] 대중교통 실제 경로 API 연동 (B-10) +- [x] 성능·쿼터 최적화(실 API 호출을 O(n²)에서 n-1로 줄이고 leg 캐시 적용) (B-9) +- [x] 배포(AWS 산출물과 Docker CI + 실제 라이브 Cloudflare Tunnel·Vercel) (B-11) +- [x] solver·스케줄·캐시 단위 테스트와 CI - [ ] 경로별 총시간·총거리·우선순위 만족도 동시 표시, 지도 구간 정보 강화 -- [x] 성능·쿼터: 실 API 호출 O(n²)→n-1 축소 + leg 캐시(LRU·TTL) — **완료(B-9)** / 병렬화는 향후 -- [x] **배포**: AWS 산출물+Docker CI 완비 + **실제 라이브(Cloudflare Tunnel+Vercel)** — **완료(B-11)**, https://wtg-mate.vercel.app -- [~] 테스트: 순수 헬퍼(clamp_priority·importance_score·haversine) 유닛테스트 12개 추가(**진행 중**, `backend/tests/test_helpers.py`). 여기서 `clamp_priority(0)`이 `value or 3` 때문에 3이 되던 버그도 발견·수정(출발지 priority=0에 영향). 향후 우선순위 역전·API 실패·후보 변경 등 케이스로 확대. +- [ ] 대중교통 호출량 병렬화 --- -*문서 관리: 기능이 바뀌면 이 파일을 함께 수정하고, 상단 "0. 업데이트 이력"에 한 줄 추가한다.* -*포트폴리오 제출용 Word가 필요하면 이 Markdown에서 변환해 내보낸다.* +문서 관리: 기능이 바뀌면 이 파일도 함께 수정하고, 상단 업데이트 이력에 한 줄 추가합니다. diff --git a/README.md b/README.md index 86e1c7f..0109af6 100644 --- a/README.md +++ b/README.md @@ -1,49 +1,29 @@ -# WTGMate — Smart Route Planner +# WTGMate -자연어로 적은 하루 일정을 **방문 장소로 추출·검증**하고, **우선순위·약속시각·이동수단**을 반영해 -**경로를 최적화·비교**하고 지도에 **실제 경로**로 그려주는 웹앱. +Smart Route Planner. 하루 일정을 문장으로 적으면 방문할 장소를 뽑아 확인하고, 우선순위와 약속 시각, 이동수단을 반영해 방문 순서를 정해 주는 웹앱입니다. +[![ci](https://github.com/ysb2152/WTGMate/actions/workflows/ci.yml/badge.svg)](https://github.com/ysb2152/WTGMate/actions/workflows/ci.yml) [![docker-build](https://github.com/ysb2152/WTGMate/actions/workflows/docker-build.yml/badge.svg)](https://github.com/ysb2152/WTGMate/actions/workflows/docker-build.yml) -> 🔴 **라이브 데모: https://wtg-mate.vercel.app** -> 프론트는 Vercel에 상시 배포돼 있고, 백엔드/LLM은 비용 절감을 위해 개발 PC에서 Cloudflare Tunnel로 -> 노출한다(데모 실행 중일 때만 백엔드 기능 동작). 설계·운영 방식은 [DEPLOY.md](DEPLOY.md) · -> [deploy/CLOUDFLARE.md](deploy/CLOUDFLARE.md) 참고. +라이브 데모: https://wtg-mate.vercel.app -![WTGMate — 자연어 일정으로 계산한 AI 추천 경로](docs/screenshot.png) +프론트엔드는 Vercel에 올려 두었습니다. 백엔드와 로컬 LLM은 비용을 줄이기 위해 개발용 PC에서 Cloudflare Tunnel로 띄우기 때문에, 데모를 실행 중일 때만 장소 추출과 경로 계산이 동작합니다. 배포 방식은 [DEPLOY.md](DEPLOY.md)와 [deploy/CLOUDFLARE.md](deploy/CLOUDFLARE.md)에 정리했습니다. ---- +![WTGMate 실행 화면](docs/screenshot.png) -## 무엇을 푸는가 +## 만들게 된 이유 -- 여러 곳을 들르는 하루 일정에서 **방문 순서를 사람이 직접 고민**해야 하는 불편함 -- "가장 가까운 곳부터"가 항상 좋은 게 아니다 — **일정의 중요도**와 **약속 시각**을 반영해야 한다 -- **AI 추천 / 최단시간 / 내 우선순위** 경로를 나란히 **비교**하고 싶다 +여러 곳을 들르는 하루 일정에서 방문 순서를 매번 직접 고민하는 것이 번거로웠습니다. 가장 가까운 곳부터 도는 방법은 일정의 중요도나 약속 시각을 반영하지 못한다고 생각했습니다. 그래서 일정을 문장으로 입력하면 장소를 뽑아 주고, 중요도와 약속 시각을 함께 고려해 방문 순서를 정해 주는 도구를 만들었습니다. -## 핵심 기능 +## 주요 기능 -- **자연어 → 장소 추출**: "오후 2시 광화문에서 회의, 명동에서 점심, 강남역에서 저녁" → 장소·할일·중요도·약속시각 추출 - - **로컬 파인튜닝 LLM**이 의미를 뽑고, 좌표는 **Kakao Local**로 다시 조회해 검증(모델의 좌표를 믿지 않음) -- **세 가지 최적화 모드**(각각 목적함수가 실제로 다름) - - `최단시간`: 순수 이동시간 최소화 - - `내 우선순위`: 중요도 그룹 순서를 절대 기준으로 강제(그룹 내에서만 이동시간 최소) — DP로 정확 계산 - - `AI 추천`: 이동시간 + (중요도 × 방문순번) 페널티 균형 -- **시간 제약 스케줄링**: 약속시각(자동추출)·체류시간 기반 도착시각 계산, 지각 경고, **"몇 시에 나가야 하나" 역산 추천** -- **실제 경로 표시**(직선이 아닌 실도로/인도/대중교통) - - 자동차 = Kakao Mobility · 도보 = Tmap 보행자 · 대중교통 = ODsay - - 무료 쿼터가 적어, **순서 최적화는 직선거리 추정으로, 실 API는 확정된 최종 경로에만**(n-1회) 호출 + leg 캐시(LRU·TTL) +**자연어에서 장소 추출.** "오후 2시 광화문에서 회의, 명동에서 점심, 강남역에서 저녁" 같은 문장에서 장소와 할 일, 중요도, 약속 시각을 뽑아냅니다. 장소의 의미는 로컬에서 파인튜닝한 LLM이 추출하고, 모델이 답한 좌표는 신뢰하지 않고 Kakao Local로 다시 조회해 확인합니다. -## 아키텍처 +**세 가지 최적화 모드.** 최단시간은 이동시간만 최소화합니다. 내 우선순위는 중요한 그룹을 반드시 먼저 방문하고 같은 그룹 안에서만 이동시간을 줄입니다. AI 추천은 이동시간과 우선순위를 함께 반영합니다. -``` -브라우저 ──▶ React + Vite (Kakao Maps JS SDK) - │ /api - ▼ - FastAPI (Python) - ├─ 장소 추출: 로컬 파인튜닝 LLM (Ollama) → 좌표는 Kakao Local로 검증 - ├─ 최적화: 완전탐색(≤9곳) / OR-Tools(그 이상) - └─ 실제 경로/시간: Kakao Mobility(자동차) · Tmap(도보) · ODsay(대중교통) -``` +**시간 제약.** 약속 시각과 체류 시간으로 각 장소의 도착 시각을 계산하고 지각을 알립니다. 출발 시각 없이 약속만 입력하면, 약속을 지킬 수 있는 가장 늦은 출발 시각을 역산해 알려 줍니다. + +**실제 경로 표시.** 직선이 아니라 실제 도로와 인도, 대중교통 경로로 지도에 그립니다. 자동차는 Kakao Mobility, 도보는 Tmap, 대중교통은 ODsay를 사용했습니다. 무료 호출 한도가 적기 때문에, 방문 순서를 정할 때는 직선거리 추정으로 계산하고 실제 API는 확정된 최종 경로에만 호출한 뒤 결과를 캐시했습니다. ## 기술 스택 @@ -51,37 +31,40 @@ |---|---| | Frontend | React, Vite, Kakao Maps JS SDK | | Backend | FastAPI, OR-Tools | -| LLM | Qwen2.5-3B QLoRA 파인튜닝 → GGUF → Ollama(`wtgmate-parser`) | +| LLM | Qwen2.5-3B QLoRA 파인튜닝, GGUF로 변환해 Ollama에 등록(`wtgmate-parser`) | | 지도/경로 API | Kakao Local·Mobility, Tmap 보행자, ODsay 대중교통 | -| 배포 | Docker / docker-compose, nginx (AWS 준비 완료) · Cloudflare Tunnel + Vercel (라이브) | +| 배포 | Docker, docker-compose, nginx(AWS 준비), Cloudflare Tunnel + Vercel(라이브) | ## 로컬 실행 -**백엔드** +백엔드는 다음과 같이 실행합니다. + ```bash cd backend -python -m venv venv && venv\Scripts\pip install -r requirements.txt # (Windows) -cp .env.example .env # 키 채우기 (KAKAO_REST_API_KEY 필수, TMAP/ODSAY는 선택) +python -m venv venv && venv\Scripts\pip install -r requirements.txt # Windows +copy .env.example .env # 키 입력 (KAKAO_REST_API_KEY 필수, TMAP/ODSAY는 선택) venv\Scripts\python -m uvicorn main:app --port 8000 ``` -LLM(장소 추출)을 쓰려면 [Ollama](https://ollama.com) 설치 후 GGUF로 모델 등록: -`ollama create wtgmate-parser -f backend/finetune/Modelfile` (GGUF는 Colab 노트북으로 생성 — git 제외) -**프론트엔드** (Kakao JS 키 도메인 때문에 5173 포트) +장소 추출을 쓰려면 [Ollama](https://ollama.com)를 설치하고 파인튜닝한 GGUF로 모델을 등록합니다. GGUF는 용량이 커서 저장소에 포함하지 않았고 Colab 노트북으로 생성합니다. + +```bash +ollama create wtgmate-parser -f backend/finetune/Modelfile +``` + +프론트엔드는 Kakao JS 키의 도메인 때문에 5173 포트에서 실행합니다. + ```bash cd frontend npm install -cp .env.example .env # VITE_KAKAO_JAVASCRIPT_KEY 채우기 +copy .env.example .env # VITE_KAKAO_JAVASCRIPT_KEY 입력 npm run dev ``` ## 배포 -- **Docker로 한 번에**: 루트에서 `docker compose up -d --build` (ollama + 모델 자동등록 + backend). 상세 [DEPLOY.md](DEPLOY.md) -- **AWS EC2**: nginx + certbot(HTTPS) 런북 [DEPLOY.md](DEPLOY.md) -- **비용 0 라이브**: Cloudflare Tunnel + Vercel [deploy/CLOUDFLARE.md](deploy/CLOUDFLARE.md) +Docker로 백엔드와 Ollama를 한 번에 띄우려면 저장소 루트에서 `docker compose up -d --build`를 실행합니다. AWS EC2에 올리는 방법과 nginx, HTTPS 설정은 [DEPLOY.md](DEPLOY.md)에 단계별로 정리했습니다. 비용 없이 라이브로 운영하는 Cloudflare Tunnel과 Vercel 방식은 [deploy/CLOUDFLARE.md](deploy/CLOUDFLARE.md)에 있습니다. -## 개발 기록 +## 개발 과정 -기능마다의 **고민 · 선택 · 이유 · 어려웠던 점**을 리빙 문서로 남겼다 → [DEVELOPMENT_JOURNEY.md](DEVELOPMENT_JOURNEY.md) -(Gemini→로컬 모델 전환, 세 모드 목적함수 분리, 시간창 제약, 3종 실경로, 쿼터 최적화, 배포까지) +기능마다 무엇을 고민했고 왜 그렇게 정했는지, 어떤 문제를 만나 어떻게 풀었는지를 [DEVELOPMENT_JOURNEY.md](DEVELOPMENT_JOURNEY.md)에 기록했습니다.