IOS Health Kit으로 “오늘 칼로리” 연동 튜토리얼 (React Native Bare)

박정빈·2026년 4월 22일

React Native 사용기

목록 보기
39/41

이 문서는 React Native(bare) 앱에서 iOS HealthKit을 통해 "오늘 소모 칼로리(kcal)" 를 가져와 화면에 표시하는 방법을 정리한 튜토리얼입니다.

핵심 요약

  • iOS HealthKit은 activeEnergyBurned(활동 칼로리)와 basalEnergyBurned(기초대사량)를 별도 타입으로 제공합니다.
  • Android Health Connect의 ActiveCaloriesBurnedRecord와 의미를 맞추기 위해 Active 칼로리 우선, 없으면 active + basal 합산으로 폴백합니다.
  • HealthKit은 사용자가 읽기 권한을 거절해도 requestAuthorization 콜백에서 success=true를 반환하므로, 실제 허용 여부는 데이터 유무로 판단해야 합니다.

목표

  • HealthKit에서 오늘(0시~현재) 의 칼로리를 읽어와 RN 화면에 노출
  • 권한이 없으면 권한을 요청하고, 승인 후 재조회

1) HealthKit 프레임워크 추가 (Xcode Capabilities)

Xcode에서 앱 타겟을 선택한 뒤 Signing & Capabilities 탭으로 이동합니다.

  1. + Capability 버튼 클릭
  2. HealthKit 검색 후 추가

이 과정을 거치면 gg24.entitlements에 아래 항목이 자동으로 추가됩니다.

<key>com.apple.developer.healthkit</key>
<true/>
<key>com.apple.developer.healthkit.access</key>
<array/>

Capability를 추가하지 않으면 HKHealthStore.isHealthDataAvailable()true를 반환해도 실제 쿼리가 실패합니다.


2) Info.plist 설정 (권한 설명 문구)

gg24/ios/gg24/Info.plist에 HealthKit 사용 목적 문구를 추가합니다.
이 문구는 시스템 권한 요청 다이얼로그에 그대로 표시됩니다.

<key>NSHealthShareUsageDescription</key>
<string>오늘의 걸음 수와 소모 칼로리를 읽어 리워드 제공을 위해 사용됩니다.</string>
<key>NSHealthUpdateUsageDescription</key>
<string>걸음 수 및 칼로리 데이터는 읽기 전용으로만 사용하며 저장하지 않습니다.</string>
  • NSHealthShareUsageDescription — 읽기(read) 권한 요청 시 표시
  • NSHealthUpdateUsageDescription — 쓰기(write) 권한 요청 시 표시. 이 프로젝트는 쓰기를 요청하지 않지만 키가 없으면 App Store 심사에서 거절될 수 있습니다.

3) 권한 요청

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로 표시되는 것이 정상입니다.


4) "오늘 칼로리" 읽기 구현

사용하는 API: HKStatisticsQuery

iOS HealthKit은 Android의 aggregate()에 해당하는 HKStatisticsQuery를 제공합니다.
options: .cumulativeSum으로 지정하면 구간 내 모든 샘플을 합산한 값을 반환합니다.

집계 전략 (Android와 동일)

  1. activeEnergyBurned(활동 칼로리) 먼저 집계
  2. Active가 0이면 active + basal 합산으로 폴백
active > 0  →  active 반환
active == 0 →  active + basal 반환

Swift 예시 코드

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)
  }
}

5) Obj-C 브릿지 (RCT_EXTERN_MODULE)

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.HealthKitModuleundefined로 나타납니다.


6) React Native로 브릿지 연결

이 프로젝트는 RN ↔ 네이티브 통신을 NativeModules로 연결합니다.

  • TypeScript 브릿지: <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();

7) 디버깅 팁

  • 네이티브 코드(Swift/Info.plist/entitlements)를 변경했다면 iOS 앱 리빌드가 필요합니다. Fast Refresh로 반영되지 않습니다.
  • 권한 상태 초기화: 시뮬레이터 메뉴 Device > Erase All Content and Settings 또는 실기기에서 앱 삭제 후 재설치하면 권한 요청 다이얼로그를 다시 띄울 수 있습니다.
  • authorizationStatus(for:)는 참고용으로만 사용하세요. 읽기 전용 타입은 사용자가 허용해도 sharingDenied(raw=1)로 표시되는 것이 HealthKit의 정상 동작입니다.
  • HKErrorNoData(code=11)은 에러가 아닙니다. 오늘 구간에 샘플이 한 건도 없을 때 발생하며, 0으로 처리하면 됩니다.
  • 실기기에서만 확인 가능한 데이터가 있습니다. 시뮬레이터의 Health 앱에는 걷기/운동 데이터가 없으므로 칼로리가 항상 0으로 나올 수 있습니다. 실제 데이터 검증은 실기기에서 하세요.

부록: activeEnergyBurned vs basalEnergyBurned 차이

타입의미Android 대응
activeEnergyBurned활동(운동·걷기 등)으로 소모한 칼로리. BMR 제외.ActiveCaloriesBurnedRecord
basalEnergyBurned기초대사량(BMR). 생명 유지에 쓰이는 칼로리.TotalCaloriesBurnedRecord의 일부

이 프로젝트에서는 플랫폼 지표 의미를 맞추기 위해 Active 우선으로 노출하고, 데이터가 없을 때 active + basal 합산으로 폴백합니다.

0개의 댓글