Alamofire의 응답값을 처리하는 여러가지 방법

Alamofire는 URLSession을 쉽게 사용하게 해 주고 statusCode에 따른 응답 유효성 검사, 응답값 디코딩 등등 여러 가지 편의 기능들을 여럿 지원해 준다. 

 

대표적인 기능으로는 요청을 보낼때마다 공통적인 부분 혹은 로직을 분리해서 재사용할 수 있게 해주는 Interceptor, 요청을 보낼 때 어떻게 작동하고 있는지 Console의 출력을 통해 보여주는 EventMonitor 기능등이 있다. 

 

이러한 기능들은 쉽게 사용할 수 있도록 기본적인 구현이 제공된다. Alamofire에서 기본적으로 구현된것만으로 많은 오픈 API를 대응할 수 있다. 구글링을 통해 Alamofire를 사용하는 코드를 보면 대부분이 이 코드 혹은 이 코드에서 크게 벗어나지 않을 것이다.

AF.request("https://www.naver.com").validate().responseDecodable(of:RegisterUserResponseDTO.self) { response in
            switch response.result {
            case .success(let success):
                //do somthing...
            case .failure(let failure):
                //do somthing...
            }
        }

다른 코드가 있다고 한다면 validate에 Range를 지정해준다던가, Interceptor를 request에 넣는 정도..

하지만 기본적인 구현으로는 대응 할 수 없는 상황이 존재한다. 

 

예를 들면 이번 새싹에서 진행하는 SLP 프로젝트에서 제공되는 서버의 응답 에러는 다음과 같다.

 

Status Code 에러 원인 에러 body값
400 알맞지 않은 서버 키 값  "errorCode": "1"
400 옳바르지 않는 경로 "errorCode": "2"
400 Access Token 시간 만료 "errorCode": "3"

 

Status Code가 모두 동일한 상황이다. 이 상황에서 응답 에러를 구분하려면 Data를 디코딩해서 errorCode로 구분해야 한다.

하지만 기본적으로 제공되는 validate와 Interceptor에서는 이를 구분 할 수가 없다.

validate()는 status Code로 유효성을 검사하고, Interceptor에서는 오류를 받았을 때 해당 Interceptor를 트리거한 에러와 Request만을 인자값으로 제공한다.

//RequestInterceptor
func retry(_ request: Request, for session: Session, dueTo error: Error, completion: @escaping (RetryResult) -> Void) {
}

만약 엑세스 토큰이 만료되어서 Interceptor에서 토큰을 재발급받고 retry를 하려고 한다면, 응답 에러가 액세스 토큰이 만료된 응답 에러일 때 이를 수행해야 하는데, 여기서는 오류를 디코딩할 수 없으므로 액세스 토큰이 만료되었을 때만 액세스 토큰을 재발급할 수 없다.

 

물론 이런 방식으로도 위 문제를 해결 할 수 있다.

AF.request("https://www.naver.com").validate(statusCode: 200...500).responseDecodable { response in
                if response.response?.statusCode != 200 {
                    let errorModel = JSONDecoder().decode(ErrorModel.self, from: response.data)
                    //do something..
                } else {
                    //do something..
                }
            }

validate를 200부터 500까지 모두 유효하다고 하고 난 후 response에서 이를 처리하는 방식이다.

하지만 이 방식으로는 interceptor가 실행되지도 않고, Alamofire를 사용하는 이유도 없어진다고 생각한다. URLSession으로 응답값을 받아서 하는 방식과 차이점이 없다. 

 

이런 문제점을 해결하기위해 Alamofire는 유효성 검사 및 에러 핸들링하는 방식을 2가지 제시하고 있다.

  • Custom Validate
  • Custom ResponseSerializer

첫 번째 방식은 Validate를 커스텀하는 방식이고, 두 번째 방식은 ResponseSerializer를 커스텀하는 방식이다.

이 두가지 방식의 차이점을 알기 위해서는 우선 Alamofire가 어떤 흐름으로 네트워크 요청을 하고, 응답값을 처리하는지를 알아야 한다.

 

Alamofire의 Flow

대표적인 Alamofire의 사용 코드로 예시를 들겠다. 이 코드는 다음과 같은 과정을 통해 최종적으로 후행 클로저를 실행하게 된다.

