티스토리 뷰

에러 핸들링은 정말 중요합니다. 단순히 라이브러리에서 발생하는 에러를 처리하는 것 뿐만 아니라, 비즈니스 로직에 따라 적절한 에러를 생성하고 다른 레이어에서 처리해야합니다. 그래서 이번 포스팅에서는 Combine에서 에러를 핸들링하는 방법을 정리해보았습니다.

 

에러의 종류


프로그램에는 2가지 종류의 에러가 있습니다. 개발자 에러와, 사용자 에러입니다.

 

개발자 에러는 개발 도중, 즉 디버그 모드에서 발생할 수 있는 에러입니다. 개발자 에러를 내기 위해서는 assert문을 주로 사용하며 assert문은 코드를 작성하는 단계에서 문제가 있을 경우 프로그램을 중단하고 에러 메세지를 표시해줍니다. 이를 통해 개발자는 논리가 맞는지 확인하거나, 전달받은 값이 유효한 값인지 확인해볼 수 있습니다. assert문은 릴리즈 버전에서는 실행되지 않습니다.

let value = 10

assert(value > 0, "Value must be greater than 0") // 디버깅 목적으로 사용, 릴리즈되면 비활성화됨

이 외에는 fatalError가 있습니다. fatalError는 심각한 오류사항을 의미하고 앱을 강제종료시킵니다. assert문과 달리 릴리즈 버전에서도 동작하며, 앱을 더 이상 실행시킬 수 없는 심각한 오류 상황일 때 사용합니다.

let value = 10

if value < 0 {
    fatalError("Value cannot be negative") // 심각한 오류가 발생한 경우, 앱 종료
}

따라서 논리만 검토하는 경우, 즉 예상하던 값은 아니나 이후 흐름에 문제가 없을 경우assert를 사용하고, 실패하면 안되는 형변환에 실패했을 경우 · 외부 통신을 위해 받은 매개변수가 올바르지 않을 경우 등 중요한 로직이 중단되는 경우에는 fatalError를 사용합니다. fatalError는 런타임에는 실행되면 안되므로, 많은 테스트와 검증이 필요하지만 다음 흐름에서 알 수 없는 에러가 무더기로 발생하기 전에 개발자에게 문제를 알려주는 장치로 유용하게 사용할 수 있습니다.

 

사용자 에러는 런타임에서 사용자나 외부 요인에 의해 발생할 수 있는 에러입니다. 네트워크 연결 여부, 사용자가 입력값, 디바이스 상태 등에 의해 발생합니다. 이러한 에러는 개발자가 앱을 개발하는 도중에는 해결할 수 없으므로 적절한 방법으로 처리될 수 있도록 지정해주어야합니다. 즉, 사용자가 적절한 값을 입력하도록, 네트워크를 연결하도록, 디바이스 상태를 체크하도록 유도해야합니다. 그리고 이를 위해서 Error 타입을 상속받는 커스텀 에러사용합니다.

// 커스텀 에러 예시
enum NetworkError: Error {
    case invalidURL
    case requestFailed
    case responseParsingFailed
    case authenticationError
}

 

Error 형을 처리하는 방법은 라이브러리마다 다른데, async await의 경우 try-catch문을 사용하고 Combine에서는 tryMap-mapError를 주로 사용합니다. 하지만 이방식만으로는 해결되지 않는 경우들이 많습니다. 따라서 아래부터는 Combine에서 에러를 핸들링할 수 있는 다양한 방법에 대해서 설명하겠습니다.

 

에러 변환 및 발생 tryMap + mapError


에러 감지 및 변환

tryMap, mapError을 사용하면 일반적인 try-catch와 같은 방식을 스트림 형식으로 처리할 수 있습니다. tryMap, mapError는 Publisher를 받아 중간에 에러를 캐치하고 새로운 에러를 반환하는 Publisher로 변환합니다.

// May be service
enum AccountError: Error {
	case logiFailed
}

func requestLogin(loginModel: LoginRequestModel) -> AnyPublisher<Bool, AccountError> {
    return provider.requestPublisher(.login(loginModel))
        .tryMap { response in
            let decodedData = try response.map(LoginResponseModel.self)
            return true
        }
        .mapError { error in
            return AccountError.loginFailed
        }
        .eraseToAnyPublisher()
}

