포스트

MapKit (29)

iOS 15 Update

이전글에 이어서 3탄 CloudKit 관해서는 아마 마지막 글이 될듯하다.

CloudKit (3)

getCheckedInProfilesCount

코드 변경
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
// Before   
func getCheckedInProfilesCount(completed: @escaping (Result<[CKRecord.ID: Int], Error>) -> Void) {
    let predicate = NSPredicate(format: "isCheckedInNilCheck == 1")
    let query = CKQuery(recordType: RecordType.profile, predicate: predicate)
    let operation = CKQueryOperation(query: query)
    operation.desiredKeys = [DDGProfile.kIsCheckedIn]
    
    var checkedInProfiles: [CKRecord.ID: Int] = [:]
    
    operation.recordFetchedBlock = { record in
        guard let locationReference = record[DDGProfile.kIsCheckedIn] as? CKRecord.Reference else { return }
        
        if let count = checkedInProfiles[locationReference.recordID] {
            checkedInProfiles[locationReference.recordID] = count + 1
        } else {
            checkedInProfiles[locationReference.recordID] = 1
        }
    }
    
    operation.queryCompletionBlock = { cursor, error in
        guard error == nil else {
            completed(.failure(error!))
            return
        }
        completed(.success(checkedInProfiles))
    }
    
    CKContainer.default().publicCloudDatabase.add(operation)
}
// After
func getCheckedInProfilesCount() async throws -> [CKRecord.ID: Int] {
    let predicate = NSPredicate(format: "isCheckedInNilCheck == 1")
    let query = CKQuery(recordType: RecordType.profile, predicate: predicate)
    
    var checkedInProfiles: [CKRecord.ID: Int] = [:]
    
    let (matchResults, _) = try await container.publicCloudDatabase.records(matching: query, desiredKeys: [DDGProfile.kIsCheckedIn])
    
    let records = matchResults.compactMap { _, result in
        try? result.get()
    }
    
    for record in records {
        guard let locationReference = record[DDGProfile.kIsCheckedIn] as? CKRecord.Reference else { continue }
        
        if let count = checkedInProfiles[locationReference.recordID] {
            checkedInProfiles[locationReference.recordID] = count + 1
        } else {
            checkedInProfiles[locationReference.recordID] = 1
        }
    }
    
    return checkedInProfiles
}

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
// LocationMapViewModel
// Before
func getCheckedInCounts() {
    CloudKitManager.shared.getCheckedInProfilesCount { result in
        DispatchQueue.main.async { [self] in
            switch result {
            case .success(let checkedInProfiles):
                self.checkedInProfiles = checkedInProfiles
            case .failure(_):
                alertItem = AlertContext.checkedInCount
                break
            }
        }
    }
}

// After
func getCheckedInCounts() {
    Task {
        do {
            checkedInProfiles = try await CloudKitManager.shared.getCheckedInProfilesCount()
        } catch {
            alertItem = AlertContext.checkedInCount
        }
    }
}

사실 바로 이전에 만든것과 흐름 및 구조 자체는 크게 차이가 없다.

이미 이전에 나름 열심히 적었기에 여기는 언급은 굳이 안해도 될듯

batchSave

코드 변경 및 정리
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// Before
func batchSave(records: [CKRecord], completed: @escaping (Result<[CKRecord], Error>) -> Void) {
    
    let operation = CKModifyRecordsOperation(recordsToSave: records)
    operation.modifyRecordsCompletionBlock = { saveRecords, _, error in
        guard let saveRecords = saveRecords, error == nil else {
            print(error!.localizedDescription)
            completed(.failure(error!))
            return
        }
        
        completed(.success(saveRecords))
    }
    
    CKContainer.default().publicCloudDatabase.add(operation)
}
// After
func batchSave(records: [CKRecord]) async throws -> [CKRecord] {
    let (savedResult, _) = try await container.publicCloudDatabase.modifyRecords(saving: records, deleting: [])
    return savedResult.compactMap { _, result in
        try? result.get()
    }
}

사실 여기는 딱히 정리 할 부분이 없긴하지만 굳이 하나를 꼽으면 역시 Tuple이다.

1
let (savedResult, _) = try await container.publicCloudDatabase.modifyRecords(saving: records, deleting: [])

Image

여기서는 어떤걸 저장하고 어떤걸 지울지에 대한거라

지우는것은 필요없기에 _를 했지만 이것도 원래 deleting에도 뭐가 있다면