AF.request("https://www.naver.com").validate().responseDecodable(of:RegisterUserResponseDTO.self) { response in
            switch response.result {
            case .success(let success):
                //do somthing...
            case .failure(let failure):
                //do somthing...
            }
        }
  1. AF.request를 실행하게 되면 전달받은 URLRequestConvertible 혹은 String값, Interceptor로 DataRequest란 객체를 생성
  2. Session에서 생성한 Datarequest를 perform함수의 매개변수에 넣고 실행
  3. 전달받은 URLRequestConvertible의 asURLRequest()를 호출하여 URLRequest 객체 생성
    1. adapter가 있을 경우 생성한 URLRequest를 사용하며, 수정된 URLRequest 전달
  4. 생성된 URLRequest로 URLSessionTask를 만들기 위해 DataRequest에 URLRequest를 전달
  5. DataRequest는 URLSession으로 URLRequest를 전달
  6. URLSession은 URLSessionDataTask를 생성한 후 DataRequest에 리턴
  7. DataRequest는 Session에게 받은 URLSessionDataTask를 리턴
  8. Session은 받은 URLSessionDataTask를 실행(resume)하여 요청
  9. 시간이 지나 요청에 대한 응답값이 온다면, SessionDelegate에서 이를 확인하고 DataTask의 didCompleteTask를 호출
  10. DataTask는 1차적으로 validate로 응답에 대한 유효성을 검사
  11. 응답이 유효하지 않는 경우 RequestRetrier를 호출. 에러가 아닌 경우 다음단계로 넘어감 
    1. 해당 에러가 retry일 경우 SessionDelegate에게 재요청을 하겠다고 알림
    2. notRetry일 경우 responseSerializer를 실행 및 에러 전달
  12. ResponseSerializer에서 작업을 수행
  13. 작업을 수행중 에러가 발생하거나 validate로부터 응답이 아니고 에러를 받은 경우 RequestRetrier를 호출
    1. 해당 에러가 retry일 경우 SessionDelegate에게 재요청을 하겠다고 알림
    2. notRetry일 경우 에러 전달
  14. 에러가 아닌 경우 수행한 결과를 전달

이 과정을 시퀸스 다이어그램으로 표현하면 다음과 같다.

 

 

Validate

Flow를 보면 Alamofire는 우선 validate로 응답값의 유효성 검사를 수행한다. 여기서 말하는 validate는 위 코드에 있는 validate다.

기본적으로 제공되는 validate를 보면 다음과 같이 구현되어 있다.

public func validate() -> Self {
        let contentTypes: () -> [String] = { [unowned self] in
            acceptableContentTypes
        }
        return validate(statusCode: acceptableStatusCodes).validate(contentType: contentTypes())
    }

acceptableStatusCodes는 기본적으로 제공되는 유효한 statusCode로 200..<300으로 되어 있고, contentType은 "*/*"으로 되어있다. "*/*" 모든 타입을 허용한다는 뜻이다.

그래서 기본적으로 제공되는 validate는 statusCode가 200..<300인 경우 유효한 응답이라고 처리한다.

따라서 우리는 이 validate를 이용해 응답값의 유효성을 처리 할 수 있다. 물론 Alamofire에서 이를 지원해 준다.

//Request
public typealias ValidationResult = Result<Void, Error>
public typealias Validation = (URLRequest?, HTTPURLResponse, Data?) -> ValidationResult

public func validate(_ validation: @escaping Validation) -> Self {
        let validator: () -> Void = { [unowned self] in
            guard error == nil, let response = response else { return }

            let result = validation(request, response, data)

            if case let .failure(error) = result { self.error = error.asAFError(or: .responseValidationFailed(reason: .customValidationFailed(error: error))) }

            eventMonitor?.request(self,
                                  didValidateRequest: request,
                                  response: response,
                                  data: data,
                                  withResult: result)
        }

        validators.write { $0.append(validator) }

        return self
    }

해당 함수의 validation 클로저로 유효성 검사를 위한 로직을 구현하여 추가할 수 있다.

