Skip to content

hyeonghwan/Runners

Repository files navigation

Runner

사용자의 러닝 정보를 기록·분석할 수 있는 로컬 중심의 UIKit 기반 iOS 애플리케이션

Swift iOS Xcode License

프로젝트 개요

러너즈는 Clean Architecture와 ReactorKit을 기반으로 구축된 러닝 추적 애플리케이션입니다. GPS, CoreMotion, HealthKit을 활용하여 정확한 러닝 데이터를 수집하고, Apple Watch와의 실시간 동기화를 통해 최상의 사용자 경험을 제공합니다.

  • 개발 기간: 2025.10.01 ~ 2025.10.14 (2주, 앱스토어 배포) 이후 지속적으로 개발 중
  • 개발 인원: iOS 1인
  • 배포: App Store

주요 특징

  • 실시간 러닝 추적: 거리, 시간, 페이스 메트릭을 실시간 확인
  • 지도에 경로 표시: 달린 경로를 지도 위 선으로 시각화
  • 다중 기기 동기화: iPhone · Apple Watch · Live Activity 실시간 동기 제어
  • 심박수 측정: Apple Watch 연동 시 실시간 심박수 표시 (bpm)
  • 백그라운드 실행: 앱이 닫혀도 기록 유지 및 복원 기능
  • 잠금화면 표시: Live Activity로 Dynamic Island 및 잠금 화면에서 거리/시간 확인 가능
  • 통계 및 그래프: Swift Charts를 활용한 페이스/심박수/고도 추세 시각화
  • SNS 공유 이미지 생성: 경로 + 메트릭 + 사진 합성

주요 기능

1. 러닝 상태 관리

러닝은 실행 → 일시정지 → 완료의 단계를 거치며, 상태에 따라 동작이 달라집니다.

  • 상태 기반 동작

    • 실행 중: Location/Motion 실시간 업데이트, 3초마다 Metrics 갱신
    • 일시정지: 업데이트 중단하여 배터리 소모 최소화, 현재 Metrics 상태 유지
    • 재개: 유지된 Metrics 기반으로 누적 기록 이어서 갱신
    • 완료: CoreData에 최종 Metrics 및 경로 저장
  • 안전한 상태 전환

    • 잘못된 상태 전환 방지
    • 중복된 명령 자동 무시
    • 정확한 활동만 경로에 반영

2. 실시간 메트릭 모니터링

  • 측정 항목
    • 거리 (km/mile 단위 변환 지원)
    • 시간 (총 시간 / 활동 시간)
    • 페이스 (현재/평균/최고/최저)
    • 심박수 (Apple Watch 연동 시, bpm)
    • 고도 (상승/하강)
    • 케이던스 (steps/min)
    • 칼로리 (MET 기반 계산)

3. 사용자 위치 경로 그리기

  • 사용자의 이동 경로를 지도 위에 자연스럽게 연결
  • 새 위치가 추가될 때마다 부드럽게 선이 이어짐
  • 현재 위치를 중심으로 화면 자동 조정
  • 활동 중인 좌표만 필터링하여 정확한 경로 표시

4. iPhone · Watch · Live Activity 실시간 동기화

3개의 프로세스에서 동시에 러닝 상태를 표시하며, 어느 기기에서든 제어가 가능합니다.

  • 상태 동기화

    • 정지/실행/종료 상태를 세 기기에서 실시간 동기화
    • Watch 심박수와 iPhone 메트릭을 상호 전송하여 정보 일치
    • 연결 상태에 따라 실시간 전송 또는 재연결 시 자동 동기화
  • 데이터 전송

    • 3초마다 Metrics 갱신
    • 일시정지 시 타이머 정지, 재개 시 이어서 계산
    • 연결 끊김 시에도 데이터 유실 방지

5. 통계 및 차트

  • Swift Charts 기반 시각화

    • 페이스 분석 차트
    • 고도 프로필 차트
    • 심박수 추세 그래프
    • 성과 그래프
    • 인터랙티브 차트 (탭하여 상세 값 표시)
  • 기록 관리

    • 전체 러닝 기록 조회
    • 날짜별/주간/월간 필터링
    • 썸네일 미리보기
    • 상세 기록 편집 (제목, 메모)

