[iOS] 토스터 앱의 에러 처리 (Error Handling) 흐름을 소개합니다 (feat. Moya, Combine, networkFlatMap)

2025. 6. 8. 22:06Apple Framework, Library/Combine

저 몰래 천천히 야금야금 진행하던 <토스터 TOASTER> 코드 리팩토링이 거의 마무리 단계에 진입했습니다!

"중복 코드의 최소화" 그리고 "명확한 데이터 처리 흐름"을 목표로 코드 리팩토링을 계속 진행해왔고,
블로그로도 (언제가 될지는 모르겠지만) 차근차근 한 주제씩 다뤄보겠습니다! 오늘은 이 중 에러 처리 (Error Handling)에 집중해볼거구요.

  • Combine 적용한 선언적 데이터 스트림 흐름의 ViewModel 구조 (Input-transform-Output 흐름) 적용하기
  • Completion 핸들러 기반 네트워크 비동기 처리 메서드 -> Combine 기반 네트워크 비동기 처리 메서드로 변경 (코드 일관성 증대)
  • 네트워크 메서드에서 발생하는 에러 처리 흐름 (Error Handling Stream) 구조화
  • 이벤트 브로드캐스팅 (Event Broadcasting, NotificationCenter) 방식으로 토큰 만료시 수행되는 화면 전환 로직 중앙화
  • Swift 6 마이그레이션으로 동시성 안전 (Concurrency Safety) 보장
 

[iOS] TOASTER 토스터 - 링크 아카이빙 & 리마인드

‎TOASTER 토스터 - 링크 아카이빙 & 리마인드‎[더 이상 링크를 태우지 마세요. 토스트 먹듯이 간단하게! TOASTER] 그동안 여러 플랫폼 이곳 저곳에 링크를 저장해왔나요? 링크가 필요할 때 바로바로

mini-min-dev.tistory.com

 

1. ViewModel의 Input으로 이벤트 발생 트리거

☑️ 이번 에러 처리가 필요한 예시 상황으로
"View에서 사용자가 완료 버튼을 클릭해 새로운 타이머를 추가 (postCreateTimer)하는 요청에서 발생하는 에러 로직"을 따라가며 설명해보겠습니다.

<토스터 TOASTER> 앱에서 사용하는 ViewModel은 가본적으로 Combine 기반 Input-Output 패턴이 적용되어 있습니다.

View에서 발생하는 이벤트가 Input Publisher에서 트리거되면, 
ViewModel 내부에서 비즈니스 로직을 거쳐 특정 모델로 변환되고 -> Output Publisher로 다시 View에 방출하는 구조죠.

그래서 일단 View에서 유저의 완료 버튼을 탭하는 이벤트에서 시작합니다.

// RemindTimerAddViewModel struct 내부
// MARK: - Input State
struct Input {
    let completeAddButtonTapped: Driver<(Int, RemindTimerAddModel)>
    ...
}

 

 

2. .networkFlatMap (Custom Operator)으로 네트워크 메서드 호출

해당 Input 스트림을 바탕으로, 네트워크 메서드 (postCreateTimerAPI)를 호출해줄 차례입니다.

// RemindTimerAddViewModel struct 내부
// func transform(_ input: Input, cancelBag: CancelBag) -> Output 메서드 내용 중 일부
input.completeAddButtonTapped
    .networkFlatMap(self, { context, body in
        context.postCreateTimerAPI(forClipID: body.0, forModel: body.1)
    })

네트워크 메서드를 호출할 때, 저희 팀은 커스텀 .networkFlatMap 연산자 (Operator)를 사용합니다.
.networkFlatMap Operator는 flatMap과 catch 로직, 그리고 메모리 누수 방지 로직을 추상화해 Publisher 익스텐션에 정의해둔 Custom Operator입니다.

✔️ flatMap : Upstream Publisher로부터 전달받은 각 요소를 가지고 새로운 Publisher를 생성하고, 해당 Publisher가 방출하는 값을 Downstream으로 publish하는 Transforming Operator
✔️ catch : 에러가 발생했을 때, 해당 에러를 처리하고 새로운 Publisher로 대체하여 처리하는 Control Operator


먼저, networkFlatMap 연산자의 제네릭과 파라미터가 각각 의미하는 것이 무엇인지 이해하고 넘어가봅시다.

