XLOG

[TCA] TCA 를 활용한 에러핸들링 전략 본문

Swift/Etc

[TCA] TCA 를 활용한 에러핸들링 전략

X_PROFIT 2024. 5. 19. 17:32

회사에서 근무를 하며 백엔드에서 날려주는 상황별 Error Code 는 매우 다양했다.

 

하지만 Client 측에선 다양한 Error Code 라도 유저가 할 수 있는 Action 은 그리 다양하지 않았다.

그리고 회사에서 TCA 를 적용해서 프로젝트를 진행하였기 때문에 Error Handling 을 Composable 하게 구성할 수 없을까 고민을 했다. 그래서 Error Handling 을 위한 Redcuer 와 View 를 만들어 각 Feature 적용해야 겠다는 생각을 했다.

우선 내가 throw 할 수 있는 Error Case 와 해당 Error 마다 할 수 있는 액션들의 정의가 필요했다.

//
//  Created by Sooik Kim on 5/15/24.
//

import SwiftUI

// API 통신 후 statusCode 에 따른 error 를 throw 해준다.
enum CustomErrorCase: Error {
    case notAuth
    case notFound
    case timeOut
    case etc
}

// Alert Button을 누를때 발생할 수 있는 Error Action
enum ErrorActionCase: String, CaseIterable {
    case tryAgain
    case signIn
    case cancel
    case dismiss
    case confirm
}

extension ErrorActionCase {
	// Alert Button 생성을 자동화 할 때 사용할 Button Role
    var buttonRole: ButtonRole {
        switch self {
        case .tryAgain:
                .destructive
        case .signIn:
                .destructive
        case .cancel:
                .cancel
        case .dismiss:
                .cancel
        case .confirm:
                .cancel
        }
    }
}

extension CustomErrorCase {
	// 해당 Error에서 사용할 수 있는 Button 리스트
    var actionCases: [ErrorActionCase] {
        switch self {
        case .notAuth:
            [.cancel, .signIn]
        case .notFound:
            [.confirm]
        case .timeOut:
            [.cancel, .tryAgain]
        case .etc:
            [.confirm]
        }
    }
    
    var alertTitle: String {
        switch self {
        case .notAuth:
            "권한이 없습니다."
        case .notFound:
            "파일을 찾을 수 없습니다."
        case .timeOut:
            "시간 초과"
        case .etc:
            "알수없는 오류"
        }
    }
}

이제 Error 를 Handling 할 Reducer 와 View 가 필요하다.


import ComposableArchitecture

@Reducer
struct CommonError {
    struct State: Equatable {
        let error: CustomErrorCase
        @BindingState var isPresented: Bool = true
        
        init(error: Error) {
            if let error = error as? CustomErrorCase {
                self.error = error
            } else {
                self.error = .etc
            }
        }
    }
    
    enum Action: Equatable, BindableAction {
        case errorAction(ErrorActionCase)
        case binding(BindingAction<State>)
    }
    
    var body: some ReducerOf<Self> {
        Reduce { _, action in
            switch action {
            case .errorAction(let errorAction):
                switch errorAction {
                case .signIn:
                    // Sign In Page Open
                    return .none
                default:
                    return .none
                }
                
            default:
                return .none
            }
        }
    }
}

struct CommonErrorView: View {
    
    let store: StoreOf<CommonError>
    
    var body: some View {
        WithViewStore(self.store, observe: { $0 }) { viewStore in
		        // EmptyView 로 하게 되면 Alert 도 동작을 하지 않는다....
            ZStack {
                
            }
            .alert(viewStore.error.alertTitle, isPresented: viewStore.$isPresented) {
                ForEach(viewStore.error.actionCases, id: \.self) { actionCase in
                    Button(actionCase.rawValue, role: actionCase.buttonRole, action: { viewStore.send(.errorAction(actionCase) )})
                }
            }
        }
    }
}

이제 CommonError 모듈이 완성되었다. 

각 Feature 에서 Error 가 발생하면 error 값을 받아 CommonError 를 Init 을 해주고 IfLetStore 로 CommonErrorView 를 build 하여 alert 가 발생하도록 하는게 핵심 이었다.

alert 에 버튼은 error 가 가질 수 있는 actionCases 를 통해 자동으로 생성이 된다.

이렇게 만든것을 각 Feature 에 적용하는 것이다.

import Foundation
import ComposableArchitecture

@Reducer
struct Root {
    struct State: Equatable {
        var commonError: CommonError.State?
    }
    
    enum Action: Equatable {
        case resultFail(CustomErrorCase)
        case commonError(CommonError.Action)
    }
    
    var body: some ReducerOf<Self> {
        Reduce { state, action in
            switch action {
            case .resultFail(let error):
                state.commonError = .init(error: error)
                return .none
                
            case .commonError(.errorAction(let errorAction)):
		            // alert 에서 액션이 발생했다는 얘기는 해당 view가 필요없기에 nil 로 만들어준다.
                state.commonError = nil
                switch errorAction {
                case .tryAgain:
                    // return .request
                    // 필요한 api 통신을 다시 호출한다.
                    return .none
                    
                default:
                    return .none
                }
                
            default:
                return .none
            }
        }
        .ifLet(\.commonError, action: \.commonError) {
            CommonError()
        }
    }
}

각 feature 의 리듀서는 scope 로 CommonError 를 가지고 있어 CommonError에서 발생하는 액션에 맞게 적절한 sideEffect 를 사용하여 Error 에 맞는 동작을 구현할 수 있다.

import SwiftUI
import ComposableArchitecture

struct ContentView: View {
    
    let store: StoreOf<Root>
    
    init() {
        self.store = .init(initialState: Root.State(), reducer: { Root() })
    }
    
    var body: some View {
        WithViewStore(self.store, observe: { $0 }) { viewStore in
            VStack (spacing: 20) {
                Button(action: {
                    viewStore.send(.resultFail(.notAuth))
                }, label: {
                    Text("Not Auth")
                })
                
                Button(action: {
                    viewStore.send(.resultFail(.notFound))
                }, label: {
                    Text("Not Found")
                })
                
                Button(action: {
                    viewStore.send(.resultFail(.timeOut))
                }, label: {
                    Text("Time out")
                })
            }
            .overlay {
                IfLetStore(self.store.scope(state: \.commonError, action: \.commonError), then: {
                    CommonErrorView(store: $0)
                })
            }
        }
    }
}

해당 파일에선 View에서 강제로 error 발생 상황을 만들어 error 를 전달하였는데, 실제 프로젝트에선 api 통신의 결과로 발생한 error를 받아서 동작하도록 했다. 사실 overlay 로 빈 View와 alert를 덮는 방식이 마음에 들진 않지만..우선 급한대로 이렇게 처리를 했다.