Neoself의 기술 블로그

WatchOS에서의 CoreData 사용기 - 데이터 손실 없이 안정적으로 서버에 데이터 전송하기 본문

개발지식 정리/Swift

WatchOS에서의 CoreData 사용기 - 데이터 손실 없이 안정적으로 서버에 데이터 전송하기

Neoself 2024. 10. 25. 19:35

Testflight를 통해 실제 뮤지컬관람환경에서 실시간 심박수 측정 기능을 사용한 결과, WatchOS에서 전송한 분간 최대 심박수 데이터들에 대한 로그기록을 서버를 통해 확인할 수 있었습니다.
(사실 가독성은 구려서 안 보시는게 눈건강에 더 나을 수도 있습니다. 백엔드는 잘 몰라서, 구현하기에 바빴습니다... )

보시면 아시겠지만, 오후 9시 35분부터 오후 10시 6분 사이의 데이터가 누락이 되는 이슈가 있었습니다. 당시 타임라인을 추적해본 결과, 심박수 데이터를 측정하는 전체 2시간 반동안 애플워치의 배터리를 16% 소모하였으며, 9시 35분부터는 배터리가 10% 이하로 떨어지면서 애플워치가 저전력 모드로 전환돼, 앱 내부 로직이 아예 막혀 서버로 데이터 전송이 되지 않았음을 확인할 수 있었습니다.

 

또한 이와는 별개로, 뮤지컬 내부에서는 모바일 기기를 꺼놓아야 하는 에티켓이 있는데, 애플워치의 데이터를 서버에 저장하기 위해선 아이폰을 켜놓아야했기 때문에 사용자 경험이 좋지 않았다는 의견 또한 수집할 수가 있었습니다. 이건 어찌보면 매우 당연한 사실인데, 문화생활을 많이 하지 못했던 저로써는 굉장히 아차싶었던 인사이트였습니다.

 

따라서 해결해야하는 이슈를 2개로 정리할 수 있었습니다.

  1. 아이폰을 꺼놓고, 뮤지컬 종료 이후에 아이폰을 키더라도, 뮤지컬 동안 측정된 심박수가 서버에 저장될 수 있게 구현하기
  2. 앱을 상시 Foreground 상태로 유지함으로 인해, 생기는 막대한 배터리 소모량 줄이기

2번째 이슈 또한 중간에 측정에 끊길 수 있다는 가능성 때문에 큰 이슈였지만, 무엇보다도 아이폰으로부터의 소음을 차단하지 못한 채로 뮤지컬을 본다는 것 자체가 사용자 경험에 크게 저해된다고 판단하였기에 1번째 이슈 해결을 먼저 해결하기로 방향을 정하였습니다.

 

우선 제가 기존에 구현한 WatchOS와 서버 간의 통신 플로우는 아래 그림과 같았습니다.

3~8초마다 한번씩 실시간으로 변경되는 사용자의 심박수를 감지하면, WatchOS 내부 ViewModel은 전달받은 심박수 데이터들을 종합해 최대 심박수를 상시 계산하며, ViewModel에서 생성된 Timer 객체를 통해 60초마다 한번씩(TestFlight의 경우 데이터 누락조건 세밀히 파악하기 위해 30초로 임의 설정) 최대 심박수 데이터를 WatchOS에서 직접 api 호출해 데이터를 전송하게끔 로직을 구성하였습니다. 

 

여기서 제가 간과한 점은 아무리 api함수를 WatchOS에서 독립적으로 처리한다고 하여도, 결국 블루투스 모델의 애플워치는 아이폰의 자원을 사용해야만 네트워크에 접속할 수 있었기 때문에, 아이폰의 전원이 상시 켜져있어야 한다는 점이였습니다.

 

때문에, 뮤지컬의 완전한 몰입을 위해 아이폰을 꺼놓을 경우, 실시간 심박수 데이터 측정은 동작하지만, 네트워크가 연결되지 않기 때문에 서버로 데이터가 전송되지 않은 채, 주기적으로 계속 데이터가 소실됩니다. 따라서, 네트워크가 불안정하거나 아예 접근이 불가능할 경우, 애플 워치 내부에서 계산된 최대심박수 데이터를 보관한 후에, 네트워크가 연결이 될때, 한번에 서버로 전송하는 매커니즘 구현이 필요했습니다.

 