let (savedResult, deletedResult) 이런식으로 Tuple을 썼을 것이다.

save & fetchRecord

save
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Before
func save(record: CKRecord, completed: @escaping (Result<CKRecord, Error>) -> Void) {
    CKContainer.default().publicCloudDatabase.save(record) { record, error in
        guard let record = record, error == nil else {
            completed(.failure(error!))
            return
        }
        
        completed(.success(record))
    }
}
// After
func save(record: CKRecord) async throws -> CKRecord {
    return try await container.publicCloudDatabase.save(record)
}

fetchRecord
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Before
func fetchRecord(with id: CKRecord.ID, completed: @escaping (Result<CKRecord, Error>) -> Void) {
    CKContainer.default().publicCloudDatabase.fetch(withRecordID: id) { record, error in
        guard let record = record, error == nil else {
            completed(.failure(error!))
            return
        }
        
        completed(.success(record))
    }
}
// After
func fetchRecord(with id: CKRecord.ID) async throws -> CKRecord {
    return try await container.publicCloudDatabase.record(for: id)
}

save 와 fetchRecord는 거의 쌍둥이라고 봐도 무방하긴 하다.


getCheckedInStatus (DetailVM)
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
// LocationDetailViewModel
// Before
func getCheckedInStatus() {
    guard let profileRecordID = CloudKitManager.shared.profileRecordID else {
        return
    }
    
    CloudKitManager.shared.fetchRecord(with: profileRecordID) { [self] result in
        DispatchQueue.main.async {
            switch result {
            case .success(let record):
                if let reference = record[DDGProfile.kIsCheckedIn] as? CKRecord.Reference {
                    isCheckedIn = reference.recordID == location.id
                } else {
                    isCheckedIn = false
                }
            case .failure(_):
                alertItem = AlertContext.unableToGetCheckInStatus
            }
        }
    }
}
// After
func getCheckedInStatus() {
    guard let profileRecordID = CloudKitManager.shared.profileRecordID else {
        return
    }
    
    Task {
        do {
            let record = try await CloudKitManager.shared.fetchRecord(with: profileRecordID)
            if let reference = record[DDGProfile.kIsCheckedIn] as? CKRecord.Reference {
                isCheckedIn = reference.recordID == location.id
            } else {
                isCheckedIn = false
            }
        } catch {
            alertItem = AlertContext.unableToGetCheckInStatus
        }
    }
}

getCheckedInStatus (ProfileVM)
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
// ProfileViewModal
// Before
func getCheckedInStatus() {
    guard let profileRecordID = CloudKitManager.shared.profileRecordID else { return }
    
    CloudKitManager.shared.fetchRecord(with: profileRecordID) { [self] result in
        DispatchQueue.main.async {
            switch result {
            case .success(let record):
                if let _ = record[DDGProfile.kIsCheckedIn] as? CKRecord.Reference {
                    isCheckedIn = true
                } else {
                    isCheckedIn = false
                }
            case .failure(_):
                break
            }
        }
    }
}
// After
func getCheckedInStatus() {
    guard let profileRecordID = CloudKitManager.shared.profileRecordID else { return }
    
    Task {
        do {
            let record = try await CloudKitManager.shared.fetchRecord(with: profileRecordID)
            if let _ = record[DDGProfile.kIsCheckedIn] as? CKRecord.Reference {
                isCheckedIn = true
            } else {
                isCheckedIn = false
            }
        } catch {
            print("Unable to get checked in status")
        }
    }
}

getProfile
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
// ProfileViewModel
// Before
func getProfile() {
    guard let userRecord = CloudKitManager.shared.userRecord else {
        alertItem = AlertContext.noUserRecord
        return
    }
    
    guard let profileReference = userRecord["userProfile"] as? CKRecord.Reference else {
        return
    }
    
    let profileRecordID = profileReference.recordID
    
    showLoadingView()
    CloudKitManager.shared.fetchRecord(with: profileRecordID) { result in
        DispatchQueue.main.async { [self] in
            hideLoadingView()
            
            switch result {
            case .success(let record):
                existingProfileRecord = record
                let profile = DDGProfile(record: record)
                firstName = profile.firstName
                lastName = profile.lastName
                companyName = profile.companyName
                bio = profile.bio
                avatar = profile.avatarImage
                
            case .failure(_):
                // show alert
                alertItem = AlertContext.unableToGetProfile
                break
            }
        }
    }
}
// After
func getProfile() {
    guard let userRecord = CloudKitManager.shared.userRecord else {
        alertItem = AlertContext.noUserRecord
        return
    }
    
    guard let profileReference = userRecord["userProfile"] as? CKRecord.Reference else { return }
    let profileRecordID = profileReference.recordID
    
    showLoadingView()
    
    Task {
        do {
            let record = try await CloudKitManager.shared.fetchRecord(with: profileRecordID)
            existingProfileRecord = record
            
            let profile = DDGProfile(record: record)
            firstName   = profile.firstName
            lastName    = profile.lastName
            companyName = profile.companyName
            bio         = profile.bio
            avatar      = profile.avatarImage
            
            hideLoadingView()
        } catch {
            alertItem = AlertContext.unableToGetProfile
        }
    }
}

