Skip to content

Feature: map detail 페이지 도입#48

Merged
Monixc merged 41 commits into
mainfrom
feature/map-details-api
Sep 17, 2025
Merged

Feature: map detail 페이지 도입#48
Monixc merged 41 commits into
mainfrom
feature/map-details-api

Conversation

@Monixc

@Monixc Monixc commented Sep 15, 2025

Copy link
Copy Markdown
Contributor

작업 내용

  • 기존 경로 카드 선택->바로 run으로 이동 흐름에서 중간에 detail 페이지를 거치도록 수정하였습니다.
  • 카드 선택->detail로 이동->달리기 버튼 longpress 시 run으로 이동합니다.

참고 사항

- 북마크한 경로와 완주한 경로를 검색하는 API 함수 추가
- 상태 관리에 북마크한 경로 및 완주한 경로 관련 데이터 추가
- 관련 타입 정의 및 인터페이스 업데이트
- 북마크한 경로와 완주한 경로를 로드하는 훅(useBookmarkedCourses, useCompletedCourses) 추가
- Route 컴포넌트에서 북마크 및 완주한 경로 데이터 로드 로직 통합
- RouteGrid 컴포넌트에서 활성 탭에 따라 적절한 데이터 및 핸들러 사용하도록 수정
- RouteStackNavigator에 Detail 화면 추가
- TabNavigator에서 Detail 탭의 표시 여부를 조정하여 사용자 경험 개선
- RouteStackParamList의 Detail 타입에 null 허용 추가
- Draw 네비게이션 시 undefined 전달하도록 수정
- handleRouteCardPress에서 타입 안전성 개선"
- 경로 상세 정보를 표시하는 Detail 컴포넌트 구현
- 이미지 로딩 및 오류 처리 로직 추가
- 경로 정보, 거리, 예상 시간, 생성일 등의 정보 표시
- 공유 및 인증 기능을 위한 버튼 추가
- 로딩 및 오류 상태에 따른 UI 처리 구현
- CourseState에 currentCourseId 및 currentCourseData 필드 추가
- RunState에 setCurrentCourse 및 clearCurrentCourse 액션 추가
- 초기 상태에서 currentCourseId 및 currentCourseData를 null로 설정
- resetRunState에서 코스 데이터 유지 로직 수정
- courseId가 없을 때 토폴로지 API 호출 방지
- useCourseValidation에서 courseId 조건 추가
- Run 탭 직접 접근 시 불필요한 API 호출 제거"
- 날짜를 한국어 형식으로 포맷하는 formatDate 함수 추가
- 거리 단위를 미터와 킬로미터로 포맷하는 formatDistance 함수 추가
- 시간을 시간과 분으로 포맷하는 formatTime 함수 추가
- 페이스를 분과 초로 포맷하는 formatPace 함수 추가
- 사용자가 길게 누를 때 동작을 처리하는 useLongPress 훅 구현
- 누름 상태 및 진행 상황을 관리하는 상태 추가
- 애니메이션 효과와 타이머를 통해 누름 완료 시 onComplete 콜백 호출
- 누름 중 진행 상황을 퍼센트로 표시하는 기능 포함
- 이미지 로딩 상태 및 오류 처리를 위한 useImageLoading 훅 구현
- 이미지 로딩 중 상태 관리 및 오류 발생 시 처리 로직 추가
- 이미지 상태 초기화 기능 포함
- 코스 정보를 공유하는 useShare 훅 구현
- courseId와 courseData를 인자로 받아 공유 메시지 및 URL 생성
- 공유 실패 시 오류 처리 로직 추가
- useInitialLocation에서 위치 가져오기 오류 메시지를 주석 처리
- useLocationTracking에서 디버깅 로그 및 오류 메시지 제거
- useRunMap에서 courseId가 있을 때만 코스 shape 폴리곤 생성 및 지도 맞춤
- useRunModals에서 currentCourseId 초기화 로직 추가
- useRunStats에서 통계 업데이트 로직을 로컬 상태로 관리하도록 변경
- StatsContainer에서 전역 상태 대신 로컬 상태로 통계 관리 및 업데이트
- useCourseData에서 path 유효성 검사 추가
- useInitialLocation에서 현재 위치 가져오기 로직 개선
- useLocationManager에서 초기 위치 기본값 설정
- useRouteApi에서 cursor 처리 방식 개선
- DrawMap에서 그려진 경로 유효성 검사 추가 및 코드 정리
- courses.service에서 완료된 코스 검색 API 경로 수정
- Card, FloatingButton, Modal, TabNavigation, Detail, Run, DrawMap 등 여러 컴포넌트의 색상 변경
- LinearGradient를 사용하여 버튼 및 텍스트 배경 스타일 추가
- 색상 일관성을 위해 테마 색상 조정
- LinearGradient 제거 및 ButtonContainer에 배경색 '#ff6b35' 추가
- 불필요한 코드 및 주석 정리
- navigation prop의 타입을 'Run'에서 'RunTab'으로 변경하여 정확한 네비게이션 경로 반영
- Card 컴포넌트에 variant prop 추가하여 스타일 조정
- useRouteApi에 주변 경로 검색 기능 추가
- RunTabNavigator에서 추천 경로 로드 및 표시 기능 구현
- RecommendationContainer 컴포넌트 추가하여 추천 경로 리스트 표시
- useLongPress 훅에서 불필요한 상태 제거 및 코드 정리
- Home 페이지에서 추천 경로 관련 코드 정리
- RecommendationContainer 컴포넌트에서 departure 좌표를 배열 형식으로 변경하여 reverseGeocode 함수 호출 시 올바른 인자 전달
- courses.types.ts에서 departure 타입을 RouteCoordinate에서 [number, number]로 수정
- useLongPress 훅에서 pressProgress 상태를 제거하여 코드 간결성 향상
- 코스 선택 시 최소 이동 거리 체크 로직 추가하여 유효성 검사 강화
- 런닝 기록 저장 요청 및 응답에 대한 로그 추가로 디버깅 용이성 향상
- API 응답 상태코드 및 데이터 로그 추가하여 오류 처리 개선
- RunTabNavigator에서 StartButtonContainer의 위치를 아래로 20px 조정
- TabNavigator에서 탭 바 높이를 8px 증가시키고 배경색을 반투명으로 변경
- TabNavigator의 스타일에 패딩 및 테두리 추가
- Route 페이지의 FloatingButton에 기본 스타일 적용
- RecommendationContainer에서 카드의 서브타이틀 형식 수정
- RunTabNavigator의 StartButtonProgress와 Detail 페이지의 DrawButtonProgress 배경색을 '#4ECDC4'에서 '#000000'으로 변경하여 UI 일관성 향상
- useRunningRecords 훅을 추가하여 런닝 기록을 로드하고 관리하는 기능 구현
- useRunningDashboard 훅을 추가하여 런닝 대시보드를 로드하는 기능 구현
- Records 페이지에 시간 범위 선택기, 통계 표시 및 기록 목록 컴포넌트 추가
- RecordsList 및 StatsDisplay 컴포넌트 생성하여 UI 구성
- 시간 범위 계산 및 표시를 위한 유틸리티 함수 추가
- API 호출을 위한 서비스 함수(searchRunningRecords, getRunningDashboard) 추가
- 관련 타입 정의(records.types.ts) 추가하여 타입 안정성 향상
- RecommendationContainer에 안내 카드 추가하여 사용자에게 런닝 시작을 유도
- FlatList에서 데이터 배열을 안내 카드와 추천 경로로 구성하여 표시 방식 개선
- Card 컴포넌트의 스타일을 조정하여 UI 일관성 향상
- GuideCard 및 관련 텍스트 스타일 추가하여 안내 카드의 가독성 및 디자인 개선
- CardContainer의 flex 속성을 수정하여 UI 일관성 향상
- CardImage의 크기를 조정하여 이미지 비율 개선
- GuideCard의 최소 높이를 조정하여 디자인 최적화
- RunTabNavigator에서 '바로가기'를 '바로 달리기'로 변경하여 명확한 의미 전달
- Detail 페이지에서 버튼 텍스트를 '그리기...'에서 '달리기...'로 수정하여 사용자 경험 개선
Monixc and others added 11 commits September 14, 2025 09:45
- Map 컴포넌트에 onMapReady 프로퍼티 추가하여 지도 로딩 완료 시 콜백 실행
- DrawMap 컴포넌트에서 onMapReady를 사용하여 지도 준비 상태 관리
- useMapGestures 훅에서 불필요한 상태 초기화 코드 제거
- Draw 페이지에서 탭 이탈 시 그린 선 초기화 로직 추가
- 스타일 개선: 그린 선 및 완료된 선의 색상 변경
- LoadingIndicator 컴포넌트에 메시지 프로퍼티를 추가하여 로딩 메시지를 동적으로 설정 가능하도록 개선
- Draw 페이지에서 로딩 중 메시지를 "매칭중..."으로 변경
- RouteSave 페이지에서 내비게이션을 'TabNavigator'의 'Home' 화면으로 수정하여 사용자 흐름 개선
- Draw 탭 이탈 시 그린 선 초기화 로직을 RouteSave에서 돌아오는 경우를 제외하도록 수정
- RunTabNavigator를 리셋하는 RunTabWithReset 컴포넌트 추가하여 사용자 흐름 개선
- Route 페이지의 버튼 배경색을 '#111827'에서 '#000000'으로 변경하여 UI 일관성 향상
- useMapCapture 훅에 캡처 전후 대기 시간을 추가하여 안정성 향상
- DrawMap 컴포넌트에서 캡처 상태에 따라 그리기 소스 표시 로직을 개선하여 사용자 경험 향상
- Draw 페이지 이탈 시 그린 선 초기화 로직을 간소화하여 코드 가독성 개선
- eas.json 파일에 Android 빌드 타입을 "apk"로 설정하여 Android 빌드 구성 개선
- eas.json 파일에 Android 빌드 타입을 "apk"로 설정하여 Android 빌드 구성 개선
- 불필요한 공백을 제거하여 코드 가독성 향상
- Google Sign-In 초기화 과정에서 환경변수 상태 및 설정 정보를 로그로 출력하여 디버깅 용이성 향상
- 로그인 시도 및 토큰 요청 과정에서 상세한 로그를 추가하여 문제 발생 시 원인 파악 용이
- 서버 로그인 요청 시 오류 상태에 따른 상세한 로그를 추가하여 사용자에게 가능한 원인 안내
- 토큰 갱신 및 로그아웃 과정에서도 로그를 추가하여 전체적인 흐름 추적 가능
- 로그인 상태 확인 시 현재 사용자 정보를 로그로 출력하여 상태 확인 용이성 향상
- Google Sign-In 초기화 및 로그인 과정에서 로그 메시지를 영어로 변경하여 일관성 유지
- 오류 메시지를 개선하여 사용자에게 더 명확한 원인 안내 제공
- 전체적인 로그 메시지의 가독성을 향상시켜 디버깅 용이성 증대
- useRouteData, useBookmarkedCourses, useCompletedCourses 훅에서 로딩 상태 확인 방식을 간소화하여 가독성 향상
- 불필요한 로그 메시지를 제거하고, API 호출 시 요청 및 응답 로그를 삭제하여 코드 정리
- Route 컴포넌트의 useEffect 의존성에서 불필요한 항목을 제거하여 성능 개선
@Monixc Monixc requested a review from ksy90101 as a code owner September 15, 2025 00:24
@vercel

