본문 바로가기

iOS

[Swift] Alamofire를 Moya처럼 사용해보자! By Router Pattern (1편 - Foundation Setting)

URLSession, Alamofire, Moya 간의 선택

 지금껏 iOS 앱 개발을 하면서 서버통신 시에 Moya Library를 주로 사용해 왔습니다. Moya는 Alamofire 라이브러리를 한층 더 Wrapping하여 사용하기 편리하도록 한 라이브러리입니다. Wrapping은 사용의 편의성이라는 장점도 제공하지만 URLSession에 접근하기 어렵다는 한계 또한 가지고 있습니다. 최근 이를 느끼고 한 계층 낮은 Alamofire 라이브러리를 사용할지, 아니면 순수 URLSession을 Module화하여 사용할지 고민하다가, 그래도 조금은 Module화가 되어있는 Alamofire 라이브러리를 사용하는 것이 돌발상황 대처에 좋을 것 같아 Alamofire를 사용하기로 결정했습니다.

 

개인적으로 Moya의 사용에 익숙해져 있고, 체계적으로 기능이 분리되어 있는 라이브러리의 특성상 규모가 커질수록 유지보수에 장점이 있다는 점을 느꼈기에 Alamofire를 Moya처럼 만들어보기로 했습니다.

 

아래는 그 과정에서 참고한 자료입니다.

https://ios-development.tistory.com/797

 

[iOS - swift] Moya, RxMoya, Networking 레이어

해당 글에서 편리함을 위해 사용된 다른 프레임워크 참고 RxSwift, RxCocoa RxDataSources Kingfisher Reusable Then Moya 프레임워크 Alamofire를 Wrapping한 모듈 (Moya는 직접적인 네트워킹을 수행하지 않고..

ios-development.tistory.com

 

Networking Layer 구조

우선 전체적인 구조는 아래와 같습니다.

* Foundation : Module화의 기틀을 제공하는 클래스, 구조체들이 정의되어 있습니다.

   - Router : URLRequestConvertible을 채택한 프로토콜로, Routers 폴더에 있는 구체 타입들이 이 프로토콜을 채택합니다.

   - BaseService : 서버통신을 요청하기 위한 Service들이 공통적으로 상속하는 superClass입니다. 커스텀 AFManger와 judgeStatus 메서드가 존재합니다.

   - HeaderType : Router에서 반환할 헤더의 종류들이 정의되어 있습니다.

   - NetworkResult : 서버통신의 결과를 저장할 열거형 타입입니다.

* NetworkConstants : 서버통신에 필요한 BaseURL 또는 timeOut과 같은 상수들을 관리합니다.

* Routers : Router 프로토콜을 채택하여 header, path, parameter 등을 커스텀하여 request를 만들게 해줍니다.

* Service : 실제로 서버통신을 요청하기 위해 사용해야 하는 클래스입니다. 여러 endpoint들을 정의해두고 사용할 수 있습니다.


 

Foundation

우선 Module화의 기틀을 제공하는 클래스, 구조체들이 정의된 Foundation 폴더부터 살펴보겠습니다.

 

 

* Router

Router는 URLRequestConvertible 프로토콜을 상속하는 프로토콜입니다. 모듈화에 있어 가장 기본이 되는 아이디어가 URLRequestConvertible 프로토콜입니다.

URLRequestConvertible를 채택하고, asURLRequest()와 아래 메서드를 구현해주면 원하는 방식으로 request를 가공해줄 수 있습니다. 따라서, asURLRequest() 메서드에서 request를 만드는 과정에 접근할 수 있도록 프로토콜에 method, path, parameter, headerType과 같은 프로퍼티들을 추가로 선언한다면! endPoint별로 case를 나눠서 적절한 request를 만들 수 있지 않을까? 라는 생각을 하게 된 것입니다.

 

우선 전체적인 코드는 아래와 같습니다. ( 추후 3, 4편에서 전체적인 코드를 수정하니 참고해주세요 )

더보기
import Foundation
import Alamofire

protocol Router: URLRequestConvertible {
    var baseURL: String { get }
    var method: HTTPMethod { get }
    var path: String { get }
    var parameters: RequestParams { get }
    var header: HeaderType { get }
}

// MARK: asURLRequest()

extension Router {
    
    // URLRequestConvertible 구현
    func asURLRequest() throws -> URLRequest {
        let url = try baseURL.asURL()
        var urlRequest = try URLRequest(url: url.appendingPathComponent(path), method: method)
        
        urlRequest = self.makeHeaderForRequest(to: urlRequest)
        
        return try self.makePrameterForRequest(to: urlRequest, with: url)
    }
    
    private func makeHeaderForRequest(to request: URLRequest) -> URLRequest {
        ...
    }
    
    private func makePrameterForRequest(to request: URLRequest, with url: URL) throws -> URLRequest {
        ...
    }
}

// MARK: baseURL & header

extension Router {
    var baseURL: String {
        return URLConstants.baseURL
    }
    
    var header: HeaderType {
        return HeaderType.default
    }
}

// MARK: ParameterType

enum RequestParams {
    case query(_ parameter: Codable?)
    case body(_ parameter: Codable?)
    case requestParameters(_ parameter: [String : Any])
}

// MARK: toDictionary

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
    }
}

 

