API의 response status를 좀 더 구조적으로 핸들링 해보자

이번 새싹 LSLP에서 많은 API를 사용하게 되면서 API마다 response값을 별도로 관리함이 필요하다고 느꼈다.

http프로토콜을 이용해 response를 받을 때, statuscode를 이용해 response의 유효성을 검증하고, 응답 에러(사용자가 유효하다고 정의한 response를 제외한 response)를 받을 경우 이를 대응해야 한다. 

 

이를 위해 다음과 같이 필요한 기능을 및 문제를 정의했다.

  • API가 많아 질수록 응답 에러 정의에 어려움과 처리가 어려워짐
  • 응답 에러마다 수행할 작업 정의 (alert로 이벤트 전달, 재시도 등..)
  • alert에 들어갈 description과 title 핸들링

API 응답 에러 정의 및 처리의 어려움

API 양이 많아질수록 이를 한 군데 에서 정의 및 처리하기는 어려워진다.

예시로 LSLP에서 7개의 API를 사용한 결과 이 정도의 응답 에러가 나왔다.

이 정도도 많은 편이지만, 만약 API를 20개, 30개 사용하게 된다면

이 정도의 응답 에러가 나올 수도 있다. 이를 해결하기 위해 같은 종류의 API를 묶어서 분리해 보자. 댓글 기능과 관련된 API는 서로 묶어두고 인증과 관련된 API는 서로 묶어둔다고 하면 

이렇게 분리할 수 있다. 또한 Int를 채택하여 status code를 매핑을 해주자. 현재 까지는 문제가 없다.

하지만 같이 묶인 API의 status code가 동일한 경우는 어떻게 해야 할까? 일단 또 분리해 보자.

그 결과 중복된 case를 가진 응답 에러가 여러 개 정의되었다. 여기서 발생한 문제는 다음과 같다.

  • 동일한 case의 중복
  • status code가 변경될 경우 여러 군데를 수정해 주어야 함
  • status code가 변경되어서 또 중복이 생길 경우 또 분리를 해주어야 함

각 응답 에러 수행할 작업 정의

기본적으로 사용자가 버튼을 클릭하고 난 후 네트워크 작업을 수행한 후에 응답 에러를 받을 시 사용자에게 이를 알려주어 상호작용을 한다. 하지만 access token이 만료가 되어서 토큰을 재발급받아야 하는 경우 굳이 사용자에게 이를 알릴 필요 없이 토큰을 재발급받고 다시 한번 작업을 수행하면 된다. 이처럼 어떤 응답 에러가 왔을 때 사용자에게 바로 오류를 보여주는 것이 아니라, 어떤 작업을 한 다음에 다시 네트워크 작업을 수행하는 작업이 필요할 때도 있고, 틀린 아이디나 패스워드를 입력해서 로그인이 실패했을 경우 같이 네트워크 응답 에러를 받았을 경우 이를 알려 주는 작업도 필요하다. 이 기능 같은 경우 Alamofire의 interceptor를 참고하였다.

 

Alert에 들어갈 title 및 description 핸들링

네트워크 응답 에러에 관해 Alert를 띄울 때 title과 description을 응답 에러마다 다르게 해주려고 했다. 로그인 오류가 발생했을 경우 로그인 응답 에러의 title과 description, 회원가입이 실패했을 경우 회원가입이 실패했다는 이유와 왜 실패했는지에 대한 설명이 들어가 있다.

title이야 사용자가 정의하면 되지만, desctription의 경우 두 가지 경우로 나눌필요가 있다. iOS에서 정의한 description과 서버에서 응답 에러와 함께 온 description 값으로 나뉜다. 따라서 이에 따른 대응 역시 필요하다.

 

이런 문제를 해결함과 동시에 기능이 필요하기에 다음과 같이 네트워크 모듈을 설계했다.

구조

  • NetworkService: 직접적인 네트워크 통신을 담당 및 response의 statuscode의 유효성 검사. 유효한 status코드일시 data값을 전달, 유효하지 않는 status코드일시 statuscode와 data 전달.
  • DataTransferService: NetworkService로부터 받은 값을 decoding 하는 역할
  • ResponseErrorHandler: 이번에 구현할 모듈. NetworkService로부터 받은 statusCode와 data를 매핑 후 retry 할 것인지, 아니면 그냥 전달할 것인지 같은 API별 응답 에러 작업 관리

