본문 바로가기

iOS/Swift

🔧 [iOS] Moya + Alamofire RequestInterceptor doNotRetryWithError 커스텀 에러 받지 못하는 문제 해결

RequestInterceptor에서 doNotRetryWithError로 전달한 커스텀 에러가 예상과 다른 에러로 변환되는 문제를 해결한 과정을 공유해보겠습니다.

이 문제는 Moya와 Alamofire의 에러 래핑 구조를 정확히 이해해야 해결할 수 있었습니다.

🚨 문제 상황

기대했던 동작

RequestInterceptor에서 리프레시 토큰이 만료된 경우, 다음과 같이 커스텀 에러를 전달했습니다:

switch result {
case .success:
    reqeustsForRetry.forEach { $0(.retry) }

case .failure:
    // RequestRetrier에서 전달한 에러
    let error = NetworkError.expired(ErrorState(status: "fail", msg: "로그인 정보가 만료되었습니다\n다시 로그인 해주세요"))
    reqeustsForRetry.forEach { $0(.doNotRetryWithError(error)) }
}

NetworkManager에서는 이 에러를 그대로 받을 것으로 예상했습니다:

// 기대했던 결과
expired(ErrorState(status: "fail", msg: "로그인이 필요합니다"))

실제 발생한 문제

하지만 실제로는 완전히 다른 에러가 전달되었습니다:

// 실제로 받은 결과
server(ErrorState(status: "fail", msg: "잘못된 요청입니다"))

기존 토큰 만료 에러 처리 방식으로는 RequestInterceptor에서 전달한 커스텀 에러를 받을 수 없었습니다:

// 기존 방식 - 커스텀 에러를 받지 못함
switch response.statusCode {
case {토큰 만료 에러 코드}:
    do {
        let error = try response.map(ErrorState.self)
        completion(.failure(.expired(error))) // 서버 응답을 매핑
    } catch {
        completion(.failure(.decoding))
    }
}

🔍 원인 분석

Moya의 에러 구조 이해

문제의 핵심은 Moya의 에러 구조에 있었습니다. MoyaError는 여러 케이스를 가지고 있으며,

그 중 underlying 케이스가 하위 계층(Alamofire, URLSession)에서 발생한 에러를 래핑합니다:

/// A type representing possible errors Moya can throw.
public enum MoyaError: Swift.Error {

    /// Indicates a response failed to map to an image.
    case imageMapping(Response)

    /// Indicates a response failed to map to a JSON structure.
    case jsonMapping(Response)

    /// Indicates a response failed to map to a String.
    case stringMapping(Response)

    /// Indicates a response failed to map to a Decodable object.
    case objectMapping(Swift.Error, Response)

    /// Indicates that Encodable couldn't be encoded into Data
    case encodableMapping(Swift.Error)

    /// Indicates a response failed with an invalid HTTP status code.
    case statusCode(Response)

    /// Indicates a response failed due to an underlying `Error`.
    case underlying(Swift.Error, Response?) // 👈 여기가 핵심!

    /// Indicates that an `Endpoint` failed to map to a `URLRequest`.
    case requestMapping(String)

    /// Indicates that an `Endpoint` failed to encode the parameters for the `URLRequest`.
    case parameterEncoding(Swift.Error)
}

에러 전달 과정

RequestInterceptor에서 전달한 에러가 어떻게 변환되는지 살펴보겠습니다:

  1. RequestInterceptor: NetworkError.expired 생성 및 전달
  2. Alamofire: 에러를 AFError.requestRetryFailed로 래핑
  3. Moya: Alamofire 에러를 MoyaError.underlying으로 다시 래핑
  4. NetworkManager: 중첩된 에러 구조를 언래핑해야 함

💡 해결 방법

중첩 에러 언래핑

RequestInterceptor에서 전달한 커스텀 에러를 받기 위해서는 4단계의 언래핑 과정이 필요합니다:

