[iOS] WeTri: HealthKit, MapKit, WebSocket을 하나의 화면에서 조화롭게.

WhiteHyun
22 min readDec 23, 2023

--

안녕하세요. We-Tri의 홍승현입니다.

이번 글에서는 HealthKit과 MapKit 그리고 WebSocket 사용기에 대해 제가 공부하고, 실제로 적용했던 경험을 작성해보려고 해요.

좀 더 깔끔한 글로 보고싶다면 아래 링크에서 글을 읽어주세요.

세 가지의 기술이 전부 들어간 화면이 있다?

세션화면

제가 맡은 화면은 위 사진이에요. 세션화면이라고 할게요.

이 세션화면에 진입하기 이전에, 사용자가 매칭을 통해 유저들과 함께 운동을 할 것인지, 또는 혼자 운동을 할 것인지를 선택할 수 있고, 매칭이 성사되거나 혼자하게 될 때 카운트 다운과 함께 세션화면이 나타나게 돼요.

세션화면에서는 양쪽 스와이프를 통해 운동하는 사용자 목록을 보거나 내가 얼만큼 운동했는지를 지도로 볼 수 있어요. 그리고 종료버튼을 누르면 운동이 종료돼요.

세션화면에는 세 가지의 기능이 들어가있는데요. 사용자의 위치 정보를 받아 지도에 Polyline을 그리는 기능, HealthKit으로부터 사용자의 건강 데이터를 받아와 View에 업데이트하는 기능, 또 실시간으로 사용자의 운동 데이터를 웹소켓을 통해 서버로부터 전달하는 기능까지. 총 세가지의 기능을 하나의 화면에서 이루어지도록 구현해야했어요.

MapKit 사용하기

사용자로부터 위치 권한을 받아 Info.plist에 설정한 뒤, CLLocationManager의 delegate를 설정하여 사용자의 위치 정보를 받아다가 지도에 그려주면 되었거든요. 물론, 갑자기 튀는 값들이 가끔 발생하였기에, 잡음을 잡기 위한 기능으로 칼만 필터를 도입해서 해결하게 되었어요. 칼만 필터는 잡음이 껴있는 실시간 데이터값에 따라 상태 값을 측정하는 알고리즘이에요. 자세한 내용은 칼만 필터 구현기를 참고해 주세요.

HealthKit에서 특정 시간대의 새로운 데이터를 가져오기

세션 화면에서는 운동을 시작했을 때 사용자의 건강 데이터를 가져와야 했어요. 그러기 위해서는 HKHealthStore를 이용해서 Query를 요청해 Sample 값을 가져와야만 했습니다.

여기서 HKHealthStore는 HealthKit에 접근하기 위한 DB 같은 녀석이고, Query는 HKHealthStore에 요청할 조건식들, 그리고 Sample은 운동 데이터 결괏값입니다.

다행히 특정 시간 이후부터 데이터를 가져올 수 있도록 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를 통해 받게 돼요. 이 값을 잘 저장하고 있다가 다시 요청을 보내면 중복된 데이터를 받지 않고 새로운 데이터만 처리할 수 있어요. 아래처럼요!

그래서 코드는 다음과 같이 정의내릴 수 있습니다.

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)

하지만 HKAnchoredobjectQueryupdateHandler가 사용자의 건강데이터가 업데이트되는 즉시 호출되면 좋겠지만, 그러지 않는 것을 확인했어요. 그래서 저는 updateHandler에 의존하지 않고, Timer를 사용하여 주기적으로 Query 요청을 진행했어요. 데이터를 최대한 실시간으로 가져올 수 있도록 하고 싶었거든요.

그래서 아래와 같은 구조를 생각해보았어요.

이렇게 HealthKit을 사용해서 데이터를 가져와 ViewController에 전달하기까지 하나의 흐름을 만들 수 있었어요.

WebSocket을 통해 실시간으로.

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문을 넣는 것을 원하지 않았어요.

그래서 저는 비즈니스 로직을 수정하지 않고 혼자하기 모드일 때 소켓 연결을 하지 않도록 하는 방법을 고민했고,

