티스토리 뷰

헬스킷을 프로젝트에 적용하게 되면서 고민했던 점들을 한번 작성해볼까 합니다. 헬스킷 자체만으로도 많은 API를 가지고 있는데다가 비즈니스 로직까지 합쳐지며 코드 부담이 꽤나 커졌는데요, 이를 풀어갔던 과정을 공유해보겠습니다.

 

요구사항


제가 기획했던 서비스에서 필요했던 요구사항을 간단히 정리해보자면 다음과 같습니다.

사용자의 수면, 심박, 활동 데이터를 하루 단위로 정리해서 (서버에) 저장한다.
이때, 애플워치 데이터를 우선시한다.

세부적으로 필요한 데이터(분당심박평균, 총수면시간 등등 ..)들을 수집할 수 있는지는 문서와 실기기 테스트로 어느정도 검증한 상태였기 때문에 어려울게 없어보였는데요, 막상 작업을 시작해보니 난관이 있었습니다.

 

먼저 건강데이터들은 헬스킷 데이터베이스에 저장이 됩니다. 그래서 단순 메서드 호출이 아니라 RDBMS의 쿼리를 작성하듯 조회를 해야했고 데이터들이 초단위, 분단위로 수집되기 때문에 그것을 하루 단위로 통합하고 계산하는 부분에 많은 코드가 필요했습니다. (건강 앱에서 보여주는 데이터는 어느정도 정리된 데이터이고 실제 데이터는 더 raw한 경우가 많았습니다...)

 

패키지 구조에 대한 고민


먼저 헬스킷 로직의 위치를 고민했습니다.

기존 프로젝트 구조는 위와 같았는데요, 많은 클라이언트 앱이 그렇듯 네트워킹 코드를 위주로 고려한 구조였습니다.

먼저 MVVM 패턴을 이용한 프레젠테이션 계층과 Moya라이브러리를 이용한 비즈니스 로직 계층이 있고

푸시노티나 햅틱, 날짜 계산 등 환경 의존적이거나 자주 사용되는 코드들을 Helper로,

그 외 익스텐션, 상수등을 Util로 구분해 관리하고 있었습니다.

 

이 구조에서 헬스킷을 어떻게 녹여낼까 고민했을 때 기존 구조의 비즈니스 계층에 있는 코드들은 대부분 네트워킹 코드기 때문에, 성격이 많이 다르다는 생각이 들어 따로 패키지로 분리하여 관리하기로 했습니다. 의존성 측면에서도 좋은 결정이었던 것 같습니다.

 

첫번째 아키텍처: Provider-Service구조


분리된 패키지에서 작업할 때도 여느때와 다름 없이 익숙한 구조의 Provider-Service구조를 우선 채택하게 되었습니다.

Provider

우선 쿼리와 조회에 대한 중복 코드를 줄이기 위해 Provider를 작성했습니다.

일반적으로 네트워킹할때 기본적인 네트워킹 설정 코드를 담은 객체를 Provider라고 많이 칭하는데요, 여기서 차용해 비슷한 역할을 부여했습니다.

class HKProvider: HKProviderProtocol {

    let healthStore = HKHealthStore()
    
    func getCategoryTypeSamples(identifier: HKCategoryTypeIdentifier,
                               predicate: NSPredicate,
                               completion: @escaping ([HKCategorySample], HKError?) -> Void) {
        // identifier로 Type 정의
        guard let sleepType = HKObjectType.categoryType(forIdentifier: identifier) else {
            fatalError("""
                Unexpected identifier \(identifier).
                Please check if you have entered the correct identifier.
            """)
        }
        
        // 최신 데이터를 먼저 가져오도록 sort 기준 정의
        let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierEndDate, ascending: false)
        
        // 쿼리 수행 완료시 실행할 콜백 정의
        let query = HKSampleQuery(sampleType: sleepType,
                                  predicate: predicate,
                                  limit: HKObjectQueryNoLimit,
                                  sortDescriptors: [sortDescriptor]) { (_, samples, error) -> Void in
            if let error = error {
                // 에러 처리를 수행합니다.
                completion([], HKError.providerFetchSamplesFailed(error: error))
            }
            
            if let result = samples {
                // 결과가 비어있을 시 error throw
                if result.isEmpty {
                    completion([], HKError.providerDataNotFound)
                }  
                let categorySamples = result.compactMap { $0 as? HKCategorySample }
                completion(categorySamples, nil)
            }
        }
        
        // HealthKit store에서 쿼리를 실행
        healthStore.execute(query)
    }
}

헬스킷에서는 건강데이터 특성에 따라 다양한 샘플타입을 사용하고 있어 실제 메서드는 더 다양한데요, 예시로 카테고리 샘플을 다루는 메서드를 가져왔습니다.