switch response.statusCode {
case {토큰 만료 에러 코드}:
    // 1. MoyaError.underlying 케이스 확인
    if case .underlying(let underlyingError, _) = error,
       // 2. AFError로 변환
       let afError = underlyingError.asAFError,
       // 3. AFError.requestRetryFailed 케이스 확인
       case .requestRetryFailed(let retryError, _) = afError,
       // 4. 원본 NetworkError로 캐스팅
       let networkError = retryError as? NetworkError {
        completion(.failure(networkError))
    } else {
        completion(.failure(.decoding))
    }
}

완성된 구현

다음은 수정된 NetworkManager의 에러 처리 부분입니다:

// provider는 Moya의 MoyaProvider
provider.request(target) { result in
    switch result {
    case .success(let response):
        do {
            let value = try response.map(type.self)
            completion(.success(value))
        } catch {
            completion(.failure(.decoding))
        }

    case .failure(let error):
        guard let response = error.response else {
            completion(.failure(.unknown))
            return
        }

        switch response.statusCode {
        case {토큰 만료 에러 코드}:
            // RequestInterceptor에서 전달한 커스텀 에러 처리
            if case .underlying(let underlyingError, _) = error,
               let afError = underlyingError.asAFError,
               case .requestRetryFailed(let retryError, _) = afError,
               let networkError = retryError as? NetworkError {
                completion(.failure(networkError))
            } else {
                do {
                    let errorState = try response.map(ErrorState.self)
                    completion(.failure(.expired(errorState)))
                } catch {
                    completion(.failure(.decoding))
                }
            }

        case 400...500:
            do {
                let errorState = try response.map(ErrorState.self)
                completion(.failure(.server(errorState)))
            } catch {
                completion(.failure(.decoding))
            }

        default:
            completion(.failure(.request(error)))
        }
    }
}

 

✅ 결과 확인

수정 후 다음과 같이 정확한 에러를 받을 수 있었습니다:

// 수정 후 - 올바른 에러 전달
expired(ErrorState(status: "fail", msg: "로그인이 필요합니다"))

🎯 핵심 포인트

1. 에러 래핑 구조 이해

  • Moya는 하위 계층 에러를 underlying 케이스로 래핑
  • Alamofire는 retry 실패를 requestRetryFailed로 래핑
  • 각 단계별로 적절한 언래핑이 필요

2. 타입 안전성

  • 캐스팅 실패 시 대안 처리 로직 필요
  • 각 단계에서 옵셔널 바인딩으로 안전하게 처리

3. 에러 처리 일관성

  • RequestInterceptor와 NetworkManager 간 일관된 에러 모델 사용
  • 예상치 못한 케이스에 대한 fallback 처리

📚 학습 포인트

이 문제를 통해 다음을 배울 수 있었습니다:

  1. 프레임워크 구조 이해의 중요성: Moya와 Alamofire의 에러 래핑 구조를 정확히 이해해야 함
  2. 디버깅 접근법: 에러가 어떻게 변환되는지 단계별로 추적하는 방법
  3. 타입 안전성: Swift의 타입 시스템을 활용한 안전한 에러 처리

결론

Moya와 Alamofire를 함께 사용할 때는 각 라이브러리의 에러 래핑 구조를 정확히 이해하고, 적절한 언래핑 과정을 거쳐야 합니다.

특히 RequestInterceptor에서 전달한 커스텀 에러를 받기 위해서는 중첩된 에러 구조를 단계별로 해체하는 과정이 필요합니다.

이런 세부적인 구현 사항들이 견고한 네트워킹 레이어를 만드는 데 중요한 요소가 됩니다.

'iOS > Swift' 카테고리의 다른 글

🔄 [iOS] 토큰 자동 갱신 시 중복 요청 방지 구현하기  (0) 2025.06.06
Swift - 생성자(Initializer)  (2) 2024.04.28
Swift - 속성과 메서드  (2) 2024.04.23
Swift - Any와 AnyObject  (1) 2024.04.18
Swift Optional(2)  (1) 2024.04.06