본문으로 건너뛰기
버전: 개발 버전 (최신)

External Health 데이터 수집 가이드

개요

이 가이드는 모바일 앱에서 iOS HealthKit, Android Health Connect를 통해 사용자의 건강 데이터를 수집하고 서버에 제출하는 방법을 설명합니다.

핵심 개념

External Health 시스템은 개인화된 데이터 수집 방식을 사용합니다:

  • 모든 사용자에게 동일한 데이터를 수집하는 것이 아닙니다.
  • LLM/Agent가 각 사용자를 분석하여 필요한 데이터만 선택적으로 요청합니다.
  • 앱은 서버에서 요청한 데이터만 수집하여 제출합니다.

데이터 수집 플로우

시퀀스 다이어그램

단계별 설명

1단계: 대기 중인 요청 조회

GET /v1/external-health/requests?platform=iOS&filterByGrantedPermissions=true
  • 서버에서 LLM/Agent가 생성한 데이터 수집 요청 목록을 조회합니다.
  • platform 파라미터로 iOS/Android를 지정합니다.
  • filterByGrantedPermissions=true로 승인된 권한에 해당하는 요청만 조회합니다.

2단계: SDK로 데이터 수집

  • 응답받은 requests[] 배열의 각 요청에 대해 데이터를 수집합니다.
  • iOS: HealthKit SDK 사용
  • Android: Health Connect SDK 사용

3단계: 수집 데이터 제출

POST /v1/external-health/responses
  • 수집한 데이터를 서버에 제출합니다.
  • 여러 요청에 대한 응답을 한 번에 제출할 수 있습니다.

호출 시점

앱에서 데이터 수집 플로우를 실행해야 하는 시점:

시점설명
Onboarding 완료HealthKit/Health Connect 권한 동의 후 즉시
Home Screen 진입앱 실행 또는 Home Screen 표시 시

데이터 유형

Quantity 타입 (수치 데이터)

데이터 유형설명집계 방식단위
stepCount걸음 수sum (합계)count
heartRate심박수average (평균)count/min
heartRateVariability심박변이도average (평균)ms
respiratoryRate호흡수average (평균)/min
oxygenSaturation산소포화도average (평균)%
activeEnergyBurned활동 에너지sum (합계)kcal

Category 타입 (범주형 데이터)

데이터 유형설명
sleepAnalysis수면 분석 (수면 단계 포함)

수면 단계 (Sleep Category)

iOS HealthKitAndroid Health Connect내부 값설명
inBed-inBed침대에 누워있음
asleepUnspecifiedsleepingasleepUnspecified수면 (상세 미지정)
asleepCorelightasleepCore얕은 수면
asleepDeepdeepasleepDeep깊은 수면
asleepREMremasleepREMREM 수면
awakeawakeawake깨어있음

구현 가이드

iOS (Swift) 구현 예시

// 1. 대기 중인 요청 조회
func fetchPendingRequests() async throws -> [DataCollectionRequest] {
let url = URL(string: "\(baseURL)/v1/external-health/requests?platform=iOS&filterByGrantedPermissions=true")!
var request = URLRequest(url: url)
request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")

let (data, _) = try await URLSession.shared.data(for: request)
let response = try JSONDecoder().decode(DataCollectionRequestsResponse.self, from: data)
return response.requests
}

// 2. HealthKit에서 데이터 수집
func collectHealthKitData(for request: DataCollectionRequest) async throws -> DataCollectionResponse {
let healthStore = HKHealthStore()

switch request.type {
case "heartRate":
// 심박수 데이터 수집
let heartRateType = HKQuantityType.quantityType(forIdentifier: .heartRate)!
// ... HealthKit 쿼리 실행
case "sleepAnalysis":
// 수면 데이터 수집
let sleepType = HKCategoryType.categoryType(forIdentifier: .sleepAnalysis)!
// ... HealthKit 쿼리 실행
default:
break
}
// 응답 데이터 생성
}

// 3. 데이터 제출
func submitResponses(_ responses: [DataCollectionResponse]) async throws {
let url = URL(string: "\(baseURL)/v1/external-health/responses")!
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")

let body = DataCollectionResponsesRequest(responses: responses)
request.httpBody = try JSONEncoder().encode(body)

let (_, _) = try await URLSession.shared.data(for: request)
}

Android (Kotlin) 구현 예시

// 1. 대기 중인 요청 조회
suspend fun fetchPendingRequests(): List<DataCollectionRequest> {
val response = apiService.getPendingRequests(
platform = "Android",
filterByGrantedPermissions = true
)
return response.requests
}

// 2. Health Connect에서 데이터 수집
suspend fun collectHealthConnectData(
healthConnectClient: HealthConnectClient,
request: DataCollectionRequest
): DataCollectionResponse {
return when (request.type) {
"heartRate" -> {
val response = healthConnectClient.readRecords(
ReadRecordsRequest(
recordType = HeartRateRecord::class,
timeRangeFilter = TimeRangeFilter.between(
Instant.ofEpochMilli(request.startTime),
Instant.ofEpochMilli(request.endTime)
)
)
)
// 응답 데이터 생성
}
"sleepAnalysis" -> {
val response = healthConnectClient.readRecords(
ReadRecordsRequest(
recordType = SleepSessionRecord::class,
timeRangeFilter = TimeRangeFilter.between(
Instant.ofEpochMilli(request.startTime),
Instant.ofEpochMilli(request.endTime)
)
)
)
// 응답 데이터 생성
}
else -> throw IllegalArgumentException("Unknown type: ${request.type}")
}
}

// 3. 데이터 제출
suspend fun submitResponses(responses: List<DataCollectionResponse>) {
apiService.submitResponses(
DataCollectionResponsesRequest(responses = responses)
)
}

오류 처리

주요 오류 코드

코드메시지설명대응 방법
21010INVALID_REQUEST_ID유효하지 않은 요청 ID요청 목록 재조회
21011DATA_VALIDATION_FAILED데이터 검증 실패데이터 형식 확인
21012DUPLICATE_RESPONSE중복 응답 제출무시 (이미 처리됨)
21022PERMISSION_NOT_GRANTED권한 미승인권한 요청 안내

오류 처리 원칙

  1. 데이터 수집 실패 시에도 앱의 주요 플로우(Onboarding, Home Screen)는 차단되지 않아야 합니다.
  2. 실패한 데이터는 로컬에 캐싱하고 이후 재시도합니다.
  3. 네트워크 오류 시 백그라운드에서 자동 재시도합니다.

백그라운드 업로드

iOS

Background URLSession을 활용하여 앱이 백그라운드에 있을 때도 데이터를 업로드할 수 있습니다.

let config = URLSessionConfiguration.background(withIdentifier: "com.app.externalHealth")
let session = URLSession(configuration: config, delegate: self, delegateQueue: nil)

Android

WorkManager를 활용하여 안정적인 백그라운드 업로드를 구현합니다.

val uploadWork = OneTimeWorkRequestBuilder<DataUploadWorker>()
.setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
)
.build()

WorkManager.getInstance(context).enqueue(uploadWork)

관련 API 레퍼런스

관련 문서