extension DataRequest {
    func slpValidate() -> Self {
        validate { _, response, data in
            if response.statusCode == 200 {
                return .success(())
            }
            guard let data else { return .failure(//전달할 에러) }
            do {
                let errorCode = try JSONDecoder().decode(SLPErrorModel.self, from: data)
                //errCode를 이용해 에러 매핑
                return .failure(//매핑한 에러 전달)
            } catch {
                return .failure(//전달할 에러)
            }
        }
    }
}

 

Response Serializer

두 번째 방법인 Response Serializer를 이용하는 방법이다.

ResponseSerializer이 뭔가 할 수 있는데, 우리는 늘 사용하고 있다. 위 코드에서는 바로 responseDecodable() 함수다. 해당 함수의 구현 부분에 들어가게 되면 

   @discardableResult
    public func responseDecodable<T: Decodable>(of type: T.Type = T.self,
                                                queue: DispatchQueue = .main,
                                                dataPreprocessor: DataPreprocessor = DecodableResponseSerializer<T>.defaultDataPreprocessor,
                                                decoder: DataDecoder = JSONDecoder(),
                                                emptyResponseCodes: Set<Int> = DecodableResponseSerializer<T>.defaultEmptyResponseCodes,
                                                emptyRequestMethods: Set<HTTPMethod> = DecodableResponseSerializer<T>.defaultEmptyRequestMethods,
                                                completionHandler: @escaping (AFDataResponse<T>) -> Void) -> Self {
        response(queue: queue,
                 responseSerializer: DecodableResponseSerializer(dataPreprocessor: dataPreprocessor,
                                                                 decoder: decoder,
                                                                 emptyResponseCodes: emptyResponseCodes,
                                                                 emptyRequestMethods: emptyRequestMethods),
                 completionHandler: completionHandler)
    }
}

이 함수는 DecodableResponseSerialzer이라는 객체를 생성하여 사용하는 것 을 볼 수 있다.

Response Serializer는 요청의 응답값을 마지막에 처리해 전달을 하는 역할을 한다. 

 

DecodableResponseSerialzer의 구현 부분은 그저 응답값을 디코딩하는 역할을 한다. 응답값을 디코딩하여 디코딩에 성공할 시 값을 전달하고, 실패할 시 실패했다는 오류를 발생시킨다. 그 외에도 data가 없는 경우 같은 로직도 처리하지만 해당 객체의 핵심은 응답값을 디코딩해 값을 전달을 해주는 역할을 한다.

public func serialize(request: URLRequest?, response: HTTPURLResponse?, data: Data?, error: Error?) throws -> T {
        guard error == nil else { throw error! }

        guard var data = data, !data.isEmpty else {
            guard emptyResponseAllowed(forRequest: request, response: response) else {
                throw AFError.responseSerializationFailed(reason: .inputDataNilOrZeroLength)
            }

            guard let emptyResponseType = T.self as? EmptyResponse.Type, let emptyValue = emptyResponseType.emptyValue() as? T else {
                throw AFError.responseSerializationFailed(reason: .invalidEmptyResponse(type: "\(T.self)"))
            }

            return emptyValue
        }

        data = try dataPreprocessor.preprocess(data)

        do {
            return try decoder.decode(T.self, from: data)
        } catch {
            throw AFError.responseSerializationFailed(reason: .decodingFailed(error: error))
        }
    }

이를 이용해 이번 slp의 유효성을 검증하는 로직을 만든다면  다음과 같이 만들어 줄 수 있다.

func serialize(request: URLRequest?, response: HTTPURLResponse?, data: Data?, error: Error?) throws -> T {
        guard error == nil, let response else { throw error! }
        guard let data, !data.isEmpty else {
            return try responseEmpty(request: request, response: response)
        }
        if !acceptableStatusCode.contains(response.statusCode) {
            let errorCode = try decoder.decode(SLPErrorModel.self, from: data).errorCode
            try mappingError(errorCode: errorCode)
        }
        do {
            return try decoder.decode(T.self, from: data)
        } catch {
            throw AFError.responseSerializationFailed(reason: .decodingFailed(error: error))
        }
    }

 

언제 Validate를 사용하고, 언제 Response Serializer를 사용할까?

이 글을 보다 보면 자연스럽게 드는 의문이 "그렇다면 validate와 Response Serializer의 차이는 무엇이고, 언제 어떤 걸 사용해야 할까?"라는 생각이 들것이다. 

 

우선은 Validate는 응답이 왔을 때 처음 실행된다. 그 후 RequestRetrier를 호출한다.

반면에 Response Serializer는 validate로부터 받은 값을 처리한 후 RequestRetrier를 호출한다.

Validate에서 응답값을 처리했을 때 에러가 발생하면 RequestRetrier이 1번 호출되게 되고 slp에서는 JSONDecoder를 생성한 후 디코딩 할 것이다. 그 후 Response Serializer가 값을 받아 또 에러를 받는다면 RequestRetrier이 또 호출되어 2번 호출되고 JSONDecoder를 생성하여 또 디코딩할 것이다. 이는 매우 비효율적이다. 따라서 Response Serializer를 이용하여 응답값의 유효성을 검사하는 것이 더 효율적이다. 

비슷한 사례가 있나 싶어서 issue에서 검색해 보니 alamofire 만든 사람도 Response Serializer를 만들어서 사용한다고 한다. validate는 가벼운 유효성 검사를 하는 것을 권장하고 있다.

물론 상황마다 다 다르니까 반드시 Response Serializer를 사용하는 게 옳은 것만은 아니겠지만, 이러한 로직을 고려하여 코드를 짠다면 좀 더 효율적인 코드를 짤 수 있을 거라 생각한다.