티스토리 뷰

iOS

[Combine] Chapter16 : Error Handling

Peppo 2023. 3. 1. 17:39
728x90

지금까지 Combine 내용에선 무조건 성공하는 케이스만 다뤄봤는데,

이번엔 실패했을경우 에러 처리를 어떻게 해야하는지, 어떤 에러처리 방법이 있는지 다뤄보도록 하겠습니다.

Failure 부분!


Never

Failure 타입이 Never인 publisher는 erorr가 없는 경우 입니다. 

 

 



example(of: "Never sink") {
    Just("Hello")
        .sink(receiveValue: { print($0) })
        .store(in: &subscriptions)
}​

Just의 경우 내부를 보면 Failure = Never인걸 확인할 수 있습니다.



만약 Never 타입에서 동작하는 operator(연산자)는 몇개 더 있습니다. 대표적으로 많이 쓰는 `setFailureType`을 먼저 봅시다.


setFailureType

import Foundation
import Combine

var subscriptions = Set<AnyCancellable>()

enum MyError: Error {
    case ohNo
}

example(of: "setFailureType") {
    Just("Hello")
        .setFailureType(to: MyError.self)
        .sink(receiveCompletion: { completion in
            switch completion {
            case .failure(.ohNo):
                print("Finished with Oh no")
            case .finished:
                print("Finished successfully")
            }
        }, receiveValue: { value in
            print("Got value: \(value)")
        }
        )
        .store(in: &subscriptions)
}

// ——— Example of: setFailureType ———
// Got value: Hello
// Finished successfully

Just가 실패하지 않는 타입이기 때문에 결과값은 위와 같이 나옵니다.



assign(to:on)

assign은 Just가 이벤트를 방출할 때마다 값을 바꿔줍니다.

*setFailureType과 같이 Never에서만 동작합니다.

import Foundation
import Combine

var subscriptions = Set<AnyCancellable>()

example(of: "assign(to:on:)") {
    // 1
    class Person {
        let id = UUID()
        var name = "Unknown"
    }
    
    // 2
    let person = Person()
    print("1", person.name)
    
    Just("Peppo")
        .handleEvents(
            receiveCompletion: { _ in
                print("2", person.name)
            }
        )
        .assign(to: \.name, on: person) // 4
        .store(in: &subscriptions)
}
//——— Example of: assign(to:on:) ———
//1 Unknown
//2 Peppo

1. id, name 프로퍼티를 가진 Person class를 정의
2. Person()을 인스턴스화 해 name을 프린트
3. handleEvents를 사용해, publisher가 완료 이벤트(completion event)를 보내면 person name을 다시 프린트
4. publisher가 방출하는대로 person name을 세팅하기 위해 assign 사용



assign(to:) - memory leak 방지

들어가기전에 아래 코드 예시에서 assign(to:on)을 사용했을때 문제점을 봅시다.

import Foundation
import Combine

var subscriptions = Set<AnyCancellable>()

example(of: "assign(to:)") {
    class MyViewModel: ObservableObject {
        // 1
        @Published var currentDate = Date()
        
        init() {
            Timer.publish(every: 1, on: .main, in: .common) // 2
                .autoconnect()
                .prefix(3) // 3
                .assign(to: \.currentDate, on: self) // 4
                .store(in: &subscriptions)
        }
    }
    
    // 5
    let vm = MyViewModel()
    vm.$currentDate
        .sink(receiveValue: { print($0) })
        .store(in: &subscriptions)
}

//——— Example of: assign(to:) ———
//2023-02-22 00:30:23 +0000
//(1초후)2023-02-22 00:30:24 +0000
//(1초후)2023-02-22 00:30:25 +0000
//(1초후)2023-02-22 00:30:26 +0000


1. MyViewModel 내부에 @Published 프로퍼티 currentDate를 정의 해주고, 초기화 시켜줍니다.
2. 매 1초마다 현재 날짜,시간을 방출하는 timer publisher를 만들어줍니다.
3. 3개의 업데이트만 받기 위해 prefix 연산자(operator)를 사용합니다.
4. assign(to:on:) 연산자를 사용해, @Published 프로퍼티 (currentDate)를 매번 date 업데이트 해줍니다.
5. currentDate를 구독(sink)해 방출되는 값을 print 합니다.

정상 작동하는것 처럼 보이지만, 아래 그림과 같이 memory leak 이 나고 있습니다. 

아래 그림을 보면,


assign(to:on:)를 호출하게 되면,  강하게 유지하는 self의 subscription을 만듭니다.
즉, self가 subscription에 메어 있고, subscription도 self에 메어있는 상태로 retain cycle이 일어나기 때문에 memory leak이 발생합니다.

이걸 해결하기 위해 assign(to:)를 사용합니다. 

import Foundation
import Combine

var subscriptions = Set<AnyCancellable>()

example(of: "assign(to:)") {
    class MyViewModel: ObservableObject {
        
        @Published var currentDate = Date()
        
        init() {
            Timer.publish(every: 1, on: .main, in: .common)
                .autoconnect()
                .prefix(3)
                .assign(to: &$currentDate) // 수정
        }
    }
    
    // 5
    let vm = MyViewModel()
    vm.$currentDate
        .sink(receiveValue: { print($0) })
        .store(in: &subscriptions)
}

//——— Example of: assign(to:) ———
//2023-02-22 00:30:23 +0000
//(1초후)2023-02-22 00:30:24 +0000
//(1초후)2023-02-22 00:30:25 +0000
//(1초후)2023-02-22 00:30:26 +0000

