Gildle — 반려견 산책 경로 추천
서울 전역 보행 그래프에 나무·결빙·경사 점수와 건물 그림자를 입혀, 계절과 시간대에 맞는 산책 경로 후보를 이유와 함께 보여 주는 서비스. 웹과 안드로이드 앱(길들)이 같은 API로 같은 기능을 제공한다.
주요 기능
| 기능 | 설명 |
|---|---|
| 경로 후보 | 출발·도착을 찍으면 빠른 길·그늘 많은 길·푸른 길·편한 길·언덕길 후보를 고를 이유, 거리·시간·그늘·나무·오르막 요점과 함께 제시. |
| 계절 모드 | 봄·가을(푸른 길 추천) · 그늘 모드(그늘 길 추천) · 겨울 안전(결빙 회피 — 결빙 데이터가 적어 아직 약함). |
| 그늘 경로 | 건물 그림자와 수관을 합쳐 시간대별 그늘 비율을 계산. 같은 출발·도착도 08시·13시·17시에 다른 길이 나온다. 밤엔 그늘 계산을 끈다. |
| 문장 추천 | “40분 동안 3키로 편하게”, “동물병원으로 가는 최단 경로” — 조건을 읽어 출발지로 돌아오는 코스나 목적지까지의 길을 추천. |
| 반려동물 장소 | 경로 곁 동물병원·펫샵·용품점·애견카페 표시, 누르면 들렀다 가는 길을 만든다. |
| 산책 기록 | 걸은 길을 따라 그리며 거리·시간·페이스 표시, 저장·목록·통계·삭제. |
| 웹 · 앱 | 네이버 지도 위에서 같은 화면 구성·같은 문구·같은 표기. |
기능 요구 사항
| ID | 기능 | 설명 | 상태 |
|---|---|---|---|
| G-001 | 보행 그래프 | OSM 도보 네트워크 추출 (서울 전역) | ✅ |
| G-002 | 환경 점수 | 수관(가로수·공원·숲)·결빙·고도 점수 | ✅ |
| G-003 | 경로 계산 | A* 가중 경로, 그래프 캐시·최근접 인덱스 | ✅ |
| G-004 | 계절 모드 | 봄가을·여름 그늘·겨울 안전 가중치 | ✅ |
| G-005 | 건물 그림자 | 월별 12벌 × 13시간대 사전 계산 | ✅ |
| G-006 | 경로 후보 | 성격별 후보 + 이유·요점 + 추천 표시 | ✅ |
| G-007 | 지역 확장 | 영등포구 → 서울 전역 | ✅ (2026-08-25) |
| G-008 | 문장 추천 | 시간·거리·선호·들를 곳·목적지 | ✅ |
| G-009 | 산책 기록 | 추적·저장·통계·삭제, 계정 데이터 삭제 | ✅ |
| G-010 | 모바일 앱 | Flutter 안드로이드(길들), Play 출시 준비 | 진행중 |
데이터 파이프라인
보행 그래프
OpenStreetMap (서울 전역)
│ Overpass 타일 분할 · 미러 3곳 순환 · 지수 백오프 · 이어받기
▼
osmnx → NetworkX 보행 그래프
│
▼
노드 164,740 · 간선 약 23만 → scored_edges.json (기동 시 메모리 적재, 그래프 캐시)
초기 시범 데이터는 영등포구 1,616 간선이었고 2026-08-25에 서울 전역으로 넓혔다.
환경 점수
수관 OSM 나무·나무열·공원·숲 + 서울 열린데이터 가로수(cp949) ──→ tree_score (0~1)
결빙 도로결빙 취약지점 ──→ 근접 20m 판정 (데이터가 소량이라 겨울 모드는 아직 약하다)
고도 SRTM 30m ──→ 노드 고도 → 오르막 합(편한 길·언덕길)
건물 그림자 — 시간대 × 월별 표 (2026-09-22 · 09-29)
브이월드 건물 풋프린트 + 높이(결측은 GBM 높이 모델·격자 중앙값)
│
▼ 대표일(매달 15일) × 07~19시 13슬롯의 태양 고도·방위각
건물별 그림자 폴리곤 → 서울 전역 23만 간선과 교차 길이 비율 → 나무 그늘과 결합
│
▼
shade_scores_MM.json (12벌, 한 벌 13분 · 20MB)
- 요청 시각을 가장 가까운 슬롯으로, 오늘 날짜를 가장 가까운 달의 표로 고른다. 밤 판정은 그날 실제 태양 고도(일출·일몰 자동). 여름 정오 74°·겨울 29°라 그림자 길이가 6배 다르므로 월별 표가 필요했다 — 요청마다 재계산하면 건물 356MB를 파드 메모리에 올려야 해 배제.
- 간선의 실제 그늘은
max(건물 그늘, 수관)— 건물이 없는 산길·공원길이 “완전 햇빛”으로 계산되던 문제를 고친 규칙이다.
경로 계산
계절 모드별 가중치 (도메인 서비스 RouteWeightCalculator)
봄·가을 cost = 거리 × (1 − 0.6 × 수관) 보너스 수종 가로수길이면 추가 30% 감면
그늘 모드 cost = 거리 × (1 + 4.0 × (1 − 그늘)) 완전 햇빛 구간은 5배
겨울 안전 cost = 거리 × 6 결빙 취약지점 20m 이내 구간
외부 라이브러리에 의존하지 않는 순수 규칙이다. 감면이 있는 모드에서도 A* 휴리스틱이 정확하도록 모드별 최소 배율을 함께 제공한다.
경로 후보 (POST /api/gildle/routes/options)
출발·도착 + 계절 모드 + (출발 시각)
│
▼
빠른 길(최단) ─┬─ 그늘 많은 길 (낮에만)
├─ 푸른 길
├─ 편한 길 (오르막 최소)
└─ 언덕길 (문장 추천에서 요청했을 때)
│ 빠른 길과 사실상 같은 후보는 합치고, 모드에 맞는 후보에 "추천" 표시
▼
후보마다: 이유 한 줄 · 요점(거리·시간 · 그늘 % · 나무 % · 오르막 m) · 경로 곁 반려동물 장소
- 들렀다 가기(
/routes/via): 고른 장소를 경유하는 길을 같은 성격으로 다시 계산.
산책 추천 — 문장 한 줄로 (POST /api/gildle/walk/plan)
"강아지 병원으로 가는 최단 경로 알려줘" / "오늘은 그늘로만 40분"
│
▼ EXAONE 7.8B 이해 → {kind, minutes, distance_km, preference, stops, destination} → 규칙 검증
├─ 목적지 종류(동물병원…)만 오면 카카오 로컬로 출발지 3km 안 가장 가까운 장소를 도착지로
├─ 들를 곳(stops)이 있으면 빠른·그늘·푸른·편한 길 후보 전부가 그 장소를 경유
└─ 루프면 목표 거리·제한 시간 안에서 들를 곳 지나는 루프 우선
문장 없이 시간·거리·선호·들를 곳을 직접 골라도 같은 API가 받는다.
웹 · 앱
| 항목 | 웹 (Next.js) | 앱 (Flutter, 길들) |
|---|---|---|
| 지도 | 네이버 지도 JS v3 | 네이버 지도 SDK |
| 경로·추천 API | 같은 엔드포인트 (/routes/options · /routes/via · /walk/plan) | 같음 |
| 위치 | 브라우저 Geolocation | 포그라운드 서비스 알림으로 화면이 꺼져도 추적 |
| 로그인 | httpOnly 쿠키 세션 | 카카오·이메일, 토큰은 secure storage |
| 표기 | lib/gildle-format.ts | format.dart — 같은 규칙 |
2026-09-28~29에 웹에만 기능이 들어가면서 이틀 만에 둘이 벌어졌다(웹은 경로 후보·문장 추천, 앱은 경로 1개·거리 슬라이더). 09-30에 같은 API·같은 상태 모델·같은 표기로 통일했다 — 화면부터가 아니라 데이터 모양부터 맞췄다. 앱은 v1.0.1(빌드 2) 번들까지 만들었고 Play 출시를 준비 중이다.
기술 스택
| 구분 | 기술 |
|---|---|
| 백엔드 | Python 3.12 · FastAPI · PostgreSQL · Clean Architecture |
| 웹 | Next.js 16 · TypeScript · 네이버 지도 JS v3 |
| 앱 | Flutter · Riverpod · Dio · flutter_naver_map · geolocator |
| 데이터 | OpenStreetMap / osmnx · 브이월드 건물 · 서울 열린데이터(가로수·결빙) · SRTM 고도 · 카카오 로컬 |
| 알고리즘 | A* (가중 최단 경로) · 그리드 최근접 인덱스 · 태양 위치(NOAA 근사) · shapely STRtree |
| LLM | EXAONE 3.5 7.8B (문장 이해, Ollama) |
| 테스트 | pytest 256건 (레이어별 단위·통합) · flutter analyze / flutter test |
아키텍처 (Clean Architecture)
adapter/
├── inbound/api/v1/
│ ├── route_router.py ← 경로·후보·들렀다 가기·산책 추천
│ ├── walk_router.py ← 산책 기록 CRUD·통계
│ └── account_router.py · app_router.py · push_token_router.py
└── outbound/
├── graph/ ← OSM 그래프 로더 · 다익스트라/NetworkX 어댑터
├── http/kakao_pet_place_adapter.py
├── llm/exaone_walk_understanding_adapter.py
└── pg/ ← 산책·푸시 토큰·시범 그래프 저장소
app/
├── ports/input/ ← RouteOptionsUseCase · WalkPlanUseCase · WalkUseCase …
├── ports/output/ ← RouteGraphPort · PetPlacePort · WalkUnderstandingPort …
└── use_cases/ ← Interactor (앱 예외를 던지고 라우터가 상태 코드로 변환)
domain/
├── entities/ ← walk · push_token · tree_segment · hazard_zone
├── services/route_weight_calculator.py ← 모드별 가중치 규칙
└── value_objects/ ← route_edge · coordinate · season_mode · route_weight
저장소를 CSV에서 PostgreSQL로 바꿀 때 Port를 유지하고 Adapter만 교체해 도메인·애플리케이션 레이어 변경 0건이었다.
트러블슈팅
Overpass 차단 — 이어받을 수 있게 수집하기
- 상황: 1초 간격으로 수집하다 53타일 만에 429 → IP 수준 연결 차단
- 원인: 단일 서버에 연속 요청, 실패하면 처음부터 다시
- 해결: 타일 분할, 미러 3곳 순환, 지수 백오프(15s→60s→180s), 타일 단위 저장 + 이어받기
- 결과: 2회차 308타일 실패 0으로 완주
봄·가을 경로가 사실상 최단거리였다
- 상황: 봄·가을 모드 경로가 최단 경로와 거의 같았다
- 원인: 보너스 수종 샘플 CSV 3건만 보고 있었다
- 해결: 간선 수관 점수에 비례해 최대 60% 감면하는 “푸른 길” 가중치 도입
목적지 추천에서 출발지로 돌아가는 직선 (2026-09-30)
- 상황: “동물병원으로 가는 최단 경로”를 받으면 목적지에서 출발지로 직선이 하나 더 그려짐
- 원인: “추천이면 루프”라고 가정해 경로 끝에 출발지를 다시 붙였다
- 해결: 응답의
kind가 loop일 때만 되돌아오게 수정
그늘 계산의 한계 — 먼저 말해 두는 것
- 그림자는 볼록껍질 근사라 ㄷ자 건물 안쪽도 그림자로 친다. 높이는 층수 × 3m 가정, 결측은 모델로 채운다.
- 검증은 합성 격자 하네스와 물리적 타당성(정오 최소·아침저녁 최대의 U자 곡선)까지다. 사진 실측 검증은 하지 못했다.
CSV 인코딩 통합 (cp949)
- 상황: 서울 열린데이터 CSV가 cp949 인코딩, Python 기본 utf-8로 읽으면 깨짐
- 해결:
encoding="cp949"명시, 통합 테스트에서 실제 CSV 파일로 검증
스크린샷