위 코드는 Combine moya의 provider가 제공하는 publisher를 중간 스트림에서 받아 결과를 decode하고 새로운 에러를 만드는 코드입니다. tryMap안에서 실행된 try문이 실패하면 mapError문으로 넘어가 에러 스트림을 발생시킵니다. 또한, 위 코드에서는 mapError에서 무조건 loginFailed를 발생시키고 있지만 클로저로 받은 error를 이용해 네트워크 에러, 서버 에러 등 더 상세한 오류를 반환할 수도 있습니다.

 

새로운 에러 발생 시키기 

enum AccountError: Error {
    case signupFailed
    case signupEmailDuplicated
}

func requestSignup(signupModel: SignupReqeustModel) -> AnyPublisher<SignupResponseModel, AccountError> {
    return provider.requestPublisher(.signup(signupModel))
        .tryMap { response in
            let decodedData = try response.map(SignupResponseModel.self)
            if decodedData.message == "There are duplicate users" {
                throw AccountError.signupEmailDuplicated // ✅
            }
            return decodedData
        }
        .mapError { error in
            // 내부 Publisher에서 발생한 에러를 다른 에러 타입으로 변환
            if error is MoyaError {
                return AccountError.signupFailed
            } else {
                return error as! AccountError
            }
        }
        .eraseToAnyPublisher()
}

mapError를 통해 변환하는 것이 아니라 tryMap안에서 새로운 에러를 발생시키려면 throw문을 사용하면 됩니다. 기타 네트워크 에러(MoyaError)를 커스텀에러로 변환하고 tryMap의 에러(signupEmailDuplicated)는 그대로 전달하는 코드입니다.

☑️ 단순히 에러만 변환하고 싶은 경우 tryMap을 생략할 수도 있습니다.

 

스트림 방식으로 if문 정리하기

func requestLogin(loginModel: LoginRequestModel) -> AnyPublisher<LoginResponseModel, AccountError> {
    return provider.requestPublisher(.login(loginModel))
        .mapError { _ in
            return AccountError.login(.loginFaild)
        }
        .tryMap { response in
            let decodedData = try response.map(LoginResponseModel.self)
            if decodedData.mid == 0 {
                throw AccountError.login(.wrongPassword)
            } else if decodedData.mid == -2 {
                throw AccountError.login(.userNotFound)
            }
            return decodedData
        }
        .mapError { error in
            return error as! AccountError
        }
        .eraseToAnyPublisher()
}

또한, 위와 같이 mapError를 두번 사용하면 네트워크 에러는 첫번째 mapError를 통해 처리되고 tryMap에서 발생하는 에러는 두번째 mapError가 처리하도록 구성해 if문을 생략해 더 명확한 코드를 작성할 수도 있습니다.

 

에러 처리 1. sink


이렇게 변환된 Publisher의 에러를 받아 처리하려면 어떻게 해야할까요?

// ViewModel or other
func tapLoginButton() {
    self.requestLogin(loginModel: LoginRequestModel(id: self.editEmail,
                                                    pw: self.editPassword))
        .sink(receiveCompletion: { completion in
            switch completion {
            case .finished: break
            case .failure(let error):
                self.loginErrorMessage = "로그인에 실패했습니다" // ✅
            }
        }, receiveValue: { response in
            self.loginErrorMessage = ""
        }).store(in: &cancellables)
}

sink를 이용하면 receiveCompletion > .failure 클로저를 통해 간단히 error를 받아볼 수 있습니다. failure case 부분에 원하는 에러 처리를 하면 됩니다.

 

에러 처리 2. flatMap + catch


단순히 결과값을 에러 메세지에 매핑하는 것 뿐이라면 더 단순하게 하나의 스트림으로 표현할 수 있습니다.

