포스트

MapKit (28)

iOS 15 Update

이전글에 이어서 2탄 이번에도 꽤나 글이 길지않을까 예상

CloudKit (2)

getCheckedInProfilesDictionary

코드 변경

이건 코드블럭 자체가 꽤나 길지만 그래도 생략없이 전부 적어본다.

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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
// Before
func getCheckedInProfilesDictionary(completed: @escaping (Result<[CKRecord.ID: [DDGProfile]], Error>) -> Void) {
        let predicate = NSPredicate(format: "isCheckedInNilCheck == 1")
        let query = CKQuery(recordType: RecordType.profile, predicate: predicate)
        let operation = CKQueryOperation(query: query)
        
        var checkedInProfiles: [CKRecord.ID: [DDGProfile]] = [:]
        
        operation.recordFetchedBlock = { record in
            let profile = DDGProfile(record: record)
            
            guard let locationReference = record[DDGProfile.kIsCheckedIn] as? CKRecord.Reference else { return }
            
            checkedInProfiles[locationReference.recordID, default: []].append(profile)
        }
        
        operation.queryCompletionBlock = { cursor, error in
            guard error == nil else {
                completed(.failure(error!))
                return
            }
            
            if let cursor = cursor {
                self.continueWithCheckedInProfilesDict(cursor: cursor, dictionary: checkedInProfiles) { result in
                    switch result {
                    case .success(let profiles):
                        completed(.success(profiles))
                    case .failure(let error):
                        completed(.failure(error))
                    }
                }
            } else {
                completed(.success(checkedInProfiles))
            }
        }
        
    CKContainer.default().publicCloudDatabase.add(operation)
}
// After
func getCheckedInProfilesDictionary() async throws -> [CKRecord.ID: [DDGProfile]] {
    let predicate = NSPredicate(format: "isCheckedInNilCheck == 1")
    let query = CKQuery(recordType: RecordType.profile, predicate: predicate)
    
    var checkedInProfiles: [CKRecord.ID: [DDGProfile]] = [:]
    
    let (matchResults, cursor) = try await container.publicCloudDatabase.records(matching: query)
    let records = matchResults.compactMap { _, result in
        try? result.get()
    }
    
    for record in records {
        let profile = DDGProfile(record: record)
        
        guard let locationReference = record[DDGProfile.kIsCheckedIn] as? CKRecord.Reference else { continue }
        
        checkedInProfiles[locationReference.recordID, default: []].append(profile)
    }
    
    guard let cursor = cursor else {
        return checkedInProfiles
    }
        
    do {
        return try await continueWithCheckedInProfilesDict(cursor: cursor, dictionary: checkedInProfiles)
    } catch {
        throw error
    }
}

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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
// Before
func continueWithCheckedInProfilesDict(cursor: CKQueryOperation.Cursor, dictionary: [CKRecord.ID: [DDGProfile]], completed: @escaping (Result<[CKRecord.ID: [DDGProfile]], Error>) -> Void) {
    var checkedInProfiles = dictionary
    let operation = CKQueryOperation(cursor: cursor)
    //        operation.resultsLimit = 1
    
    operation.recordFetchedBlock = { record in
        let profile = DDGProfile(record: record)
        guard let locationReference = record[DDGProfile.kIsCheckedIn] as? CKRecord.Reference else { return }
        checkedInProfiles[locationReference.recordID, default: []].append(profile)
    }
    
    operation.queryCompletionBlock = { cursor, error in
        guard error == nil else {
            completed(.failure(error!))
            return
        }
        
        if let cursor = cursor {
            //                print("⭕️ Recursive cursor is not nil - \(cursor)")
            //                print("👨‍👩‍👧‍👦 Current dictionary - \(checkedInProfiles)")
            self.continueWithCheckedInProfilesDict(cursor: cursor, dictionary: checkedInProfiles) { result in
                switch result {
                case .success(let profiles):
                    //                        print("😀⭕️ Recursive Success - \(profiles)")
                    completed(.success(profiles))
                case .failure(let error):
                    //                        print("❌⭕️ Recursive cursor error - \(error)")
                    completed(.failure(error))
                }
            }
        } else {
            completed(.success(checkedInProfiles))
        }
    }
    
    CKContainer.default().publicCloudDatabase.add(operation)
}
// After
private func continueWithCheckedInProfilesDict(cursor: CKQueryOperation.Cursor, dictionary: [CKRecord.ID: [DDGProfile]]) async throws -> [CKRecord.ID: [DDGProfile]] {
    var checkedInProfiles = dictionary
    
    let (matchResults, cursor) = try await container.publicCloudDatabase.records(continuingMatchFrom: cursor)
    let records = matchResults.compactMap { _, result in
        try? result.get()
    }
    
    for record in records {
        let profile = DDGProfile(record: record)
        
        guard let locationReference = record[DDGProfile.kIsCheckedIn] as? CKRecord.Reference else { continue }
        
        checkedInProfiles[locationReference.recordID, default: []].append(profile)
    }
    
    guard let cursor = cursor else {
        return checkedInProfiles
    }
    
    do {
        return try await continueWithCheckedInProfilesDict(cursor: cursor, dictionary: checkedInProfiles)
    } catch {
        throw error
    }
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// LocationListViewModel
// Before
func getCheckedInProfilesDictionary() {
    CloudKitManager.shared.getCheckedInProfilesDictionary { result in
        DispatchQueue.main.async {
            switch result {
            case .success(let checkedInProfiles):
                self.checkedInProfiles = checkedInProfiles
            case .failure(_):
                self.alertItem = AlertContext.unableToGetAllCheckedInProfiles
            }
        }
    }
}
// After
func getCheckedInProfilesDictionary() {
    Task {
        do {
            checkedInProfiles = try await CloudKitManager.shared.getCheckedInProfilesDictionary()
        } catch {
            alertItem = AlertContext.unableToGetAllCheckedInProfiles
        }
    }
}

코드가 꽤나 길다. 하지만 그래도 모두 포함시키는게 좋아보여서 이렇게 했다.

일단 두 함수가 거의 한개의 세트라 쩔수기도 했다.

무튼, 실행해서 제대로 작동하는지 확인해보니 작동은 잘 된다.

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
31
32
33
34
35
// LocationListView
// Before
.onAppear {
    viewModel.getCheckedInProfilesDictionary()
}
// After
.task {
    viewModel.getCheckedInProfilesDictionary()
}
// LocationDetailView
// Before
.onAppear(perform: {
    viewModel.getCheckedInProfiles()
    viewModel.getCheckedInStatus()
})
// After
.task {
    viewModel.getCheckedInProfiles()
    viewModel.getCheckedInStatus()
}
// LocationMapView
// Before
.onAppear {
    if locationManager.locations.isEmpty {
        viewModel.getLocations(for: locationManager)
    }
    viewModel.getCheckedInCounts()
}
// After
.task {
    if locationManager.locations.isEmpty {
        viewModel.getLocations(for: locationManager)
    }
    viewModel.getCheckedInCounts()
}

사실 지금 코드 구조에서는 .onAppear.task로 바꾼 것이 동작상 아주 큰 의미를 가지진 않는다.

왜냐하면 뷰모델에서 호출하는 함수(getCheckedInProfilesDictionary() 등) 자체가 async로 선언된 비동기 함수가 아니라, 내부에서 자체적으로 Task { ... }를 생성해버리는 일반 함수이기 때문이다.

다만 SwiftUI에서 .task가 가지는 진짜 강력한 이점은, 뷰가 사라질 때(onDisappear) 진행 중이던 비동기 작업을 시스템이 알아서 취소(Cancel)해 준다는 점이다.

지금 당장은 단순히 강의의 흐름이나 최신 트렌드에 맞춰 통일감을 준 느낌이 강하지만, 나중에 뷰모델의 구조를 완전한 비동기로 리팩토링하게 된다면 이 .task의 자동 취소 기능이 큰 빛을 발할 것이다.

정리

이전글에 빼먹었는데 우선 이런 함수를 변환할때 기존 함수에서 어떤걸 리턴을 하는지를 중점으로 봐야한다.

1
(completed: @escaping (Result<[CKRecord.ID: [DDGProfile]], Error>) -> Void)

이걸보고 누군가는 함수뒤에 -> 이게 있으면 그게 리턴이지 하고 Void라고 말한다면… 아마도 다시 공부를 해야할 필요가 있다.

이건 CompletionHandler 였었고, return은 (Result<[CKRecord.ID: [DDGProfile]], Error>) 이렇게 했었다.

throws가 있으니 error는 무시해도 되니까, 결국 이 함수가 리턴하는 최종값은 [CKRecord.ID: [DDGProfile]] 이게 된다.

그래서

1
2
3
func getCheckedInProfilesDictionary() async throws -> [CKRecord.ID: [DDGProfile]] {
    //생략
}

이렇게 된것.

일단 가장 큰 변화라고 본다면?

기존에는 operation을 통해 코드 전개가 이루어 졌었다.

1
2
3
4
5
6
7
8
let operation = CKQueryOperation(cursor: cursor)

operation.recordFetchedBlock = { record in
// 생략
}
operation.queryCompletionBlock = { cursor, error in
// 생략
}

그걸 없애고 이전처럼

1
2
3
4
5
6
7
8
9
let predicate = NSPredicate(format: "isCheckedInNilCheck == 1")
let query = CKQuery(recordType: RecordType.profile, predicate: predicate)

var checkedInProfiles: [CKRecord.ID: [DDGProfile]] = [:]

let (matchResults, cursor) = try await container.publicCloudDatabase.records(matching: query)
let records = matchResults.compactMap { _, result in
    try? result.get()
}

이렇게 가져오는 코드 초반부의 형태는 거의 같다고 봐도 된다.

물론 이번엔 cursor가 생겼기에 Tuple에서 _ 대신 cursor를 사용해주었다.

그리고 그걸 Dictionary에 담아주기 위해서 for loop를 써서 담아 주었는데,

1
guard let locationReference = record[DDGProfile.kIsCheckedIn] as? CKRecord.Reference else { continue }

보통 우리가 봤던 Guard에서는 else 뒤에 return을 하곤 했다.

그래서 익숙하니까 아무렇지 않게 [CKRecord.ID: [DDGProfile]]을 리턴하는데, else { return } 이렇게 써버리면

Image

이런 에러가 발생하게 된다.

guard let ~ else { return }에서의 return은 조건이 맞지 않거나 값이 없을 때, 앱이 잘못된 상태로 빠지는 것을 막기 위해 ‘함수의 실행을 즉시 중단하고 빠져나오는(Early Exit)’ 강력한 방어 기제다.

근데 갑자기 value를 return하라고 에러가 뜨니 말이 안된다.

그래서 continue를 통해 함수 실행 중단하는것이 아닌 건너뛰고 계속 진행하라는 것


이후

1
2
3
4
5
do {
    return try await continueWithCheckedInProfilesDict(cursor: cursor, dictionary: checkedInProfiles)
} catch {
    throw error
}

이부분을 통해 cursor를 사용하는데,

1
return try await continueWithCheckedInProfilesDict

return 뒤에 저렇게 있는건 continueWithCheckedInProfilesDict 이 함수가 리턴을 하는게 같은 type이기 때문

그리고 print를 하지는 않겠지만 cursor 작동확인을 위해 limit를 걸때는

1
let (matchResults, cursor) = try await container.publicCloudDatabase.records(continuingMatchFrom: cursor, resultsLimit: 1)

이렇게 해주면 된다.

보완하면 좋을 점 (리팩토링)

강의를 따라가며 코드를 작성하긴 했지만, 굳이 없어도 되는데 작성된 부분이 있어서 이 부분을 조금 다듬어 보려한다.

1
2
3
4
5
6
7
8
// Before
do {
    return try await continueWithCheckedInProfilesDict(cursor: cursor, dictionary: checkedInProfiles)
} catch {
    throw error
}
// After
return try await continueWithCheckedInProfilesDict(cursor: cursor, dictionary: checkedInProfiles)

이 코드는 완벽하게 작동하지만 약간의 중복이 숨어 있다.

우리가 작성한 getCheckedInProfilesDictionary() 함수 상단을 다시 보면, 이미 async throws로 만들어 졌다.

즉, 이 함수 내부에서 에러가 터지면 내가 억지로 수습하지 않고 호출한 쪽으로 던져버릴게(throw)라고 되어있다.

따라서 굳이 do-catch 블록을 열어서 에러를 잡은(catch) 다음 똑같은 에러를 다시 던질(throw) 필요가 전혀 없다. 어차피 에러가 발생해도 함수 자체의 throws 성질 덕분에 자동으로 상위 호출부로 에러가 전달되기 때문이다.

이렇게 코드를 다듬으면 5줄짜리 코드가 1줄로 줄어들면서 가독성이 훨씬 좋아진다.


Github: Dub-Dub-Grub Repository

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.