구현

 

ResponseErrorHandler 정의

외부에 값을 전달할 에러. title과 description은 Alert에 띄울 값. originError는 원래 정의된 응답 에러

struct NetworkError: Error {
    let title: String
    let description: String
    let originError: Error
}

 

응답 에러의 description을 data로부터 decoding 할 타입 정의.  description()은 디코딩한 데이터에서 NetworkError의 description에 들어갈 값을 리턴하는 역할.

protocol DecodingErrorType: Decodable {
    func description() -> String
}

 

ErrorDecoding 타입 정의. ErrorDecoding 타입은 응답 에러의 description을 사용자가 정의한 값으로 할 건지, 서버로부터 받은 값으로 할 건지를 정하는 타입. DecodingErrorType으로 디코딩 함으로써 API마다 응답 에러의 포맷이 다른 경우에도 대응할 수 있도록 구현.

enum ErrorDecoding {
    case localized(description: String)
    case decoding(decoding: DecodingErrorType.Type)
}

 

 

응답 에러마다 수행할 작업을 정의하는 enum 타입 정의.

enum RetryResult {
    case notRetry(title: String, errorDecoding: ErrorDecoding)
    case retry(endpoint: Requestable, title: String, errorDecoding: ErrorDecoding, maxCount: Int = 1)
}
  • notRetry: 받은 statuscode를 그대로 외부로 전달
  • retry: 실패한 네트워크 작업을 다시 수행. 다시 수행해도 maxCount이상 실패 했을 경우 마지막으로 받은 응답 에러를 외부로 전달

응답 에러 정의. retry가 호출됨으로써 응답 에러마다 정의한 작업이 수행됨.

protocol ResponseErrorType: Error {
    func retry(endpoint: Requestable, completion: @escaping(RetryResult) -> Void)
}

 

ResponseErrorHandler 정의. 각 API별 응답 에러를 정의하는 것을 강제하기 위해 associatedType으로 ResponseError을 추가.

protocol ResponseErrorHandler {
    associatedtype ResponseError: ResponseErrorType
    
    func mappingStatusCode(statusCode: Int) -> ResponseErrorType?
}

 

DataTransferService에서의 동작 구현

이번 LSLP에는 모든 API에 들어가는 공통 응답 에러가 있기에 defaultResponseErrorHandler를 인스턴스 변수로 추가.

final class DataTransferService<DefaultErrorHandler: ResponseErrorHandler> {
    private let defaultResponseErrorHandler: DefaultErrorHandler
    .
    .
    .
}

 

statusCode를 매핑 및 case별 동작을 정의하는 함수

private func handleStatusCode<T: Decodable>(endpoint: Requestable,
                                                endpointResponseHandler: (some ResponseErrorHandler)?,
                                                statusCode: Int,
                                                data: Data,
                                                count: Int,
                                                completion: @escaping (Result<T, NetworkError>) -> Void) {
       
    }

 

defaultResponseErrorHandler와 endpointResponderHandler로 statusCode를 정의된 응답 에러로 매핑.

 //handleStatusCode
 var responseError: ResponseErrorType?
        if let defaultStatus = defaultResponseErrorHandler.mappingStatusCode(statusCode: statusCode) {
            responseError = defaultStatus
        } else if let endpointStatus = endpointResponseHandler?.mappingStatusCode(statusCode: statusCode) {
            responseError = endpointStatus
        }
        guard let responseError else {
            let error = DataTransferServiceError.unknownStatusCode(statusCode: statusCode)
            completion(.failure(.init(title: error.title, description: error.localizedDescription, originError: error)))
            return
        }

 

응답에러의 retry를 호출하여 retryResult 타입을 동작을 구현. notRetry, retry의 경우 외부로부터 NetworkError를 전달해야 하므로 completion을 호출하여 이를 전달.