checkOut
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
// ProfileViewModel
// Before
func checkOut() {
    guard let profileID = CloudKitManager.shared.profileRecordID else {
        alertItem = AlertContext.unableToGetProfile
        return
    }
    
    showLoadingView()
    CloudKitManager.shared.fetchRecord(with: profileID) { result in
        switch result {
        case .success(let record):
            record[DDGProfile.kIsCheckedIn] = nil
            record[DDGProfile.kIsCheckedInNilCheck] = nil
            
            CloudKitManager.shared.save(record: record) { [self] result in
                DispatchQueue.main.async {
                    switch result {
                    case .success(_):
                        HapticManager.playSuccess()
                        isCheckedIn = false
                    case .failure(_):
                        alertItem = AlertContext.unableToCheckInOrOut
                    }
                }
            }
            
        case .failure(_):
            self.hideLoadingView()
            DispatchQueue.main.async {
                self.alertItem = AlertContext.unableToCheckInOrOut
            }
        }
    }
}
// After
func checkOut() {
    guard let profileID = CloudKitManager.shared.profileRecordID else {
        alertItem = AlertContext.unableToGetProfile
        return
    }
    
    showLoadingView()
    
    Task {
        do {
            let record = try await CloudKitManager.shared.fetchRecord(with: profileID)
            record[DDGProfile.kIsCheckedIn] = nil
            record[DDGProfile.kIsCheckedInNilCheck] = nil
            
            let _ = try await CloudKitManager.shared.save(record: record)
            HapticManager.playSuccess()
            isCheckedIn = false
            hideLoadingView()
        } catch {
            hideLoadingView()
            alertItem = AlertContext.unableToCheckInOrOut
        }
    }
}

createProfile
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
69
70
71
// ProfileViewModel
// Before
func createProfile() {
    guard isValidProfile() else {
        alertItem = AlertContext.invalidProfile
        return
    }
    
    // Create our CKRecord from the profile view
    let profileRecord = createProfileRecord()
    
    guard let userRecord = CloudKitManager.shared.userRecord else {
        alertItem = AlertContext.noUserRecord
        return
    }
    
    userRecord["userProfile"] = CKRecord.Reference(recordID: profileRecord.recordID, action: .none)
    
    showLoadingView()
    CloudKitManager.shared.batchSave(records: [userRecord, profileRecord]) { result in
        DispatchQueue.main.async { [self] in
            hideLoadingView()
            
            switch result {
            case .success(let records):
                for record in records where record.recordType == RecordType.profile {
                    existingProfileRecord = record
                    CloudKitManager.shared.profileRecordID = record.recordID
                }
                alertItem = AlertContext.createProfileSuccess
            case .failure(_):
                alertItem = AlertContext.createProfileFailure
                
                break
            }
        }
        
    }
}
// After
private func createProfile() {
    guard isValidProfile() else {
        alertItem = AlertContext.invalidProfile
        return
    }
    
    let profileRecord = createProfileRecord()
    guard let userRecord = CloudKitManager.shared.userRecord else {
        alertItem = AlertContext.noUserRecord
        return
    }
    
    userRecord["userProfile"] = CKRecord.Reference(recordID: profileRecord.recordID, action: .none)
    
    showLoadingView()
    
    Task {
        do {
            let records = try await CloudKitManager.shared.batchSave(records: [userRecord, profileRecord])
            for record in records where record.recordType == RecordType.profile {
                existingProfileRecord = record
                CloudKitManager.shared.profileRecordID = record.recordID
            }
            hideLoadingView()
            alertItem = AlertContext.createProfileSuccess
        } catch {
            hideLoadingView()
            alertItem = AlertContext.createProfileFailure
        }
    }
}