Provider에선 조회와 전달받은 인자에 대한 유효성 검사, 정렬, 빈값제거기본적인 전처리를 하는 로직을 포함시켰습니다. 이때 적절한 에러 핸들링을 통해 상위에서 디버깅하거나 처리하기 용이하도록 구성했습니다.

 

Service

마찬가지로, Provider를 이용해 실제 조회를 수행하거나 비즈니스로직을 수행하는 객체를 Service라고 칭하는데 이와 비슷한 객체를 만들고자 했습니다.

수면, 활동, 심박에 대해서 각각 HKSleepService, HKActivityService, HKHeartRateService를 생성하고 Provider에 의존해 데이터를 조회/가공하기로 하였습니다.

protocol HKSleepServiceProtocol {
    /// 전체 수면 시간 중 특정 상태인 시간의 총시간(단위: 분)을 구합니다.
    ///
    /// - Note: 어제 오후 6시~ 오늘 오후 6시 사이를 오늘 수면 시간으로 판단합니다.
    ///
    /// - 수면 상태 종류
    ///     - inbed: 애플 워치 미착용시 수면 (총 수면)
    ///     - rem: 렘 수면
    ///     - core: 가벼운 수면
    ///     - deep: 깊은 수면
    ///     - awake: 수면 중 깨어남
    ///
    /// - Parameters:
    ///     - date: 수면 데이터를 추출할 대상 날짜입니다.
    ///     - sleepCategory: 가져오고자 하는 수면 상태 종류입니다.
    /// - Returns: 총 시간을 Int(단위: 분)로 반환합니다.
    func getSleepRecord(date: Date, sleepCategory: HKSleepCategory.origin) -> AnyPublisher<Int, HKError>
}

class HKSleepService: HKSleepServiceProtocol{
    
    private var provider: HKProvider
    private var dateHelper: DateHelperType
    
    init(provider: HKProvider, dateHelper: DateHelperType) {
        self.provider = provider
        self.dateHelper = dateHelper
    }

    func getSleepRecord(date: Date, sleepCategory: HKSleepCategory.origin) -> AnyPublisher<Int, HKError> {
        return self.fetchSleepSamples(date: date)
            .tryMap { samples in
                return self.calculateSleepTimeQuentity(sleepType: sleepCategory.identifier,
                                                       samples: samples)
            }
            .mapError { error in
                return error as! HKError
            }
            .eraseToAnyPublisher()
    }
}

// MARK: - Private
extension HKSleepService {
    
    /// 내부적으로 Healthkit Provider를 통해 데이터를 가져옵니다. 결과는 completion으로 전달합니다.
    ///
    /// - Parameters:
    ///    - date: 데이터를 가져오고자 하는 날짜를 주입합니다.
    ///    - completion: sample을 전달받는 콜백 클로저입니다.
    /// - Returns: 수면 데이터를 [HKCategorySample] 형으로 Future에 담아 반환합니다.
    private func fetchSleepSamples(date: Date) -> Future<[HKCategorySample], HKError> {
        return Future() { promise in
            
            if date > Date() {
                fatalError("Future dates are not accessible.")
            }
            
            // 조건 날짜 정의 (그날 오후 6시 - 다음날 오후 6시)
            let endDate = self.dateHelper.getTodaySixPM(date)
            let startDate = self.dateHelper.getYesterdaySixPM(date)
            let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate)
            
            // 수면 데이터 가져오기
            self.provider.getCategoryTypeSamples(identifier: .sleepAnalysis,
                                                 predicate: predicate) { samples, error  in
                if let error = error { promise(.failure(error)) }
                promise(.success(samples))
            }
        }
    }
    
    /// 애플워치 측정 데이터에 대해 수면 데이터 종류에 따라 수면 총량(분)을 구합니다.
    ///
    /// 어제 오후 6시~ 오늘 오후 6시 사이에 모든 수면 데이터 중 해당 종류의
    /// 수면데이터의 startDate와 endDate의 차이를 분으로 환산해 합산합니다.
    /// - Important: 애플워치로 측정된 데이터만 반환합니다.
    ///
    /// - Parameters:
    ///    - sleepType: 구하고자 하는 수면 데이터 종류의 식별자입니다.
    ///    - samples: 어제 오후 6시~ 오늘 오후 6시 사이의 모든 수면 데이터입니다.
    /// - Returns: 총량을 Int형(단위: 분)으로 반환합니다.
    private func calculateSleepTimeQuentity(sleepType: HKCategoryValueSleepAnalysis,
                                            samples: [HKCategorySample]) -> Int {
        var watchSamples = samples
                .filter{ $0.sourceRevision.productType?.contains("Watch") ?? true }
        let calendar = Calendar.current
        let sum = watchSamples.filter{ $0.value == sleepType.rawValue }
            .reduce(into: 0) { (result, sample) in
                let minutes = calendar.dateComponents([.minute], from: sample.startDate, to: sample.endDate).minute ?? 0
                result += minutes
            }
        return sum
    }
}

