4T2F / ThinkBig

🌟씽크빅 스터디🌟
5 stars 1 forks source link

Combine의 에러핸들링에 대해 설명하시오. (2) #64

Open ha-nabi opened 6 months ago

ha-nabi commented 6 months ago

에러 핸들링은 정말 중요하다.

단순히 라이브러리에서 발생하는 에러를 처리하는 것 뿐만 아니라, 비즈니스 로직에 따라 적절한 에러를 생성하고 다른 레이어에서 처리해야한다.

이번에는 Combine에서 에러를 핸들링하는 방법을 정리해보도록 하자.


Errors Handling


분류된 이름에서 느낄 수 있듯 에러를 처리하는 Publisher와 Operator들을 알아보자. Handling Errors에 분류된 Publisher들은 아래와 같다.

그리고 이를 활용해서 만든 Operator는 아래와 같다.

그럼 바로 하나씩 자세히 알아보도록 하자.


AssertNoFailure

가장 먼저 알아볼 Publisher는 AssertNoFailure 다.

failure event를 받으면 fatal error를 downstream에 전달하는 Publisher

그 외의 경우엔 모두 Downstream으로 전달한다고 한다.

즉 Upstream이 절대로 실패하면 안되는 타입이어야만 문제없이 동작하도록 하는 Publisher다.


assertNoFailure(_:file:line:)

AssertNoFailure Publisher를 활용해서 만든 Operator는 assertNoFailure(_:file:line:) 다.

Upstream Publisher에서 failure를 받으면 fatal error를 발생시키는 역할

그리고 Failure를 제외한 값은 모두 Downstream으로 전달한다.


간단하게 예시코드를 보자.

struct Error: Error { }

let intPublisher = PassthroughSubject<Int, Error>()

intPublisher
    .assertNoFailure()
    .sink(receiveCompletion: { print($0) },
          receiveValue: { print($0) })

intPublisher.send(1)
intPublisher.send(2)
intPublisher.send(completion: .failure(Error())) // fatal Error 발생

// assertNoFailure(_:file:line:) 예제 코드
1
2

위 코드를 실제로 실행해보면 에러를 내보내기 전까지는 정상적으로 값이 Downstream에 전달되고 있지만 failure를 내려보내면 fatal error가 발생하는 것을 볼 수 있다.

이러한 특징 때문에 실제 서비스에서 사용하는 것은 위험할 수 있지만, 개발할 때나 테스트할 때는 유용하게 사용할 수 있을 거 같다.


Catch

다음으로 알아볼 것은 Catch다.

실패한 Publisher를 다른 Publisher로 바꿔서 Upstream에서 에러가 발생하더라고 처리할 수 있는 Publisher

catch(_:)

Catch Publisher를 활용해서 만든 Operator는 catch(_:)다.

Upstream의 Publisher에서 에러가 발생하면 다른 Publisher로 교체해서 처리하는 역할


간단하게 예시 코드를 보자.

struct Error: Error { }

let intPublisher = [4, 6, 5, 12, 7, 9, 10].publisher

intPublisher
    .tryMap { value in
        guard value % 2 == 0 else { throw Error() }
        return value * 2
    }
    .catch { error in
        Just(-1)
    }
    .sink(receiveCompletion: { print($0) },
          receiveValue: { print($0) })
          
// catch(_:) 예제 코드
8
12
-1
finished

위 코드는 보면 짝수는 두 배로 내려보내고 홀수는 Error를 던지는 코드다.

그래서 4, 6까지는 두배로 출력되지만 5에서 Error를 던지게 되어 catch로 처리되는데, catch에서는 에러가 발생하면 Just(-1)로 Publisher를 바꿔버린다.

그래서 -1이 내보내지고 끝나게 된다.


TryCatch

이번엔 TryCatch다.

보통 Try는 기존 거에 에러를 던질 수 있다는 차이밖에 없었는데, 이번에도 동일하다.

아까 Catch는 에러를 받으면 다른 Publisher로 바꾸기만 했다면 TryCatch는 다른 Publisher로 바꾸거나 다른 에러를 던질 수 있다.

tryCatch(_:)

TryCatch Publisher를 활용해서 만든 Operator는 tryCatch(_:)다.

catch(_:)와는 에러를 던질 수 있다는 차이점만 존재하니 바로 사용해보고 넘어가자.