vercel Bot commented Sep 15, 2025

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Preview Comments Updated (UTC)
runova-web Ignored Ignored Sep 15, 2025 0:25am

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary of Changes

Hello @Monixc, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

이 풀 리퀘스트는 사용자에게 러닝 경로에 대한 상세 정보를 제공하고, 개인 러닝 기록을 체계적으로 관리할 수 있는 새로운 기능을 도입합니다. 기존 사용자 흐름을 개선하고, 새로운 '기록' 탭을 추가하여 러닝 데이터를 시각화합니다. 또한, 앱의 전반적인 디자인을 업데이트하고, 성능 최적화 및 위치 추적 안정성을 강화하여 사용자 경험을 향상시키는 데 중점을 두었습니다.

Highlights

  • 새로운 사용자 흐름 도입: 기존의 경로 카드 선택 후 바로 러닝으로 이동하는 흐름이, 경로 상세 페이지를 거쳐 달리기 버튼을 길게 눌러 시작하는 방식으로 변경되었습니다. 이는 사용자에게 러닝 시작 전 경로에 대한 더 많은 정보를 제공합니다.
  • 새로운 '기록' 탭 추가: 사용자의 러닝 기록과 통계를 한눈에 볼 수 있는 전용 '기록' 탭이 추가되었습니다. 총 거리, 시간, 칼로리, 평균 페이스 등 다양한 통계를 기간별로 조회할 수 있습니다.
  • 경로 관리 기능 확장: 이제 '경로' 탭에서 사용자가 생성한 경로 외에 북마크한 경로와 완주한 경로를 별도로 관리하고 조회할 수 있습니다. 또한, 현재 위치 기반으로 주변 추천 경로를 제공하는 기능이 추가되었습니다.
  • UI/UX 디자인 업데이트: 앱 전반에 걸쳐 새로운 색상 팔레트와 컴포넌트 스타일링이 적용되었습니다. 카드 디자인, 모달 버튼, 탭 내비게이션 등 시각적인 요소들이 개선되어 일관된 사용자 경험을 제공합니다.
  • 성능 최적화 및 안정성 향상: 여러 컴포넌트와 훅에 React.memo와 같은 성능 최적화 기법이 적용되었으며, 위치 정보 획득, 러닝 상태 관리, 코스 유효성 검사 로직이 개선되어 앱의 안정성과 효율성이 향상되었습니다.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point in your pull request via creating an issue comment (i.e. comment on the pull request page) using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in issue comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