먼저 아래 asURLRequest를 보면

func asURLRequest() throws -> URLRequest {
    let url = try baseURL.asURL()
    var urlRequest = try URLRequest(url: url.appendingPathComponent(path), method: method)

    urlRequest = self.makeHeaderForRequest(to: urlRequest)

    return try self.makePrameterForRequest(to: urlRequest, with: url)
}

1. urlRequest를 만들어주고

2. makeHeaderForRequest(to: urlRequest)

3. makeParameterForRequest(to: urlRequest, with: url)

4. 가공된 request를 return합니다.

2번과 3번은 case별로 request에 헤더와 파라미터를 부여합니다.

 

makeHeaderForRequest에서는, hearType별로 header를 request에 부여해주고 있습니다.

private func makeHeaderForRequest(to request: URLRequest) -> URLRequest {
    var request = request

    switch header {

    case .default:
        request.setValue(HeaderContent.json.rawValue, forHTTPHeaderField: HTTPHeaderField.contentType.rawValue)

    case .withToken:
        request.setValue(HeaderContent.json.rawValue, forHTTPHeaderField: HTTPHeaderField.contentType.rawValue)
        request.setValue(HeaderContent.tokenSerial.rawValue, forHTTPHeaderField: HTTPHeaderField.accesstoken.rawValue)

    case .multiPart:
        request.setValue(HeaderContent.multiPart.rawValue, forHTTPHeaderField: HTTPHeaderField.contentType.rawValue)

    case .multiPartWithToken:
        request.setValue(HeaderContent.multiPart.rawValue, forHTTPHeaderField: HTTPHeaderField.contentType.rawValue)
        request.setValue(HeaderContent.tokenSerial.rawValue, forHTTPHeaderField: HTTPHeaderField.accesstoken.rawValue)
    }

    return request
}

 

makeParameterForRequest에서는, RequestParams별로 request에 parameter를 부여해주고 있습니다.

 

private func makeParameterForRequest(to request: URLRequest, with url: URL) throws -> URLRequest {
    var request = request

    switch parameters {

    case .query(let query):
        let params = query?.toDictionary() ?? [:]
        let queryParams = params.map { URLQueryItem(name: $0.key, value: "\($0.value)") }
        var components = URLComponents(string: url.appendingPathComponent(path).absoluteString)
        components?.queryItems = queryParams
        request.url = components?.url

    case .body(let body):
        let params = body?.toDictionary() ?? [:]
        request.httpBody = try JSONSerialization.data(withJSONObject: params, options: [])

    case .requestParameters(let requestParams):
        let params = requestParams
        request.httpBody = try JSONSerialization.data(withJSONObject: params, options: [])
    }

    return request
}

 

또한 아래와 같이 baseURL과 header를 초기구현하여 편의성을 높였습니다.

 

extension Router {
    var baseURL: String {
        return URLConstants.baseURL
    }
    
    var header: HeaderType {
        return HeaderType.default
    }
}

 

지금까지는 alamofire에 내장된 URLRequestConvertible을 상속한 Router 프로토콜을 만들고, request를 만드는 흐름을 제어했습니다. 다음으로는 이렇게 만든 Router를 채택하는 주체이자, 모든 Service들의 superClass인 BaseService에 대해서 알아보겠습니다.


* BaseService

 서버통신을 요청하기 위한 Service들이 공통적으로 상속하는 SuperClass이며, 커스텀 AFManger와 judgeStatus 메서드가 존재합니다. 한번 살펴보겠습니다.

 

전체 코드는 아래와 같습니다.

import Foundation
import Alamofire

class BaseService {

    @frozen enum DecodingMode {
        case model
        case message
        case general
    }
    
    let AFManager: Session = {
        var session = AF
        let configuration = URLSessionConfiguration.af.default
        configuration.timeoutIntervalForRequest = NetworkEnvironment.requestTimeOut
        configuration.timeoutIntervalForResource = NetworkEnvironment.resourceTimeOut
        session = Session(configuration: configuration)
        return session
    }()
    
    func judgeStatus<T: Codable>(by statusCode: Int, _ data: Data, type: T.Type, decodingMode: DecodingMode = .general) -> NetworkResult<Any> {
        let decoder = JSONDecoder()
        guard let decodedData = try? decoder.decode(GeneralResponse<T>.self, from: data)
        else { return .pathErr }
        
        switch statusCode {
        case 200..<300:
            
            switch decodingMode {
            case .model:
                return .success(decodedData.data ?? "None-Data")
                
            case .message:
                return .success(decodedData.message ?? "None-Data")
                
            case .general:
                return .success(decodedData)
            }
            
        case 400..<500:
            return .requestErr(decodedData.status)
            
        case 500:
            return .serverErr
            
        default:
            return .networkFail
        }
    }
}

 

우선 DecodingMode 열거형부터 살펴보겠습니다. model, message, general 타입은 서버통신이 성공하고, decoding 시에 어떤 데이터를 어떤 형태로 return할지에 대한 선택지입니다. success에서 GeneralResponse 모델 전체가 필요할 수 있고, model만 필요할 수 있으며 message만 필요할 수 있기 때문에 이러한 선택지를 미리 구성해 놓았습니다.

 

@frozen enum DecodingMode {
    case model
    case message
    case general
}

 

다른 요소에 대해 알아보기에 앞서 GeneralResponse에 대해서도 알아보겠습니다. General Response란 이름 그대로와 같이 서버에서 오는 일반적인 응답의 모델입니다. 통상적으로 success/failure, status, message, data의 형식으로 응답이 오기 때문에 이런 식으로 정의되어 있습니다. 보통의 경우 presentation에서는 data에 관심이 있기 때문에 이를 따로 가공하는 과정이 필요하고, 그 decoding 과정을 간편하게 하기 위해 DecodingMode 열거형을 사용했습니다.

 

import Foundation

struct GeneralResponse<T> {
    let success : Bool
    let status: Int
    let message: String?
    let data: T?
    
    enum CodingKeys: String, CodingKey {
        case success
        case status
        case message
        case data
    }
}

extension GeneralResponse: Decodable where T: Decodable  {
    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        success = try container.decode(Bool.self, forKey: .success)
        status = try container.decode(Int.self, forKey: .status)
        message = try? container.decode(String.self, forKey: .message)
        data = try? container.decode(T.self, forKey: .data)
    }
}

 

다음으로 AFMagner입니다. AFManager는 URLSession에 존재하는 timeout 관련 상수들을 정해주기 위해 선언했으며, Service 전역적으로 적용되어야 할 요소가 있을때 사용합니다.

 

let AFManager: Session = {
    var session = AF
    let configuration = URLSessionConfiguration.af.default
    configuration.timeoutIntervalForRequest = NetworkEnvironment.requestTimeOut
    configuration.timeoutIntervalForResource = NetworkEnvironment.resourceTimeOut
    session = Session(configuration: configuration)
    return session
}()

 

마지막으로 judgeStatus<T: Codable> 메서드입니다. 이 메서드는 각 Service에 존재하는 request 메서드들이 데이터를 어떤 형식으로 받을지에 대해 선택하고, statusCode와 DecodingMode에 따라 다른 결과값을 return합니다. decodingMode의 기본 타입은 .general로 되어 있습니다. 따라서 GeneralResponse 데이터 전체가 넘어가게 됩니다.

 

func judgeStatus<T: Codable>(by statusCode: Int, _ data: Data, type: T.Type, decodingMode: DecodingMode = .general) -> NetworkResult<Any> {
    let decoder = JSONDecoder()
    guard let decodedData = try? decoder.decode(GeneralResponse<T>.self, from: data)
    else { return .pathErr }

    switch statusCode {
    case 200..<300:

        switch decodingMode {
        case .model:
            return .success(decodedData.data ?? "None-Data")

        case .message:
            return .success(decodedData.message ?? "None-Data")

        case .general:
            return .success(decodedData)
        }

    case 400..<500:
        return .requestErr(decodedData.status)

    case 500:
        return .serverErr

    default:
        return .networkFail
    }
}

지금까지 Alamofire를 Moya처럼 사용하기 위해 case별로 request를 만들 수 있도록 하는 기초 요소들이 포함된 Foundation을 살펴봤습니다. 다음 편에서는 이렇게 구현한 Router와 BaseService를 채택하여 endpoint별 request를 만드는 Routers와 Services에 대해 알아보겠습니다!