제네릭 이름 설명 실제 무엇이 해당?
SelfPublisher 해당 연산자 (networkFlatMap)를 사용하는 자기 자신 RemindTimerAddViewModel
NewPublisher 내부에서 네트워크 메서드를 호출하고 반환되는 결과 postCreateTimerAPI의 결과
AnyPublisher<Void, ToasterError>
파라미터 이름 설명 실제 무엇이 해당?
weakSelf self의 순환 참조 방지를 위한 약한 참조 역할
(Upstream Publisher를 갖고 있고, networkFlatMap을 사용하는 자기 자신)
네트워크 메서드를 담고있는
RemindTimerAddViewModel
firstTransform flatMap을 통해 새로운 Publisher로 변환하는 연산을 수행하는 역할
(Nework Service를 호출하는 ViewModel 내 네트워크 메서드 호출)
실제 이 Operator가 담당하는 작업을 담은 클로저 
onError catch를 통해 firstTransform에서 에러가 발생할 때 side-effect의 동작을 정의하는 클로저 이 부분은 밑에 5번 파트에서 더 자세하게 설명해볼게요 ..^^
secondTransform 에러가 발생했을 때 대체되는 Publisher. default값은 Empty가 수행 Error가 발생하면 아무것도 방출하지 않도록 함.


아직 조금 감이 안오죠?

그럼 위의 실제 사용 코드와 파라미터가 의미했던 객체를 기준으로 networkFlatMap 메서드 내부로 직접 들어가서, 순서대로 차근차근 어떻게 동작이 되는지 함께 이해해볼게요.

  1. 위에서 봤던 Upsteram Publisher를 통해 버튼 탭에 따른 데이터 스트림 (Int, RemindTimerAddModel) 값이 output으로 들어갑니다.
  2. 메모리 누수를 방지하는 목적에서 약한 참조로 자기 자신을 캡처하죠. -> self가 해제되면 바로 Empty()를 방출하고 종료됩니다!
  3. 그리고 난 다음 firstTransform(SelfPublisher, Output)을 통해 네트워크 메서드를 (드디어) 호출하고 -> 그 네트워크의 결과가 NewPublisher로 연결됩니다. 
  4. 여기서 firstTransform, 즉 네트워크 요청 메서드의 결과로 에러가 발생하면 catch 연산자에 의해 onError 클로저가 실행됩니다.
    이후 fallback Publisher에 해당되는 secondTransform (아무것도 방출하지 않는 Empty())를 반환하고 해당 Publisher는 종료됩니다. 
import Combine

public extension Publisher {
    func networkFlatMap<SelfPublisher: AnyObject, NewPublisher: Publisher>(
        _ weakSelf: SelfPublisher?,
        _ firstTransform: @escaping (SelfPublisher, Output) -> NewPublisher,
        onError: ((Error) -> Void)? = nil,
        _ secondTransform: @escaping (Error) -> AnyPublisher<NewPublisher.Output, Never> = { _ in
            Empty().eraseToAnyPublisher()
        }
    ) -> AnyPublisher<NewPublisher.Output, Failure> {
        
        self.flatMap { [weak weakSelf] output -> AnyPublisher<NewPublisher.Output, Never> in
            guard let weakSelf else { return Empty().eraseToAnyPublisher() }
            return firstTransform(weakSelf, output)
                .catch {
                    onError?($0)
                    return secondTransform($0)
                }
                .eraseToAnyPublisher()
        }
        .eraseToAnyPublisher()
    }
}

결론! 
networkFlatMap은 ViewModel에서 네트워크 메서드를 호출할 때,
Combine의 flatMap 연산을 수행하면서, 메모리 누수 방지 (weak weakSelf)와 에러 처리 (onError)를 도와주는 커스텀 Operator입니다.

 

 

3. Service 호출 네트워크 메서드 (postCreateTimerAPI -> AnyPublisher<Output, ToasterError>) 실행

다시 돌아와서!

ViewModel에서는 위에서 정의한 networkFlatMap Custom Operator을 사용해 네트워크 메서드를 호출합니다.

네트워크 메서드의 구현부는 싱글톤으로 구현되어 있는 NetworkService.shared 객체에 접근해 세부 Domain에 해당하는 네트워크 메서드를 호출 (NetworkService.shared.timerService.postCreateTimer)하는 부분.
그리고 호출된 결과값을 View의 사용 양식 (Model)에 맞게 매핑 (map)해주는 부분을 담당하고 있습니다.