Socket을 처리하는 Session 쪽을 추상화하자는 아이디어를 실천하게 되었습니다.

Session을 추상화하자

위에있는 그림을 다시 가져와 볼게요.

현재는 UseCase가 Timer에 맞춰 HealthKit로부터 데이터를 요청하고, 들어오는 데이터를 가지고 서버의 소켓 Repository에게 전달하는 모습이에요.

여기서 Socket Repository의 실제 코드를 볼게요.

// MARK: - WorkoutSocketRepository

struct WorkoutSocketRepository {
private let provider: TNSocketProvider<WorkoutSocketEndPoint>

// ...

init(session: URLSessionWebSocketProtocol, dependency: WorkoutSocketRepositoryDependency) {
provider = .init(
session: session,
endPoint: .init(
headers: [
.init(key: "roomId", value: dependency.roomID),
.authorization(bearer: String(data: Keychain.shared.load(key: Tokens.accessToken)!, encoding: .utf8)!),
]
)
)
task = receiveParticipantsData()
}

// MARK: - Private Methods..
// ...
}

initializer로 WebSocketProtocol를 받는 것을 볼 수 있어요. 지금처럼 확장할 때를 대비해서 Session을 protocol로 설정해두었는데, 덕분에 쉽게 활용할 수 있었습니다.

그래서 기존의 코드에서는 Repository를 생성할 때 아래와 같이 URLSession을 그대로 넣어준 것을 볼 수 있어요.

// ...
let socketRepository = WorkoutSocketRepository(session: URLSession.shared, dependency: workoutSessionComponents)
let sessionUseCase = WorkoutSessionUseCase(
healthRepository: healthRepository,
socketRepository: socketRepository,
dependency: workoutSessionComponents
)
// ...

이제 이 코드 내에서 혼자하기 모드일 때면 Session을 갈아 끼워, 비즈니스 로직은 소켓으로 통신하는 것처럼 두고 세션 내에서는 받았던 값을 그대로 돌려주는 Stub을 만들면 되는 거에요.

아래 그림처럼 말이죠!

추상화를 하기 위해 진행했던 코드를 살펴보면 아래와 같아요.

URLSessionWebSocketProtocol이라는 프로토콜을 기준으로 URLSession이 이를 준수하고 있는 걸 볼 수 있어요.

// MARK: - URLSessionWebSocketProtocol

public protocol URLSessionWebSocketProtocol {
func webSocketTask(with request: URLRequest) -> URLSessionWebSocketTask
}

// MARK: - URLSession + URLSessionWebSocketProtocol

extension URLSession: URLSessionWebSocketProtocol {}

하지만 Stub을 만들고 URLSessionWebSocketProtocol을 준수하도록 URLSessionWebSocketTask를 반환해주어야했으나, URLSessionWebsocKetTask는 initializer가 없기 때문에 생성자로 주입을 해줄 수가 없었어요.

그리고, 주입을 한다고 하더라도, URLSessionWebSocketTask가 갖고있는 메서드인 send(_:), receive(_:)를 우리의 입맛에 맞게 받는 즉시 반환하도록 구성해야 우리가 원하는 Stub의 기능을 하므로, URLSessionWebSocketTask마저도 추상화를 해주어야했어요.

Task를 추상화하지 않으면 데이터를 송신하거나 수신하는 것을 우리가 직접 다룰 수 없으니까요.

그래서 이름은 WebSocketTaskProtocol로 이름짓고, URLSession은 자신의 webSocketTask메서드를 호출함으로써 추상화 해주었어요.

WebSocketTaskStub은 서버와 동일하게 동작해야 해요. 안그러면 비즈니스로직을 수정해야하고, 그러면, Stub을 만드는 이유가 없으니까요. 내부 로직은 이 데이터의 송수신이 서버로 가는지, Stub을 이용하는지는 상관없이 동작되어야해요.

