이 글은 누구를 위한 것인가
- 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시간 단위 타임라인을 적절히 설정하라.