From fe40d46da729a157b0b0f6f63e6120403b6f532f Mon Sep 17 00:00:00 2001 From: kyhlsd <113406379+kyhlsd@users.noreply.github.com> Date: Tue, 2 Dec 2025 15:19:04 +0900 Subject: [PATCH 01/14] Update version to 1.4.1 and enhance README Updated the version number and improved project description. --- README.md | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 934d2c9..22a38de 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,19 @@ # 오름 > 한국의 산 정보를 제공하고, 등산 기록을 측정/관리하는 iOS 앱 > [앱스토어](https://apps.apple.com/kr/app/%EC%98%A4%EB%A6%84-%EB%82%98%EC%9D%98-%EB%93%B1%EC%82%B0-%EA%B8%B0%EB%A1%9D/id6753770017) -> 현재 버전: 1.3.0 +> 현재 버전: 1.4.1
## 📌 프로젝트 소개 > 프로젝트 기간: 2025/09/21 ~ 2025/10/09 +> 업데이트 기간: 2025/10/10 ~ 현재 > 개인 프로젝트 **오름**은 등산 기록 및 관리 앱입니다. -- Restful API를 통해 한국의 산 정보를 제공하고 -- Healthkit을 활용하여 등산 활동을 추적하며 -- Realm DB를 통해 사진, 후기 등의 기록을 관리할 수 있습니다. +- 한국의 산 정보를 제공하고 +- 등산 활동을 추적해 통계를 제공하며 +- 사진, 후기, 별점으로 등산 기록을 관리할 수 있습니다.
@@ -30,10 +31,13 @@ - Tuist를 활용해 Clean Architecture 계층을 모듈화하여 의존성 관리 및 빌드 효율 향상 - MVVM-C와 Combine 기반 구조체 Input/Output 패턴으로 명확한 데이터 흐름과 반응형 프로그래밍 구현 - DI Container를 적용해 서비스/개발 환경별 Repository 주입 분리 및 테스트 용이성 확보 +- NSCache와 FileManager를 활용해 캐싱을 구현해 데이터 접근 속도 개선 및 네트워크 단절 상황 대응 +- Github Actions와 Fastlane으로 자동화된 테스트와 배포 시스템 구현 +- HealthKit을 활용해 앱이 종료된 상태에서의 데이터 수집 +- WidgetKit으로 측정 중 정보 표기, 측정 지속 여부를 확인하는 Push 알림 구현 - Alamofire와 Router 패턴으로 API 통신 구조 표준화, NWPathMonitor로 네트워크 상태 감지, 응답 값 기반 에러 처리 - Realm을 활용한 1:N 관계형 DB 설계, 양방향 관계 설정으로 데이터 무결성 보장 -- 지도 줌 레벨 기반 Grid 클러스터링으로 시각적 가독성 개선 -- HealthKit을 활용해 앱 종료 중에도 데이터 수집 +- 지도 줌 레벨 기반 Quad-Tree 알고리즘 클러스터링으로 시각적 가독성과 효율성 개선 - 기기 크기에 따른 이미지 리사이징으로 저장 공간 절감 및 성능 향상 - Firebase Analytics와 Crashlytics로 사용자 행동 및 안정성 모니터링 - UIKit(code base, +SwiftUI), Combine, MapKit, CoreLocation, HealthKit, Charts, NWPathMonitor, Tuist, Realm, Alamofire, Kingfisher, SnapKit, Toast, Firebase Analytics, Firebase Crashlytics From 5dab6bba7eeb13694922b5445349547a05067d80 Mon Sep 17 00:00:00 2001 From: kyhlsd <113406379+kyhlsd@users.noreply.github.com> Date: Tue, 2 Dec 2025 15:23:17 +0900 Subject: [PATCH 02/14] Update image links in README.md Replaced old image links with new ones in the README. --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 22a38de..03c4ee7 100644 --- a/README.md +++ b/README.md @@ -53,10 +53,10 @@ 검색/산 정보 보기 - 등산 기록 관리 - 기록 측정/통계 - 명산 지도 - 검색/산 정보 보기 + 등산 기록 관리 + 기록 측정/통계 + 명산 지도 + 검색/산 정보 보기 From ff61fa8c1ae8e32ab14692ffba89d41a974c2fd2 Mon Sep 17 00:00:00 2001 From: kyhlsd <113406379+kyhlsd@users.noreply.github.com> Date: Tue, 2 Dec 2025 15:26:24 +0900 Subject: [PATCH 03/14] Update README with widget feature and clustering method --- README.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 03c4ee7..1532fd5 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,9 @@ 기록 측정/통계 - HealthKit을 활용하여 앱이 실행 중이 아니더라도 걸음 수와 이동 거리 측정 -- 실시간으로 등산 진행 상황을 확인 +- Widget으로 실시간으로 등산 진행 상황을 확인 +- 측정 시작 3시간 이후 1시간마다 측정 중인지 확인하는 Push 알림 +- 휴리스틱 필터와 칼만 필터를 적용하여 측정 데이터 보정 - 운동 시간, 휴식 시간 분석 및 걸음 수, 이동 거리 통계 정보 제공 - Charts를 활용한 시간 별 걸음 수/이동 거리 시각화 @@ -84,7 +86,7 @@ 명산 지도 - MapKit을 사용하여 전국의 명산 위치를 지도에 표시 -- 줌 레벨에 따른 Grid 기반 Clustering +- 줌 레벨에 따른 Quad-Tree 기반 Clustering - 현재 위치 기반으로 주변 산 탐색 - 지도에서 산을 선택하여 상세 정보 확인 가능 @@ -152,7 +154,7 @@ tuist generate ### Try -- **API 복구 이후에는 더 풍부한 데이터를 활용해 로직을 단순화하고, 제공 정보를 확장할 예정입니다.** +- **API 복구 이후에는 더 풍부한 데이터를 활용해 로직을 단순화하고, 제공 정보를 확장할 예정입니다. (업데이트 완료)** - **Clean Architecture, Coordinator, DIContainer 등 이론적으로 학습했던 개념을 직접 적용해보며 많은 인사이트를 얻었습니다.** 앞으로도 다양한 설계 개념을 지속적으로 학습하고 적용하여, 보다 완성도 높은 구조와 코드 품질을 구현할 수 있도록 노력하겠습니다. From 86827440c68435369ebec89b325375da60ae4fd4 Mon Sep 17 00:00:00 2001 From: kyhlsd <113406379+kyhlsd@users.noreply.github.com> Date: Tue, 2 Dec 2025 15:31:52 +0900 Subject: [PATCH 04/14] Enhance README with data correction methods Updated README to include heuristic and Kalman filter data correction details. --- README.md | 27 +++++++-------------------- 1 file changed, 7 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 1532fd5..f79b3c2 100644 --- a/README.md +++ b/README.md @@ -33,11 +33,11 @@ - DI Container를 적용해 서비스/개발 환경별 Repository 주입 분리 및 테스트 용이성 확보 - NSCache와 FileManager를 활용해 캐싱을 구현해 데이터 접근 속도 개선 및 네트워크 단절 상황 대응 - Github Actions와 Fastlane으로 자동화된 테스트와 배포 시스템 구현 -- HealthKit을 활용해 앱이 종료된 상태에서의 데이터 수집 +- HealthKit을 활용해 앱이 종료된 상태에서의 데이터 수집, 휴리스틱 필터와 칼만 필터로 데이터 보정 - WidgetKit으로 측정 중 정보 표기, 측정 지속 여부를 확인하는 Push 알림 구현 - Alamofire와 Router 패턴으로 API 통신 구조 표준화, NWPathMonitor로 네트워크 상태 감지, 응답 값 기반 에러 처리 - Realm을 활용한 1:N 관계형 DB 설계, 양방향 관계 설정으로 데이터 무결성 보장 -- 지도 줌 레벨 기반 Quad-Tree 알고리즘 클러스터링으로 시각적 가독성과 효율성 개선 +- Quad-Tree 알고리즘 클러스터링으로 지도의 시각적 가독성과 효율성 개선 - 기기 크기에 따른 이미지 리사이징으로 저장 공간 절감 및 성능 향상 - Firebase Analytics와 Crashlytics로 사용자 행동 및 안정성 모니터링 - UIKit(code base, +SwiftUI), Combine, MapKit, CoreLocation, HealthKit, Charts, NWPathMonitor, Tuist, Realm, Alamofire, Kingfisher, SnapKit, Toast, Firebase Analytics, Firebase Crashlytics @@ -60,19 +60,14 @@ -
-등산 기록 관리 - +**등산 기록 관리** - Realm DB를 활용하여 등산 기록을 로컬에 저장하고 관리 - 등산 중 촬영한 사진과 후기, 별점과 측정 정보 등을 함께 저장 - 디바이스 크기에 따른 이미지 Resizing으로 저장 공간 관리 - 수정/삭제, 검색/북마크 기능 지원 -
- -
-기록 측정/통계 +**기록 측정/통계** - HealthKit을 활용하여 앱이 실행 중이 아니더라도 걸음 수와 이동 거리 측정 - Widget으로 실시간으로 등산 진행 상황을 확인 - 측정 시작 3시간 이후 1시간마다 측정 중인지 확인하는 Push 알림 @@ -80,29 +75,21 @@ - 운동 시간, 휴식 시간 분석 및 걸음 수, 이동 거리 통계 정보 제공 - Charts를 활용한 시간 별 걸음 수/이동 거리 시각화 -
- -
-명산 지도 +**명산 지도** - MapKit을 사용하여 전국의 명산 위치를 지도에 표시 -- 줌 레벨에 따른 Quad-Tree 기반 Clustering +- Quad-Tree 기반 Clustering - 현재 위치 기반으로 주변 산 탐색 - 지도에서 산을 선택하여 상세 정보 확인 가능 -
- -
-검색/산 정보 보기 +**검색/산 정보 보기** - 전국 산 정보 검색 - 검색 결과에서 산을 선택하여 상세 페이지로 이동 - Geocoding과 기상청 API를 연결해 날씨 정보 제공 - 산 이름, 위치, 높이 등의 기본 정보 제공 - Realm DB를 통한 최근 검색어 기능 지원 -
-
## 📌 설치 및 실행 From 8758eb63b1e4fd9deb26dd3d1d0b3f2a26a715d7 Mon Sep 17 00:00:00 2001 From: kyhlsd <113406379+kyhlsd@users.noreply.github.com> Date: Tue, 2 Dec 2025 15:52:43 +0900 Subject: [PATCH 05/14] Fix typo in widget description in README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index f79b3c2..381c9f9 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ **기록 측정/통계** - HealthKit을 활용하여 앱이 실행 중이 아니더라도 걸음 수와 이동 거리 측정 -- Widget으로 실시간으로 등산 진행 상황을 확인 +- Widget으로 등산 측정 진행 상황을 확인 - 측정 시작 3시간 이후 1시간마다 측정 중인지 확인하는 Push 알림 - 휴리스틱 필터와 칼만 필터를 적용하여 측정 데이터 보정 - 운동 시간, 휴식 시간 분석 및 걸음 수, 이동 거리 통계 정보 제공 From c0aa6baf4766271a7d18e35ac0749b38b7edec1f Mon Sep 17 00:00:00 2001 From: kyhlsd <113406379+kyhlsd@users.noreply.github.com> Date: Tue, 2 Dec 2025 16:57:34 +0900 Subject: [PATCH 06/14] Revise technology stack and hiking record sections Updated technology stack section with detailed descriptions of architecture, network, data, CI/CD, monitoring, and frameworks. Enhanced hiking record management and statistics sections with additional features and clarifications. --- README.md | 50 +++++++++++++++++++++++++++++++------------------- 1 file changed, 31 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 381c9f9..cbe1689 100644 --- a/README.md +++ b/README.md @@ -26,20 +26,33 @@
-## 📌 기술스택 +## 📌 기술스택 +**Architecture** - Tuist를 활용해 Clean Architecture 계층을 모듈화하여 의존성 관리 및 빌드 효율 향상 - MVVM-C와 Combine 기반 구조체 Input/Output 패턴으로 명확한 데이터 흐름과 반응형 프로그래밍 구현 -- DI Container를 적용해 서비스/개발 환경별 Repository 주입 분리 및 테스트 용이성 확보 -- NSCache와 FileManager를 활용해 캐싱을 구현해 데이터 접근 속도 개선 및 네트워크 단절 상황 대응 -- Github Actions와 Fastlane으로 자동화된 테스트와 배포 시스템 구현 -- HealthKit을 활용해 앱이 종료된 상태에서의 데이터 수집, 휴리스틱 필터와 칼만 필터로 데이터 보정 -- WidgetKit으로 측정 중 정보 표기, 측정 지속 여부를 확인하는 Push 알림 구현 -- Alamofire와 Router 패턴으로 API 통신 구조 표준화, NWPathMonitor로 네트워크 상태 감지, 응답 값 기반 에러 처리 + +**Network** +- NSCache와 FileManager를 활용한 캐싱 구현으로 데이터 접근 속도 개선 및 네트워크 단절 상황 대응 +- NWPathMonitor로 네트워크 상태를 감지해 단절/재연결 배너를 상단 표기 +- Alamofire와 Router 패턴으로 API 통신 구조 표준화, 응답 값 기반 에러 처리 + +**Data** - Realm을 활용한 1:N 관계형 DB 설계, 양방향 관계 설정으로 데이터 무결성 보장 +- HealthKit을 활용해 앱이 종료된 상태에서의 데이터 수집, 휴리스틱 필터와 칼만 필터로 데이터 보정 +- WidgetKit으로 측정 중 정보 표기 +- 로컬 Push 알림으로 등산 완료 시 앱에서도 측정 종료 유도 - Quad-Tree 알고리즘 클러스터링으로 지도의 시각적 가독성과 효율성 개선 - 기기 크기에 따른 이미지 리사이징으로 저장 공간 절감 및 성능 향상 + +**CI / CD** +- DI Container를 적용해 서비스/개발 환경별 Repository 주입 분리 및 테스트 용이성 확보 +- Github Actions와 Fastlane으로 자동화된 테스트와 배포 시스템 구현 + +**Mornitoring** - Firebase Analytics와 Crashlytics로 사용자 행동 및 안정성 모니터링 + +**Frameworks** - UIKit(code base, +SwiftUI), Combine, MapKit, CoreLocation, HealthKit, Charts, NWPathMonitor, Tuist, Realm, Alamofire, Kingfisher, SnapKit, Toast, Firebase Analytics, Firebase Crashlytics
@@ -61,34 +74,33 @@ **등산 기록 관리** -- Realm DB를 활용하여 등산 기록을 로컬에 저장하고 관리 +- 등산 기록을 저장하고 관리 - 등산 중 촬영한 사진과 후기, 별점과 측정 정보 등을 함께 저장 -- 디바이스 크기에 따른 이미지 Resizing으로 저장 공간 관리 - 수정/삭제, 검색/북마크 기능 지원 **기록 측정/통계** -- HealthKit을 활용하여 앱이 실행 중이 아니더라도 걸음 수와 이동 거리 측정 -- Widget으로 등산 측정 진행 상황을 확인 -- 측정 시작 3시간 이후 1시간마다 측정 중인지 확인하는 Push 알림 -- 휴리스틱 필터와 칼만 필터를 적용하여 측정 데이터 보정 +- 등산 중 걸음 수와 이동 거리 측정 +- 실제 측정 기록고 유사하도록 측정 데이터 보정(휴리스틱 필터, 칼만 필터) - 운동 시간, 휴식 시간 분석 및 걸음 수, 이동 거리 통계 정보 제공 -- Charts를 활용한 시간 별 걸음 수/이동 거리 시각화 +- 시간 별 걸음 수/이동 거리, 평균 대비 기록을 차트로 시각화 +- 위젯으로 등산 측정 진행 상황을 표기(산 이름, 경과 시간) +- 측정 시작 3시간 이후 1시간마다 측정 중인지 확인하는 로컬 Push 알림 **명산 지도** -- MapKit을 사용하여 전국의 명산 위치를 지도에 표시 -- Quad-Tree 기반 Clustering +- 전국의 명산 위치를 지도에 표시 - 현재 위치 기반으로 주변 산 탐색 -- 지도에서 산을 선택하여 상세 정보 확인 가능 +- 밀집된 영역은 묶어서 개수로 함께 표기 +- 산을 선택하여 상세 정보 확인 가능 **검색/산 정보 보기** - 전국 산 정보 검색 - 검색 결과에서 산을 선택하여 상세 페이지로 이동 -- Geocoding과 기상청 API를 연결해 날씨 정보 제공 +- 국토교통부와 기상청 API를 연결해 날씨 정보 제공 - 산 이름, 위치, 높이 등의 기본 정보 제공 -- Realm DB를 통한 최근 검색어 기능 지원 +- 최근 검색어 기능 지원
From ff77f6288e2736fef63d20dd7fd8c4c67afabe3e Mon Sep 17 00:00:00 2001 From: kyhlsd <113406379+kyhlsd@users.noreply.github.com> Date: Tue, 2 Dec 2025 17:00:27 +0900 Subject: [PATCH 07/14] Update README to include XMLCoder framework Added XMLCoder to the list of frameworks in README. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index cbe1689..e074a32 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ - Firebase Analytics와 Crashlytics로 사용자 행동 및 안정성 모니터링 **Frameworks** -- UIKit(code base, +SwiftUI), Combine, MapKit, CoreLocation, HealthKit, Charts, NWPathMonitor, Tuist, Realm, Alamofire, Kingfisher, SnapKit, Toast, Firebase Analytics, Firebase Crashlytics +- UIKit(code base, +SwiftUI), Combine, MapKit, CoreLocation, HealthKit, Charts, NWPathMonitor, Tuist, Realm, Alamofire,XMLCoder, Kingfisher, SnapKit, Toast, Firebase Analytics, Firebase Crashlytics
From 397019389e23dac805ee1562d7d27faf0d5b7728 Mon Sep 17 00:00:00 2001 From: kyhlsd <113406379+kyhlsd@users.noreply.github.com> Date: Tue, 2 Dec 2025 17:23:11 +0900 Subject: [PATCH 08/14] =?UTF-8?q?Update=20image=20source=20for=20=EB=AA=85?= =?UTF-8?q?=EC=82=B0=20=EC=A7=80=EB=8F=84=20in=20README?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e074a32..9f277f4 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ 등산 기록 관리 기록 측정/통계 - 명산 지도 + 명산 지도 검색/산 정보 보기 From 86925637ad5ea8b8b6a4c0b90753a411ad0838ce Mon Sep 17 00:00:00 2001 From: kyhlsd <113406379+kyhlsd@users.noreply.github.com> Date: Tue, 2 Dec 2025 18:00:49 +0900 Subject: [PATCH 09/14] Fix typo in measurement data correction description --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 9f277f4..3c858b1 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ **기록 측정/통계** - 등산 중 걸음 수와 이동 거리 측정 -- 실제 측정 기록고 유사하도록 측정 데이터 보정(휴리스틱 필터, 칼만 필터) +- 실제 측정 기록과 유사하도록 측정 데이터 보정(휴리스틱 필터, 칼만 필터) - 운동 시간, 휴식 시간 분석 및 걸음 수, 이동 거리 통계 정보 제공 - 시간 별 걸음 수/이동 거리, 평균 대비 기록을 차트로 시각화 - 위젯으로 등산 측정 진행 상황을 표기(산 이름, 경과 시간) From 396b92cb16d78a699af592baaa4f02f668d639c9 Mon Sep 17 00:00:00 2001 From: kyhlsd Date: Sun, 7 Dec 2025 20:29:24 +0900 Subject: [PATCH 10/14] =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=EB=84=A4?= =?UTF-8?q?=EC=9D=B4=EC=85=98=20=EC=A4=91=EB=B3=B5=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AddClimbRecordViewModel.swift | 37 ++++++++++++++++--- .../MeasureScene/MeasureViewModel.swift | 37 ++++++++++++++++--- .../Scenes/SearchScene/SearchViewModel.swift | 37 ++++++++++++++++--- 3 files changed, 93 insertions(+), 18 deletions(-) diff --git a/Presentation/Sources/Scenes/ClimbRecordScene/AddClimbRecord/AddClimbRecordViewModel.swift b/Presentation/Sources/Scenes/ClimbRecordScene/AddClimbRecord/AddClimbRecordViewModel.swift index 3aabc77..39802c1 100644 --- a/Presentation/Sources/Scenes/ClimbRecordScene/AddClimbRecord/AddClimbRecordViewModel.swift +++ b/Presentation/Sources/Scenes/ClimbRecordScene/AddClimbRecord/AddClimbRecordViewModel.swift @@ -62,6 +62,7 @@ final class AddClimbRecordViewModel: BaseViewModel { let currentKeywordSubject = CurrentValueSubject("") let isLastPageSubject = CurrentValueSubject(false) let currentMountainsSubject = CurrentValueSubject<[Mountain], Never>([]) + let totalReceivedCountSubject = CurrentValueSubject(0) // 새로운 검색 input.searchTrigger @@ -70,6 +71,7 @@ final class AddClimbRecordViewModel: BaseViewModel { currentKeywordSubject.send(keyword) currentPageSubject.send(1) isLastPageSubject.send(false) + totalReceivedCountSubject.send(0) isLoadingSubject.send(true) }) .flatMap { [weak self] keyword -> AnyPublisher, Never> in @@ -83,10 +85,18 @@ final class AddClimbRecordViewModel: BaseViewModel { switch result { case .success(let response): let mountains = response.body.items.item.map { $0.toMountain() } - currentMountainsSubject.send(mountains) - searchResultsSubject.send(mountains) - // 마지막 페이지 체크 + // API 응답 자체에 중복이 있을 수 있으므로 중복 제거 (순서 유지) + var seenIds = Set() + let uniqueMountains = mountains.filter { seenIds.insert($0.id).inserted } + + currentMountainsSubject.send(uniqueMountains) + searchResultsSubject.send(uniqueMountains) + + // 받은 데이터 개수 업데이트 (중복 포함) + totalReceivedCountSubject.send(mountains.count) + + // 마지막 페이지 체크 (중복 포함 받은 데이터 개수 기준) if mountains.count >= response.body.totalCount { isLastPageSubject.send(true) } @@ -94,6 +104,7 @@ final class AddClimbRecordViewModel: BaseViewModel { errorSubject.send(("검색 실패", error.localizedDescription)) currentMountainsSubject.send([]) searchResultsSubject.send([]) + totalReceivedCountSubject.send(0) isLastPageSubject.send(true) } } @@ -117,13 +128,27 @@ final class AddClimbRecordViewModel: BaseViewModel { switch result { case .success(let response): let newMountains = response.body.items.item.map { $0.toMountain() } - let allMountains = currentMountainsSubject.value + newMountains + + // API 응답 자체에 중복이 있을 수 있으므로 먼저 중복 제거 (순서 유지) + var seenIdsInResponse = Set() + let deduplicatedNewMountains = newMountains.filter { seenIdsInResponse.insert($0.id).inserted } + + // 기존 데이터와 비교하여 중복 체크 + let existingMountains = currentMountainsSubject.value + let existingIds = Set(existingMountains.map { $0.id }) + let uniqueNewMountains = deduplicatedNewMountains.filter { !existingIds.contains($0.id) } + + let allMountains = existingMountains + uniqueNewMountains currentMountainsSubject.send(allMountains) searchResultsSubject.send(allMountains) currentPageSubject.send(currentPageSubject.value + 1) - // 마지막 페이지 체크 - if allMountains.count >= response.body.totalCount { + // 누적 받은 데이터 개수 업데이트 (API 응답 내 중복 제거 후) + let totalReceived = totalReceivedCountSubject.value + deduplicatedNewMountains.count + totalReceivedCountSubject.send(totalReceived) + + // 마지막 페이지 체크 (누적 개수 기준 또는 새 데이터가 없는 경우) + if totalReceived >= response.body.totalCount || uniqueNewMountains.isEmpty { isLastPageSubject.send(true) } case .failure(let error): diff --git a/Presentation/Sources/Scenes/MeasureScene/MeasureViewModel.swift b/Presentation/Sources/Scenes/MeasureScene/MeasureViewModel.swift index eaa75cc..d43501f 100644 --- a/Presentation/Sources/Scenes/MeasureScene/MeasureViewModel.swift +++ b/Presentation/Sources/Scenes/MeasureScene/MeasureViewModel.swift @@ -96,6 +96,7 @@ final class MeasureViewModel: BaseViewModel { let currentKeywordSubject = CurrentValueSubject("") let isLastPageSubject = CurrentValueSubject(false) let currentMountainsSubject = CurrentValueSubject<[MountainInfo], Never>([]) + let totalReceivedCountSubject = CurrentValueSubject(0) let viewDidLoad = input.viewDidLoad .share() @@ -195,6 +196,7 @@ final class MeasureViewModel: BaseViewModel { currentKeywordSubject.send(keyword) currentPageSubject.send(1) isLastPageSubject.send(false) + totalReceivedCountSubject.send(0) isLoadingSubject.send(true) }) .flatMap { [weak self] keyword -> AnyPublisher, Never> in @@ -208,10 +210,18 @@ final class MeasureViewModel: BaseViewModel { switch result { case .success(let response): let mountains = response.body.items.item - currentMountainsSubject.send(mountains) - searchResultsSubject.send(mountains) - // 마지막 페이지 체크 + // API 응답 자체에 중복이 있을 수 있으므로 중복 제거 (순서 유지) + var seenIds = Set() + let uniqueMountains = mountains.filter { seenIds.insert($0.id).inserted } + + currentMountainsSubject.send(uniqueMountains) + searchResultsSubject.send(uniqueMountains) + + // 받은 데이터 개수 업데이트 (중복 포함) + totalReceivedCountSubject.send(mountains.count) + + // 마지막 페이지 체크 (중복 포함 받은 데이터 개수 기준) if mountains.count >= response.body.totalCount { isLastPageSubject.send(true) } @@ -219,6 +229,7 @@ final class MeasureViewModel: BaseViewModel { errorMessageSubject.send(("검색 실패", error.localizedDescription)) currentMountainsSubject.send([]) searchResultsSubject.send([]) + totalReceivedCountSubject.send(0) isLastPageSubject.send(true) } } @@ -242,13 +253,27 @@ final class MeasureViewModel: BaseViewModel { switch result { case .success(let response): let newMountains = response.body.items.item - let allMountains = currentMountainsSubject.value + newMountains + + // API 응답 자체에 중복이 있을 수 있으므로 먼저 중복 제거 (순서 유지) + var seenIdsInResponse = Set() + let deduplicatedNewMountains = newMountains.filter { seenIdsInResponse.insert($0.id).inserted } + + // 기존 데이터와 비교하여 중복 체크 + let existingMountains = currentMountainsSubject.value + let existingIds = Set(existingMountains.map { $0.id }) + let uniqueNewMountains = deduplicatedNewMountains.filter { !existingIds.contains($0.id) } + + let allMountains = existingMountains + uniqueNewMountains currentMountainsSubject.send(allMountains) searchResultsSubject.send(allMountains) currentPageSubject.send(currentPageSubject.value + 1) - // 마지막 페이지 체크 - if allMountains.count >= response.body.totalCount { + // 누적 받은 데이터 개수 업데이트 (API 응답 내 중복 제거 후) + let totalReceived = totalReceivedCountSubject.value + deduplicatedNewMountains.count + totalReceivedCountSubject.send(totalReceived) + + // 마지막 페이지 체크 (누적 개수 기준 또는 새 데이터가 없는 경우) + if totalReceived >= response.body.totalCount || uniqueNewMountains.isEmpty { isLastPageSubject.send(true) } case .failure(let error): diff --git a/Presentation/Sources/Scenes/SearchScene/SearchViewModel.swift b/Presentation/Sources/Scenes/SearchScene/SearchViewModel.swift index a48c8fd..0283992 100644 --- a/Presentation/Sources/Scenes/SearchScene/SearchViewModel.swift +++ b/Presentation/Sources/Scenes/SearchScene/SearchViewModel.swift @@ -59,6 +59,7 @@ final class SearchViewModel: BaseViewModel { let currentKeywordSubject = CurrentValueSubject("") let isLastPageSubject = CurrentValueSubject(false) let currentMountainsSubject = CurrentValueSubject<[MountainInfo], Never>([]) + let totalReceivedCountSubject = CurrentValueSubject(0) // 최근 검색어 Fetch let loadRecentSearchTrigger = PassthroughSubject() @@ -134,6 +135,7 @@ final class SearchViewModel: BaseViewModel { currentKeywordSubject.send(keyword) currentPageSubject.send(1) isLastPageSubject.send(false) + totalReceivedCountSubject.send(0) isLoadingSubject.send(true) }) .flatMap { [weak self] keyword -> AnyPublisher, Never> in @@ -147,10 +149,18 @@ final class SearchViewModel: BaseViewModel { switch result { case .success(let response): let mountains = response.body.items.item - currentMountainsSubject.send(mountains) - searchResultsSubject.send(mountains) - // 마지막 페이지 체크 + // API 응답 자체에 중복이 있을 수 있으므로 중복 제거 (순서 유지) + var seenIds = Set() + let uniqueMountains = mountains.filter { seenIds.insert($0.id).inserted } + + currentMountainsSubject.send(uniqueMountains) + searchResultsSubject.send(uniqueMountains) + + // 받은 데이터 개수 업데이트 (중복 포함) + totalReceivedCountSubject.send(mountains.count) + + // 마지막 페이지 체크 (중복 포함 받은 데이터 개수 기준) if mountains.count >= response.body.totalCount { isLastPageSubject.send(true) } @@ -158,6 +168,7 @@ final class SearchViewModel: BaseViewModel { errorMessageSubject.send(("검색 실패", error.localizedDescription)) currentMountainsSubject.send([]) searchResultsSubject.send([]) + totalReceivedCountSubject.send(0) isLastPageSubject.send(true) } } @@ -181,13 +192,27 @@ final class SearchViewModel: BaseViewModel { switch result { case .success(let response): let newMountains = response.body.items.item - let allMountains = currentMountainsSubject.value + newMountains + + // API 응답 자체에 중복이 있을 수 있으므로 먼저 중복 제거 (순서 유지) + var seenIdsInResponse = Set() + let deduplicatedNewMountains = newMountains.filter { seenIdsInResponse.insert($0.id).inserted } + + // 기존 데이터와 비교하여 중복 체크 + let existingMountains = currentMountainsSubject.value + let existingIds = Set(existingMountains.map { $0.id }) + let uniqueNewMountains = deduplicatedNewMountains.filter { !existingIds.contains($0.id) } + + let allMountains = existingMountains + uniqueNewMountains currentMountainsSubject.send(allMountains) searchResultsSubject.send(allMountains) currentPageSubject.send(currentPageSubject.value + 1) - // 마지막 페이지 체크 - if allMountains.count >= response.body.totalCount { + // 누적 받은 데이터 개수 업데이트 (API 응답 내 중복 제거 후) + let totalReceived = totalReceivedCountSubject.value + deduplicatedNewMountains.count + totalReceivedCountSubject.send(totalReceived) + + // 마지막 페이지 체크 (누적 개수 기준 또는 새 데이터가 없는 경우) + if totalReceived >= response.body.totalCount || uniqueNewMountains.isEmpty { isLastPageSubject.send(true) } case .failure(let error): From 0a8c27f93a729fbbbf8723e203c9a8666c4a58a8 Mon Sep 17 00:00:00 2001 From: kyhlsd Date: Sun, 7 Dec 2025 20:49:43 +0900 Subject: [PATCH 11/14] =?UTF-8?q?=EA=B8=B0=EB=A1=9D=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=20=EC=8B=9C=20=ED=86=B5=EA=B3=84=20=EA=B0=B1=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ClimbRecordDetailViewModel.swift | 20 +++--- .../RecordList/ClimbRecordListViewModel.swift | 65 ++++++++++++++++--- 2 files changed, 66 insertions(+), 19 deletions(-) diff --git a/Presentation/Sources/Scenes/ClimbRecordScene/RecordDetail/ClimbRecordDetailViewModel.swift b/Presentation/Sources/Scenes/ClimbRecordScene/RecordDetail/ClimbRecordDetailViewModel.swift index 9e2e4b6..6b5cd8e 100644 --- a/Presentation/Sources/Scenes/ClimbRecordScene/RecordDetail/ClimbRecordDetailViewModel.swift +++ b/Presentation/Sources/Scenes/ClimbRecordScene/RecordDetail/ClimbRecordDetailViewModel.swift @@ -14,6 +14,7 @@ protocol ClimbRecordDetailViewModelDelegate: AnyObject { func updateReview(id: String, rating: Int, comment: String) func deleteRecord(id: String) func updateImages(id: String, images: [String]) + func refreshStats() } final class ClimbRecordDetailViewModel: BaseViewModel { @@ -495,6 +496,7 @@ final class ClimbRecordDetailViewModel: BaseViewModel { switch result { case .success: delegate?.deleteRecord(id: climbRecord.id) + delegate?.refreshStats() popVCSubject.send(()) case .failure(let error): errorMesssageSubject.send(("기록 삭제에 실패했습니다", error.localizedDescription)) @@ -555,15 +557,15 @@ final class ClimbRecordDetailViewModel: BaseViewModel { .eraseToAnyPublisher() } } - .sink { result in - if let result = result { - switch result { - case .success: - NotificationCenter.default.post(name: .climbRecordDidSave, object: nil) - saveCompletedSubject.send(()) - case .failure(let error): - errorMesssageSubject.send(("저장 실패", error.localizedDescription)) - } + .sink { [weak self] result in + guard let self, let result else { return } + switch result { + case .success: + NotificationCenter.default.post(name: .climbRecordDidSave, object: nil) + delegate?.refreshStats() + saveCompletedSubject.send(()) + case .failure(let error): + errorMesssageSubject.send(("저장 실패", error.localizedDescription)) } } .store(in: &cancellables) diff --git a/Presentation/Sources/Scenes/ClimbRecordScene/RecordList/ClimbRecordListViewModel.swift b/Presentation/Sources/Scenes/ClimbRecordScene/RecordList/ClimbRecordListViewModel.swift index 9c42426..216e3c1 100644 --- a/Presentation/Sources/Scenes/ClimbRecordScene/RecordList/ClimbRecordListViewModel.swift +++ b/Presentation/Sources/Scenes/ClimbRecordScene/RecordList/ClimbRecordListViewModel.swift @@ -26,6 +26,7 @@ final class ClimbRecordListViewModel: BaseViewModel { private let reloadDataSubject = PassthroughSubject() private lazy var emptyStateTextSubject = CurrentValueSubject(emptyText) private let recordUpdatedSubject = PassthroughSubject() + private let statSubject = PassthroughSubject<(mountainCount: Int, climbCount: Int, totalHeight: Int), Never>() init(fetchUseCase: FetchClimbRecordUseCase, toggleBookmarkUseCase: ToggleBookmarkUseCase, fetchRecordImageUseCase: FetchRecordImageUseCase, getRecordStatsUseCase: GetRecordStatsUseCase) { self.fetchUseCase = fetchUseCase @@ -55,7 +56,6 @@ final class ClimbRecordListViewModel: BaseViewModel { let isOnlyBookmarkedSubject = CurrentValueSubject(isOnlyBookmarked) let searchTextSubject = CurrentValueSubject("") let errorMessageSubject = PassthroughSubject<(String, String), Never>() - let statSubject = PassthroughSubject<(mountainCount: Int, climbCount: Int, totalHeight: Int), Never>() // viewDidLoad 시 전체 데이터 불러와서 통계와 일지 모두 업데이트 input.viewDidLoad @@ -105,18 +105,11 @@ final class ClimbRecordListViewModel: BaseViewModel { } .store(in: &cancellables) - // 검색, 북마크 필터, 새 기록 저장 시 일지만 다시 불러오기 + // 검색, 북마크 필터 변경 시 일지 다시 불러오기 let searchText = searchTextSubject.eraseToAnyPublisher() let isOnlyBookmarked = isOnlyBookmarkedSubject.eraseToAnyPublisher() - let climbRecordDidSave = NotificationCenter.default - .publisher(for: .climbRecordDidSave) - .map { _ in (searchTextSubject.value, isOnlyBookmarkedSubject.value) } - .eraseToAnyPublisher() - Publishers.Merge( - Publishers.CombineLatest(searchText, isOnlyBookmarked), - climbRecordDidSave - ) + Publishers.CombineLatest(searchText, isOnlyBookmarked) .flatMap { [fetchUseCase] keyword, isOnlyBookmarked in fetchUseCase.execute(keyword: keyword, isOnlyBookmarked: isOnlyBookmarked) .map { result -> (Result<[ClimbRecord], Error>, String) in @@ -147,6 +140,40 @@ final class ClimbRecordListViewModel: BaseViewModel { } } .store(in: &cancellables) + + // 새 기록 저장 시 일지 + 통계 갱신 + NotificationCenter.default + .publisher(for: .climbRecordDidSave) + .flatMap { [fetchUseCase] _ in + fetchUseCase.execute(keyword: searchTextSubject.value, isOnlyBookmarked: isOnlyBookmarkedSubject.value) + } + .sink { [weak self] result in + guard let self else { return } + + switch result { + case .success(let records): + climbRecordList = records + recordImageDatas = [:] + reloadDataSubject.send(()) + + if records.isEmpty { + if searchTextSubject.value.isEmpty { + emptyStateTextSubject.send(emptyText) + } else { + emptyStateTextSubject.send("검색 결과가 없습니다\n다른 키워드로 검색해보세요") + } + } + + // 통계 갱신 (전체 데이터 기준) + self.refreshStats() + + case .failure(let error): + errorMessageSubject.send(("기록 불러오기 실패", error.localizedDescription)) + climbRecordList = [] + reloadDataSubject.send(()) + } + } + .store(in: &cancellables) // 셀 북마크 토글 let bookmarkToggledSubject = PassthroughSubject() @@ -268,4 +295,22 @@ extension ClimbRecordListViewModel: ClimbRecordDetailViewModelDelegate { recordUpdatedSubject.send(id) } } + + // 통계 갱신 + func refreshStats() { + fetchUseCase.execute(keyword: "", isOnlyBookmarked: false) + .sink { [weak self] result in + guard let self else { return } + + switch result { + case .success(let records): + // 전체 데이터로 통계 재계산 + let stats = self.getRecordStatsUseCase.execute(records: records) + self.statSubject.send((mountainCount: stats.mountainCount, climbCount: stats.climbCount, totalHeight: stats.totalHeight)) + case .failure: + break + } + } + .store(in: &cancellables) + } } From dc1913901df370f16bc58c5a2e1382b51257db40 Mon Sep 17 00:00:00 2001 From: kyhlsd Date: Sun, 7 Dec 2025 20:52:47 +0900 Subject: [PATCH 12/14] =?UTF-8?q?build=20number=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Project.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Project.swift b/Project.swift index 5687651..dd14c4e 100644 --- a/Project.swift +++ b/Project.swift @@ -3,7 +3,7 @@ import ProjectDescription let iOSVersion = "16.0" let teamID = "4QUWH828P3" let appVersion = "1.4.1" -let buildNumber = "1" +let buildNumber = "2" let project = Project( name: "Oreum", From 51c185c85008d382ca49a7c60e4c89962115a26c Mon Sep 17 00:00:00 2001 From: kyhlsd Date: Sun, 7 Dec 2025 20:57:27 +0900 Subject: [PATCH 13/14] =?UTF-8?q?=EC=A7=80=EB=8F=84=20=EB=84=A4=ED=8A=B8?= =?UTF-8?q?=EC=9B=8C=ED=81=AC=20=EB=B0=B0=EB=84=88=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Scenes/MapScene/MapViewController.swift | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/Presentation/Sources/Scenes/MapScene/MapViewController.swift b/Presentation/Sources/Scenes/MapScene/MapViewController.swift index d086144..c114e85 100644 --- a/Presentation/Sources/Scenes/MapScene/MapViewController.swift +++ b/Presentation/Sources/Scenes/MapScene/MapViewController.swift @@ -10,15 +10,12 @@ import Combine import MapKit import Domain -final class MapViewController: UIViewController, BaseViewController, NetworkStatusObservable { +final class MapViewController: UIViewController, BaseViewController { var pushInfoVC: ((MountainInfo) -> Void)? let mainView = MapView() let viewModel: MapViewModel - - var networkStatusBanner: NetworkStatusBannerView? - var networkStatusCancellable: AnyCancellable? private var cancellables = Set() private lazy var dataSource = createDataSource() @@ -52,7 +49,6 @@ final class MapViewController: UIViewController, BaseViewController, NetworkStat setupDelegates() setupMapBoundary() setupAnnotationViewBuilder() - setupNetworkStatusObserver() bind() viewDidLoadSubject.send(()) @@ -67,10 +63,6 @@ final class MapViewController: UIViewController, BaseViewController, NetworkStat super.viewWillDisappear(animated) navigationController?.setNavigationBarHidden(false, animated: animated) } - - deinit { - removeNetworkStatusObserver() - } func bind() { let input = MapViewModel.Input( From f55b694dab102827309d0cffc9fd536edee60b7a Mon Sep 17 00:00:00 2001 From: kyhlsd <113406379+kyhlsd@users.noreply.github.com> Date: Sun, 7 Dec 2025 21:36:01 +0900 Subject: [PATCH 14/14] Revise README for implementation details and features Updated the README to reflect changes in implementation details and features. --- README.md | 72 +++++++++++++++++++++++++++++++++++-------------------- 1 file changed, 46 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 3c858b1..299aade 100644 --- a/README.md +++ b/README.md @@ -26,24 +26,44 @@
-## 📌 기술스택 +## 📌 구현 사항 **Architecture** -- Tuist를 활용해 Clean Architecture 계층을 모듈화하여 의존성 관리 및 빌드 효율 향상 +- Tuist를 활용한 Clean Architecture 계층 모듈화로 의존성 관리 및 빌드 효율 향상 - MVVM-C와 Combine 기반 구조체 Input/Output 패턴으로 명확한 데이터 흐름과 반응형 프로그래밍 구현 **Network** -- NSCache와 FileManager를 활용한 캐싱 구현으로 데이터 접근 속도 개선 및 네트워크 단절 상황 대응 +- Alamofire와 Router 패턴으로 API 통신 구조 표준화, protocol을 활용한 응답 값 기반 에러 처리 - NWPathMonitor로 네트워크 상태를 감지해 단절/재연결 배너를 상단 표기 -- Alamofire와 Router 패턴으로 API 통신 구조 표준화, 응답 값 기반 에러 처리 -**Data** +**Database** - Realm을 활용한 1:N 관계형 DB 설계, 양방향 관계 설정으로 데이터 무결성 보장 -- HealthKit을 활용해 앱이 종료된 상태에서의 데이터 수집, 휴리스틱 필터와 칼만 필터로 데이터 보정 -- WidgetKit으로 측정 중 정보 표기 -- 로컬 Push 알림으로 등산 완료 시 앱에서도 측정 종료 유도 -- Quad-Tree 알고리즘 클러스터링으로 지도의 시각적 가독성과 효율성 개선 -- 기기 크기에 따른 이미지 리사이징으로 저장 공간 절감 및 성능 향상 +- Index를 활용한 DB 검색 및 정렬 성능 최적화 + +**Caching** +- NSCache와 FileManager를 활용한 캐싱 구현으로 데이터 접근 속도 개선 및 네트워크 단절 대응 +- NSCache로 메모리 캐시, FileManager로 디스크 캐시를 LRU 방식으로 구현 +- 캐시 유효기간 설정으로 E-Tag 미지원 환경에서 데이터 정합성 확보 +- 메모리 캐시 -> 디스크 캐시 -> 네트워크 파이프라인 구축 + +**HealthKit** +- HealthKit을 활용한 앱 종료 상태에서의 데이터 수집, 휴리스틱 필터와 칼만 필터로 데이터 보정 + 1. iOS HealthKit의 배터리 최적화로 일부 기록이 누락되고 데이터가 일괄 업데이트 되는 문제 발견 + 2. 휴리스틱 필터 기반 이상치 감지 및 균등 배분 + 3. 칼만 필터 기반 보정을 통해 실제 걸음 수 / 이동 거리에 가깝게 복원 +- 권한 미허용 시 설정으로 이동 유도, 앱이 Active 상태가 되었을 때 권한 확인 + +**Clustering** +- Quad-Tree 기반 클러스터링으로 지돚의 시각적 가독성과 효율성 개선 + 1. 전체 데이터의 경계를 계산하고, Quad-Tree 생성하여, capacity 초과 시 재귀적으로 4분할하며 데이터 삽입 + 2. 지도의 줌 레벨에 따라 Quad-Tree의 최대 탐색 깊이를 조정하여 확대 시 개별 마커, 축소 시 클러스터를 표시 + 3. 가시 영역 검사로 필요한 노드만 조회, 실제 거리 기반 서브 클러스터링 +- 지도 범위 한국으로 한정 + +**Widget** +- App Group과 UserDefaults를 활용하며 메인 앱과 위젯 간 측정 데이터를 공유 +- 측정 상태에서 5분 간격의 Timeline Entry를 미리 생성하는 방식으로 위젯의 실시간 업데이트 한계 극복 +- 측정 종료 시 새로운 정적 Timeline으로 교체하여 불필요한 리소스 소비 방지 및 배터리 효율성 최적화 **CI / CD** - DI Container를 적용해 서비스/개발 환경별 Repository 주입 분리 및 테스트 용이성 확보 @@ -52,6 +72,11 @@ **Mornitoring** - Firebase Analytics와 Crashlytics로 사용자 행동 및 안정성 모니터링 +**etc.** +- 기기 크기 기반 이미지 리사이징으로 저장 공간 절감 및 성능 향상 +- 로컬 Push 알림으로 등산 완료 시 앱에서의 측정 종료 유도 +- Offset 기반 페이지네이션 중복 데이터 제거 + **Frameworks** - UIKit(code base, +SwiftUI), Combine, MapKit, CoreLocation, HealthKit, Charts, NWPathMonitor, Tuist, Realm, Alamofire,XMLCoder, Kingfisher, SnapKit, Toast, Firebase Analytics, Firebase Crashlytics @@ -74,32 +99,27 @@ **등산 기록 관리** -- 등산 기록을 저장하고 관리 -- 등산 중 촬영한 사진과 후기, 별점과 측정 정보 등을 함께 저장 -- 수정/삭제, 검색/북마크 기능 지원 +- 등산 기록을 사진, 후기, 별점 등으로 저장 +- 등산 기록 통계 제공(산 개수, 총 등산 횟수, 총 높이) +- 기록 수정 / 삭제, 검색 및 북마크 기능 **기록 측정/통계** - 등산 중 걸음 수와 이동 거리 측정 -- 실제 측정 기록과 유사하도록 측정 데이터 보정(휴리스틱 필터, 칼만 필터) -- 운동 시간, 휴식 시간 분석 및 걸음 수, 이동 거리 통계 정보 제공 -- 시간 별 걸음 수/이동 거리, 평균 대비 기록을 차트로 시각화 -- 위젯으로 등산 측정 진행 상황을 표기(산 이름, 경과 시간) -- 측정 시작 3시간 이후 1시간마다 측정 중인지 확인하는 로컬 Push 알림 +- 5분 단위, 평균 대비 기록 분석 및 차트로 시각화된 통계 데이터 제공 +- 위젯을 통한 등산 측정 진행 상황 표기(산 이름, 측정 경과 시간) +- 로컬 Push 알림 발송으로 등산 완료 시 측정 종료 유도 **명산 지도** -- 전국의 명산 위치를 지도에 표시 -- 현재 위치 기반으로 주변 산 탐색 -- 밀집된 영역은 묶어서 개수로 함께 표기 -- 산을 선택하여 상세 정보 확인 가능 +- 전국 100대 명산 지도 표기 +- 현재 위치 기반 산과의 거리 표기 +- 산이 밀집된 지역은 그룹화하여 개수로 표기 +- 산 선택 시 상세 정보 확인 가능 **검색/산 정보 보기** -- 전국 산 정보 검색 -- 검색 결과에서 산을 선택하여 상세 페이지로 이동 -- 국토교통부와 기상청 API를 연결해 날씨 정보 제공 -- 산 이름, 위치, 높이 등의 기본 정보 제공 +- 전국 산 정보 검색으로 산 상세 정보와 해당 지역 날씨 정보 제공 - 최근 검색어 기능 지원