6. SNS 공유 이미지 생성

  • 템플릿 기반 공유 이미지 생성
  • 경로 지도 + 러닝 메트릭 + 사진 합성
  • 소셜 미디어 공유 최적화

아키텍처

설계 배경

러닝 앱은 GPS 추적, 실시간 센서 데이터 수집, 다중 기기 동기화 등 복잡한 상태 관리가 필요했습니다. 특히 iPhone, Apple Watch, Live Activity 세 곳에서 동시에 러닝을 제어할 수 있어야 했기 때문에 각 입력 소스를 통합하여 일관되게 처리할 수 있는 아키텍처가 필요했습니다.

초기에는 MVVM 패턴을 고려했으나, 여러 입력 소스에서 오는 명령을 ViewModel에서 분기 처리하면 코드가 복잡해지고 중복 명령 처리가 어려웠습니다.

이를 해결하기 위해 ReactorKit을 도입했습니다. ReactorKit은 단방향 데이터 플로우로 Action → Mutation → State 흐름을 명확히 하며 입력 출처를 명시적으로 관리할 수 있었습니다.

Clean Architecture를 적용하여 Presentation-Domain-Data-Infrastructure 레이어로 분리하고 Domain에서 Repository Protocol을 정의하여 의존성을 역전시켰습니다.

이를 통해 각 레이어의 책임을 명확히 하고, Mock Repository를 주입하여 러닝 세션 로직을 독립적으로 테스트할 수 있었습니다.

전체 아키텍처 구조

┌─────────────────────────────────────────────────────────────────┐
│                    UI / Presentation Layer                      │
│                                                                 │
│  ┌──────────┐      ┌──────────┐                               │
│  │   View   │─────▶│ Reactor  │                               │
│  └──────────┘      └──────────┘                               │
│       ▲                                                         │
│       │                                                         │
│  ┌──────────────┐         ReactorKit (단방향 데이터 플로우)    │
│  │ Coordinator  │         RxSwift/RxCocoa                      │
│  └──────────────┘                                              │
└─────────────────────────────────────────────────────────────────┘
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│                       Domain Layer                              │
│                                                                 │
│  ┌──────────┐      ┌──────────────┐                           │
│  │ UseCase  │─────▶│ Repository   │ (Protocol)                │
│  └──────────┘      │  Interface   │                           │
│                    └──────────────┘                           │
│                                                                 │
│  - Business Logic                                               │
│  - Domain Models                                                │
│  - Protocol Interfaces (의존성 역전)                            │
└─────────────────────────────────────────────────────────────────┘
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│                        Data Layer                               │
│                                                                 │
│  ┌──────────────┐                                              │
│  │ Repository   │ (Implementation)                             │
│  └──────────────┘                                              │
│                                                                 │
│  - Repository Implementations                                   │
│  - CoreData, Network, Sensors                                   │
└─────────────────────────────────────────────────────────────────┘
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│                   Core / Infrastructure                         │
│                                                                 │
│  LocationService, CoreMotionService, WatchConnectivity,         │
│  WeatherKit, HealthKit, etc.                                    │
└─────────────────────────────────────────────────────────────────┘

기술 스택

카테고리 기술
UI UIKit, SnapKit, MapKit
Persistence CoreData
Architecture Clean Architecture, Coordinator Pattern
Environment iOS 16.0+, Swift 5.10, Tuist, SPM
System CoreLocation, CoreMotion, HealthKit, WeatherKit
Cross-Device ActivityKit, WatchConnectivity
Reactive RxSwift, RxCocoa, ReactorKit

상세 기술 스택

  • Architecture & Patterns

    • Clean Architecture (Presentation-Domain-Data-Infrastructure)
    • ReactorKit (단방향 데이터 플로우)
    • Coordinator Pattern
    • Repository Pattern
    • Dependency Injection
  • UI Framework

    • UIKit (메인 UI)
    • SnapKit (AutoLayout DSL)
    • MapKit (지도 표시)
  • Reactive Programming

    • RxSwift
    • RxCocoa
    • ReactorKit
  • Data & Persistence

    • CoreData (러닝 기록 저장)
    • UserDefaults (설정)
    • App Group (위젯/Watch 데이터 공유)
  • iOS Frameworks

    • CoreLocation (GPS 추적)
    • CoreMotion (걸음 수, 케이던스, 활동 감지)
    • HealthKit (심박수)
    • WeatherKit (날씨)
    • MapKit (지도)
    • WatchConnectivity (Watch 통신)
    • ActivityKit (Live Activity)
  • Development Tools

    • Tuist (프로젝트 생성 및 관리)
    • SwiftLint (코드 스타일)
    • Swift Package Manager

