watchOS 앱 개발: HealthKit 운동 추적과 워치 컴플리케이션

모바일 개발

watchOSHealthKitApple Watch운동 추적Complications

이 글은 누구를 위한 것인가

  • Apple Watch 앱으로 운동 데이터를 수집하고 싶은 팀
  • HealthKit 권한과 운동 세션 관리를 처음 구현하는 개발자
  • Watch Face 컴플리케이션으로 앱 데이터를 표시하고 싶은 엔지니어

들어가며

Apple Watch는 심박수, 활동 데이터, 산소포화도를 수집할 수 있다. HealthKit으로 이 데이터에 접근하고 HKWorkoutBuilder로 운동 세션을 관리하는 방법을 알아보자.

이 글은 bluefoxdev.kr의 watchOS 개발 가이드 를 참고하여 작성했습니다.


1. watchOS 앱 아키텍처

[watchOS 앱 구성]

독립형 앱 (Standalone):
  Watch App만으로 완전히 동작
  iOS 앱 없어도 됨 (watchOS 7+)
  App Store에서 직접 설치 가능

페어드 앱:
  iPhone 앱과 함께 동작
  WatchConnectivity로 데이터 동기화
  iPhone의 더 많은 처리 능력 활용

[HealthKit 데이터 유형]
  HKQuantityType: 수치 데이터
    .heartRate: 심박수 (bpm)
    .activeEnergyBurned: 활동 칼로리
    .distanceWalkingRunning: 이동 거리
    .stepCount: 걸음 수
  
  HKWorkoutType: 운동 세션
    달리기, 사이클링, 수영 등

[권한 요청 전략]
  Share (쓰기) + Read (읽기) 권한 분리
  필요한 것만 요청
  권한 거부 시 앱 동작 설명 필요

[컴플리케이션 유형]
  Modular Large: 큰 텍스트 표시
  Circular Small: 아이콘 + 숫자
  Graphic Circular: 원형 게이지
  Graphic Corner: 모서리 게이지

2. HealthKit 운동 앱 구현

import SwiftUI
import HealthKit

@MainActor
class WorkoutManager: NSObject, ObservableObject {
    let healthStore = HKHealthStore()
    private var workoutBuilder: HKWorkoutBuilder?
    private var workoutSession: HKWorkoutSession?
    
    @Published var heartRate: Double = 0
    @Published var activeCalories: Double = 0
    @Published var elapsedTime: TimeInterval = 0
    @Published var workoutState: WorkoutState = .idle
    
    // HealthKit 권한 요청
    func requestAuthorization() async throws {
        let typesToShare: Set<HKSampleType> = [
            HKObjectType.workoutType(),
        ]
        
        let typesToRead: Set<HKObjectType> = [
            HKObjectType.quantityType(forIdentifier: .heartRate)!,
            HKObjectType.quantityType(forIdentifier: .activeEnergyBurned)!,
            HKObjectType.quantityType(forIdentifier: .distanceWalkingRunning)!,
        ]
        
        try await healthStore.requestAuthorization(toShare: typesToShare, read: typesToRead)
    }
    
    // 운동 세션 시작
    func startWorkout(type: HKWorkoutActivityType = .running) async throws {
        let config = HKWorkoutConfiguration()
        config.activityType = type
        config.locationType = .outdoor
        
        // watchOS에서의 세션 시작
        let session = try HKWorkoutSession(healthStore: healthStore, configuration: config)
        let builder = session.associatedWorkoutBuilder()
        
        builder.dataSource = HKLiveWorkoutDataSource(
            healthStore: healthStore,
            workoutConfiguration: config,
        )
        
        session.delegate = self
        builder.delegate = self
        
        workoutSession = session
        workoutBuilder = builder
        
        session.startActivity(with: Date())
        try await builder.beginCollection(at: Date())
        
        workoutState = .active
        startTimer()
    }
    
    // 운동 일시정지
    func pauseWorkout() {
        workoutSession?.pause()
        workoutState = .paused
    }
    
    // 운동 재개
    func resumeWorkout() {
        workoutSession?.resume()
        workoutState = .active
    }
    
    // 운동 종료
    func endWorkout() async throws {
        guard let session = workoutSession,
              let builder = workoutBuilder else { return }
        
        session.end()
        
        try await builder.endCollection(at: Date())
        let workout = try await builder.finishWorkout()
        
        workoutState = .idle
        
        print("운동 완료: \(workout.duration)초, \(workout.totalEnergyBurned?.doubleValue(for: .kilocalorie()) ?? 0)kcal")
    }
    
    private func startTimer() {
        Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] timer in
            guard let self = self, self.workoutState == .active else {
                timer.invalidate()
                return
            }
            self.elapsedTime += 1
        }
    }
}

enum WorkoutState { case idle, active, paused }

extension WorkoutManager: HKWorkoutSessionDelegate {
    nonisolated func workoutSession(
        _ workoutSession: HKWorkoutSession,
        didChangeTo toState: HKWorkoutSessionState,
        from fromState: HKWorkoutSessionState,
        date: Date,
    ) {}
    