responseError.retry(endpoint: endpoint, completion: { result in
            switch result {
            case .notRetry(title: let title, errorDecoding: let errorDecoding):
                switch errorDecoding {
                case .localized(description: let description):
                    completion(.failure(.init(title: title, description: description, originError: responseError)))
                case .decoding(decoding: let decoding):
                    completion(.failure(self.errorDecode(title: title, data: data, error: responseError, decodeType: decoding)))
                }
            case .retry(let endpoint, let title, let errorDecoding, let maxCount):
                if count >= maxCount {
                    switch errorDecoding {
                    case .localized(description: let description):
                        completion(.failure(.init(title: title, description: description, originError: responseError)))
                    case .decoding(decoding: let decoding):
                        completion(.failure(self.errorDecode(title: title, data: data, error: responseError, decodeType: decoding)))
                    }
                    return
                }
                self.request(count: count, maxCount: maxCount, endpoint: endpoint, endpointResponseHandler: endpointResponseHandler, completion: completion)
                return
            }
        })

 

이제 이 handleStatusCode를 networkService의 failure의 resposeError에서 호출해 준다.

networkService.request(endPoint: endpoint) { result in
            switch result {
            case .success(let success):
                let fetchData: Result<T, NetworkError> = self.decode(data: success)
                completion(fetchData)
            case .failure(let failure):
                switch failure {
                case .responseError(statusCode: let statusCode, data: let data):
                    self.handleStatusCode(endpoint: endpoint, endpointResponseHandler: endpointResponseHandler, statusCode: statusCode, data: data ?? Data(), count: count+1, completion: completion)
                case .networkError(let netwokError):
                    completion(.failure(.init(title: failure.title, description: failure.localizedDescription, originError: netwokError)))
                case .url:
                    completion(.failure(.init(title: failure.title, description: failure.localizedDescription, originError: failure)))
                }
            }
        }

 

사용법

예시로 이메일 중복검사 API로 테스트해 보았다.

 

우선은 LSLP API의 응답 에러 데이터를 디코딩할 타입을 정의해 준다.

struct ErrorDesctiption: DecodingErrorType {
    
    let message: String
    
    func description() -> String {
        return message
    }
}

 

LSLP API에서 이메일 중복검사 API와 함께 중첩되는 응답에러를 AuthorizationCommonErrorHandler라는 공통 인증 응답 에러를 처리해 주는 객체를 만들어서 공통 응답 에러값인 400을 받을 경우 외부로 전달되도록 하였다.

struct AuthorizationCommonErrorHandler: ResponseErrorHandler {
    
    enum ResponseError: Int, ResponseErrorType {
        
        case emptyRequireValue = 400
        
        func retry(endpoint: Requestable, completion: @escaping (RetryResult) -> Void) {
            switch self {
            case .emptyRequireValue:
                completion(.notRetry(title: "인증 실패", errorDecoding: .localized(description: "필수값이 누락되었습니다.")))
            }
        }
    }
    
    func mappingStatusCode(statusCode: Int) -> ResponseErrorType? {
        return ResponseError(rawValue: statusCode)
    }
    
}

 

 

이제 이메일 인증 API의 응답 에러를 처리하는 ResponseErrorHandler 객체를 만들면 된다.

mappingStatusCode 함수 내부에서 인증 관련 API에 공통으로 들어가는 응답에러를 처리해 주는 객체인 AuthorizationCommonErrorHandler를 생성하여 해당 request로 받은 statusCode가 공통 응답에러인 경우 공통 응답 에러가 return 되게 된다.

if let 문에 의해 공통 응답 에러인지 아닌지를 먼저 처리하고, 아닌 경우 해당 API의 responseErrorHandler에서 정의한 응답에러가 리턴되게 되면서 정의한 작업이 수행되게 된다.

 

사용할 수 없는 이메일이라는 응답 에러가 오면. noRetry로 응답 에러를 바로 전달하도록 하였고, description은. decoding으로 서버에서 온 값으로 description을 만들도록 정의

struct ValidateEmailResponseErrorHandler: ResponseErrorHandler {
    
    enum ResponseError: Int, ResponseErrorType {
        
        case notValidEmail = 409
        
        func retry(endpoint: Requestable, completion: @escaping (RetryResult) -> Void) {
            completion(.notRetry(title: "사용할 수 없는 이메일입니다.", errorDecoding: .decoding(decoding: ErrorDesctiption.self)))
        }
    }
    
    func mappingStatusCode(statusCode: Int) -> ResponseErrorType? {
        if let response = AuthorizationCommonErrorHandler().mappingStatusCode(statusCode: statusCode) {
            return response
        }
        return ResponseError(rawValue: statusCode)
    }
    
}