이번에는 에러가 발생하면 다른 Publisher로 바꾸는 예제를 보자.


struct Error: Error { }

let intPublisher = [4, 6, 7].publisher
let anotherPublisher = [10, 11].publisher

intPublisher
    .tryMap { value in
        guard value % 2 == 0 else { throw Error() }
        return value * 2
    }
    .tryCatch({ error -> AnyPublisher<Int, Never> in
        return anotherPublisher.eraseToAnyPublisher()
    })
    .sink(receiveCompletion: { print($0) },
          receiveValue: { print($0) })
          
// tryCatch(_:) 예제 코드
8
12
10
11
finished

이렇게 작성하면 tryMap에서 에러가 발생했을 때 tryCatch에서 다른 Publisher로 교체하는 결과를 볼 수 있다.


그럼 이번에는 tryCatch에 에러가 전달되면 다른 에러를 던져보자.

struct Error: Error { }
struct AnotherError: Error { }
let intPublisher = [4, 6, 7].publisher
let anotherPublisher = [10, 11].publisher
intPublisher
    .tryMap { value in
        guard value % 2 == 0 else { throw Error() }
        return value * 2
    }
    .tryCatch({ error -> AnyPublisher<Int, Never> in
        if error is Error { throw AnotherError() }
        return anotherPublisher.eraseToAnyPublisher()
    })
    .sink(receiveCompletion: { print($0) },
          receiveValue: { print($0) })
          
// tryCatch(_:) 예제 코드
8
12
failure(__lldb_expr_56.(unknown context at $10855f15c).(unknown context at $10855f1f4).(unknown context at $10855f220).AnotherError())

tryCatch에 전달된 에러가 Error라면 AnotherError로 바꿔서 던지는 코드다.


Retry

마지막으로 알아볼 Publisher는Retry다.

Upstream Publisher가 실패한다고 해도 새로운 subscription을 만들어서 다시 시도하는 Publisher

네트워크 에러와 같은 상황이 발생할 때 몇 번 더 시도하고 에러를 발생시키도록 하는 등의 다양한 활용 방법이 있을 거 같은 Publisher다.

retry(_:)

Retry Publisher를 활용해서 만들어진 Operator는 retry(_:)다.

upstream Publisher가 실패하더라도 매개변수로 주어진 횟수만큼 재시도하는 역할


바로 예시코드를 보자.

struct Error: Error { }

var retryCount: Int = 0

func retryTest() throws {
    if retryCount < 2 {
        retryCount += 1
        print("\(retryCount) 번째 재시도")
        throw Error()
    }
}

let intPublisher = [1, 2, 3, 4].publisher

intPublisher
    .tryMap({ value -> Int in
        try retryTest()
        return value
    })
    .retry(3)
    .sink(receiveCompletion: { print($0) },
          receiveValue: { print("receive: \($0)") })
          
// retry(_:) 예제 코드
1 번째 재시도
2 번째 재시도
receive: 1
receive: 2
receive: 3
receive: 4
finished

위 코드는 3번까지는 실패해도 다시 시도하도록 retry(_:)을 사용하여 Publisher를 subscribe 한 코드다.

retryCount라는 변수를 만들어서 재시도할 때마다 1씩 증가시키는 것도 볼 수 있다.

그래서 결국 3번째 재시도는 성공해서 값을 받아오게 된다.


그럼 만약에 시도하는 횟수보다 실패하는 횟수가 많으면 어떻게 될까?

struct Error: Error { }

var retryCount: Int = 0

func retryTest() throws {
    if retryCount < 4 {
        retryCount += 1
        print("\(retryCount) 번째 재시도")
        throw Error()
    }
}

let intPublisher = [1, 2, 3, 4].publisher

intPublisher
    .tryMap({ value -> Int in
        try retryTest()
        return value
    })
    .retry(3)
    .sink(receiveCompletion: { print($0) },
          receiveValue: { print("receive: \($0)") })
          
// retry(_:) 예제 코드
1 번째 재시도
2 번째 재시도
3 번째 재시도
4 번째 재시도
failure(__lldb_expr_80.(unknown context at $107dd019c).(unknown context at $107dd0294).(unknown context at $107dd029c).Error())

위와 같이 시도하는 횟수보다 실패하는 횟수가 많다면 에러가 그대로 전달되어 failure로 끝나게 된다.