❤️ HealthKit 완전정복

건강 앱의 데이터를 읽고 쓰세요. 걸음수, 심박수, 수면, 운동 등 모든 건강 데이터에 접근!

✨ HealthKit이란?

HealthKit은 Apple 건강 앱의 중앙 저장소입니다. iPhone, Apple Watch의 모든 건강 데이터를 읽고 쓸 수 있습니다.

🔐 권한 요청

Authorization.swift
import HealthKit

class HealthKitManager {
    let healthStore = HKHealthStore()

    // HealthKit 사용 가능 여부
    func isHealthKitAvailable() -> Bool {
        return HKHealthStore.isHealthDataAvailable()
    }

    // 권한 요청
    func requestAuthorization() async throws {
        // 읽을 데이터 타입
        let typesToRead: Set<HKObjectType> = [
            HKObjectType.quantityType(forIdentifier: .stepCount)!,
            HKObjectType.quantityType(forIdentifier: .heartRate)!,
            HKObjectType.quantityType(forIdentifier: .activeEnergyBurned)!,
            HKObjectType.workoutType()
        ]

        // 쓸 데이터 타입
        let typesToWrite: Set<HKSampleType> = [
            HKObjectType.quantityType(forIdentifier: .stepCount)!,
            HKObjectType.workoutType()
        ]

        try await healthStore.requestAuthorization(
            toShare: typesToWrite,
            read: typesToRead
        )
    }
}

📊 걸음수 읽기

StepCount.swift
import HealthKit

func fetchTodaySteps() async throws -> Double {
    let stepType = HKQuantityType.quantityType(forIdentifier: .stepCount)!

    // 오늘 00:00부터 현재까지
    let now = Date()
    let startOfDay = Calendar.current.startOfDay(for: now)
    let predicate = HKQuery.predicateForSamples(
        withStart: startOfDay,
        end: now,
        options: .strictStartDate
    )

    // 통계 쿼리
    let query = HKStatisticsQuery(
        quantityType: stepType,
        quantitySamplePredicate: predicate,
        options: .cumulativeSum
    ) { _, result, error in
        guard let result = result,
              let sum = result.sumQuantity() else {
            return
        }

        let steps = sum.doubleValue(for: HKUnit.count())
        print("오늘 걸음수: \(steps)")
    }

    healthStore.execute(query)

    // async/await 변환
    return try await withCheckedThrowingContinuation { continuation in
        let query = HKStatisticsQuery(
            quantityType: stepType,
            quantitySamplePredicate: predicate,
            options: .cumulativeSum
        ) { _, result, error in
            if let error = error {
                continuation.resume(throwing: error)
            } else if let sum = result?.sumQuantity() {
                let steps = sum.doubleValue(for: HKUnit.count())
                continuation.resume(returning: steps)
            } else {
                continuation.resume(returning: 0)
            }
        }
        healthStore.execute(query)
    }
}

💓 심박수 읽기

HeartRate.swift
func fetchLatestHeartRate() async throws -> Double? {
    let heartRateType = HKQuantityType.quantityType(forIdentifier: .heartRate)!

    // 최신 1개만
    let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: false)

    return try await withCheckedThrowingContinuation { continuation in
        let query = HKSampleQuery(
            sampleType: heartRateType,
            predicate: nil,
            limit: 1,
            sortDescriptors: [sortDescriptor]
        ) { _, samples, error in
            if let error = error {
                continuation.resume(throwing: error)
            } else if let sample = samples?.first as? HKQuantitySample {
                let bpm = sample.quantity.doubleValue(for: HKUnit(from: "count/min"))
                continuation.resume(returning: bpm)
            } else {
                continuation.resume(returning: nil)
            }
        }
        healthStore.execute(query)
    }
}

// 사용 예시
Task {
    if let heartRate = try await fetchLatestHeartRate() {
        print("현재 심박수: \(heartRate) BPM")
    }
}

✍️ 데이터 쓰기

WriteData.swift
// 걸음수 추가
func saveSteps(_ steps: Double, date: Date) async throws {
    let stepType = HKQuantityType.quantityType(forIdentifier: .stepCount)!
    let quantity = HKQuantity(unit: HKUnit.count(), doubleValue: steps)

    let sample = HKQuantitySample(
        type: stepType,
        quantity: quantity,
        start: date,
        end: date
    )

    try await healthStore.save(sample)
}

// 체중 기록
func saveWeight(_ weight: Double) async throws {
    let weightType = HKQuantityType.quantityType(forIdentifier: .bodyMass)!
    let quantity = HKQuantity(unit: HKUnit.gramUnit(with: .kilo), doubleValue: weight)

    let now = Date()
    let sample = HKQuantitySample(
        type: weightType,
        quantity: quantity,
        start: now,
        end: now
    )

    try await healthStore.save(sample)
}

🏃 운동 기록 (Workout)

Workout.swift
import HealthKit

func saveRunningWorkout(
    distance: Double,
    duration: TimeInterval,
    calories: Double
) async throws {
    let startDate = Date().addingTimeInterval(-duration)
    let endDate = Date()

    // 운동 생성
    let workout = HKWorkout(
        activityType: .running,
        start: startDate,
        end: endDate,
        duration: duration,
        totalEnergyBurned: HKQuantity(
            unit: HKUnit.kilocalorie(),
            doubleValue: calories
        ),
        totalDistance: HKQuantity(
            unit: HKUnit.meter(),
            doubleValue: distance
        ),
        metadata: [
            HKMetadataKeyIndoorWorkout: false
        ]
    )

    try await healthStore.save(workout)
}