처음에는 parentResponseErrorHandler같이 베이스가 되는 ResponseErrorHandler을 인스턴스 변수로 넣게 할까 고민도 했는데, 독립된 API(공통된 응답 에러가 없는)가 존재하므로 직접 생성해서 넣는 방식으로 구현했다.

 

이걸 DataTransferService 객체를 생성하고 request의 인자값으로 responseErrorHandler 객체를 넣어서 실행.

dataTransferService.request(endpoint: AuthorizationEndpoints.validateEmail(request: endpoint), endpointResponseHandler: ValidateEmailResponseErrorHandler())

아무것도 안 넣고 request를 하면 AuthorizationCommonErrorHandler에서 정의한 에러와 문구가 Alert로 나오는 것을 볼 수 있고, 이미 가입한 유저의 아이디를 넣은 경우는 ValidateEmailResponseErrorHandler에서 정의한 에러와 서버로 부터 받은 description이 Alert로 나온다.

 

만약  errorDecoding 타입을 .localized로 변경하여 사용자가 정의한 값으로 변경하면

struct ValidateEmailResponseErrorHandler: ResponseErrorHandler {
    
    enum ResponseError: Int, ResponseErrorType {
        
        case notValidEmail = 409
        
        func retry(endpoint: Requestable, completion: @escaping (RetryResult) -> Void) {
        	completion(.notRetry(title: "야옹야옹", errorDecoding: .localized(description: "고양이고양이고양이")))
        }
    }
    
    func mappingStatusCode(statusCode: Int) -> ResponseErrorType? {
        if let response = AuthorizationCommonErrorHandler().mappingStatusCode(statusCode: statusCode) {
            return response
        }
        return ResponseError(rawValue: statusCode)
    }
    
}

Alert의 message가 직접 정의한 내용으로 나오는것을 볼 수 있다.

 

Access 토큰 만료 같은 응답 에러의 경우 아래와 같이 토큰을 재발급받는 API를 내부에서 호출하여 endpoint의 값을 수정하여 수정한 endpoint를 전달여 수정한 endpoint로 다시 networking을 하게 된다.

import Foundation

struct TokenErrorHandler: ResponseErrorHandler {
    enum ResponseError: Int, ResponseErrorType {
        case unknownAccessToken = 401
        case expirationAccessToken = 419
        case forbidden = 403
        
        func retry(endpoint: Requestable, completion: @escaping (RetryResult) -> Void) {
            switch self {
            case .unknownAccessToken:
                completion(.notRetry(title: "에러", errorDecoding: .decoding(decoding: ErrorDesctiption.self)))
            case .forbidden:
                completion(.notRetry(title: "에러", errorDecoding: .localized(description: "접근 권한이 없습니다.")))
            case .expirationAccessToken:
                let refreshEndpoint = TokenEndpoints.refreshAccessToken()
                let dataTransferService = DataTransferService(networkService: DefaultNetworkService(config: APINetworkConfigs.authoTestConfig), defaultResponseHandler: CommonResponseErrorHandler())
                dataTransferService.request(endpoint: refreshEndpoint, endpointResponseHandler: AccessTokenRefreshErrorHandler()) { result in
                    switch result {
                    case .success(let refreshToken):
                        var copyReq = endpoint
                        do {
                            try DefaultTokenRepository.saveTokenAtKeyChain(tokenCase: .accessToken, value: refreshToken.token)
                            copyReq.headerParameter["Authorization"] = refreshToken.token
                            completion(.retry(endpoint: endpoint, title: "토큰 갱신 실패", errorDecoding: .decoding(decoding: ErrorDesctiption.self)))
                        } catch {
                            completion(.notRetry(title: "실패", errorDecoding: .localized(description: "토큰을 저장 할 수 없습니다.")))
                        }
                    case .failure(let failure):
                        completion(.notRetry(title: "실패", errorDecoding: .decoding(decoding: ErrorDesctiption.self)))
                    }
                }
                
            }
        }
    }
    
    func mappingStatusCode(statusCode: Int) -> ResponseErrorType? {
        return ResponseError(rawValue: statusCode)
    }
}

 

전체 코드: https://github.com/Kim-Junhwan/LSLP/tree/improveNetworkLogic/LowServiceLevelProject/Network