때문에, 가장 먼저 Core Data 프레임워크가 접근하게 될 Core Data 모델을 생성해주었습니다. 이는 XCode 16기준 New file with template를 선택한 후에, DataModeld 유형의 파일을 생성해주면 됩니다. 그 이후는 XCode 13이후의 Info.plist처럼 마우스로 선택해 Entity 및 하위 속성들의 타입과 명칭을 설정해줄 수 있습니다.

애플스러운 인터페이스

 

그 다음에는, Core Data의 스택이 생성되고, 기존 데이터를 삭제하는 메서드가 포함된 HeartRateDataModel을 생성해주었습니다. 이 또한 싱글톤으로 작성이 되었습니다.

import CoreData

class HeartRateDataModel {
    static let shared = HeartRateDataModel()
    
    lazy var persistentContainer: NSPersistentContainer = {
        let container = NSPersistentContainer(name: "HeartRateData")
        container.loadPersistentStores { description, error in
            if let error = error {
                fatalError("Unable to load persistent stores: \(error)")
            }
        }
        return container
    }()
    
    var context: NSManagedObjectContext {
        return persistentContainer.viewContext
    }
    
    func deleteOldData() {
        let context = persistentContainer.viewContext
        let fetchRequest: NSFetchRequest<NSFetchRequestResult> = HeartRateEntity.fetchRequest()
        // 동기화된 데이터만 삭제하도록 조건 추가
        fetchRequest.predicate = NSPredicate(format: "isSynced == YES")
        
        let deleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest)
        deleteRequest.resultType = .resultTypeObjectIDs
        
        context.performAndWait {
            do {
                let result = try context.execute(deleteRequest) as? NSBatchDeleteResult
                if let objectIDs = result?.result as? [NSManagedObjectID] {
                    let changes = [NSDeletedObjectsKey: objectIDs]
                    NSManagedObjectContext.mergeChanges(fromRemoteContextSave: changes, into: [self.context])
                }
            } catch {
                print("Failed to delete old data: \(error)")
            }
        }
    }
}

 

마지막으로는 Core Data 프레임워크를 사용해 영구저장소에 접근, 데이터를 저장, fetch, 삭제를 수행하는 HeartRateSyncManager 클래스를 추가하였습니다.

  • Core Data로 영구 저장소에 접근하기 위해선 여러 컴포넌트, 즉 스택이 생성 및 관리되어야 함에 따라 할애되는 리소스가 큼
    ex. NSManagedObject, NSManagedObjectContext, NSPersistentStoreCoordinator
  • 비동기성으로 인해 여러 화면 및 컨트롤러에서 데이터를 접근하고자 할때, 데이터 일관성을 보장할 수 없음.

위와 같은 Core Data 프레임워크의 특성으로 인해 싱글톤 패턴을 적용하여 HeartRateSyncManager 클래스를 생성했습니다.

import Foundation
import UIKit
import CoreData
import Alamofire

class HeartRateSyncManager {
    static let shared = HeartRateSyncManager() // 싱글톤 패턴
    private let context = HeartRateDataModel.shared.context // 
    private let syncQueue = DispatchQueue(label: "com.heartrate.sync", qos: .utility)
    private var isSyncing = false
    
    func forceSyncPendingData() {
        syncPendingData(forced: true)
    }
    