프로젝트 구조

Runner/
├── Projects/
│   ├── Runner/                    # 메인 앱
│   │   ├── Sources/
│   │   │   ├── App/              # 앱 진입점, DI, 코디네이터
│   │   │   ├── Core/             # 핵심 서비스
│   │   │   │   ├── LocationService/      # GPS 추적, Kalman Filter
│   │   │   │   ├── CoreMotion/           # 걸음 수, 모션 감지
│   │   │   │   ├── Weather/              # WeatherKit 통합
│   │   │   │   ├── WatchConnectivity/    # Watch 통신
│   │   │   │   └── Analytics/            # Firebase Analytics
│   │   │   ├── Domain/           # 도메인 모델
│   │   │   ├── Data/             # Repository 구현
│   │   │   ├── Features/         # 기능별 모듈
│   │   │   │   ├── Home/         # 홈 대시보드
│   │   │   │   ├── Run/          # 러닝 추적
│   │   │   │   ├── History/      # 기록 & 통계
│   │   │   │   ├── PhotoEditor/  # 사진 편집
│   │   │   │   └── Setting/      # 설정
│   │   │   └── Utils/            # 유틸리티
│   │   └── Resources/
│   ├── RunnerWatch/              # Apple Watch 앱
│   ├── RunnerLiveActivityWidget/ # Live Activity
│   ├── Common/                   # 공통 모델
│   └── CommonUI/                 # 공통 UI
├── Config/                       # 환경별 설정
├── Tuist/                        # 프로젝트 생성 설정
└── Module/hwan_macro/            # Swift Macro

Dependency Injection

커스텀 DI Container를 구현하여 타입 안전한 의존성 주입을 제공합니다:

// 사용 예시
@Injected(RunUseCaseKey.self, .transient)
private var runUsecase: RunUseCase

// 등록
Container.register(RunUseCaseKey.self) {
    DefaultRunUseCase()
}

특징

  • @Injected Property Wrapper
  • Singleton / Transient / Automatic 인스턴스 타입
  • 타입 안전성 (InjectionKey 프로토콜)
  • 테스트 모드 지원

ReactorKit 데이터 플로우

// User Action
User Interaction (버튼 탭)
    
Action (.resumeRunning)
    
Mutation (.updateRunningState(.running))
    
State (runningState = .running)
    
View 자동 업데이트 (바인딩)

// Sensor Data
5초마다 센서 폴링
    
Observable<(LocationData?, MotionData, WatchData?)>
    
Mutation (.updateSensorData)
    
RunCalculationUseCase
    
State 업데이트 (runRecord, runSnapshot)
    
계산 프로퍼티 재평가 (currentPace, totalDistance)
    
View 자동 업데이트

주요 기술 구현

1. 활동 기반 GPS 데이터 필터링

문제 상황

GPS는 정지 상태에서도 신호 노이즈로 인해 거리가 누적되는 드리프트 현상이 발생했으며, 거리 임계값이나 속도 기반 필터링은 실제 활동과 정지 상태를 정확히 구분하기 어려웠습니다.

해결 방법

CMMotionActivityManager를 활용한 활동 감지

  • CMMotionActivityManager실제로 걷거나 뛰는 상태를 감지
  • CMPedometer로 걸음 수와 페이스를 측정하여 활동 중일 때만 거리를 계산
let shouldCalculateDistance =
    isValidActivity &&              // CoreMotion이 walking/running 감지
    wasActiveInPreviousUpdate &&    // 이전에도 활동 중
    !wasPaused &&                   // 일시정지 아님
    location.accuracy < 15          // GPS 정확도 15m 이내

if shouldCalculateDistance {
    let distance = calculateDistance(from: last, to: current)
    totalDistance += distance
}