이번 PR은 기존의 경로 카드 선택에서 바로 달리기로 이어지던 흐름을 중간에 상세 페이지를 거치도록 변경하는 중요한 기능 개선을 포함하고 있습니다. 전반적으로 useRunStore의 상태 보존 로직 개선이나 StatsContainer의 성능 최적화 등 아키텍처 측면에서 훌륭한 변경 사항들이 눈에 띕니다. 다만, 몇 가지 개선이 필요한 부분이 있습니다. 특히 auth.service.ts에 포함된 과도한 디버그 로그는 보안상 위험하므로 반드시 제거해야 합니다. 또한, useCourseData 훅의 데이터 페칭 방식이 비효율적이며, 여러 파일에 걸쳐 스타일 가이드를 위반하는 하드코딩된 값(색상, 매직 넘버)들이 발견되었습니다. 이러한 부분들을 수정하여 코드의 안정성과 유지보수성을 높이는 것이 좋겠습니다.

Comment on lines 18 to 221
export function initializeGoogleSignIn(): void {
GoogleSignin.configure({
webClientId: process.env.EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID,
iosClientId: process.env.EXPO_PUBLIC_GOOGLE_IOS_CLIENT_ID,
profileImageSize: 120,
});
try {
console.log('🔧 Google Sign-In initialization started');

const webClientId = process.env.EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID;
const iosClientId = process.env.EXPO_PUBLIC_GOOGLE_IOS_CLIENT_ID;

// CRITICAL: Always log the Web Client ID
console.error('EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID:', webClientId);
console.error('EXPO_PUBLIC_GOOGLE_IOS_CLIENT_ID:', iosClientId);

console.log('Web Client ID status:', webClientId ? 'SET' : '❌ NOT SET');
console.log('iOS Client ID status:', iosClientId ? 'SET' : '❌ NOT SET');

if (webClientId) {
console.log('Web Client ID length:', webClientId.length);
console.log(
'Web Client ID first 20 chars:',
webClientId.substring(0, 20) + '...',
);
}

if (iosClientId) {
console.log('iOS Client ID length:', iosClientId.length);
console.log(
'iOS Client ID first 20 chars:',
iosClientId.substring(0, 20) + '...',
);
}

if (!webClientId) {
console.error('❌ Web Client ID is NOT SET!');
console.error(
'Check EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID environment variable',
);
}

if (!iosClientId) {
console.error('❌ iOS Client ID is NOT SET!');
console.error(
'Check EXPO_PUBLIC_GOOGLE_IOS_CLIENT_ID environment variable',
);
}

const config = {
webClientId,
iosClientId,
profileImageSize: 120,
};

console.log('Google Sign-In config:', config);
GoogleSignin.configure(config);

console.log('✅ Google Sign-In initialization completed');
} catch (error) {
console.error('❌ Google Sign-In initialization error:', error);
throw error;
}
}

