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 HealthKit | Android Health Connect | 내부 값 | 설명 |
|---|---|---|---|
inBed | - | inBed | 침대에 누워있음 |
asleepUnspecified | sleeping | asleepUnspecified | 수면 (상세 미지정) |
asleepCore | light | asleepCore | 얕은 수면 |
asleepDeep | deep | asleepDeep | 깊은 수면 |
asleepREM | rem | asleepREM | REM 수면 |
awake | awake | awake | 깨어있음 |
구현 가이드
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)
)
}
오류 처리
주요 오류 코드
| 코드 | 메시지 | 설명 | 대응 방법 |
|---|---|---|---|
21010 | INVALID_REQUEST_ID | 유효하지 않은 요청 ID | 요청 목록 재조회 |
21011 | DATA_VALIDATION_FAILED | 데이터 검증 실패 | 데이터 형식 확인 |
21012 | DUPLICATE_RESPONSE | 중복 응답 제출 | 무시 (이미 처리됨) |
21022 | PERMISSION_NOT_GRANTED | 권한 미승인 | 권한 요청 안내 |
오류 처리 원칙
- 데이터 수집 실패 시에도 앱의 주요 플로우(Onboarding, Home Screen)는 차단되지 않아야 합니다.
- 실패한 데이터는 로컬에 캐싱하고 이후 재시도합니다.
- 네트워크 오류 시 백그라운드에서 자동 재시도합니다.
백그라운드 업로드
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)