내부적으로 subscription에 대한 메모리 관리를 자동으로 처리하므로 .store(in: &subscriptions)생략할 수 있습니다.



assertNoFailure

에러를 감지하면 fatalError를 발생시키고(?), 아니면 받았던 모든 input을 republish 합니다. 

import Foundation
import Combine

var subscriptions = Set<AnyCancellable>()

enum MyError: Error {
    case ohNo
}

example(of: "assertNoFailure") {
    Just("Hello")
        .setFailureType(to: MyError.self)
        .assertNoFailure()
        .sink(receiveValue: { print("God value: \($0) ")})
        .store(in: &subscriptions)
}

//——— Example of: assertNoFailure ———
//God value: Hello


여기서 tryMap을 사용해 에러를 감지 시켜보면 ?

import Foundation
import Combine

var subscriptions = Set<AnyCancellable>()

enum MyError: Error {
    case ohNo
}

example(of: "assertNoFailure") {
    Just("Hello")
        .setFailureType(to: MyError.self)
        .tryMap { _ in throw MyError.ohNo} // 추가
        .assertNoFailure()
        .sink(receiveValue: { print("God value: \($0) ")})
        .store(in: &subscriptions)
}
// fatalerror
fatalError 발생

근데 어디에 쓰는지는 잘 모르겠다..



 

 

 


 

Dealing with failure

 

지금까지 에러가 발생하지 않는경우만 봤는데,

에러가 발생하는 경우에 대해 알아 보겠습니다.

try* operators

 

try-prefixed 의 연산자를 사용해서 알아보겠습니다. 

먼저 아래코드를 보면
import Foundation
import Combine

var subscriptions = Set<AnyCancellable>()

example(of: "tryMap") {
    enum NameError: Error {
        case tooShort(String)
        case unowned
    }
    
    let names = ["Marin", "Shai", "Florent"].publisher
    
        names
        .tryMap { value -> Int in
            let length = value.count
            
            guard length >= 5 else {
                throw NameError.tooShort(value)
            }
            
            return value.count
        }
        .sink(receiveCompletion: { print("Completed with \($0)") },
              receiveValue: { print("Got value: \($0)") }
        )
}

//——— Example of: tryMap ———
//Got value: 5
//Completed with failure(...NameError.tooShort("Shai"))


위 코드는 length가 5미만인 요소인경우 NameError.tooShort의 에러로 throw 됩니다.



 

 

Mapping errors (map vs try map)

 

* map, tryMap의 차이점
map - publihser의 값만 조작, 기존 에러타입을 반영
tryMap - error throwing, 에러타입을 Swift Error로 변형(erase) 함.
아래는 map 사용했을때의 코드입니다. 

 

import Foundation
import Combine

var subscriptions = Set<AnyCancellable>()

example(of: "map vs tryMap") {
    enum NameError: Error {
        case tooShort(String)
        case unowned
    }
    
    Just("Hello")
        .setFailureType(to: NameError.self) // 1
        .map { $0 + " World!" }
        .sink(
            receiveCompletion: { completion in
                switch completion {
                case .finished:
                    print("Done!")
                case .failure(.tooShort(let name)):
                    print("\(name) is too short!")
                case .failure(.unowned):
                    print("An unknown name error occurred")
                }
            },
            receiveValue: { print("Got value \($0)") }
        )
        .store(in: &subscriptions)
}
//——— Example of: map vs tryMap ———
//Got value Hello World!
//Done!

1. setFailureType을 사용해서 실패유형을 NameError로 세팅.


여기서 map을 tryMap으로 바꿔보면 에러가 나오는데,

map → tryMap으로 변경시


이유는 즉 아직 Swift에서 try-prefixed 연산자를 사용할 경우 typed Error (e.g. NameError)를 throw하지 못합니다.

대안으로 Combine에서 mapError를 제공합니다.

example(of: "map vs tryMap") {
    enum NameError: Error {
        case tooShort(String)
        case unowned
    }
    
    Just("Hello")
        .setFailureType(to: NameError.self)
        .tryMap { $0 + " World!" } // map → tryMap
        .mapError { $0 as? NameError ?? .unowned } // 추가
        .sink(
            receiveCompletion: { completion in
                switch completion {
                case .finished:
                    print("Done!")
                case .failure(.tooShort(let name)):
                    print("\(name) is too short!")
                case .failure(.unowned):
                    print("An unknown name error occurred")
                }
            },
            receiveValue: { print("Got value \($0)") }
        )
        .store(in: &subscriptions)
}
//——— Example of: map vs tryMap ———
//Got value Hello World!
//Done!


mapError는 upstream publisher에서 에러가 발생했을시 타입캐스팅된 에러타입으로 error thrown합니다.

아래처럼 바로 error thrown 할 수도 있습니다.

example(of: "map vs tryMap") {
    enum NameError: Error {
        case tooShort(String)
        case unowned
    }
    
    Just("Hello")
        .setFailureType(to: NameError.self)
        .tryMap { throw NameError.tooShort($0) } // 변경
        .mapError { $0 as? NameError ?? .unowned }
        .sink(
            receiveCompletion: { completion in
                switch completion {
                case .finished:
                    print("Done!")
                case .failure(.tooShort(let name)):
                    print("\(name) is too short!")
                case .failure(.unowned):
                    print("An unknown name error occurred")
                }
            },
            receiveValue: { print("Got value \($0)") }
        )
        .store(in: &subscriptions)
}
//——— Example of: map vs tryMap ———
//Hello is too short!

 

 

 

 

 

728x90