// RemindTimerAddViewModel struct 내부
func postCreateTimerAPI(
    forClipID: Int,
    forModel: RemindTimerAddModel
) -> AnyPublisher<Void, ToasterError> {
    return NetworkService.shared.timerService.postCreateTimer(
        requestBody: PostCreateTimerRequestDTO(
            categoryId: forClipID,
            remindTime: forModel.remindTime,
            remindDates: forModel.remindDates
        )
    )
    .map { _ in () }
    .eraseToAnyPublisher()
}

잠깐 여기서 네트워크를 담당하는 Concrete Service 부분을 또 다시 타고 들어가보면,

구현부 메서드 (postCreateTimer)를 볼 때 / 네트워크 라이브러리 Moya Provider에게 요청하고 그 결과값을 AnyPublisher<Output, ToasterError> 타입으로 받고 있습니다.
어떤 경우에 Output으로 Publish 되는지. 어떤 경우에 ToasterError로 Publish 되는지. 알기 위해서는 requestWithoutDecodeWithCombine 메서드를 타고 넘어가봐야겠군요!

import Combine
import Foundation
import Moya

protocol TimerAPIServiceProtocol {
    func postCreateTimer(requestBody: PostCreateTimerRequestDTO) -> AnyPublisher<NoneDataResponseDTO?, ToasterError>
    ...
}

final class TimerAPIService: BaseAPIService<TimerTargetType>, TimerAPIServiceProtocol {
    private let provider = MoyaProvider<TimerTargetType>.init(session: Session(interceptor: APIInterceptor.shared), plugins: [MoyaPlugin()])

    func postCreateTimer(requestBody: PostCreateTimerRequestDTO) -> AnyPublisher<NoneDataResponseDTO?, ToasterError> {
        return requestWithoutDecodeWithCombine(
            provider: provider,
            target: .postCreateTimer(requestBody: requestBody)
        )
    }
    ...
}

 

 

4. BaseAPIService에 정의된 디코딩 여부/statusCode에 따라 분기 처리 시키기

requestWithoutDecodeWithCombine과 requestWithCombine은 Moya와 Combine을 활용해
네트워크 응답값의 디코딩 처리 & 에러 핸들링을 수행한 후 -> Combine의 AnyPublisher<Output, ToasterError> 타입으로 매핑 시키는 부분입니다.

MoyaProvider의 request 응답값에 따라 분기처리 (.success vs .failure)를 나눠주고 있고,
세부적으로는 그 응답값에 담겨있는 상태 코드 (response.statusCode)에 따라 방출하는 에러 타입 (ToasterError)을 결정하고 있습니다.

import Combine
import Foundation

import Moya

class BaseAPIService<Target: TargetType> {

    /// 200 받았을 때 decoding 할 데이터가 없는 경우 (대부분의 PATCH, PUT, DELETE)
    func requestWithoutDecodeWithCombine(
        provider: MoyaProvider<Target>,
        target: Target
    ) -> AnyPublisher<NoneDataResponseDTO?, ToasterError> {
        return Future { promise in
            provider.request(target) { result in
                switch result {
                case .success(let response):
                    switch response.statusCode {
                    case 200, 201, 204: return promise(.success(nil))
                    default: return promise(.failure(.networkFail))
                    }
                case .failure(let error):
                    guard let statusCode = error.response?.statusCode else { return promise(.failure(.networkFail)) }
                    switch statusCode {
                    case 400: return promise(.failure(.badRequest))
                    case 401: return promise(.failure(.unAuthorized))
                    case 404: return promise(.failure(.notFound))
                    case 422: return promise(.failure(.unProcessable))
                    case 500: return promise(.failure(.serverErr))
                    default: return promise(.failure(.networkFail))
                    }
                }
            }
        }.eraseToAnyPublisher()
    }   
    
    /// 200 받았을 때 decoding 할 데이터가 있는 경우 (대부분의 GET)
    func requestWithCombine<T: Decodable>(
        provider: MoyaProvider<Target>,
        target: Target,
        responseType: T.Type
    ) -> AnyPublisher<T, ToasterError> {
        // 큰 틀은 위의 메서드와 동일함돠!
        ...
        
}

앗 참고로 앱에서 사용하는 커스텀 에러 타입 (ToasterError)은
Error 프로토콜을 채택한 열거형 (enum)으로 networkFail부터 serverErr까지 직접 statusCode에 따라 나누어 만들어서 사용하고 있죠!

import Foundation

enum ToasterError: Error {
    case networkFail        // 네트워크 연결 실패했을 때
    case decodeErr          // 데이터는 받아왔으나 DTO 형식으로 decode가 되지 않을 때
    