그러므로 WebSocketTaskStub은 아래처럼 동작되어야했습니다.

  1. receive(_:)로부터 data(_:)타입의 Message가 들어오면 데이터 값을 우리가 지정한 json형태의 string(_:)타입으로 넘겨주어야한다.
  2. 실행 순서와 상관없이 값을 정상적으로 받아와야한다.
  3. async await 메서드로 처리해야한다(이미 URLSession을 이용하여 async, await을 사용하고 있기 때문)

Stub은 네트워킹을 하는 객체가 아니므로 async-await과 연동하고자 Continuation을 붙여 해결했어요.

그리고 호출 순서와 상관없이 값을 받아와야하기에 Continuation과 Message를 프로퍼티로 갖도록 처리했어요.

이렇게하면 receive가 먼저 호출되어도, send 구현체에서 continuation을 호출해주므로 값을 정상적으로 받아올 수 있을 뿐더러, send가 먼저 호출된다고 해도 Message값을 저장해두고 있기에, 바로 값을 반환할 수 있어요.

public final class WebSocketTaskStub<DataModel: Codable>: WebSocketTaskProtocol {
private var sentMessage: URLSessionWebSocketTask.Message?
private var receiveContinuation: CheckedContinuation<URLSessionWebSocketTask.Message, Never>?
private let jsonEncoder: JSONEncoder = .init()
private let jsonDecoder: JSONDecoder = .init()

public init() {}

public func send(_ message: URLSessionWebSocketTask.Message) async throws {
switch message {
case let .data(jsonData):
guard let jsonString = String(data: jsonData, encoding: .utf8) else {
throw MockWebSocketError.stringConversionFailed
}

let stringMessage = URLSessionWebSocketTask.Message.string(jsonString)
sentMessage = stringMessage
receiveContinuation?.resume(returning: stringMessage)
default:
sentMessage = message
receiveContinuation?.resume(returning: message)
}
}

public func receive() async throws -> URLSessionWebSocketTask.Message {
if let receivedMessage = sentMessage {
sentMessage = nil
return receivedMessage
}

return await withCheckedContinuation { continuation in
receiveContinuation = continuation
}
}

public func resume() {}
}

테스트 코드 작성하기

Stub을 만들었다면, 정상적으로 동작하는지 우선 검증해야합니다. Unit Test를 이용해서요!

그래서 data타입으로 보냈을 때 string타입으로 들어오는지, send와 receive를 비동기로 처리했을 때 잘 받아오는지 테스트를 통해 검증할 수 있었어요.

func testSendAndReceive() async throws {
// arrange
let testModel = TestModel()

// act
Task {
try await self.socketProvider?.send(model: testModel)
}
let receivedMessage = try await socketProvider?.receive()

// assert
// Receive 메서드를 비동기로 호출하여 결과 검증
guard case let .string(string) = receivedMessage else {
XCTFail("Received message is not of type .string")
return
}

guard let jsonData = string.data(using: .utf8) else {
XCTFail("data cannot parse to data")
return
}

let receivedModel = try JSONDecoder().decode(WebSocketFrame<TestModel>.self, from: jsonData)
XCTAssertEqual(receivedModel.data, testModel, "Received model does not match sent model")
}

결과

HealthKit과 WebSocket, 그리고 MapKit까지 하나의 화면에서 구성하고자 Container View를 사용했었는데요. 그래서 자식 ViewController가 받아온 이벤트를 부모 ViewController가 처리해서 화면전환을 하도록 유도하고 있어요.

그래서 최종 데이터 흐름은 아래와 같아요.

단순한 화면임에도 하나의 화면에서 많은 걸 구성하다보니, 흐름을 만드는 것이 저에게는 큰 도전이었습니다.

그래도 흐름을 관리하기 위해 머릿속으로 정리해뒀던 걸 그림으로 그려가니 어느정도 코드로 녹여낼 수 있었던 뜻깊은 시간이었습니다.

읽어주셔서 감사하며, 저희 프로젝트가 궁금하시다면 언제든지 GitHub 레포지토리에 방문해주세요. 😊

--

--