    nonisolated func workoutSession(
        _ workoutSession: HKWorkoutSession,
        didFailWithError error: Error,
    ) {
        print("운동 세션 에러: \(error)")
    }
}

extension WorkoutManager: HKLiveWorkoutBuilderDelegate {
    nonisolated func workoutBuilderDidCollectEvent(_ workoutBuilder: HKLiveWorkoutBuilder) {}
    
    nonisolated func workoutBuilder(
        _ workoutBuilder: HKLiveWorkoutBuilder,
        didCollectDataOf collectedTypes: Set<HKSampleType>,
    ) {
        Task { @MainActor in
            for type in collectedTypes {
                guard let quantityType = type as? HKQuantityType else { continue }
                
                let statistics = workoutBuilder.statistics(for: quantityType)
                
                switch quantityType {
                case HKQuantityType(.heartRate):
                    let hrUnit = HKUnit.count().unitDivided(by: .minute())
                    heartRate = statistics?.mostRecentQuantity()?.doubleValue(for: hrUnit) ?? 0
                    
                case HKQuantityType(.activeEnergyBurned):
                    activeCalories = statistics?.sumQuantity()?.doubleValue(for: .kilocalorie()) ?? 0
                    
                default: break
                }
            }
        }
    }
}

// watchOS 운동 뷰
struct WorkoutView: View {
    @EnvironmentObject var workoutManager: WorkoutManager
    
    var body: some View {
        VStack(spacing: 8) {
            HStack {
                Label("\(Int(workoutManager.heartRate)) bpm", systemImage: "heart.fill")
                    .foregroundColor(.red)
                Spacer()
            }
            
            HStack {
                Label("\(Int(workoutManager.activeCalories)) kcal", systemImage: "flame.fill")
                    .foregroundColor(.orange)
                Spacer()
            }
            
            Text(formatElapsedTime(workoutManager.elapsedTime))
                .font(.system(size: 32, weight: .bold, design: .rounded))
                .monospacedDigit()
            
            HStack {
                Button(workoutManager.workoutState == .paused ? "재개" : "일시정지") {
                    if workoutManager.workoutState == .paused {
                        workoutManager.resumeWorkout()
                    } else {
                        workoutManager.pauseWorkout()
                    }
                }
                
                Button("종료") {
                    Task { try? await workoutManager.endWorkout() }
                }
                .tint(.red)
            }
            .buttonStyle(.borderedProminent)
        }
        .padding()
    }
    
    private func formatElapsedTime(_ time: TimeInterval) -> String {
        let minutes = Int(time) / 60
        let seconds = Int(time) % 60
        return String(format: "%02d:%02d", minutes, seconds)
    }
}

// Complications (WidgetKit for watchOS)
import WidgetKit

struct HeartRateComplication: Widget {
    var body: some WidgetConfiguration {
        StaticConfiguration(kind: "heartrate_complication", provider: HeartRateProvider()) { entry in
            HeartRateComplicationView(entry: entry)
        }
        .configurationDisplayName("심박수")
        .description("현재 심박수를 표시합니다")
        .supportedFamilies([.accessoryCircular, .accessoryCorner])
    }
}

struct HeartRateEntry: TimelineEntry {
    let date: Date
    let heartRate: Int
}

struct HeartRateProvider: TimelineProvider {
    func placeholder(in context: Context) -> HeartRateEntry {
        HeartRateEntry(date: Date(), heartRate: 72)
    }
    
    func getSnapshot(in context: Context, completion: @escaping (HeartRateEntry) -> Void) {
        completion(HeartRateEntry(date: Date(), heartRate: 75))
    }
    
    func getTimeline(in context: Context, completion: @escaping (Timeline<HeartRateEntry>) -> Void) {
        let entry = HeartRateEntry(date: Date(), heartRate: 80)
        let timeline = Timeline(entries: [entry], policy: .atEnd)
        completion(timeline)
    }
}

struct HeartRateComplicationView: View {
    let entry: HeartRateEntry
    
    @Environment(\.widgetFamily) var family
    
    var body: some View {
        switch family {
        case .accessoryCircular:
            ZStack {
                AccessoryWidgetBackground()
                VStack {
                    Image(systemName: "heart.fill").foregroundColor(.red)
                    Text("\(entry.heartRate)").font(.caption2.bold())
                }
            }
        default:
            Text("\(entry.heartRate) bpm")
        }
    }
}

마무리

watchOS 운동 앱의 핵심은 HKWorkoutSession과 HKLiveWorkoutBuilder의 협력이다. 세션이 운동 상태를 관리하고, 빌더가 실시간 데이터를 수집한다. HealthKit 권한은 iOS 앱에서도 Watch에서도 따로 요청해야 하며, 거부 시 명확한 안내를 제공해야 한다. 컴플리케이션은 WidgetKit으로 구현하고 15분~1시간 단위 타임라인을 적절히 설정하라.