// 운동 조회
func fetchWorkouts() async throws -> [HKWorkout] {
    let workoutType = HKObjectType.workoutType()
    let sortDescriptor = NSSortDescriptor(
        key: HKSampleSortIdentifierStartDate,
        ascending: false
    )

    return try await withCheckedThrowingContinuation { continuation in
        let query = HKSampleQuery(
            sampleType: workoutType,
            predicate: nil,
            limit: 20,
            sortDescriptors: [sortDescriptor]
        ) { _, samples, error in
            if let error = error {
                continuation.resume(throwing: error)
            } else {
                let workouts = samples as? [HKWorkout] ?? []
                continuation.resume(returning: workouts)
            }
        }
        healthStore.execute(query)
    }
}

📈 일별 통계 (Statistics Collection)

DailyStats.swift
// 최근 7일 걸음수
func fetchWeeklySteps() async throws -> [(Date, Double)] {
    let stepType = HKQuantityType.quantityType(forIdentifier: .stepCount)!

    let now = Date()
    let startDate = Calendar.current.date(byAdding: .day, value: -7, to: now)!

    // 일별 집계
    var interval = DateComponents()
    interval.day = 1

    let predicate = HKQuery.predicateForSamples(
        withStart: startDate,
        end: now,
        options: .strictStartDate
    )

    return try await withCheckedThrowingContinuation { continuation in
        let query = HKStatisticsCollectionQuery(
            quantityType: stepType,
            quantitySamplePredicate: predicate,
            options: .cumulativeSum,
            anchorDate: startDate,
            intervalComponents: interval
        )

        query.initialResultsHandler = { _, results, error in
            if let error = error {
                continuation.resume(throwing: error)
                return
            }

            var data: [(Date, Double)] = []

            results?.enumerateStatistics(from: startDate, to: now) { statistics, _ in
                let steps = statistics.sumQuantity()?.doubleValue(for: HKUnit.count()) ?? 0
                data.append((statistics.startDate, steps))
            }

            continuation.resume(returning: data)
        }

        healthStore.execute(query)
    }
}

🔔 실시간 업데이트 (Observer Query)

ObserverQuery.swift
class HealthObserver {
    let healthStore = HKHealthStore()
    var query: HKObserverQuery?

    func startObserving() {
        let stepType = HKObjectType.quantityType(forIdentifier: .stepCount)!

        query = HKObserverQuery(sampleType: stepType, predicate: nil) { _, completionHandler, error in
            if let error = error {
                print("에러: \(error)")
                return
            }

            // 걸음수 변경 감지
            print("걸음수가 업데이트되었습니다!")
            Task {
                let steps = try await fetchTodaySteps()
                print("현재 걸음수: \(steps)")
            }

            // 백그라운드 작업 완료 알림
            completionHandler()
        }

        healthStore.execute(query!)

        // 백그라운드 전달 활성화
        healthStore.enableBackgroundDelivery(for: stepType, frequency: .immediate) { success, error in
            print("백그라운드 전달: \(success)")
        }
    }

    func stopObserving() {
        if let query = query {
            healthStore.stop(query)
        }
    }
}

📱 SwiftUI 통합

HealthView.swift
import SwiftUI

@Observable
class HealthViewModel {
    var steps: Double = 0
    var heartRate: Double?
    var isAuthorized = false

    let manager = HealthKitManager()

    func requestPermission() async {
        do {
            try await manager.requestAuthorization()
            isAuthorized = true
            await loadData()
        } catch {
            print("권한 요청 실패: \(error)")
        }
    }

    func loadData() async {
        do {
            steps = try await fetchTodaySteps()
            heartRate = try await fetchLatestHeartRate()
        } catch {
            print("데이터 로드 실패: \(error)")
        }
    }
}

struct HealthDashboard: View {
    var viewModel = HealthViewModel()

    var body: some View {
        VStack(spacing: 20) {
            if viewModel.isAuthorized {
                VStack {
                    Text("🚶 걸음수")
                        .font(.headline)
                    Text("\(Int(viewModel.steps))")
                        .font(.system(size: 48, weight: .bold))
                }

                if let heartRate = viewModel.heartRate {
                    VStack {
                        Text("❤️ 심박수")
                            .font(.headline)
                        Text("\(Int(heartRate)) BPM")
                            .font(.system(size: 36, weight: .bold))
                    }
                }
            } else {
                Button("건강 데이터 접근 권한 요청") {
                    Task {
                        await viewModel.requestPermission()
                    }
                }
                .buttonStyle(.borderedProminent)
            }
        }
        .task {
            if viewModel.isAuthorized {
                await viewModel.loadData()
            }
        }
    }
}

⚙️ Info.plist 설정

Info.plist
<!-- 필수: 사용 목적 설명 -->
<key>NSHealthShareUsageDescription</key>
<string>건강 데이터를 읽어 운동 기록을 보여줍니다</string>

<key>NSHealthUpdateUsageDescription</key>
<string>운동 기록을 건강 앱에 저장합니다</string>

<!-- Xcode Capabilities에서 "HealthKit" 추가 필수 -->

💡 HIG 체크리스트
✅ 필요한 데이터 타입만 요청
✅ 사용 목적 명확히 설명 (Info.plist)
✅ 권한 거부 시 앱이 정상 동작
✅ 건강 데이터는 절대 외부로 전송 금지
✅ 백그라운드 업데이트 신중히 사용

📦 학습 자료

💻
GitHub 프로젝트
🍎
Apple HIG 원문
📖
Apple 공식 문서