updateProfile
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
// ProfileViewModel
// Before
func updateProfile() {
    guard isValidProfile() else {
        alertItem = AlertContext.invalidProfile
        return
    }
    
    guard let profileRecord = existingProfileRecord else {
        alertItem = AlertContext.unableToGetProfile
        return
    }
    
    profileRecord[DDGProfile.kFirstName] = firstName
    profileRecord[DDGProfile.kLastName] = lastName
    profileRecord[DDGProfile.kCompanyName] = companyName
    profileRecord[DDGProfile.kBio] = bio
    profileRecord[DDGProfile.kAvatar] = avatar.convertToCKAsset()
    
    showLoadingView()
    CloudKitManager.shared.save(record: profileRecord) { result in
        DispatchQueue.main.async { [self] in
            hideLoadingView()
            switch result {
            case .success(_):
                alertItem = AlertContext.updateProfileSuccess
            case .failure(_):
                alertItem = AlertContext.updateProfileFailure
            }
        }
    }
    
}
// After
private func updateProfile() {
    guard isValidProfile() else {
        alertItem = AlertContext.invalidProfile
        return
    }
    
    guard let profileRecord = existingProfileRecord else {
        alertItem = AlertContext.unableToGetProfile
        return
    }
    
    profileRecord[DDGProfile.kFirstName]    = firstName
    profileRecord[DDGProfile.kLastName]     = lastName
    profileRecord[DDGProfile.kCompanyName]  = companyName
    profileRecord[DDGProfile.kBio]          = bio
    profileRecord[DDGProfile.kAvatar]       = avatar.convertToCKAsset()
    
    showLoadingView()
    
    Task {
        do {
            let _ = try await CloudKitManager.shared.save(record: profileRecord)
            hideLoadingView()
            alertItem = AlertContext.updateProfileSuccess
        } catch {
            hideLoadingView()
            alertItem = AlertContext.updateProfileFailure
        }
    }
}

updateCheckInStatus
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
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
// LocationDetailViewModel
// Before
func updateCheckInStatus(to checkinStatus: CheckInStatus) {
    guard let profileRecordID = CloudKitManager.shared.profileRecordID else {
        alertItem = AlertContext.unableToGetProfile
        return
    }
    
    showLoadingView()
    
    CloudKitManager.shared.fetchRecord(with: profileRecordID) { [self] result in
        switch result {
        case .success(let record):
            switch checkinStatus {
            case .checkedIn:
                record[DDGProfile.kIsCheckedIn] = CKRecord.Reference(recordID: location.id, action: .none)
                record[DDGProfile.kIsCheckedInNilCheck] = 1
            case .checkedOut:
                record[DDGProfile.kIsCheckedIn] = nil
                record[DDGProfile.kIsCheckedInNilCheck] = nil
            }
            
            CloudKitManager.shared.save(record: record) { result in
                DispatchQueue.main.async {
                    hideLoadingView()
                    switch result {
                    case .success(let record):
                        HapticManager.playSuccess()
                        let profile = DDGProfile(record: record)
                        switch checkinStatus {
                        case .checkedIn:
                            checkedInProfiles.append(profile)
                        case .checkedOut:
                            checkedInProfiles.removeAll(where: {$0.id == profile.id})
                        }
            
                        isCheckedIn.toggle()
                    case .failure(_):
                        alertItem = AlertContext.unableToCheckInOrOut
                    }
                }
            }
        case .failure(_):
            hideLoadingView()
            alertItem = AlertContext.unableToCheckInOrOut
        }
    }
}
// After
func updateCheckInStatus(to checkInStatus: CheckInStatus) {
    guard let profileRecordID = CloudKitManager.shared.profileRecordID else {
        alertItem = AlertContext.unableToGetProfile
        return
    }
    
    showLoadingView()
    
    Task {
        do {
            let record = try await CloudKitManager.shared.fetchRecord(with: profileRecordID)
            switch checkInStatus {
                case .checkedIn:
                    record[DDGProfile.kIsCheckedIn] = CKRecord.Reference(recordID: location.id, action: .none)
                    record[DDGProfile.kIsCheckedInNilCheck] = 1
                case .checkedOut:
                    record[DDGProfile.kIsCheckedIn] = nil
                    record[DDGProfile.kIsCheckedInNilCheck] = nil
            }
            
            let savedRecord = try await CloudKitManager.shared.save(record: record)
            HapticManager.playSuccess()
            let profile = DDGProfile(record: savedRecord)
            switch checkInStatus {
                case .checkedIn:
                    checkedInProfiles.append(profile)
                case .checkedOut:
                    checkedInProfiles.removeAll(where:{ $0.id == profile.id })
            }
            
            isCheckedIn.toggle()
            hideLoadingView()
        } catch {
            alertItem = AlertContext.unableToCheckInOrOut
        }
    }
}