$tapLoginButton // Subject로, 버튼을 탭하면 Void를 의미하는 빈 튜플을 방출합니다.
    .flatMap { value -> AnyPublisher<String, Never> in // 1️⃣
        self.requestLogin(loginModel: LoginRequestModel(id: self.editEmail,
                                                        pw: self.editPassword))
            .map { _ in "" } // 2️⃣
            .catch { error in // 3️⃣
                return Just("로그인에 실패했습니다.")
            }
            .eraseToAnyPublisher() // 4️⃣
    }
    .assign(to: \.loginErrorMessage, on: self) // 5️⃣ @Published로 UI에 바인딩됩니다.
    .store(in: &cancellables)

위에서 본 requestLogin함수가 버튼을 탭하면 동작하고 결과를 UI에 나타낼 수 있도록 체이닝을 통해 연결해주었습니다. requestLogin Publisher는 에러를 반환하지만 UI코드에선 에러가 발생하면 안되므로 최종 에러 타입을 Never로 만들기 위해 .catch 연산자를 사용했습니다.

 

1️⃣ flatMap안의 코드는 AnyPublisher<String, Never>형을 반환하는데, 이때 flatMap이 AnyPublisher형의 output값을 다운스트림으로 내보내도록 해주는 역할을 합니다.

2️⃣ reqeustLogin의 Publisher의 반환값이 Bool이기 때문에 성공시 반환하는 값을 String형으로 변환합니다.

3️⃣ catch는 에러를 감지하고 변환하는 역할을 합니다. 최종 Publisher의 에러형이 Never이므로 에러 타입을 Never로 가지는 Just를 반환합니다.

4️⃣ map과 catch를 사용한 과정을 숨기기 위해 .eraseToAnyPublisher()를 사용해 최종 타입을 맞춥니다.

5️⃣ 최종적으로 방출되는 String값은 UI에 바인딩되어있는 변수에 할당됩니다.

 

이를 통해 버튼 탭 > 로그인 요청 > 에러 결과 표시 라는 한 흐름을 더 응집되게 표현할 수 있습니다.

 

☑️ 이 외에도 순차적인 비동기 처리를 할 때 flatMap은 유용하므로 기억해두면 좋습니다! (Publisher 결과 클로저 안에서 다른 Publisher를 실행할때 flatMap을 통해 Output을 뽑아낼 수 있습니다.)

 

에러 처리 3. replaceError


사실 더 간단한 연산자가 있습니다 😁

$tapLoginButton
    .flatMap { value -> AnyPublisher<String, Never> in
        self.requestLogin(loginModel: LoginRequestModel(id: self.editEmail,
                                                        pw: self.editPassword))
            .map { _ in "" }
            .replaceError(with: "로그인에 실패했습니다.") // ✅
            .eraseToAnyPublisher()
    }
    .assign(to: \.loginErrorMessage, on: self)
    .store(in: &cancellables)

.replaceError를 사용하면 에러 발생시에 with에 할당된 값을 대신 방출해주므로 Error에 대응할 수 있습니다. 단순히 Never형 Publisher로 바꿔야하는 경우 유용하며, 여러 Error상황에 대응해야할 때는 부적절합니다 🥲

 

에러 처리 4. retry


앞에서는 최종적으로 UI에 나타내기 위해 에러를 처리하는 방법을 살펴보았는데요, 네트워크 실패 같은 경우는 다른 방법을 적용할 수도 있습니다. 백엔드 서버나 사용자의 네트워크 상황이 일시적으로 안좋았을 수도 있기 때문에 몇차례 더 시도하면 성공할 수도 있기 때문이죠. 이 경우에 retry연산자를 사용할 수 있습니다.

 

다시 requestLogin부분을 보겠습니다.

func requestLogin(loginModel: LoginRequestModel) -> AnyPublisher<LoginResponseModel, AccountError> {
    return provider.requestPublisher(.login(loginModel))
        .retry(3) // ✅
        .mapError { _ in
            return AccountError.loginFailed
        }
        .tryMap { response in
            let decodedData = try response.map(LoginResponseModel.self)
            return decodedData
        }
        .mapError { error in
            return error as! AccountError
        }
        .eraseToAnyPublisher()
}

retry의 사용법은 간단합니다. 기존 코드에 retry(시도 횟수)구문만 추가해주면 됩니다.

 