    // 영구 저장소에 있는 심박수 데이터를 fetch해온 후, 해당 데이터 서버로 전송
    func syncPendingData(forced: Bool = false) {
    // 비동기처리문이므로, isSyncing 불리언으로 중복 실행 차단
        guard !isSyncing || forced else { return } 
        
        // GCD에게 전송하게 될 작업들 큐에 추가
        syncQueue.async { [weak self] in
            self?.isSyncing = true // 처리 도중에는 IsSyncing 변수 참으로 변경
            
            // 영구저장소에서 isSynced 하위 속성은 No인 HeartRateEntity 데이터 fetch하는 Request 객체 생성
            let fetchRequest: NSFetchRequest<HeartRateEntity> = HeartRateEntity.fetchRequest()
            fetchRequest.predicate = NSPredicate(format: "isSynced == NO")
            
            do {
                // 실질적인 영구저장소 접근이 수행되는 구문
                let unsyncedData = try self?.context.fetch(fetchRequest)
                guard let dataToSync = unsyncedData else { return }
                
                // fetch한 데이터 서버로 전송
                for heartRateData in dataToSync {
                    self?.sendToServer(heartRate: Int(heartRateData.heartRate)) { success in
                        if success {
                            self?.context.perform {
                                // 전송처리 완료된 HeartRateDataEntity의 isSynced 속성 변경하여 다시 fetch되지 않게!
                                heartRateData.isSynced = true
                                try? self?.context.save()
                            }
                        }
                    }
                }
                
                HeartRateDataModel.shared.deleteOldData()
            } catch {
                print("Failed to fetch unsynced data: \(error)")
            }
            self?.isSyncing = false
        }
    }

    private func sendToServer(heartRate: Int, completion: @escaping (Bool) -> Void) {
        // 서버 통신 구문
    }
    
    // ViewModel로부터 새로 전달받은 heartRate 데이터 영구저장소에 저장하는 구문
    func saveHeartRate(_ heartRate: Int, timestamp: Date = Date()) {
            context.perform {
                // HeartRateEntity 생성 및 하위 속성에 전달받은 데이터 주입
                let heartRateEntity = HeartRateEntity(context: self.context)
                heartRateEntity.heartRate = Int64(heartRate)
                heartRateEntity.timestamp = timestamp
                heartRateEntity.isSynced = false
                
                do {
                    // 영구저장소에 저장 후, 서버에 전송
                    try self.context.save()
                    self.syncPendingData()
                } catch {
                    print("Failed to save heart rate: \(error)")
                }
            }
        }
}

 

이로써, 아래 그림과 같이 CoreData 프레임워크를 통해 네트워크 연결이 되어있지 않을 때에도, 심박수 데이터를 안정적으로 영구 저장소에 보관한 후, 네트워크 연결이 성공되었을 때 일괄 전송하는 매커니즘을 구현할 수 있게 됩니다.

하지만, 현재까지의 코드만으로는, WatchOS가 언제 api 호출을 시도해야할지 알 수 있는 방법이 없습니다. 때문에 아이폰을 켜더라도, iOS 앱 내부에서 WCSession을 통해 WatchOS에게 api 호출명령을 내려야만 했습니다. 이는 사용자의 추가적인 상호작용이 필요했기 때문에, 사용자경험이 저하되었고, 무엇보다 상호작용이 되지 않을 경우, 영원히 서버로 데이터가 전송되지 않는 경우도 발생할 수 있었습니다.

 

이를 위해 사용한 것이 바로 Network 프레임워크입니다.

import Network

class NetworkManager {
    static let shared = NetworkManager()
    private let monitor = NWPathMonitor()
    private var status: NWPath.Status = .requiresConnection
    var isReachable: Bool { status == .satisfied }
    
    private init() {
        startMonitoring()
    }
    
    func startMonitoring() {
        monitor.pathUpdateHandler = { [weak self] path in
            self?.status = path.status
            if path.status == .satisfied {
                // 네트워크 연결되면 pending 데이터 전송 시도
                HeartRateSyncManager.shared.syncPendingData()
            }
        }
        monitor.start(queue: DispatchQueue.global())
    }
}

네트워크의 연결상태를 실시간으로 추적하며, 네트워크가 연결이 된것을 확인하면, 앞서 구현한 syncPendingData함수를 자동으로 호출해여 별도의 상호작용없이도, 서버로 데이터가 동기화될 수 있게끔 최종 로직을 구성할 수 있게 되었습니다.

 

19시 57분 35초부터 아이폰을 비행기모드로 전환한 후, 20시 28분 35초에 와이파이를 다시 연결하여 데이터 안정성을 테스트해보았습니다. 와이파이가 재연결된 28분 35초에 기존 영구저장소로 저장되어있던 60초 주기의 최대 심박수 데이터 30개가 일괄 전송되는 것을 확인할 수 있습니다.

 

감사합니다.