모든 좌표에 유효성 플래그 추가

  • 지도 렌더링 시 활동 중인 좌표만 필터링하여 표시
  • 정지 중 GPS 노이즈로 인한 거리 오차를 제거

결과

  • GPS drift 현상 제거
  • 정확한 러닝 거리 측정
  • 자연스러운 경로 표시

2. iPhone · Watch · Live Activity 통합 제어 구조

문제 상황

3개의 프로세스(iPhone, Apple Watch, Live Activity)에서 동시에 러닝 상태를 표시하며 어느 기기에서든 제어가 가능해야 했으나:

  • 폴링 방식은 배터리 소모가 크고
  • 단순 Watch의 sendMessage연결 끊김 시 데이터가 유실되는 문제가 있었습니다.

해결 방법

1) Live Activity → App 통신

  • **Darwin Notification (CFNotification)**으로 이벤트 기반 IPC를 구현
  • 폴링 없이 즉시 명령을 수신하도록 구현

2) Watch ↔ iPhone 통신 (하이브리드 방식)

  • WatchConnectivityisReachable로 연결 상태를 확인
  • 연결 시: sendMessage로 즉시 전송
  • 끊김 시: transferUserInfo로 백그라운드 큐에 저장하여 재연결 시 자동 전송
// WatchConnectivity 하이브리드 전송
if session.isReachable {
    // 연결 시: 즉시 전송
    session.sendMessage(data) { reply in
        // 성공 처리
    }
} else {
    // 끊김 시: 백그라운드 큐에 저장
    session.transferUserInfo(data)
}

3) Reactor의 입력 출처 추상화

  • Reactor의 input으로 들어오는 Event들을 ControlSource로 입력 출처를 추상화
  • 0.5초 디바운싱으로 중복 명령을 차단하여 세 기기가 항상 동일한 상태를 유지
enum ControlSource {
    case phone
    case watch
    case liveActivity
}

// 0.5초 디바운싱으로 중복 명령 차단
controlEvents
    .debounce(.milliseconds(500), scheduler: MainScheduler.instance)
    .map { Mutation.updateState($0) }

결과

  • 세 기기 간 실시간 상태 동기화
  • 연결 끊김 시에도 데이터 유실 방지
  • 중복 명령 방지로 안정적인 제어

3. 백그라운드 세션 복구

문제 상황

러닝 중 사용자가 다른 앱을 사용하면 iOS는 현재 앱을 백그라운드로 전환하는데, 이때 메모리가 부족하면 시스템이 앱을 강제 종료하거나 사용자가 의도치 않게 앱을 종료했을 경우 진행 중이던 러닝 기록(거리, 시간, 경로 등)이 모두 사라지는 문제가 있었습니다.

해결 방법

HeartBeat 패턴 구현

러닝 세션 자체는 Background Mode를 활성화하여 백그라운드에서 계속 실행되지만, 데이터 유실 상황을 방지하기 위해 HeartBeat 패턴에 착안했습니다.

1) 주기적으로 현재 상태를 저장

  • 주기적으로 현재 상태를 Codable 구조체로 직렬화하여 JSON 스냅샷으로 저장
  • Application Support 디렉토리에 저장

2) 주기적으로 알림을 schedule

  • 저장 시마다 알림을 schedule하여 강제 종료 상황에서는 사용자에게 Notification을 보내도록 구현
  • 정상 동작 시 알림이 계속 미래로 연기됨
  • 앱 종료 시 마지막 알림이 발송됨
앱 실행 중 (백그라운드)
→ 2분마다 임시 세션 저장
→ 저장 시 Notification 재등록 (10분 후로 리셋)
→ Notification은 계속 미래로 연기됨

앱 강제종료/크래시
→ 마지막 Notification 발송 (10분 후)
→ 사용자 알림 클릭
→ 앱 재시작 → Temp 세션 로드
→ 복원 Alert 표시

3) 세션 유효성 검증

  • 앱 재실행 시 24시간 이내 저장 기록이 있으면 사용자가 복원 여부를 선택할 수 있도록 함
  • 예기치 않은 앱 종료 상황에서도 데이터를 보존하도록 개선

구현 컴포넌트

  • TempRunSessionManager: JSON 기반 임시 세션 저장
  • RunningNotificationManager: Heartbeat 알림 관리
  • RunningSessionCoordinator: 전체 조율
  • RunReactor: 세션 복원 처리

