iOS HealthKit으로 피트니스 앱 구현: 걸음수부터 수면까지

iOS

HealthKitiOS피트니스Apple Watch건강 데이터

이 글은 누구를 위한 것인가

  • 걸음수, 심박수, 수면 등 건강 데이터를 앱에 통합하려는 팀
  • 운동 기록 앱을 HealthKit과 연동하려는 개발자
  • Apple Watch 운동 세션을 구현하려는 팀

들어가며

HealthKit은 사용자의 건강 데이터에 접근하는 중앙 저장소다. 걸음수, 심박수, 수면 분석, 칼로리 등 다양한 데이터를 읽고 쓸 수 있다. 사용자 동의가 필수며, 요청하는 데이터 타입을 최소화해야 한다.

이 글은 bluefoxdev.kr의 iOS HealthKit 피트니스 앱 가이드 를 참고하여 작성했습니다.


1. HealthKit 설계 원칙

[HealthKit 데이터 타입]
  수량 타입 (HKQuantityType):
    stepCount: 걸음수
    heartRate: 심박수 (beats/min)
    activeEnergyBurned: 활성 칼로리
    distanceWalkingRunning: 이동 거리
    bodyMass: 체중

  범주 타입 (HKCategoryType):
    sleepAnalysis: 수면 (in bed, asleep)
    mindfulSession: 마음챙김

  운동 (HKWorkoutType):
    workout: 운동 세션

[권한 요청 원칙]
  필요한 타입만 요청
  읽기 + 쓰기 각각 별도 요청
  거부 시 기능 비활성화 (크래시 X)
  
[Info.plist]
  NSHealthShareUsageDescription: "읽기 사용 설명"
  NSHealthUpdateUsageDescription: "쓰기 사용 설명"

2. HealthKit 구현

import HealthKit
import Combine

class HealthKitManager: ObservableObject {
    private let healthStore = HKHealthStore()
    
    @Published var todaySteps: Int = 0
    @Published var heartRate: Double = 0
    @Published var sleepHours: Double = 0
    
    // 권한 요청
    func requestAuthorization() async throws {
        guard HKHealthStore.isHealthDataAvailable() else {
            throw HealthKitError.notAvailable
        }
        
        let readTypes: Set<HKObjectType> = [
            HKQuantityType(.stepCount),
            HKQuantityType(.heartRate),
            HKQuantityType(.activeEnergyBurned),
            HKCategoryType(.sleepAnalysis),
        ]
        
        let writeTypes: Set<HKSampleType> = [
            HKQuantityType(.activeEnergyBurned),
            HKWorkoutType.workoutType(),
        ]
        
        try await healthStore.requestAuthorization(toShare: writeTypes, read: readTypes)
    }
    
    // 오늘 걸음수 조회
    func fetchTodaySteps() async -> Int {
        let type = HKQuantityType(.stepCount)
        let calendar = Calendar.current
        let startOfDay = calendar.startOfDay(for: Date())
        let predicate = HKQuery.predicateForSamples(withStart: startOfDay, end: Date())
        
        return await withCheckedContinuation { continuation in
            let query = HKStatisticsQuery(
                quantityType: type,
                quantitySamplePredicate: predicate,
                options: .cumulativeSum
            ) { _, statistics, _ in
                let steps = statistics?.sumQuantity()?.doubleValue(for: .count()) ?? 0
                continuation.resume(returning: Int(steps))
            }
            healthStore.execute(query)
        }
    }
    
    // 심박수 실시간 관찰 (Observer Query)
    func startHeartRateObserver() {
        let type = HKQuantityType(.heartRate)
        
        let observerQuery = HKObserverQuery(sampleType: type, predicate: nil) { [weak self] _, completionHandler, error in
            guard error == nil else { return }
            
            Task {
                let latest = await self?.fetchLatestHeartRate() ?? 0
                await MainActor.run { self?.heartRate = latest }
            }
            
            completionHandler()
        }
        
        healthStore.execute(observerQuery)
        healthStore.enableBackgroundDelivery(for: type, frequency: .immediate) { _, _ in }
    }
    
    private func fetchLatestHeartRate() async -> Double {
        let type = HKQuantityType(.heartRate)
        let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierEndDate, ascending: false)
        
        return await withCheckedContinuation { continuation in
            let query = HKSampleQuery(sampleType: type, predicate: nil, limit: 1, sortDescriptors: [sortDescriptor]) { _, samples, _ in
                let bpm = (samples?.first as? HKQuantitySample)?.quantity.doubleValue(for: HKUnit(from: "count/min")) ?? 0
                continuation.resume(returning: bpm)
            }
            healthStore.execute(query)
        }
    }
    
    // 운동 세션 기록
    func saveWorkout(type: HKWorkoutActivityType, startDate: Date, endDate: Date, calories: Double, distance: Double) async throws {
        let workout = HKWorkout(
            activityType: type,
            start: startDate,
            end: endDate,
            duration: endDate.timeIntervalSince(startDate),
            totalEnergyBurned: HKQuantity(unit: .kilocalorie(), doubleValue: calories),
            totalDistance: HKQuantity(unit: .meter(), doubleValue: distance),
            metadata: nil
        )
        
        try await healthStore.save(workout)
    }
}

enum HealthKitError: Error { case notAvailable }

마무리

HealthKit의 핵심은 사용자 동의다. 요청하는 데이터 타입을 최소화하고, 거부 시 앱이 우아하게 기능을 비활성화해야 한다. HKObserverQuery + 백그라운드 배달로 앱이 백그라운드에서도 심박수, 걸음수 변화를 실시간으로 받아 처리할 수 있다.