    case badRequest         // BAD REQUEST EXCEPTION (400)
    case unAuthorized       // UNAUTHORIZED EXCEPTION (401)
    case notFound           // NOT FOUND (404)
    case unProcessable      // UNPROCESSABLE_ENTITY (422)
    case serverErr          // INTERNAL_SERVER_ERROR (500번대)
}

 

 

5. onError 클로저 블록 (네트워크 요청이 실패했을 경우 불러지는 부분) 호출

지금까지 어떤 흐름을 타고 왔는지 정리 한번하고 갈게요! 

📌 ViewModel의 Input으로 이벤트 발생 트리거 -> 📌 networkFlatMap 커스텀 Operator로 네트워크 요청 메서드 호출 -> 📌 네트워크 요청 메서드에서는 statusCode와 데이터 디코딩 여부에 따라 성공/실패 여부를 방출 -> 📌 실패 시에는  
networkFlatMap 내부에 정의된 catch 블럭에 의해 onError 클로저 블럭이 수행되고, 방출 값은 Empty로 종료


뭐 이것저것 많은 것 같은데 정리해보니 깔끔하군요!
결국은 다시 networkFlatMap의 파라미터에 정의되었던 onError 클로저 블록으로 돌아갈 차례입니다.

promise(.failure(ToasterError)) 타입으로 방출되면, 
networkFlatMap Operator 내부에서 transform(SelfPublisher, Output) 메서드를 호출하고 이어지는 catch 블록에 에러로 잡히게 될 것이기 때문이죠!

onError 블록에서는 이 catch에서 잡힌 에러를 어떻게 처리할지 정의해주면 됩니다.

// RemindTimerAddViewModel struct 내부
// func transform(_ input: Input, cancelBag: CancelBag) -> Output 메서드 내용 중 일부
input.completeAddButtonTapped
    .networkFlatMap(self, { context, body in
        context.postCreateTimerAPI(forClipID: body.0, forModel: body.1)
    }, onError: { error in
        guard let error = error as? ToasterError else { return }
        switch error {
        case .unProcessable:
            output.onError.send(StringLiterals.ToastMessage.noticeSetTimer)
        case .badRequest:
            output.onError.send(StringLiterals.ToastMessage.noticeMaxTimer)
        default:
            output.onError.send(StringLiterals.ToastMessage.error)
        }
    })

 

 

6. ViewModel의 Output으로 에러 스트림 방출

<토스터 TOASTER>에서는 에러 또한 ViewModel의 Output 형태로 Publish하도록 구조를 통일했습니다. 

위의 상황 같은 경우에는
.unProcessable (처리할 수 없음)과 .badRequest (잘못된 요청), 그리고 그 외 네트워크 에러 상황에 대해 각각 에러를 View로 다시 넘겨주는 로직이입니다.

View에서는 여기서 case 별로 넘겨받은 Message (String Type) 값을 바탕으로 토스트 메시지를 표출해줄 수 있었죠.
*어떤 경우에서는 View에 Alert를 띄우는 방식으로, 혹은 Root 화면으로 넘어가거나, 앱 자체를 즉시 종료시키는 방식으로 로직을 써볼 수도 있을 것 같네요!

// RemindTimerAddViewModel struct 내부
// MARK: - Output State
struct Output {
    ...
    let onError = PassthroughSubject<String, Never>()
// RemindTimerAddViewController class 내부
output.onError
    .sink { [weak self] error in
        guard let self else { return }
        showToastMessage(width: 297, status: .warning, message: error)
    }.store(in: cancelBag)

 

 

결론!

결국은 네트워크 통신을 도와주는 .networkFlatMap Operator에 담겨있는 onError 클로저에 에러 처리 로직을 담는다.
그리고 그 에러 값 역시 View에서 처리해주기 위해 Output Publisher로 값을 방출해준다.가 이 글의 핵심 내용이었습니다.

또 글에는 담지 못했지만,
MoyaPlugin에서는 네트워크 연결 오류 (데이터 차단) 상황을 먼저 감지하고 Alert를 표출해주는 로직도 담아서 사용하고 있습니다!

아직은 많이 부족하고 부끄러운 코드인 것 같네요.
더 많이 공부하고, 또 다른 개선된 방법이 무엇이 있을지 / 이 방법에서 미처 고려하지 못한 요소들이 무엇이 있을지 봐야할 것 같습니다.
댓글로 질문이나 지적 얼마든지 환영합니다..! 
긴 글 읽느라 고생많으셨어요😊 그럼 오늘은 여기까지 !