결과

  • 앱 강제종료 시에도 러닝 데이터 보존
  • 사용자 경험 개선
  • 데이터 손실 방지

4. ReactorKit 데이터 플로우

Action → Mutation → State 단방향 흐름

// User Action
User Interaction (버튼 탭)
    
Action (.resumeRunning)
    
Mutation (.updateRunningState(.running))
    
State (runningState = .running)
    
View 자동 업데이트 (바인딩)

// Sensor Data
5초마다 센서 폴링
    
Observable<(LocationData?, MotionData, WatchData?)>
    
Mutation (.updateSensorData)
    
RunCalculationUseCase
    
State 업데이트 (runRecord, runSnapshot)
    
계산 프로퍼티 재평가 (currentPace, totalDistance)
    
View 자동 업데이트

5. 데이터 모델 & 저장

도메인 모델

struct RunRecord {
    let id: String
    let startedAt: Date
    var finishedAt: Date
    var totalDistance: Double      // m (원값 - SI 단위)
    var totalDuration: Double      // sec (원값 - SI 단위)
    var avgPace: Double            // s/m (원값 - SI 단위)

    var splits: [Split]            // 1km 구간
    var routes: [LocationPoint]    // GPS 좌표

    // ... 기타 메트릭
}

원값 저장 원칙

  • 모든 메트릭을 SI 단위로 저장 (m, sec, kg)
  • 표시 변환은 State의 계산 프로퍼티로 처리
  • 단위 변환 유연성 확보

CoreData Repository

protocol RunRecordRepositoryInterface {
    func save(_ record: RunRecord) -> Observable<Result<String, Error>>
    func fetchAll() -> Observable<Result<[RunRecord], Error>>
    func delete(recordId: String) -> Observable<Result<Void, Error>>
}

주요 화면

홈 화면

  • 날씨 정보 카드
  • 오늘의 러닝 요약
  • 최근 기록 (썸네일 포함)

러닝 추적

  • 실시간 메트릭 표시
  • 지도 경로 오버레이
  • 컨트롤 버튼 (일시정지/재개/종료)
  • Live Activity 지원

히스토리 & 통계

  • 러닝 기록 리스트
  • 통계 대시보드
  • Swift Charts 기반 상세 차트 분석
  • 페이스/고도/심박수 그래프

Apple Watch

  • 실시간 메트릭 동기화
  • Watch에서 러닝 시작 가능
  • 심박수 모니터링
  • iPhone과 양방향 동기화

설치 및 실행

요구사항

  • Xcode: 15.0+
  • iOS: 16.0+
  • Swift: 5.10
  • watchOS: 9.0+ (Watch 앱)
  • 빌드 도구: Tuist, SPM

설치

  1. 저장소 클론

    git clone https://github.com/yourusername/Runner.git
    cd Runner
  2. Tuist로 프로젝트 생성

    tuist generate
  3. Xcode에서 열기

    open Runner.xcworkspace
  4. 환경 선택 및 빌드

    • Runner-DEV: 개발 환경
    • Runner-PROD: 프로덕션 환경
    • Command + R로 빌드 및 실행

필수 권한

앱 실행 시 다음 권한이 필요합니다:

  • 위치 권한: 러닝 추적을 위한 GPS 사용
  • 모션 권한: 걸음 수 및 케이던스 측정
  • HealthKit 권한 (선택): Apple Watch 심박수 데이터
  • 날씨 권한: WeatherKit 사용
  • 알림 권한: 백그라운드 세션 복원 알림

성능 최적화

  • 센서 폴링 간격: 5초 (배터리 vs 정확도 균형)
  • 활동 기반 GPS 필터링: GPS drift 현상 제거
  • CoreData Batch Insert: 대량 LocationPoint 저장 최적화
  • DisposeBag 분리: 타이머/센서 독립적으로 dispose
  • 계산 프로퍼티: State에서 단위 변환 처리

라이선스

This project is licensed under the MIT License - see the LICENSE file for details

개발자

Hyeonghwan - iOS Developer


Built with Swift and iOS

About

러너즈 앱 레포지토리 입니다.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages