[iOS] Moya 라이브러리 커스텀 구현으로 Network Layer 재구성

김범수·2022년 6월 2일
0

iOS

목록 보기
7/8

Why


API 통신 코드 작성 시마다 반복되는 코드 줄이 상당하고 공통적으로 사용하는 변수 (baseUrl, header ...) 및 OAuth 인증 시스템을 위한 Interceptor를 구현하기 위해 Network 관련 함수를 Layer를 설계하여 Refactoring을 진행하기로 결정하였습니다.

ex)

func getExampleModel() -> Single<Result<ExampleModel, APIError>> {
        return Single.create { single in
            guard let url = URL(string: "\(APIParameters.shared.baseUrl)/api/examples") else {
                single(.success(.failure(APIError(statusCode: -1, description: "uri error"))))
                return Disposables.create()
            }

            APIParameters.shared.session
                .request(url, method: .get, parameters: nil, headers: APIParameters.shared.authorizedHeader)
                .validate(statusCode: 200 ..< 300)
                .responseJSON(completionHandler: { response in
                    switch response.result {
                    case .success:
                        if let data = response.data {
                            do {
                                let model = try JSONDecoder().decode(ExampleModel.self, from: data)
                                single(.success(.success(model)))
                            } catch {
                                single(.success(.failure(APIError(statusCode: -1, description: "parsing error"))))
                            }
                        }
                    case let .failure(error):
                        guard let statusCode = response.response?.statusCode else {
                            single(.success(.failure(APIError(statusCode: error._code,
                                                              description: error.errorDescription))))
                            return
                        }
                        single(.success(.failure(APIError(statusCode: statusCode, description: error.errorDescription))))
                    }
                })
                .resume()

            return Disposables.create()
        }
    }

How


Network Layer에 대해 research 중 열거형을 사용하여 타입이 안전한 방식으로 네트워크 요청을 캡슐화한 Network Layer를 제공하는 **Moya 라는 라이브러리를 알게 되었습니다.

**Moya 라이브러리를 이용하여 Refactoring을 진행하려 했으나 Interceptor같은 세부 기능에 대한 research 결과가 많지 않았고 Moya도 Alamofire를 이용해 Network 통신이 진행되지만 Alamofire를 직접 조금 더 다뤄보고 싶다는 욕심, 최신 Alamofire 버전을 지원하지 않는다는 점에서 직접 Custom하여 진행하기로 하였습니다.
Custom 과정은 아래 블로그를 참고하여 진행했습니다
https://ios-development.tistory.com/731?category=899471

Interceptor, adapt, retry


우선 Refresh Token을 이용한 Access Token 재발급 (OAuth) 및 API 공통 오류 처리를 위해 Interceptor 구현을 진행했습니다.

해당 내용은 https://www.notion.so/Alamofire-Advanced-Usage-f3fab086136646d9a488667317d3efd8 에서 확인이 가능합니다.

Target Type


Target Type은 API들이 공통적으로 가지고 있는 end point를 가지고 있는 module입니다

충전했'오'에서 사용되는 API에서는 개별적인 header 및 encoding을 정의할 필요가 있어 TargetType에 추가하여 진행하였습니다.

parameter들은 Encodable을 준수하는 struct를 받도록 설계했으며, extension으로 toDictionary() 메소드도 정의하였습니다.

import Alamofire
import Foundation

protocol TargetType: URLRequestConvertible {
    var baseURL: String { get }
    var method: HTTPMethod { get }
    var header: HTTPHeaders { get }
    var path: String { get }
    var parameters: RequestParams? { get }
    var encoding: ParameterEncoding { get }
}

extension TargetType {
    // URLRequestConvertible 구현
    func asURLRequest() throws -> URLRequest {
        let url = try baseURL.asURL()
        var urlRequest = try URLRequest(url: url.appendingPathComponent(path), method: method)
        urlRequest.headers = header

        switch parameters {
        case let .query(request):
            let params = request?.toDictionary() ?? [:]
            let queryParams = params.map { URLQueryItem(name: $0.key, value: "\($0.value)") }
            var components = URLComponents(string: url.appendingPathComponent(path).absoluteString)
            components?.queryItems = queryParams
            urlRequest.url = components?.url
            return try encoding.encode(urlRequest, with: params)
        case let .body(request):
            let params = request?.toDictionary() ?? [:]
            urlRequest.httpBody = try JSONSerialization.data(withJSONObject: params, options: [])
            return try encoding.encode(urlRequest, with: params)
        case .none:
            return urlRequest
        }

        return urlRequest
    }
}

enum RequestParams {
    case query(_ parameter: Encodable?)
    case body(_ parameter: Encodable?)
}

extension Encodable {
    func toDictionary() -> [String: Any] {
        guard let data = try? JSONEncoder().encode(self),
              let jsonData = try? JSONSerialization.jsonObject(with: data),
              let dictionaryData = jsonData as? [String: Any] else { return [:] }
        return dictionaryData
    }
}

Domain, Request, Response 모델


Model의 경우 Domain / Response (Decodable) / Request (Encodable)의 형태로 Refactoring하려 했으나 아직까지 Domain / Response 을 구분하여 사용할만큼 API response와 client에서 사용하는 model과의 차이가 없어 Domain(Docodable) / Request (Encodable) 로 우선 Refactoring을 진행하였습니다

ex) 회원가입 Request

import Foundation

struct SigninRequest: Encodable {
    let email: String?
    let password: String?

    enum CodingKeys: String, CodingKey {
        case email, password
    }
}

API, Target 모듈


아래 code는 Station Domain에서 사용되는 end-point를 정의한 StationTarget 파일입니다.

import Alamofire

enum ExampleTarget {
    case getExamples(GetExamplesRequest)
    case addExample(AddExampleRequest)
    case deleteExample(DeleteExampleRequest)
}

extension ExampleTarget: TargetType {
    var baseURL: String {
        return Constant.baseURl
    }

    var method: HTTPMethod {
        switch self {
        case .getExamples:
            return .get
        case .addExample:
            return .post
        case .deleteExample:
            return .delete
        }
    }

    var path: String {
        switch self {
        case .getExamples:
            return "/examples"
        case .addExample:
            return "/examples"
        case let .deleteExample(request):
            return "/examples/\(request.exampleId)"
        }
    }

    var header: HTTPHeaders {
        return Constant.header
    }

    var parameters: RequestParams? {
        switch self {
        case .getExamples: return nil
        case let .addExample(request): return .body(request)
        case .deleteExample: return nil
        }
    }

    var encoding: ParameterEncoding {
        switch self {
        case .getExamples:
            return URLEncoding.default
        case .addExample, .deleteExample:
            return JSONEncoding.default
        }
    }
}

또한 모든 domain에서 공통적으로 사용하는 request파일을 generic type을 이용하여 API Class 내에 정의하였습니다.

import Alamofire
import RxSwift

class API {
    static let session: Session = {
        let configuration = URLSessionConfiguration.af.default
        configuration.timeoutIntervalForRequest = 10
        configuration.timeoutIntervalForResource = 10
        let apiLogger = APIEventLogger()
        return Session(configuration: configuration, eventMonitors: [apiLogger])
    }()

    func request<T: Codable>(_ urlConvertible: URLRequestConvertible) -> Single<Result<T, APIError>> {
        return Single.create { single in
            let request = API.session
                .request(urlConvertible, interceptor: MyRequestInterceptor())
                .validate(statusCode: 200 ..< 300)
                .responseDecodable(of: T.self) { response in
                    switch response.result {
                    case let .success(data):
                        single(.success(.success(data)))
                    case let .failure(error):
                        single(.success(.failure(APIError(statusCode: response.response?.statusCode,
                                                          description: error.errorDescription))))
                    }
                }
            return Disposables.create {
                request.cancel()
            }
        }
    }

    func request(_ urlConvertible: URLRequestConvertible) -> Single<Result<Void, APIError>> {
        return Single.create { single in
            let request = API.session
                .request(urlConvertible, interceptor: MyRequestInterceptor())
                .validate(statusCode: 200 ..< 300)
                .response { response in
                    switch response.result {
                    case .success:
                        single(.success(.success(())))
                    case let .failure(error):
                        single(.success(.failure(APIError(statusCode: response.response?.statusCode,
                                                          description: error.errorDescription))))
                    }
                }
            return Disposables.create {
                request.cancel()
            }
        }
    }
}

위에서 ExampleTarget에서 end point 정의 후 request 함수를 가지고 있는 API protocol을 채택하면 Example Domain API를 간단하게, 중복되는 code 없이 구현이 가능합니다.

import Alamofire
import RxSwift

class ExampleAPI: API {
    static let shared = ExampleAPI()
    private let bag = DisposeBag()
    override private init() {}

    func getExamples() -> Single<Result<[ExampleModel], APIError>> {
        return request(ExampleTarget.getExamples)
    }

    func addExample(_ requestModel: AddExampleRequest) -> Single<Result<ExampleModel, APIError>> {
        return request(ExampleTarget.addExample(requestModel))
    }

    func deleteExample_ requestModel: DeleteExampleRequest) -> Single<Result<Void, APIError>> {
        return request(ExampleTarget.deleteExample(requestModel))
    }
}

또한 공통적으로 사용하는 변수들을 정적 변수를 통해 정의하였으며, baseURI의 경우 Debug 시 변동하여 사용하는 만큼 .gitignore 파일에 추가하여 remote repository에 변동되지 않도록 설정하였습니다

import Alamofire
import Foundation

struct Constant {
    static let baseURl = "http://projectohbackend-env-1.eba-k5dneaqn.ap-northeast-2.elasticbeanstalk.com/api"

    static let header: HTTPHeaders = [
        .init(name: "Content-Type", value: "application/json"),
        .init(name: "Accept", value: "application/json"),
    ]
}

ref: https://ios-development.tistory.com/731

profile
iOS Developer

0개의 댓글