feat : 기록 화면 지도 개선 및 포토 리포트 오버레이 컴포넌트화#130
Conversation
- 하드코딩 SVG 지도 → NaverMapView + GPS 경로(노란 선) + 사진·출발·도착 마커로 교체 - HikingRecordDetail 타입에 track(GeoJSON), photos 필드 추가 - 날짜 배지 디자인 피그마 스펙 반영 (배경색, 날짜/시간 텍스트 분리) - CourseBottomSheet → hikingRecordId 올바르게 전달하도록 버그 수정 - 포토 리포트 오버레이 3열 우측 여백 부족 수정
- GPS 트랙 없음 → 코스 폴리라인 + 시작/끝 마커 - GPS 트랙 있음, 사진 없음 → GPS 경로 + 시작/끝 마커 - GPS 트랙 있음, 사진 있음 → GPS 경로 + 시작/끝 마커 + 원형 사진 마커 - 정적 이미지 폴백(mapbasic.svg) 제거, NaverMapView 항상 렌더링 - bottom-sheet.tsx 네비게이션에 courseId 파라미터 추가
There was a problem hiding this comment.
Code Review
This pull request implements a detailed hiking record view and an interactive photo report editor, integrating Naver Map overlays to display routes and milestone photos, alongside customizable stats overlay templates. The review feedback highlights several critical improvements to ensure robustness: validating the session ID before restoring state to prevent cross-session leaks, merging loaded tracking photos with saved ones to avoid UI lockouts, cleaning up map camera-fitting timers to prevent memory leaks, and adding defensive checks for invalid dates and empty coordinates to avoid rendering errors.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| useEffect(() => { | ||
| if (trackingPhotos.length > 0 && photos.length === 0) { | ||
| setPhotos(trackingPhotos.map((uri) => ({ key: uri, source: { uri } }))); | ||
| setSelectedPhoto(0); | ||
| } | ||
| }, [trackingPhotos]); |
There was a problem hiding this comment.
photos 배열이 이전 화면의 저장된 상태(길이 1)로 초기화된 경우, photos.length === 0 조건이 거짓이 되어 trackingPhotos를 로드하는 useEffect가 실행되지 않습니다. 이로 인해 사용자는 해당 기록에서 촬영한 다른 사진들을 썸네일 목록에서 볼 수 없고 오직 저장된 사진 한 장만 보게 되는 심각한 사용성 문제가 발생합니다. 기존 사진 목록에 trackingPhotos를 중복 없이 병합하도록 개선해야 합니다.
useEffect(() => {
if (trackingPhotos.length > 0) {
setPhotos((prev) => {
const existingKeys = new Set(prev.map((p) => p.key));
const newTrackingPhotos = trackingPhotos
.filter((uri) => !existingKeys.has(uri))
.map((uri) => ({ key: uri, source: { uri } }));
const merged = [...prev, ...newTrackingPhotos];
if (selectedPhoto === null && merged.length > 0) {
setSelectedPhoto(0);
}
return merged;
});
}
}, [trackingPhotos]);
| const photoReportShotRef = useRef<ViewShot | null>(null); | ||
| const mapRef = useRef<NaverMapViewRef>(null); | ||
| const activeTabPublic = isPublicByTab[activeTab]; | ||
| const trackCoords = parseTrack(recordDetail?.track); |
There was a problem hiding this comment.
이미 @/features/tracking/utils/parse-course-polyline에 동일한 GeoJSON LineString 파싱 로직을 수행하는 parseCoursePolyline 함수가 정의되어 있습니다. 중복 코드를 방지하기 위해 parseTrack 함수를 제거하고 parseCoursePolyline을 직접 재사용하는 것을 권장합니다.
| const trackCoords = parseTrack(recordDetail?.track); | |
| const trackCoords = parseCoursePolyline(recordDetail?.track); |
| function formatMapDateParts(startedAt?: string, endedAt?: string): { date: string; time: string } { | ||
| if (!startedAt) return { date: "", time: "" }; | ||
| const d = new Date(startedAt); | ||
| const yy = d.getFullYear(); | ||
| const mm = String(d.getMonth() + 1).padStart(2, "0"); | ||
| const dd = String(d.getDate()).padStart(2, "0"); | ||
| const day = DAY_KO[d.getDay()]; | ||
| const startTime = `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`; | ||
| if (!endedAt) return { date: `${yy}.${mm}.${dd} ${day}`, time: startTime }; | ||
| const e = new Date(endedAt); | ||
| const endTime = `${String(e.getHours()).padStart(2, "0")}:${String(e.getMinutes()).padStart(2, "0")}`; | ||
| return { date: `${yy}.${mm}.${dd} ${day}`, time: `${startTime} - ${endTime}` }; | ||
| } |
There was a problem hiding this comment.
startedAt 또는 endedAt에 유효하지 않은 날짜 문자열이 전달될 경우, new Date()는 Invalid Date를 반환하며 이후 getFullYear(), getMonth() 등의 메서드가 NaN을 반환하여 화면에 "NaN.NaN.NaN undefined"와 같이 비정상적인 텍스트가 표시될 수 있습니다. 날짜가 유효한지 확인하는 방어 코드를 추가하는 것이 안전합니다.
function formatMapDateParts(startedAt?: string, endedAt?: string): { date: string; time: string } {
if (!startedAt) return { date: "", time: "" };
const d = new Date(startedAt);
if (isNaN(d.getTime())) return { date: "", time: "" };
const yy = d.getFullYear();
const mm = String(d.getMonth() + 1).padStart(2, "0");
const dd = String(d.getDate()).padStart(2, "0");
const day = DAY_KO[d.getDay()];
const startTime = `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
if (!endedAt) return { date: `${yy}.${mm}.${dd} ${day}`, time: startTime };
const e = new Date(endedAt);
if (isNaN(e.getTime())) return { date: `${yy}.${mm}.${dd} ${day}`, time: startTime };
const endTime = `${String(e.getHours()).padStart(2, "0")}:${String(e.getMinutes()).padStart(2, "0")}`;
return { date: `${yy}.${mm}.${dd} ${day}`, time: `${startTime} - ${endTime}` };
}
| useEffect(() => { | ||
| if (trackCoords.length < 2) return; | ||
| const lats = trackCoords.map((c) => c.latitude); | ||
| const lngs = trackCoords.map((c) => c.longitude); | ||
| const minLat = Math.min(...lats); | ||
| const maxLat = Math.max(...lats); | ||
| const minLng = Math.min(...lngs); | ||
| const maxLng = Math.max(...lngs); | ||
| const padding = 0.15; | ||
| setTimeout(() => { | ||
| mapRef.current?.animateCameraWithTwoCoords({ | ||
| coord1: { latitude: minLat - (maxLat - minLat) * padding, longitude: minLng - (maxLng - minLng) * padding }, | ||
| coord2: { latitude: maxLat + (maxLat - minLat) * padding, longitude: maxLng + (maxLng - minLng) * padding }, | ||
| duration: 300, | ||
| }); | ||
| }, 300); | ||
| }, [trackCoords.length]); |
There was a problem hiding this comment.
useEffect 내에서 setTimeout을 사용하여 카메라를 이동시키고 있습니다. 컴포넌트가 300ms 이내에 언마운트되거나 trackCoords.length가 변경되어 이펙트가 재실행될 때 타이머가 정리(cleanup)되지 않으면, 메모리 누수가 발생하거나 이미 언마운트된 컴포넌트의 ref에 접근하려는 경고가 발생할 수 있습니다. 반환 함수(cleanup function)에서 타이머를 클리어해 주어야 합니다.
| useEffect(() => { | |
| if (trackCoords.length < 2) return; | |
| const lats = trackCoords.map((c) => c.latitude); | |
| const lngs = trackCoords.map((c) => c.longitude); | |
| const minLat = Math.min(...lats); | |
| const maxLat = Math.max(...lats); | |
| const minLng = Math.min(...lngs); | |
| const maxLng = Math.max(...lngs); | |
| const padding = 0.15; | |
| setTimeout(() => { | |
| mapRef.current?.animateCameraWithTwoCoords({ | |
| coord1: { latitude: minLat - (maxLat - minLat) * padding, longitude: minLng - (maxLng - minLng) * padding }, | |
| coord2: { latitude: maxLat + (maxLat - minLat) * padding, longitude: maxLng + (maxLng - minLng) * padding }, | |
| duration: 300, | |
| }); | |
| }, 300); | |
| }, [trackCoords.length]); | |
| useEffect(() => { | |
| if (trackCoords.length < 2) return; | |
| const lats = trackCoords.map((c) => c.latitude); | |
| const lngs = trackCoords.map((c) => c.longitude); | |
| const minLat = Math.min(...lats); | |
| const maxLat = Math.max(...lats); | |
| const minLng = Math.min(...lngs); | |
| const maxLng = Math.max(...lngs); | |
| const padding = 0.15; | |
| const timer = setTimeout(() => { | |
| mapRef.current?.animateCameraWithTwoCoords({ | |
| coord1: { latitude: minLat - (maxLat - minLat) * padding, longitude: minLng - (maxLng - minLng) * padding }, | |
| coord2: { latitude: maxLat + (maxLat - minLat) * padding, longitude: maxLng + (maxLng - minLng) * padding }, | |
| duration: 300, | |
| }); | |
| }, 300); | |
| return () => clearTimeout(timer); | |
| }, [trackCoords.length]); |
| const lats = mapCoords.map((c) => c.latitude); | ||
| const lngs = mapCoords.map((c) => c.longitude); | ||
| const latKm = (Math.max(...lats) - Math.min(...lats)) * 111; | ||
| const lngKm = (Math.max(...lngs) - Math.min(...lngs)) * 89; | ||
| const dominantRatio = Math.max(lngKm / 12, latKm / 7); | ||
| const zoom = mapCoords.length > 1 | ||
| ? Math.min(Math.max(Math.round(11 + Math.log2(0.6 / dominantRatio)), 6), 18) | ||
| : 13; |
There was a problem hiding this comment.
mapCoords가 비어있을 때(예: 데이터 로딩 중이거나 경로 정보가 없을 때), Math.max(...lats)와 Math.min(...lats)는 각각 -Infinity와 Infinity를 반환합니다. 이로 인해 latKm 및 lngKm이 -Infinity가 되고, dominantRatio 또한 -Infinity가 되어 Math.log2 계산 시 비정상적인 값이 발생합니다. 비록 삼항 연산자(mapCoords.length > 1)로 실제 zoom 값은 13으로 대체되지만, 불필요하고 위험한 수학적 연산이 무조건 실행되므로 해당 계산을 조건문 내부로 격리하는 것이 안전합니다.
| const lats = mapCoords.map((c) => c.latitude); | |
| const lngs = mapCoords.map((c) => c.longitude); | |
| const latKm = (Math.max(...lats) - Math.min(...lats)) * 111; | |
| const lngKm = (Math.max(...lngs) - Math.min(...lngs)) * 89; | |
| const dominantRatio = Math.max(lngKm / 12, latKm / 7); | |
| const zoom = mapCoords.length > 1 | |
| ? Math.min(Math.max(Math.round(11 + Math.log2(0.6 / dominantRatio)), 6), 18) | |
| : 13; | |
| let zoom = 13; | |
| if (mapCoords.length > 1) { | |
| const lats = mapCoords.map((c) => c.latitude); | |
| const lngs = mapCoords.map((c) => c.longitude); | |
| const latKm = (Math.max(...lats) - Math.min(...lats)) * 111; | |
| const lngKm = (Math.max(...lngs) - Math.min(...lngs)) * 89; | |
| const dominantRatio = Math.max(lngKm / 12, latKm / 7); | |
| zoom = Math.min(Math.max(Math.round(11 + Math.log2(0.6 / dominantRatio)), 6), 18); | |
| } |
| export function fmtDate(iso?: string): string { | ||
| if (!iso) return "--"; | ||
| const d = new Date(iso); | ||
| const yy = String(d.getFullYear()).slice(2); | ||
| const mm = String(d.getMonth() + 1).padStart(2, "0"); | ||
| const dd = String(d.getDate()).padStart(2, "0"); | ||
| return `${yy}.${mm}.${dd}`; | ||
| } |
There was a problem hiding this comment.
iso 매개변수에 유효하지 않은 날짜 문자열이 전달될 경우, new Date(iso)는 Invalid Date를 반환하며 getFullYear() 등의 메서드가 NaN을 반환하여 결과적으로 비정상적인 문자열이 렌더링될 수 있습니다. 날짜 유효성 검사를 추가하여 안전하게 처리해야 합니다.
| export function fmtDate(iso?: string): string { | |
| if (!iso) return "--"; | |
| const d = new Date(iso); | |
| const yy = String(d.getFullYear()).slice(2); | |
| const mm = String(d.getMonth() + 1).padStart(2, "0"); | |
| const dd = String(d.getDate()).padStart(2, "0"); | |
| return `${yy}.${mm}.${dd}`; | |
| } | |
| export function fmtDate(iso?: string): string { | |
| if (!iso) return "--"; | |
| const d = new Date(iso); | |
| if (isNaN(d.getTime())) return "--"; | |
| const yy = String(d.getFullYear()).slice(2); | |
| const mm = String(d.getMonth() + 1).padStart(2, "0"); | |
| const dd = String(d.getDate()).padStart(2, "0"); | |
| return `${yy}.${mm}.${dd}`; | |
| } |
Summary
지도
bottom-sheet.tsx네비게이션에courseId파라미터 추가포토 리포트
overlay-stats.tsx)HikingRecordDetail타입에track(GeoJSON),photos필드 추가Test plan