-
Notifications
You must be signed in to change notification settings - Fork 0
HealthKit, MapKit 그리고 WebSocket을 하나의 화면에서 조화롭게
안녕하세요. We-Tri의 홍승현입니다.
이번 글에서는 HealthKit과 MapKit 그리고 WebSocket 사용기에 대해 제가 공부하고, 실제로 적용했던 경험을 작성해보려고 해요.
제가 맡은 화면은 위 사진이에요. 세션화면이라고 할게요.
이 세션화면에 진입하기 이전에, 사용자가 매칭을 통해 유저들과 함께 운동을 할 것인지, 또는 혼자 운동을 할 것인지를 선택할 수 있고, 매칭이 성사되거나 혼자하게 될 때 카운트 다운과 함께 세션화면이 나타나게 돼요.
세션화면에서는 양쪽 스와이프를 통해 운동하는 사용자 목록을 보거나 내가 얼만큼 운동했는지를 지도로 볼 수 있어요. 그리고 종료버튼을 누르면 운동이 종료돼요.
세션화면에는 세 가지의 기능이 들어가있는데요. 사용자의 위치 정보를 받아 지도에 Polyline을 그리는 기능, HealthKit으로부터 사용자의 건강 데이터를 받아와 View에 업데이트하는 기능, 또 실시간으로 사용자의 운동 데이터를 웹소켓을 통해 서버로부터 전달하는 기능까지. 총 세가지의 기능을 하나의 화면에서 이루어지도록 구현해야했어요.
사용자로부터 위치 권한을 받아 Info.plist
에 설정한 뒤, CLLocationManager의 delegate를 설정하여 사용자의 위치 정보를 받아다가 지도에 그려주면 되었거든요. 물론, 갑자기 튀는 값들이 가끔 발생하였기에, 잡음을 잡기 위한 기능으로 칼만 필터를 도입해서 해결하게 되었어요. 칼만 필터는 잡음이 껴있는 실시간 데이터값에 따라 상태 값을 측정하는 알고리즘이에요. 자세한 내용은 칼만 필터 구현기를 참고해 주세요.
세션 화면에서는 운동을 시작했을 때 사용자의 건강 데이터를 가져와야 했어요. 그러기 위해서는 HKHealthStore
를 이용해서 Query를 요청해 Sample 값을 가져와야만 했습니다.
여기서 HKHealthStore는 HealthKit에 접근하기 위한 DB 같은 녀석이고, Query는
HKHealthStore
에 요청할 조건식들, 그리고 Sample은 운동 데이터 결괏값입니다.
sequenceDiagram
participant App as 앱
participant User as 사용자
participant HealthKit as HealthKit 스토어
App->>User: 사용자 권한 요청
User-->>App: 권한 승인/거부
Note right of App: 권한이 거부되면<br/>과정 종료
App->>HealthKit: HealthKit 스토어 설정
App->>App: 쿼리 설정
App->>HealthKit: 쿼리 실행
HealthKit-->>App: 쿼리 결과 반환
App->>App: 결과 처리 및 사용
다행히 특정 시간 이후부터 데이터를 가져올 수 있도록 Query를 설정할 때 Predicate
라는 파라미터 값을 설정할 수 있어요.
그래서 실제 코드는 아래의 흐름처럼 진행됩니다.
HealthStore를 설정하고, 내가 앞으로 받아올 HealthKit data를 설정해요.
import HealthKit
let healthStore = HKHealthStore()
let dataType: Set<HKQuantityType> = [HKQuantityType(forIdentifier: .activeEnergyBurned)]
그리고 healthStore를 이용하여 사용자에게 HealthKit을 읽기 위한 권한을 요청해요.
만약 건강 데이터를 직접 쓰고 싶다면, toShare
에다가 원하는 건강 데이터 타입을 작성하면 돼요.
클로저로 이루어져 있어 첫 번째 파라미터인 success가 성공적으로 true를 받아오면 비로소 HealthKit을 사용할 준비가 완료됩니다.
// 1. 사용자 권한 요청
healthStore.requestAuthorization(toShare: nil, read: dataType) { success, error in
// ...
}
건강 데이터를 받고자 할 시작 시각과 끝 시간을 설정해서 query를 생성할 수 있어요.
그리고 query의 handler로 받은 sample을 직접 클로저 내부에서 설정할 수 있습니다.
let startDate = ... // 특정 시작 시간
let predicate = HKQuery.predicateForSamples(withStart: startDate, end: nil)
let query = HKSampleQuery(sampleType: dataType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { query, results, error in
// 5. 샘플 데이터 가져오기
guard let samples = results as? [HKQuantitySample] else {
return
}
// 6. 결과 처리
// 예: 총 칼로리 소모량 계산
let totalCalories = samples.reduce(0) { total, sample in
total + sample.quantity.doubleValue(for: .kilocalorie())
}
// ...
}
마지막으로 healthStore가 query를 실행하면서 흐름이 끝나게 돼요.
// 4. 쿼리 실행
healthStore.execute(query)
우리는 사용자가 운동을 시작한 시간부터 운동 데이터를 받아와 앱으로 UI를 그려주고 싶었어요.
하지만 이렇게 진행하게 될 때 문제가 하나 있어요.
시작 시간으로부터 데이터를 받아오도록 구성한다면, 시간이 지나 사용자가 계속 운동을 진행하게 될 때 중복된 데이터를 받아올 수 있게 돼요.
다행히 애플에서는 이것까지 고려했는지 특정 시간부터 지속적으로 데이터를 가져와야할 때 사용하는 Query를 제공해주고 있어요. 바로 **HKAnchoredObjectQuery**
입니다.
**HKAnchoredObjectQuery**
는 다른 Query문과 다르게 anchor라는 파라미터를 추가로 받습니다. 이 anchor를 이용해서 특정 위치로부터 데이터를 받을 수 있으며, 요청을 보낼 때 새로운 anchor값을 completion handler를 통해 받게 돼요. 이 값을 잘 저장하고 있다가 다시 요청을 보내면 중복된 데이터를 받지 않고 새로운 데이터만 처리할 수 있어요. 아래처럼요!
sequenceDiagram
participant App as 앱
participant HealthKit as HealthKit 스토어
App->>HealthKit: HKAnchoredObjectQuery 초기화 (데이터 타입, 필터, 앵커 포인트)
HealthKit-->>App: 쿼리 실행
loop 결과 처리 및 업데이트 감지
HealthKit-->>App: 쿼리 결과 반환
App->>App: 결과 처리 및 새 앵커 포인트 저장
end
Note right of App: 필요한 경우, 업데이트 핸들러를<br/>통해 지속적인 모니터링 가능
그래서 코드는 다음과 같이 정의내릴 수 있습니다.
var anchor: HKQueryAnchor?
let query = HKAnchoredObjectQuery(
type: HKQuantityType(identifier),
predicate: HKQuery.predicateForSamples(withStart: startDate, end: nil),
anchor: anchor,
limit: HKObjectQueryNoLimit
) { query, samples, deleteSamples, newAnchor, error in
anchor = newAnchor // anchor 세팅하기 ...
// ...
}
query.updateHandler = { query, samples, deleteSamples, newAnchor, error in
// ...
}
healthStore.execute(query)
하지만 HKAnchoredobjectQuery
의 updateHandler
가 사용자의 건강데이터가 업데이트되는 즉시 호출되면 좋겠지만, 그러지 않는 것을 확인했어요. 그래서 저는 updateHandler
에 의존하지 않고, Timer
를 사용하여 주기적으로 Query 요청을 진행했어요. 데이터를 최대한 실시간으로 가져올 수 있도록 하고 싶었거든요.
그래서 아래와 같은 구조를 생각해보았어요.
이렇게 HealthKit을 사용해서 데이터를 가져와 ViewController에 전달하기까지 하나의 흐름을 만들 수 있었어요.
HealthKit으로 데이터를 받아왔으니, 이제 서버로부터 데이터를 전달할 준비만 하면 만사 오케이였어요.
하지만, 웹소켓으로 통신을 하게 전에 서버로부터 accessToken을 전달하므로, 서버가 사용자를 특정할 수 있으나, 클라이언트 쪽에서는 내가 어떤 사용자인지를 알 수 없어요. 앱에서는 단순히 AccessToken만을 갖고 있기 때문이에요.
처음에는 AccessToken으로 모든 것이 해결될 것이라고 생각했으나, 제 빈약한 WebSocket 지식으로 발생한 문제였어요. Swift에서는 WebSocket을 사용할 때 wss://baseURL.com/
과 같이 wss를 붙여서 바로 웹소켓 연결을 시도하지만, 실제 내부적으로는 https로 요청을 보내고, web socket으로 업그레이드 하는 과정을 전부 담고있었어요. 그리고 무엇보다 Web Socket은 TCP 위에서 동작하기 때문에 서버에게 값을 전달할 때마다 HTTPHeader에 token을 동봉해서 보내는 행동은 하지 못했어요.
URLSession을 사용하면서 WireShark로 패킷을 확인해보았습니다.
게다가 accessToken을 제공한다 하더라도 다른 사용자들이 그걸 알리 만무했지요. 그리고 다른 사용자의 accessToken을 전달하는 것도 올바르지는 않아보였습니다.
그래서 서버는 각 사용자들에게 자신이 누군지를 특정지을 수 있을만한 정보를 건네주어야 했고, 언제 연결을 성립시킬지, 언제, 어떻게 데이터를 송수신할지 정해야했어요.
처음에는 운동세션에 진입하기 전에 소켓을 연결지어 사용자의 특정 정보를 공유하도록 하려고 했어요.
하지만, 다른 화면에서 연결했던 소켓을 그대로 들고 다른 화면에 넘겨주기란 여간 까다로운 게 아니었어요.
Clean Architecture
로 이루어져 있는 구조 속에서 화면 이동을 관장하는 Coordinator가 소켓과 연결지어 통신하는 repository
를 알 수가 없었기 때문이에요.
만약 보낸다면 repository를 알고 있는 UseCase에서 Coordinator에게 보내줘야할 텐데 이렇게 되면, UseCase가 화면을 알아야하는 문제에 빠지게 돼요.
저는 소켓을 통신하기 이전에 사용자의 정보와 자신의 정보를 미리 받아와 세팅하고, 비로소 세션화면이 보일 때 연결을 진행하는 것이 낫다고 결론을 내리게 되었어요.
물론 연결된 소켓 상에서 사용자들의 정보를 공유할 수도 있으나, Socket을 사용하는 의미는 실시간으로 사용자의 데이터를 송수신하는 데에 초점이 맞춰진 프로토콜이다보니 그 규약을 따르고 싶었어요.
이렇게 한 화면에서 모든것을 이루어지게 해야했던 것을 제한함으로써, 저희 아키텍처의 구조를 지킬 수 있게 되었습니다.
그래서 사전에 설정된 아래와 같이 구조를 만들 수 있었어요.
이제 서버와 사용자간 Socket으로 값을 주고받을 때 “어떤 구조로 주고받을 것인가”에 대한 고민이 생겼어요.
그런데 이건 손쉽게 해결됐어요.
서버쪽에서는 사실상 받는 즉시 돌려보낸다고 이야기를 들었고, 생각해보니 서버에서는 사용자의 데이터를 실시간으로 저장하지 않는다는 것을 깨달았지요.
그래서 서버가 PUB-SUB구조
로 이루어져 있다는 점만 고려한다면 소켓의 데이터를 전달할 때 제가 원하는 구조로 보내도 상관이 없었어요.
{
"event": "...",
"data": {
마음대로 작성하기!
}
}
저는 사용자의 UI를 업데이트해주기위한 값만을 담아 보낼 수 있도록 data 내부에 distance와, 사용자의 nickname을 동봉해서 보내도록 했어요.
그러면 서버와 사용자간 WebSocketTask로 보낼 때 아래처럼 보내지게 될 거에요.
사진에서 보면 Client는 data
타입으로 보내나, Server는 string
타입으로 보내는 것을 알 수 있는데요.
서버분들에게 웹소켓으로는 JSON의 형태로 보낼 수 없다는 답변을 받았기에 저렇게 작성한 것이었어요.
명확한 사유는 알 수 없으나.. json형태를 string으로 받아온다는 점이기에 다행히 문제삼을만한 이슈는 아니었어요.
세션 화면
위처럼 사용자들과 함께하는 경우에는 소켓을 연결하여 사용자의 데이터를 빈번하게 전달하거나 전달받게 돼요. 하지만, 혼자하기 일 때는 어떨까요?
혼자한다면, 다른 사용자들을 위해 데이터를 서버로부터 쏴줄 이유도 없고, 단순히 HealthKit의 건강 데이터를 받아와 UI를 갱신해주기만 하면 될 것 같아보입니다.
하지만 저는 이미 같이하기 모드를 기준으로 흐름을 구현한 상황이었고, 여기서 혼자하기 일 때를 분리하고자 여러 if문을 넣는 것을 원하지 않았어요