하지만, 위 방법은 유효하지 않을 수도 있습니다. 예를 들면 400대 오류는 클라이언트 오류기 때문에 재시도해도 같은 에러를 반환받을 가능성이 높습니다. 따라서 500대 오류(서버 오류)에 한해 retry를 하는 것이 좋을 수 있습니다.

func requestLogin(loginModel: LoginRequestModel) -> AnyPublisher<LoginResponseModel, AccountError> {
    return provider.requestPublisher(.login(loginModel))
        .tryCatch { error in // ✅
            if (500..<599) ~= error.response?.statusCode ?? 0 {
                return Just(())
                  .flatMap { _ in
                      return self.provider.requestPublisher(.login(loginModel))
                  }
                  .retry(3)
                  .eraseToAnyPublisher()
            } else {
                throw error
            }
        }
        .mapError { _ in
            return AccountError.login(.loginFailed)
        }
        .tryMap { response in
            let decodedData = try response.map(LoginResponseModel.self)
            if decodedData.mid == 0 {
                throw AccountError.login(.wrongPassword)
            } else if decodedData.mid == -2 {
                throw AccountError.login(.userNotFound)
            }
            return decodedData
        }
        .mapError { error in
            return error as! AccountError
        }
        .eraseToAnyPublisher()
}

tryCatch연산자를 이용하면 중간에 error를 감지해 추가 연산을 넣을 수 있습니다. 위 코드는 500대 에러일 때만 retry연산자가 달린 같은 publisher를 반환해 재시도 하게끔합니다. 따라서 요청이 연속적으로 실패했을 때는 총 4번의 시도를 하게 됩니다. 그 외에는 error를 그대로 throw해 다음 연산자로 넘어가 처리되게 합니다.

 

그러나 문제가 또 있습니다. 지금까지의 코드는 에러를 받을때마다 바로 재시도를 했는데, 한순간에 사용자나 서버의 환경이 바뀌었을 가능성이 낮기 때문입니다. 따라서 시간차를 두고 시도하도록 delay연산자를 추가해줄 수 있습니다.

    func requestLogin(loginModel: LoginRequestModel) -> AnyPublisher<LoginResponseModel, AccountError> {
        return provider.requestPublisher(.login(loginModel))
            .print()
            .tryCatch { error in
                if (500..<599) ~= error.response?.statusCode ?? 0 {
                    return Just(())
                      .delay(for: 3, scheduler: DispatchQueue.global()) // ✅
                      .flatMap { _ in
                          return self.provider.requestPublisher(.login(loginModel))
                      }
                      .retry(3)
                      .eraseToAnyPublisher()
                } else {
                    throw error
                }
            }
            .mapError { _ in
                return AccountError.login(.loginFailed)
            }
            .tryMap { response in
                let decodedData = try response.map(LoginResponseModel.self)
                if decodedData.mid == 0 {
                    throw AccountError.login(.wrongPassword)
                } else if decodedData.mid == -2 {
                    throw AccountError.login(.userNotFound)
                }
                return decodedData
            }
            .mapError { error in
                return error as! AccountError
            }
            .eraseToAnyPublisher()
    }

 

retry는 네트워크 요청의 성공률을 높혀주지만, 최종적으로 실패를 결정할때까지의 시간이 길어지게 되므로 사용자 경험을 떨어뜨릴 수도 있습니다. 따라서 적절한 UI(로딩 등)처리를 추가하거나 구현하고자하는 기능의 특성에 맞게 사용해야합니다.

 

마치며


combine의 에러 처리는 연산자를 어떻게 체이닝하느냐에 따라 훨씬 더 다양한 방법이 존재할 수 있습니다. 또한 최종적으로 Publisher를 구독하는 곳(View or ViewModel)이냐, 중간에 Publisher를 변형하는 레이어냐에 따라 처리 방법도 달라지구요. 따라서 다양한 연산자를 찾아보고 상황에 맞게 적절히 활용하는 것이 좋은 것 같습니다! 

 

읽어주셔서 감사합니다.

 

ref

Error Handling with Combine and SwiftUI

Combine 공식 문서

댓글
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/12   »
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
글 보관함