이 문서는 React Native(bare) 앱에서 iOS HealthKit을 통해 "오늘 소모 칼로리(kcal)" 를 가져와 화면에 표시하는 방법을 정리한 튜토리얼입니다.
핵심 요약
- iOS HealthKit은
activeEnergyBurned(활동 칼로리)와basalEnergyBurned(기초대사량)를 별도 타입으로 제공합니다.- Android Health Connect의
ActiveCaloriesBurnedRecord와 의미를 맞추기 위해 Active 칼로리 우선, 없으면active + basal합산으로 폴백합니다.- HealthKit은 사용자가 읽기 권한을 거절해도
requestAuthorization콜백에서success=true를 반환하므로, 실제 허용 여부는 데이터 유무로 판단해야 합니다.
Xcode에서 앱 타겟을 선택한 뒤 Signing & Capabilities 탭으로 이동합니다.
이 과정을 거치면 gg24.entitlements에 아래 항목이 자동으로 추가됩니다.
<key>com.apple.developer.healthkit</key>
<true/>
<key>com.apple.developer.healthkit.access</key>
<array/>
Capability를 추가하지 않으면
HKHealthStore.isHealthDataAvailable()은true를 반환해도 실제 쿼리가 실패합니다.
gg24/ios/gg24/Info.plist에 HealthKit 사용 목적 문구를 추가합니다.
이 문구는 시스템 권한 요청 다이얼로그에 그대로 표시됩니다.
<key>NSHealthShareUsageDescription</key>
<string>오늘의 걸음 수와 소모 칼로리를 읽어 리워드 제공을 위해 사용됩니다.</string>
<key>NSHealthUpdateUsageDescription</key>
<string>걸음 수 및 칼로리 데이터는 읽기 전용으로만 사용하며 저장하지 않습니다.</string>
NSHealthShareUsageDescription — 읽기(read) 권한 요청 시 표시NSHealthUpdateUsageDescription — 쓰기(write) 권한 요청 시 표시. 이 프로젝트는 쓰기를 요청하지 않지만 키가 없으면 App Store 심사에서 거절될 수 있습니다.HealthKit 권한은 HKObjectType 기반으로 요청합니다.
HKQuantityTypeIdentifier.activeEnergyBurned (활동 칼로리)HKQuantityTypeIdentifier.basalEnergyBurned (기초대사량)이 프로젝트에서는 읽기 전용 권한만 요청합니다 (toShare: nil).
let readTypes: Set<HKObjectType> = [activeEnergyType, basalEnergyType]
healthStore.requestAuthorization(toShare: nil, read: readTypes) { success, error in
// success=true여도 사용자가 실제로 허용했다는 보장이 없다.
// 실제 허용 여부는 getTodayCalories() 결과 값으로 판단한다.
resolve(success)
}
중요: HealthKit은 개인정보 보호를 위해 사용자가 읽기를 거절해도 앱에 알리지 않습니다.
authorizationStatus(for:)는 쓰기(share) 권한만 반환하므로, 읽기 전용으로 요청한 타입은 사용자가 허용해도sharingDenied로 표시되는 것이 정상입니다.
HKStatisticsQueryiOS HealthKit은 Android의 aggregate()에 해당하는 HKStatisticsQuery를 제공합니다.
options: .cumulativeSum으로 지정하면 구간 내 모든 샘플을 합산한 값을 반환합니다.
activeEnergyBurned(활동 칼로리) 먼저 집계active + basal 합산으로 폴백active > 0 → active 반환
active == 0 → active + basal 반환
import Foundation
import HealthKit
func getTodayCalories(
_ resolve: @escaping RCTPromiseResolveBlock,
rejecter reject: @escaping RCTPromiseRejectBlock
) {
guard HKHealthStore.isHealthDataAvailable() else {
resolve(0)
return
}
let healthStore = HKHealthStore()
guard
let activeEnergyType = HKObjectType.quantityType(forIdentifier: .activeEnergyBurned),
let basalEnergyType = HKObjectType.quantityType(forIdentifier: .basalEnergyBurned)
else {
reject("NO_TYPE", "HKQuantityType 생성 실패", nil)
return
}
let calendar = Calendar.current
let startOfDay = calendar.startOfDay(for: Date())
let predicate = HKQuery.predicateForSamples(withStart: startOfDay, end: Date())
// activeEnergyBurned와 basalEnergyBurned를 병렬로 집계한다.
func sumKcal(
_ type: HKQuantityType,
completion: @escaping (Double?, NSError?) -> Void
) {
let query = HKStatisticsQuery(
quantityType: type,
quantitySamplePredicate: predicate,
options: .cumulativeSum
) { _, statistics, error in
if let error = error as NSError?,
error.domain == HKErrorDomain,
error.code == HKError.Code.errorNoData.rawValue {
// 오늘 샘플이 없으면 HKErrorNoData(11) → 0으로 처리
completion(0, nil)
return
}
if let error = error as NSError? {
completion(nil, error)
return
}
let kcal = statistics?.sumQuantity()?.doubleValue(for: .kilocalorie()) ?? 0
completion(kcal, nil)
}
healthStore.execute(query)
}
let group = DispatchGroup()
var activeKcal: Double?
var basalKcal: Double?
var firstError: NSError?
group.enter()
sumKcal(activeEnergyType) { value, error in
if firstError == nil, let error = error { firstError = error }
activeKcal = value
group.leave()
}
group.enter()
sumKcal(basalEnergyType) { value, error in
if firstError == nil, let error = error { firstError = error }
basalKcal = value
group.leave()
}
group.notify(queue: .main) {
if let error = firstError {
reject("GET_CALORIES_ERROR", error.localizedDescription, error)
return
}
let active = activeKcal ?? 0
let basal = basalKcal ?? 0
// Android 구현과 동일한 전략: active 우선, 없으면 active+basal 합산
let resolved = active > 0 ? active : (active + basal)
resolve(resolved)
}
}
Swift 모듈을 React Native에 노출하려면 같은 이름의 .m 파일이 필요합니다.
ios/HealthKitModule.m을 생성합니다.
#import <React/RCTBridgeModule.h>
@interface RCT_EXTERN_MODULE(HealthKitModule, NSObject)
RCT_EXTERN_METHOD(
requestPermissions:(RCTPromiseResolveBlock)resolve
rejecter:(RCTPromiseRejectBlock)reject
)
RCT_EXTERN_METHOD(
getTodaySteps:(RCTPromiseResolveBlock)resolve
rejecter:(RCTPromiseRejectBlock)reject
)
RCT_EXTERN_METHOD(
getTodayCalories:(RCTPromiseResolveBlock)resolve
rejecter:(RCTPromiseRejectBlock)reject
)
@end
.m파일이 없으면NativeModules.HealthKitModule이undefined로 나타납니다.
이 프로젝트는 RN ↔ 네이티브 통신을 NativeModules로 연결합니다.
<root>/src/shared/natives/Health.ts<root>/src/shared/hooks/useTodayCalories.ts<root>/src/features/home/screens/HomeScreen.tsx// Health.ts — 플랫폼별 모듈을 하나의 인터페이스로 추상화
import { NativeModules, Platform } from 'react-native';
const { HealthKitModule, HealthConnectModule } = NativeModules;
const nativeModule = Platform.OS === 'ios' ? HealthKitModule : HealthConnectModule;
export const Health = {
async getTodayCalories(): Promise<number> {
if (!nativeModule) return 0;
const calories = await nativeModule.getTodayCalories();
return Number(calories ?? 0);
},
};
앱 코드에서는 훅으로 감싸서 사용합니다.
const { calories, isLoading, errorMessage } = useTodayCalories();
Device > Erase All Content and Settings 또는 실기기에서 앱 삭제 후 재설치하면 권한 요청 다이얼로그를 다시 띄울 수 있습니다.authorizationStatus(for:)는 참고용으로만 사용하세요. 읽기 전용 타입은 사용자가 허용해도 sharingDenied(raw=1)로 표시되는 것이 HealthKit의 정상 동작입니다.HKErrorNoData(code=11)은 에러가 아닙니다. 오늘 구간에 샘플이 한 건도 없을 때 발생하며, 0으로 처리하면 됩니다.| 타입 | 의미 | Android 대응 |
|---|---|---|
activeEnergyBurned | 활동(운동·걷기 등)으로 소모한 칼로리. BMR 제외. | ActiveCaloriesBurnedRecord |
basalEnergyBurned | 기초대사량(BMR). 생명 유지에 쓰이는 칼로리. | TotalCaloriesBurnedRecord의 일부 |
이 프로젝트에서는 플랫폼 지표 의미를 맞추기 위해 Active 우선으로 노출하고, 데이터가 없을 때 active + basal 합산으로 폴백합니다.