거의 마지막은 코드위주긴 했다.


정리

사실 이 바뀐 코드들의 공통적인 flow는

CloudKitManager를 기준으로 잡고 수정을 한다.

우선 CloudKitManager를 사용하기전에 만들어둔 변수는 그대로 유지하고, CloudKitManager를 사용한 부분에 대해서 do-catch 블럭이 적용이 되는 매커니즘을 가진다.

그리고 기존에는 Completion Handler를 사용함에 따라서 result에 따라 switch-case로 성공이냐 실패냐에 따라서 결과값을 리턴해줬는데, 지금은 그럴 필요없이 do-catch를 통해 간단하게 해결이 된다.

do에서는 원하는 값을 저장하거나, 업데이트 하는 그런 로직이 들어가고, catch에는 기존에 구현했던 alertItem이 들어간다.

이런 공통점을 숙지를 해둔다면, 코드가 길어도 당황하지않고 수정이 가능해질것이다.

추가적으로 updateCheckInStatus의 코드를 비교해 보면 가장 체감이 큰데, 기존에는 클로저 안에 클로저가 들어가는 전형적인 콜백 지옥이 발생했었다.

하지만 async/await를 도입하니 코드가 위에서 아래로 평평하게 흘러가 가독성이 비약적으로 상승했다는 것도 빼놓을 수 없는 장점이다.


내 개인적인 시각 (Completion Handler vs Async/Await)

이번 마이그레이션 작업을 쭉 진행하면서 느낀 점을 짧게 남겨본다. 결론부터 말하자면 애플이 Async/Await를 도입한 것은 정말 획기적인 변화라고 생각한다. 하지만 두 가지 방식 모두 각자의 명확한 장단점과 진입장벽을 가지고 있다.

1. Completion Handler: “작성자”는 편하지만 “읽는 사람”이 고통받는다.

사실 클로저(Completion Handler) 방식이 손에 익으면 코드를 짜는 입장에서는 크게 불편하지 않다. 데이터를 리턴할 때 에러가 나면 결과값과 에러를 같이 묶어서 쿨하게 던져주면 그만이기 때문이다. 하지만 진짜 문제는 ‘가독성’과 ‘협업’에 있다. 코드가 우측으로 끝없이 파고드는 들여쓰기(Depth) 때문에 코드 양이 비대해지고, 본인이 짠 코드가 아니면 흐름을 단번에 파악하기가 너무 힘들다. 초보자들이 함수를 호출하고 사용하는 것 자체에 높은 진입장벽을 느끼는 이유이기도 하다.

2. Async/Await: 가독성은 최고지만, “가짜 동기화”의 함정이 있다.

이러한 콜백 지옥을 해결하기 위해 나온 Async/Await는 코드의 가독성을 비약적으로 향상해 준다. 코드가 위에서 아래로 예쁘게 떨어지니 눈이 아주 편안하다. 하지만 그렇다고 난이도가 낮아진 것은 절대 아니다. 겉보기엔 단순해 보이지만, 이 예쁜 코드를 제대로 쓰려면 Task, try, throws, @MainActor 등 스레드와 동시성에 대한 탄탄한 기본 지식이 필수적이다. 즉, 시각적인 복잡도는 줄어들었지만, 보이지 않는 곳에서 시스템이 어떻게 돌아가는지 머릿속으로 그려야 하는 ‘개념적인 진입장벽’은 여전히 존재한다.

결론

결국, 클로저는 ‘눈(시각)’이 피곤한 방식이고, Async/Await는 ‘뇌(개념)’가 피곤한 방식이다. 난이도 자체는 비슷할 수 있지만, 장기적인 유지보수와 현대적인 앱 개발 트렌드를 고려한다면 동시성의 기본기를 빡세게 다지고 Async/Await로 넘어오는 것이 확실한 정답이라고 생각한다.


Github: Dub-Dub-Grub Repository

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