본문 바로가기
iOS/Combine

[iOS] SOPT - Swift 에러 핸들링 with Combine + Clean Architecture

by 바등쪼 2023. 7. 30.

사건의 발단

작년부터 계속 개발 중인 프로젝트에서 에러 핸들링에 대한 필요성이 계속 커져가고 있었습니다.

동아리원들을 위한 앱인데 사용자가 점점 늘어나서 수백 명이 되었고 팀원들 모두 전혀 예상하지 못했던 여러 가지의 버그가 발생하기 시작했습니다.

 

SOPT 에서는 매주 토요일마다 세미나가 열리는데 이 때 동아리원들이 출석 체크를 쉽게 하도록 도와주는 기능을 추가되어 배포된 상태였습니다.

출석 시간인 토요일 14시에 무조건 앱이 잘 동작해야만 하는 상황이었는데....,,...

앱 크래시로 인해 출석 체크를 하지 못한 사람들...
앱 사용에 문제가 있는 사용자들의 문의가 들어왔던 체널톡

역시나 첫 대규모 사용인데 무사히 넘어갈리가 없었다...ㅎㅎ

 

그동안 에러 처리를 제대로 안했기 때문에 발생하는 일들이었고 당연하게도 에러 처리가 부족했기 때문에 버그를 잡는 것도 쉽지 않았습니다.

 

사진을 보면 다들 네트워크 오류라는 팝업이 나오고 앱 사용을 못했다고 하시는데 사실 에러 처리를 잘 안했기 때문에 모든 에러가 네트워크 오류라는 팝업으로 퉁쳐서 보여지고 있었던 상황이었습니다. (하하...역시 업보는 항상 돌아옵니다...)

문제의 그 팝업

문제는 발생했으니 원인을 찾아서 고쳐야 다음주 세미나에서는 문제 없이 사용자들이 앱을 사용할 수 있을텐데 모든 에러가 네트워크 오류 팝업으로 나오고 있었기 때문에 에러의 원인을 찾기 매우 어려웠습니다.

 

심지어 QA를 할 때는 전혀 문제가 없었고 200명이 사용했는데 10명 정도에게만 문제가 발생하는 상황이라니....

역시 개발자는 사람들이 많이 사용하는 서비스를 개발해야 다양한 경험을 할 수 있겠구나 다시 한번 느꼈습니다..!

 

그래서!! 이제부터라도 에러 처리 코드를 최대한 구현해서 당장 이 문제를 고치지 못하더라도 다음에 또 문제가 발생하면 원인이라도 파악을 쉽게 할 수 있도록 해야겠다고 생각했습니다.

 

 

목표

  1. 앱의 홈 화면(메인 뷰)에 진입했을 때 발생할 수 있는 에러들을 분기처리하여 각 에러 구분
  2. 어떤 에러가 발생했는지 실시간으로 확인 가능하도록 기능 추가
  3. 앱 내에서 처리할 수 있는 에러라면 적합한 화면 전환 수행

이렇게 3가지 목표를 두고 작업을 시작했습니다.

사실 이전까지 에러 핸들링을 깊이 있게 해본 경험이 부족했고 특히 Combine을 사용해 비동기 바인딩으로 구성된 프로젝트에서 에러 핸들링은 더욱 낯설었습니다.

그래도 구글링 + 여러가지 방향을 고민하면서 조금씩 처리했습니다.

 

2번의 요구 사항을 위해서 Sentry를 활용하기로 했습니다.

 

구현

우선 에러 타입을 정의해야 했습니다.

현재 프로젝트는 Tuist를 활용한 모듈화가 진행되어 있었고 당연하게도 각 모듈별로 적합한 역할과 책임이 나누어져 있습니다.

 

그리고 Clean Architecture 기반인 프로젝트이기 때문에 각 Layer의 역할 또한 중요했습니다.

 

따라서 공용 에러 타입만 만들어서 사용하는 것이 아닌 각 Layer에서 핸들링하는 것이 적합한 에러 타입을 알맞은 모듈에 생성해야 했습니다.

 

프로젝트의 모듈 구조

 