// 수면 종류 ..
enum HKSleepCategory {
    enum origin {
        /// 애플워치 착용 시 분류되는 수면 상태 별 수면 시간입니다.
        case inbed, rem, core, deep, wake
    }
    
    fileprivate var identifier: HKCategoryValueSleepAnalysis {
        switch self {
        case .inbed: return .inBed
        case .rem: return .asleepREM
        case .core: return .asleepCore
        case .deep: return .asleepDeep
        case .wake: return .awake
        }
    }
}

(역시 일부 코드입니다)

Service는 Provider로 조회를 진행한 후, 병합이 필요한 데이터들을 합쳐 서버에 전달할 수 있는 형태로 가공하는 역할을 맡았습니다. 헬스킷 데이터와 구분하기 위해 가공한 데이터는 Record라는 명칭을 사용하기로 하고 UI단에서 호출할 수 있는 메서드를 인터페이스로 만들고 내부 계산 로직을 private 함수로 캡슐화하였습니다.

 

문제점: 테스트 불가능


위 구조로 대부분의 기능 구현은 완료할 수 있었습니다. 프로토콜를 이용해 메인 앱의 Service에서 헬스킷 패키지의 Service를 의존할 수 있도록 했고, 내부 로직은 잘 캡슐화되어 있었습니다.

문제

그러나 한가지 문제점이 있었는데요, 바로 테스트가 어렵다는 점이었습니다.

테스트를 위해서는 Provider를 Mocking을 해야했는데, Provider는 헬스킷에서 제공하는 샘플타입을 리턴하고 있기 때문에 이를 직접 생성해 주입해야했죠.

그런데 샘플타입(HKCategorySample 등) 자체는 날짜데이터나 건강데이터 외에도 메타데이터와 같이 다양한 데이터들을 포함하고 있는 객체이기 때문에 직접 생성하기가 어려웠습니다.

이 때문에 실제 디바이스에서 테스트해보는 것 외에 테스트코드를 작성할 수 있는 방법이 없었습니다.

해결 방안

 

저는 이무렵 '클린아키텍처'(책)를 읽고 위 코드의 문제점을 파악할 수 있었습니다.

바로 세부사항과 정책이 구분되어 있지 않았다는 점입니다.

 

예를 들어 헬스킷에서 제공하는 샘플타입은 세부사항입니다. 외부환경(라이브러리, 기기)에 의존적이고 언제든 변경될 수 있는 사항이죠.

그리고 calculateSleepTimeQuentity라는 메서드는 정책입니다. 애플워치 데이터 선택하고, 분단위 총량을 구한다 라는 정책을 담고 있는 것입니다.

 

그럼 이때 테스트코드를 작성해야할 사항은 세부사항이 아닌 정책이 됩니다.

세부사항은 실제 디바이스 환경 등 외부 요인이 갖춰진 상태에서 테스트해보아야합니다.

 

그래서 저는 세부사항과 정책을 분리하기 위해 Core라는 새로운 컴포넌트를 만들고 여기에 정책에 관련된 코드들을 순수함수로 구성하기로 했습니다. (클린아키텍처에서는 Usecase라고 합니다)

 

또한 책을 읽으면서 알게되었는데 메인 비즈니스 로직에 DTO를 직접 이용하는 것은 비즈니스 로직이 외부 환경에 의존하게 하는 것이므로 좋지 않습니다.

헬스킷의 샘플타입을 직접 이용하는 것은 DTO를 그대로 이용하는 것이므로 헬스킷 데이터타입에 의존하지 않기 위해 Entity격에 해당하는 도메인모델을 만들기로 하였습니다.

 

두번째 아키텍처: Core


앞선 코드에서 HKSleepService에 존재했던 정책에 관련된 코드를 Core라는 새로운 객체로 옮겼습니다.

class HKSleepCore: HKSleepCoreProtocol {
    