export async function signInWithGoogle(): Promise<GoogleSignInResponse> {
try {
console.log('Google login started');

console.log('Checking Play Services...');
await GoogleSignin.hasPlayServices();
await GoogleSignin.signIn();
const { idToken } = await GoogleSignin.getTokens();
console.log('Play Services check completed');

console.log('Attempting Google login...');
const signInResult = await GoogleSignin.signIn();
console.log('Google login successful:', signInResult);

console.log('Getting tokens...');
const tokens = await GoogleSignin.getTokens();
console.log('Full token response:', tokens);

const { idToken, accessToken } = tokens;
console.log('ID Token status:', idToken ? 'ISSUED' : 'NOT ISSUED');
console.log('Access Token status:', accessToken ? 'ISSUED' : 'NOT ISSUED');

if (idToken) {
console.log('ID Token length:', idToken.length);
console.log('ID Token first 20 chars:', idToken.substring(0, 20) + '...');
}

if (!idToken) {
throw new Error('구글 로그인 중 ID 토큰을 받지 못했습니다.');
console.error('❌ ID Token was NOT issued');
console.error('Possible causes:');
console.error('1. Google Console OAuth client ID configuration error');
console.error('2. Production bundle ID not registered in Google Console');
console.error('3. Redirect URI configuration error');
console.error('4. Wrong client ID in .env file');
console.error('5. OAuth consent screen not published to production');
throw new Error('Failed to receive ID token during Google login.');
}

const { data } = await api.post<GoogleSignInResponse>('/auth/google', {
idToken,
console.log('Sending login request to server...');
console.log('Request URL:', '/auth/google');
console.log('Request payload:', {
idToken: idToken.substring(0, 20) + '...',
});

let data: GoogleSignInResponse;
try {
const response = await api.post<GoogleSignInResponse>('/auth/google', {
idToken,
});
data = response.data;
console.log('✅ Server login successful:', data);
} catch (serverError: any) {
console.error('❌ Server login failed:', serverError);
console.error('Server response status:', serverError.response?.status);
console.error('Server response data:', serverError.response?.data);
console.error('Server response headers:', serverError.response?.headers);

if (serverError.response?.status === 400) {
console.error('400 Error - Possible causes:');
console.error('1. Invalid ID token format');
console.error('2. Google Console OAuth configuration error');
console.error('3. Client ID mismatch between server and app');
console.error('4. Wrong OAuth consent screen configuration');
} else if (serverError.response?.status === 401) {
console.error('401 Error - Possible causes:');
console.error('1. ID token expired');
console.error('2. Google token verification failed on server');
console.error('3. Server Google OAuth configuration error');
console.error('4. Invalid client ID or secret on server');
} else if (serverError.response?.status === 500) {
console.error('500 Error - Server internal error');
console.error('Check server logs for details');
}

throw serverError;
}

return data;
} catch (error) {
console.error('Google login overall error:', error);

if (isErrorWithCode(error)) {
console.error('Google login code error:', error.code);
switch (error.code) {
case statusCodes.SIGN_IN_CANCELLED:
throw new Error('사용자가 로그인을 취소했습니다.');
console.error('User cancelled login');
throw new Error('User cancelled login.');
case statusCodes.IN_PROGRESS:
throw new Error('로그인이 진행 중입니다.');
console.error('Login in progress');
throw new Error('Login in progress.');
case statusCodes.PLAY_SERVICES_NOT_AVAILABLE:
console.error('Play Services not available');
throw new Error(
'구글 플레이 서비스를 사용할 수 없거나 버전이 오래되었습니다.',
'Google Play Services is not available or version is outdated.',
);
default:
throw new Error('구글 로그인 중 오류가 발생했습니다.');
console.error('Unknown Google login error:', error.code);
throw new Error('Error occurred during Google login.');
}
}
throw new Error('로그인 중 오류가 발생했습니다. 다시 시도해주세요.');

console.error('General login error:', error);
throw new Error('Error occurred during login. Please try again.');
}
}

export async function refreshToken(): Promise<RefreshTokenResponse> {
try {
console.log('Token refresh started');
const { data } = await api.post<RefreshTokenResponse>('/auth/refresh');
console.log('Token refresh response:', data);

if (!data.accessToken) {
throw new Error('토큰 갱신에 실패했습니다.');
console.error('Token refresh failed: accessToken is missing');
throw new Error('Token refresh failed.');
}

console.log('Token refresh successful');
return data;
} catch (error) {
throw new Error('토큰 갱신 중 오류가 발생했습니다.');
console.error('Token refresh error:', error);
throw new Error('Error occurred during token refresh.');
}
}

export async function signOut(): Promise<void> {
try {
console.log('Google logout started');
await GoogleSignin.signOut();
console.log('Google logout successful');
} catch (error) {
console.error('Google 로그아웃 오류:', error);
console.error('Google logout error:', error);
}
}

export async function isSignedIn(): Promise<boolean> {
try {
console.log('Checking login status...');
const userInfo = await GoogleSignin.getCurrentUser();
console.log('Current user info:', userInfo ? 'LOGGED IN' : 'NOT LOGGED IN');
return userInfo !== null;
} catch (error) {
console.error('로그인 상태 확인 오류:', error);
console.error('Login status check error:', error);
return false;
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

인증 서비스 파일에 매우 상세한 디버깅용 console.logconsole.error가 많이 포함되어 있습니다. 특히 토큰의 일부나 클라이언트 ID와 같은 민감할 수 있는 정보가 로그에 노출될 위험이 있습니다. 프로덕션 환경에 이러한 로그가 포함되는 것은 보안상 매우 위험하므로, 즉시 모두 제거해야 합니다.

Comment on lines +197 to +207
[
accessToken,
cursor,
loading,
setBookmarkedCourses,
setLoading,
setError,
setCursor,
setHasMore,
appendBookmarkedCourses,
],

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

loadBookmarkedCourses 함수의 useCallback 의존성 배열에 cursor가 포함되어 있습니다. loadBookmarkedCourses 내부에서 setCursor를 호출하면, cursor 상태가 변경되어 불필요한 리렌더링이나 잠재적인 무한 루프를 유발할 수 있습니다. useRouteData 훅에서 수정된 것처럼 useRouteStore.getState().cursor를 사용하여 콜백 내에서 최신 cursor 값을 직접 가져오는 방식을 적용하는 것이 좋습니다.

Comment on lines +85 to +113
const userCoursesResponse = await searchUserCourses(
{ cursor: null, limit: 100 },
accessToken,
);

const userCourse = userCoursesResponse.results.find(
(course) => course.id === courseId,
);

if (userCourse) {
setData(userCourse);
await loadAddress(userCourse.departure);
return;
}

const bookmarkedResponse = await searchBookmarkedCourses(
{ cursor: null, limit: 100 },
accessToken,
);

const bookmarkedCourse = bookmarkedResponse.results.find(
(course) => course.id === courseId,
);

if (bookmarkedCourse) {
setData(bookmarkedCourse);
await loadAddress(bookmarkedCourse.departure);
return;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

특정 코스 정보를 가져오기 위해 전체 목록(limit: 100)을 조회한 후 클라이언트에서 필터링하는 방식은 매우 비효율적입니다. 코스 ID로 직접 조회할 수 있는 API 엔드포인트(예: GET /api/courses/{courseId})를 추가하여 서버에서 필요한 데이터만 가져오도록 개선하는 것을 강력히 권장합니다. 이는 성능을 크게 향상시키고 불필요한 데이터 전송을 줄일 수 있습니다.

Comment on lines +134 to +135
const isCompletedCourse =
courseData && 'id' in courseData && !('title' in courseData);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

완주한 코스인지 여부를 'title' in courseData 속성의 존재 여부로 판단하는 것은 매우 불안정하며 잠재적인 버그를 유발할 수 있습니다. API 응답에 type과 같은 명시적인 필드를 추가하거나, CompletedCourseItem 타입으로 명확하게 구분하는 등 더 안정적인 방법으로 리팩토링하는 것이 필요합니다.

Comment thread src/components/Card.tsx
if (finalMode === 'image-with-text') {
const contentWidth = screenWidth - 64;
const imageSize = contentWidth * 0.45;
const imageSize = contentWidth * 0.36; // 0.45에서 0.36으로 변경 (약 20% 감소)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

이미지 크기 계산에 사용된 0.36은 매직 넘버입니다. 코드의 가독성과 유지보수성을 위해 의미 있는 이름의 상수로 정의하여 사용하는 것이 좋습니다.1

    const IMAGE_SIZE_RATIO = 0.36;
    const imageSize = contentWidth * IMAGE_SIZE_RATIO; // 0.45에서 0.36으로 변경 (약 20% 감소)

Style Guide References

Footnotes

  1. 매직 넘버(의미를 알 수 없는 숫자)는 코드 가독성을 해치므로, 이름 있는 상수로 정의하여 사용해야 합니다.

try {
const requestParams = {
...params,
limit: 20,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

페이지네이션을 위한 limit 값이 20으로 하드코딩되어 있습니다. 이 값은 매직 넘버에 해당하므로, 상수 파일에 RECORDS_PAGE_LIMIT와 같이 의미 있는 이름으로 정의하여 사용하는 것이 좋습니다.1

Suggested change
limit: 20,
limit: RECORDS_PAGE_LIMIT,

Style Guide References

Footnotes

  1. 매직 넘버는 코드의 의도를 파악하기 어렵게 만들므로, 이름 있는 상수로 치환하여 가독성과 유지보수성을 높여야 합니다.

Comment thread src/hooks/useShare.ts

interface UseShareOptions {
courseId: number;
courseData: any;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

courseData의 타입으로 any를 사용하고 있습니다. 스타일 가이드에 따라 any 타입의 사용은 금지됩니다.1 BookmarkedCourseItem, CourseSearchItem, CompletedCourseItem 등의 타입을 조합하여 더 구체적인 타입을 정의해주세요.

Suggested change
courseData: any;
courseData: Partial<BookmarkedCourseItem & CourseSearchItem & CompletedCourseItem> | null;

Style Guide References

Footnotes

  1. any 타입은 타입스크립트의 타입 검사를 무력화시키므로 사용을 금지합니다. 불가피한 경우 unknown을 우선적으로 사용하고, 가능한 한 구체적인 타입을 명시해야 합니다.

];

// 바로가기 메인 컴포넌트
function QuickStartMain({ navigation }: { navigation: any }) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

QuickStartMain 컴포넌트의 props 타입으로 { navigation: any }를 사용하고 있습니다. 스타일 가이드에 따라 any 타입 사용은 지양해야 합니다.1 React Navigation에서 제공하는 적절한 네비게이션 prop 타입을 지정해주세요.

Suggested change
function QuickStartMain({ navigation }: { navigation: any }) {
function QuickStartMain({ navigation }: { navigation: NativeStackNavigationProp<any> }) {

Style Guide References

Footnotes

  1. any 타입은 타입스크립트의 타입 검사를 무력화하므로 사용을 금지합니다. Props, State 등에는 명시적인 타입을 정의해야 합니다.

Comment thread src/pages/Run/index.tsx
} = useRunStore();

useRunStats(routeCoordinates, isTracking);
// useRunStats는 StatsContainer에서 처리

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

useRunStats 훅을 제거하고 StatsContainer 컴포넌트 내부에서 통계 관련 로직을 처리하도록 변경한 것은 좋은 성능 최적화입니다. 불필요한 상위 컴포넌트의 리렌더링을 방지하여 UI 반응성을 높일 수 있습니다. 주석으로 남겨두신 부분은 이제 제거해도 될 것 같습니다.

Comment thread src/store/run.ts
Comment on lines 296 to 333
resetRunState: () => {
set({
...initialUIState,
...initialErrorState,
...initialRunningState,
...initialCourseState,
...initialLocationTrackingState,
...initialCourseValidationState,
set((state) => {
// 런닝이 진행 중이면 상태를 보존
if (state.startTime) {
console.log('🔄 [RunStore] 런닝 상태 보존 - 통계 초기화되지 않음');
return {
...initialUIState,
...initialErrorState,
// 런닝 상태는 보존
startTime: state.startTime,
pausedTime: state.pausedTime,
pauseStartTime: state.pauseStartTime,
stats: state.stats,
// 코스 데이터는 유지 (currentCourseId, currentCourseData 보존)
courseTopology: null,
// 위치 추적 상태는 보존 (경로 데이터 유지)
routeCoordinates: state.routeCoordinates,
location: state.location,
isTracking: state.isTracking,
subscriber: state.subscriber,
errorMsg: state.errorMsg,
...initialCourseValidationState,
};
}

// 런닝이 시작되지 않은 경우에만 완전 초기화
console.log('🔄 [RunStore] 런닝 상태 완전 초기화 - 통계 초기화됨');
return {
...initialUIState,
...initialErrorState,
...initialRunningState,
// 코스 데이터는 유지 (currentCourseId, currentCourseData 보존)
courseTopology: null,
...initialLocationTrackingState,
...initialCourseValidationState,
};
});
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

resetRunState 함수가 런닝 진행 여부(startTime의 존재)에 따라 상태를 조건부로 초기화하도록 변경된 점은 매우 훌륭한 개선입니다. 이를 통해 다른 화면으로 이동했다가 다시 돌아왔을 때 런닝 기록이 유지될 수 있어 사용자 경험이 크게 향상됩니다.

@FlashingFuture

Copy link
Copy Markdown
Contributor

고생하셨습니다!

@Monixc Monixc merged commit 2cfb8ba into main Sep 17, 2025
3 checks passed
@Monixc Monixc deleted the feature/map-details-api branch September 17, 2025 08:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants