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를 연결해 날씨 정보 제공
-- 산 이름, 위치, 높이 등의 기본 정보 제공
+- 전국 산 정보 검색으로 산 상세 정보와 해당 지역 날씨 정보 제공
- 최근 검색어 기능 지원