앱 아키텍처 도식화 (https://jeonyeohun.tistory.com/305의 그림을 참고해서 직접 만들었습니다.)

 

1. 에러 타입 정의

우선 네트워크 통신 에러를 나타내도록 APIError 타입이 필요했습니다.

이 타입은 Network 모듈이 적합했습니다.

public enum APIError: Error, Equatable {
    case network(statusCode: Int)
    case unknown
    case tokenReissuanceFailed
    
    init(error: Error, statusCode: Int? = 0) {
        guard let statusCode else { self = .unknown ; return }
        
        self = .network(statusCode: statusCode)
    }
}

statusCode를 담는 network 에러 case, 토큰 재발급 실패 case 그리고 unknown case가 있습니다.

 

홈 화면에 진입했을 때 필요한 데이터들을 서버 통신을 통해 가져오는데 이 때 문제가 발생했을 가능성이 높았습니다.

따라서 네트워크 에러라면 statusCode를 가져와 분석할 필요가 있었습니다.

낮은 확률도 토큰 재발급 문제일수도 있기 때문에 해당 케이스도 배제하지 않았습니다.

 

네트워크 에러를 APIError 타입이 표현하기 때문에 이걸 한번 더 가공한 에러 타입이 필요했습니다.

UseCase와 ViewModel에서는 APIError 타입 자체에 접근하는 것이 객체 분리의 차원에서 올바르지 않다고 생각했기 때문입니다.

 

그리고 홈 화면에서 호출하는 API는 유저 정보를 가져오는 API인데 여기서 발생할 수 있는 case들을 담고 있는 타입이 필요한 것도 추가적인 이유였습니다.

그래서 MainScene에 필요한 에러 타입이기 때문에 MainError라는 다소 추상적인 이름의 타입을 만들었습니다. (네이밍이 마음에 들지는 않지만 우선은 이렇게 놓고 작업을 계속 했습니다.)

 

public enum MainError: Error {
    case networkError(message: String?)
    case unregisteredUser // 플그 미등록 유저
    case authFailed // 토큰 재발급 실패 등 인증 에러
}

extension MainError: CustomNSError {
    public var errorUserInfo: [String : Any] {
        func getDebugDescription() -> String {
            switch self {
            case .networkError(let message):
                return  message ?? ""
            case .unregisteredUser:
                return "플그 미등록"
            case .authFailed:
                return "인증 실패"
            }
        }

        return [NSDebugDescriptionErrorKey: getDebugDescription()]
    }
}

errorUserInfo 속성은 Sentry에 에러 로그를 보낼 때 더 구체적인 텍스트를 보내기 위해 추가한 것입니다.

최신 Sentry 코드에서는 이 속성 없이 열거형의 연관값 String으로 텍스트를 보낼 수 있습니다. 

자세한 사항은 링크를 참고해주세요!

 

 

2. Service 코드

에러 타입을 선언했기 때문에 service 파일에서 네트워크 통신후에 발생한 에러를 방금 만든 에러로 변환하여 보내는 함수가 필요했습니다.

func requestObjectWithNetworkErrorInCombine<T: Decodable>(_ target: API) -> AnyPublisher<T, Error> {
        return Future { promise in
            self.provider.request(target) { response in
                switch response {
                case .success(let value):
                    do {
                        guard let response = value.response else { throw NSError(domain: "이 경우는 발생 X", code: -1000) }
                        
                        switch response.statusCode {
                        case 200...399:
                            let decoder = JSONDecoder()
                            let body = try decoder.decode(T.self, from: value.data)
                            promise(.success(body))
                        case 400...599:
                        	// TODO: 서버 개발자와 에러 처리 형식 정하고 해당 타입으로 Decode하는 코드 추가
                            throw APIError(error: NSError(domain: "임시에러", code: -1001), statusCode: response.statusCode)
                        default: break
                        }
                    } catch let error {
                        SentrySDK.capture(message: "디코딩 에러")
                        promise(.failure(error))
                    }
                case .failure(let error):
                    if case MoyaError.underlying(let error, _) = error,
                       case AFError.requestRetryFailed(let retryError, _) = error,
                       let retryError = retryError as? APIError,
                       retryError == APIError.tokenReissuanceFailed {
                        promise(.failure(retryError))
                    } else {
                        promise(.failure(error))
                    }
                }
            }
        }.eraseToAnyPublisher()
    }

Moya Provider를 이용해 서버 통신을 하고 Combine Publisher 형태로 데이터를 전달하는 코드입니다.

 

case .failure 부분을 보면 앞서 정의한 APIError 타입인 retryError를 보내는 것을 확인할 수 있습니다.

 

또한 디코딩 실패인 경우를 파악하기 위해 catch 문 안에 SentrySDK.capture(message: "디코딩 에러") 를 넣어서 디코딩 에러가 발생하면 실시간으로 Sentry 모니터링이 가능하도록 했습니다. (이 부분이 추후 디버깅에 매우 중요한 역할을 했습니다!!👍)

 

 

3. Repository에서의 Error Handling

2번에 구현한 함수는 결과적으로 Repository에서 호출하고 있습니다.

(정확히는 Repository -> UserService -> requestObjectWithNetworkErrorInCombine 호출입니다.)

 

이제 Repository는 Service가 전달하는 에러 타입을 가공하여 UseCase에게 전달해야 하는 책임이 생겼습니다.

extension MainRepository: MainRepositoryInterface {
    public func getUserMainInfo() -> AnyPublisher<Domain.UserMainInfoModel?, MainError> {
        return userService.getUserMainInfo()
            .mapError { error -> MainError in
                guard let error = error as? APIError else {
                    return MainError.networkError(message: "Moya 에러")
                }
                
                switch error {
                case .network(let statusCode):
                    if statusCode == 400 {
                        return MainError.unregisteredUser
                    } else if statusCode == 401 {
                        return MainError.authFailed
                    }
                    return MainError.networkError(message: "\(statusCode) 네트워크 에러")
                case .tokenReissuanceFailed:
                    guard let appAccessToken = UserDefaultKeyList.Auth.appAccessToken else {
                        return MainError.authFailed
                    }
                    // accessToken이 빈 스트링인 경우는 플그 미등록 상태 / accessToken이 있지만 인증에 실패한 경우는 로그인 뷰로 보내기
                    return appAccessToken.isEmpty ? MainError.unregisteredUser : MainError.authFailed
                default:
                    return MainError.networkError(message: "API 에러 디폴트")
                }
            }
            .map { $0.toDomain() }
            .eraseToAnyPublisher()
    }
}

여기서는 Combine의 mapError 오퍼레이터를 사용했습니다.

우리 프로젝트처럼 Layer 별로 에러 타입이 정해져 있는 경우에 매우 효율적인 오퍼레이터입니다.

 

mapError에서는 Service가 전달해온 Error 타입을 MainError 타입으로 변환합니다.

 

Service에서 넘어온 에러의 타입이 APIError가 아닌 경우는 Moya에서 생성한 에러이기 때문에 별도로 guard문을 통해 처리했습니다.

아래는 앱 로직에 맞게 변환하는 과정입니다. (이 부분은 각자 참여하고 있는 프로젝트에 맞게 변환하면 됩니다!)

  • .network인 경우에는 StatusCode에 따라 .unregisteredUser (미등록 유저) 또는 .authFailed (인증 실패)
  • .tokenReissuanceFailed 인 경우에는 토큰값 유무에 따라 .unregisteredUser 또는 .authFailed
  • default는 .networkError (연관 값을 활용하여 텍스트 추가)

이렇게 변환하면 함수의 리턴 타입을

AnyPublisher<Domain.UserMainInfoModel?, MainError> 로 바꿀 수 있었습니다.

(기존에는 AnyPublisherUserMainInfoModel?, Error> 였습니다.)

 

 

4. UseCase에서의 Error Handling

public class DefaultMainUseCase {
  // ... 생략
    
    public var userMainInfo = PassthroughSubject<UserMainInfoModel?, Never>()
    public var mainErrorOccurred = PassthroughSubject<MainError, Never>()
  
    public init(repository: MainRepositoryInterface) {
        self.repository = repository
    }
    
    public func getUserMainInfo() {
        repository.getUserMainInfo()
            .catch { [weak self] error in
                print("MainUseCase getUserMainInfo error occurred: \(error)")
                self?.mainErrorOccurred.send(error)
                return Just<UserMainInfoModel?>(nil).eraseToAnyPublisher()
            }
            .sink { [weak self] userMainInfoModel in
                self?.setUserType(with: userMainInfoModel?.userType)
                self?.userMainInfo.send(userMainInfoModel)
            }.store(in: self.cancelBag)
    }
    
    // ... 생략
}

UseCase에서는 ViewModel이 구독하고 있는 Subject를 두개로 나누었습니다.

  1. 서버 통신으로 가져올 데이터 모델
  2. 발생한 에러 모델

하나의 Subject로 두고 해당 Subject의 에러 타입을 MainError로 하고 싶었지만 이것이 불가능했습니다.

왜냐하면 에러가 발생했을 때 해당 Subject에 에러를 보내야 하는데 

public var userMainInfo = PassthroughSubject<UserMainInfoModel?, MainError>()

이렇게 되어 있으면 

userMainInfo.send(completion: .failure(MainError.networkError))

위처럼 send(completion:)을 사용해야 에러를 보낼 수 있었습니다. (혹시 다른 방법이 있다면 댓글로 알려주세요!!!)

여기서 문제가 있는데, completion을 하게 되면 ViewModel과의 바인딩이 종료됩니다.

즉, 스트림이 끊어지기 때문에 다시 통신을 했을 때 데이터가 ViewModel까지 도착하지 못하는 상황이 발생했습니다.

그래서 에러를 보내는 Subject를 하나 만들어서 추가한 것입니다.

 

아무튼! UseCase에서 에러들을 종합하고 가공하여 mainErrorOccurred 라는 Subject에 보내면 ViewModel은 이것을 구독하고 있다가 적합한 팝업을 보여주거나 화면 전환을 수행합니다.

 

이 로직의 위치를 고민을 많이 했습니다. View와 밀접한 로직이니 ViewModel에 넣을까도 했지만 앱의 주요 로직이고 ViewModel의 로직을 최대한 분리하기 위해 UseCase에서 처리하도록 했습니다.

 

여기서는 catch 오퍼레이터를 사용했습니다.

에러를 catch 했을 때는 모델에는 nil을 넘겨주도록 했습니다.

 

 

5. ViewModel에서의 Error Handling

public class MainViewModel: MainViewModelType {
	// ... 생략
    var userMainInfo: UserMainInfoModel?
    
    // MARK: - Outputs
    
    public struct Output {
        var getUserMainInfoDidComplete = PassthroughSubject<Void, Never>()
        var isServiceAvailable = PassthroughSubject<Bool, Never>()
        var needPlaygroundProfileRegistration = PassthroughSubject<Void, Never>()
        var needNetworkAlert = PassthroughSubject<Void, Never>()
        var isLoading = PassthroughSubject<Bool, Never>()
    }
    
    // ... 생략
    
    private func bindOutput(output: Output, cancelBag: CancelBag) {
    	useCase.userMainInfo
        	.sink { [weak self] userMainInfo in
                guard let self = self else { return }
                self.userMainInfo = userMainInfo
                self.userType = userMainInfo?.userType ?? .unregisteredInactive
                self.setServiceList(with: self.userType)
                self.setSentryUser()
                output.getUserMainInfoDidComplete.send()
        	}.store(in: self.cancelBag)

        
        useCase.mainErrorOccurred
            .sink { [weak self] error in
                guard let self else { return }
                output.isLoading.send(false)
                SentrySDK.capture(error: error)
                switch error {
                case .networkError:
                    output.needNetworkAlert.send()
                case .authFailed:
                    self.onNeedSignIn?()
                case .unregisteredUser:
                    output.needPlaygroundProfileRegistration.send()
                }
            }.store(in: self.cancelBag)
    }
}

extension MainViewModel {
    private func setSentryUser() {
        SentrySDK.setUser(User(
            userId: "\(self.userType.rawValue)_\(userMainInfo?.name ?? "비회원")"
        ))
    }
}

 

UseCase의 Subject를 구독하여 받은 데이터들로 뷰를 구성하는 로직을 담당합니다.

여기서 userCase.mainErrorOccurred를 sink하는 구문이 핵심입니다.

UseCase에서 분기한 에러 케이스에 맞게 UI를 처리하는 로직이기 때문입니다!

 

  • 진짜 networkError인 경우에는 네트워크 오류 팝업을 보여줍니다. (그 팝업...)
  • authFail인 경우에는 다시 로그인 뷰로 보냅니다.
  • unregisteredUser인 경우에는 등록이 필요하다는 팝업을 보여줍니다.

 

 

결과

이렇게 에러 핸들링 코드를 넣고 업데이트 심사를 올려 배포를 했습니다.

그 결과.... 앱 크래시의 원인을 찾을 수 있었고 바로 해결 할 수 있었습니다.

이제 각 에러 케이스에 맞게 알맞은 팝업이 나왔고 Sentry에도 에러 로그들이 실시간으로 기록됩니다.

Sentry 모니터링 (HTTPClientError 401은 토큰 만료로 인한 에러 로그인데 토큰 재발급이 제대로 되고 있기 때문에 실제로 처리해야 할 에러가 아닙니다.)

현재는 Sentry 무료 버전이라 기록이 사라졌지만 원인은 Decode 실패였습니다. 앞서 Service 코드에 심어둔 SentrySDK.capture(mesage: "디코딩 에러")로 인해 디코딩 에러라는 오류 로그가 센트리에 기록되었습니다.

 

이를 바탕으로 서버 개발자 팀원과 함께 디코딩 에러가 발생할만한 원인을 역추적해서 API에서 보내주고 있는 프로필 이미지 필드에 문제가 있다는 것을 파악할 수 있었습니다!

에러의 원인을 찾은 순간! ㅎㅎ

API 명세서에 의하면 profileImage가 없어도 null이 아닌 빈 스트링이 와야하는데 null이 왔고 이것을 디코딩할 때 옵셔널로 설정하지 않았기 때문에 앱이 정상적으로 동작하지 않았습니다.

 

특정 사용자에게만 null이 오는 이유를 다시 서버 개발자분과 찾아 봤는데 한번 사용자가 프로필 사진을 등록했다가 삭제하면 백엔드 로직상null이 되는 문제가 근본적인 이유였습니다.

 

따라서, 서버 개발자 팀원이 API가 명세서처럼 동작하도록 수정해줘서 추가 배포 없이 버그를 해결 할 수 있었습니다.

 

 

 

후기

SOPT 앱을 만들면서 다양한 경험을 하고 있습니다..!

실제로 많은 사용자들이 매주 사용하는 앱이기 때문에 작은 버그도 크게 느껴집니다.

사용자들에게 직접 피드백을 받고 빠르게 수정해보는 경험은 정말 소중했습니다!!

 

에러 핸들링 코드라고 해서 첨부를 했지만 사실 많이 부족하고 부끄러운 코드들입니다.

그럼에도 제가 구현했던 흐름을 정리해 보고자 이렇게 글로 남겨봅니다..!

다양한 의견 남겨주세요!! 에러 핸들링 잘해보고 싶습니다 ㅎㅎ..!!

 

 

프로젝트 레포지토리

https://github.com/sopt-makers/SOPT-iOS

 

GitHub - sopt-makers/SOPT-iOS: SOPT 공식 어플리케이션

SOPT 공식 어플리케이션. Contribute to sopt-makers/SOPT-iOS development by creating an account on GitHub.

github.com

앱 스토어

https://apps.apple.com/kr/app/sopt/id6444594319

 

‎SOPT

‎SOPT는 IT와 벤처 창업에 뜻이 있는 대학생들이 모인 국내 최대 규모의 대학생 연합 IT 벤처 창업 동아리입니다. SOPT에서 활동하고 있는 회원들도, SOPT의 열정이 되고 싶은 분들도 모두 SOPT에 대

apps.apple.com

 

댓글