    func calculateSleepTimeQuentity(
        sleepType: HKSleepType,
        samples: [HKSleepEntity]
    ) -> Int? {
        
        let watchSamples = samples
            .filter{
                $0.dateSourceProductType == .watch
            }
        var claculatedSampels: [HKSleepEntity]
        
        // 애플워치 데이터가 없으면 전체에서 합산해서 반환
        if watchSamples.isEmpty {
            if sleepType != .inbed { // inbed 외에는 워치 데이터 필수
                return nil
            }
            claculatedSampels = samples
        }
        // 있으면 애플워치 데이터 중 계산
        else {
            claculatedSampels = watchSamples
        }
        
        let calendar = Calendar.current
        let sum = claculatedSampels
            .filter{ $0.sleepType == sleepType }
            .reduce(into: 0) { (result, sample) in
                let minutes = calendar
                    .dateComponents(
                    [.minute],
                    from: sample.startDate,
                    to: sample.endDate)
                    .minute ?? 0
                result += minutes
            }
        
        // 데이터가 없으면 nil 반환 (애플워치 데이터가 없는 경우 등)
        return sum == 0 ? nil : sum
    }
}

그리고 헬스킷과의 의존성을 분리하기 위해 아래와 같이 엔티티를 만들고

struct HKSleepEntity {
    let startDate: Date
    let endDate: Date
    let sleepType: HKSleepType
    let dateSourceProductType: HKProductType
}

익스텐션을 이용해 HKCategorySample -> HKSleepEntity로 매핑해주도록 asSleepType이라는 변수를 정의 하였습니다.

기기종류(ProductType)도 Enum을 이용해 매핑해주었습니다.

extension HKCategorySample {
    var asProductType: HKProductType {
        if ((self
            .sourceRevision
            .productType?
            .contains("Watch")) != nil)
        {
            return .watch
        } else if ((self
            .sourceRevision
            .productType?
            .contains("iPhone")) != nil)
        {
            return .iPhone
        } else {
            return .other
        }
    }
    
    var asSleepEntity: HKSleepEntity {
            return HKSleepEntity(
                startDate: self.startDate,
                endDate: self.endDate,
                sleepType: HKCategoryValueSleepAnalysis
                    .asSleepTypeValue(self.value),
                dateSourceProductType: self.asProductType
            )
    }
}

이제 HKSleepService에서는 core를 의존하고

class HKSleepService: HKSleepServiceProtocol{
    
    private var core: HKSleepCoreProtocol // ✅
    private var provider: HKProvider
    private var dateHelper: DateHelperType
    
    // ...

내부적으로 코어를 호출하고 타입을 변경해 전달합니다.

func getSleepRecord(date: Date, sleepCategory: HKSleepCategory.origin) -> AnyPublisher<Int, HKError> {
    return self.fetchSleepSamples(date: date)
        .tryMap { samples in

            return self
                .core
                .calculateSleepTimeQuentity(
                    sleepType: sleepCategory.asHKSleepType,
                    samples: samples.map { $0.asSleepEntity }
                ) ?? 0
        }
        .mapError { error in
            return error as! HKError
        }
        .eraseToAnyPublisher()
}

 

최종적인 아키텍처 형태는 다음과 같습니다.

기존/개선

 

이렇게 Core, 즉 정책을 분리함으로써 다음과 같은 장점을 얻을 수 있었습니다.

1. 정책의 보호

헬스킷에 관련된 사항이 변경되더라도 Core는 변경될 필요가 없습니다.

Core는 Entity와 같은 사용자정의 타입에만 의존하고 있기 때문에 외부에서 어떻게든 Entity에만 매핑해준다면 Core는 변경사항에 영향을 받지 않습니다.

2. 테스트 용이

기존에는 세부사항의 영향으로 정책에 대한 테스트가 어려웠는데요, Core는 순수함수로 구성되어 있으므로 쉽게 테스트할 수 있습니다.

보시다시피 애플워치에 관련된 데이터를 걸러내고 합산하는 과정이 꽤나 긴데 이를 쉽게 테스트해볼 수 있게 되었습니다.

3. 책임 분리

당연하게도, 더 구체적으로 책임분리가 되었기 때문에 코드를 더 파악하기 쉽고 변경하기도 쉬워졌습니다. 외부사항에 관한 변경은 Service 혹은 Provider에서 변경하고 만약 비즈니스 정책에 변경이 생겼다면 Core에서 수정하면 됩니다.

 

한가지 단점은 엔티티를 다시 정의하고, 매핑하는 코드를 만들고, Core의 프로토콜을 정의하고 주입하는 등 보일러플레이트 코드가 정말 많아지게 된다는 점이죠.

 

마치며


이렇게 헬스킷 사용을 위해 패키지와 아키텍처를 구성한 경험을 작성해보았는데요

다른 분들에게도 도움이 되었으면 좋겠습니다.

 

감사합니다!!

 

댓글
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/11   »
1 2
3 4 5 6 7 8 9
10 11 12 13 14 15 16
17 18 19 20 21 22 23
24 25 26 27 28 29 30
글 보관함