[Library] Alamofire의 단점을 보완한 네트워킹 라이브러리, Moya

2021. 12. 17. 13:41Framework, Library

1️⃣ Moya?


보통 iOS에서 네트워킹을 구현할 때는 URLSession을 사용하게 된다.
그리고 그 URLSession을 이용한 네트워킹을 조금 더 간편하기 위해, 앞에서도 다룬 적이 있는 Alamofire 라이브러리를 보편적으로 사용하곤 했다.

하지만, 이 Alamofire 라이브러리는 코드의 유지보수와 유닛 테스트(각 부분마다 정확하게 동작하는지 확인하는 절차 -> 모든 함수 단위단위마다 로그를 찍어볼 수 없으니)가 힘들다는 단점이 있었다.

그래서 등장한 것이 바로 이 Moya 라이브러리이다.

Moya는 URLSession을 추상화한 Alamofire를, 다시 추상화한 라이브러리로 Network Layer를 템플릿 화해서 재사용성을 높이고, 개발자가 request, response에만 집중을 하도록 쉽게 말해 개발자를 도와주는 라이브러리라고 할 수 있다.
*잠깐! 여기서 말하는 추상화란 객체들의 공통된 부분만 따로 뽑아, 재사용을 하기 쉽도록 구현하는 것을 뜻한다. 

공식문서 상에는 장점을 아래와 같이 표현한다.

  • Compile-time checking for correct API endpoint accesses. (올바른 API 종료 시점 확인을 위한 컴파일 시간 체크)
  • Lets you define a clear usage of different endpoints with associated enum values. (열거형 사용으로 type-safe 만족)
  • Treats test stubs as first-class citizens so unit testing is super-easy. (유닛 테스트가 용이함)
 

GitHub - Moya/Moya: Network abstraction layer written in Swift.

Network abstraction layer written in Swift. Contribute to Moya/Moya development by creating an account on GitHub.

github.com

 

아무튼 라이브러리라는 것이 기존의 것보다 조금 더 편하고 효율적으로 코드를 구성하기 위해 존재하는 것인 만큼, 정확하게 어떤 점이 좋은지는 차차 사용하면서 알아가기로 하고.

이 글에서는 일단 Moya 깃허브 공식문서에 써있는 가이드라인을 따르면서,
실제 개발했던 명함 어플의 아래 예시(실제 API 문서에서 조금 더 쉽게 변형했다.)들로 GET, POST, PUT, DELETE 4가지 통신이 각각 Moya를 사용했을 때 어떻게 이루어질 수 있는지에 대해 살펴보는 식으로 설명해 보겠다. 
예시로 만들어진 API 문서는 아래와 같으니, 밑에서 구현하게 될 코드에서 각각이 어떻게 사용되었는지 직접 확인해볼 수 있겠다.

HTTP Method URI Request Header Request-params Request-query Request-Body
GET ~/card/:card-id x card-id: string x x
POST ~/card multipart-form x x 내가 보낼 데이터
PUT ~/cards application/json x x 내가 보낼 데이터
DELETE ~/card/:card-id x card-id: string x x

 

 

2️⃣ ~Service 파일 : API를 열거형으로 선언하고, TargetType를 채택한 Extension이 구현되어 있는 곳


먼저, Moya의 두 번째 장점으로 써있던 enum을 선언해 주겠다.
enum형 각각의 case는 개별적인 네트워크 통신 (즉, API 한 개씩)을 담당하게 된다.

열거형 case 각각마다 들어갈 파라미터는 RequestHeader를 제외한, 클라이언트가 서버에 보내야 하는 데이터들이 들어가겠다.
즉, Request-Params, Request-query, Request-body에 들어갈 자료가 파라미터로 들어간다고 생각하면 된다. (처리는 아래에서 해줄 거야!)

enum CardService {
    case cardDetailFetch(cardID: String)				// GET
    case cardCreation(request: CardCreationRequest, image: UIImage)	// POST
    case cardListEdit(request: CardListEditRequest)			// PUT
    case cardDelete(cardID: String)					// DELETE
}

 

이제 해당 Service 파일의 TargetType 프로토콜을 준수하는 Extension을 추가하고, 필요한 속성들을 추가해 줄 거다.

TargetType 프로토콜을 채택함으로써, 아래와 같이 다양한 네트워킹 속성을 지정할 수 있게 된다.
아래에서 자세하게 어떤 네트워킹 속성들이 있는지 확인해 보고 이를 각 API 문서에 맞춰서 어떻게 처리해 줬는지도 확인해 보자.

baseURL (필수) 서버통신의 기본이 되는 base URL 주소 (API 문서 상 각 부분 ~/에 해당)
path (필수) 서버의 base URL 뒤에 추가될 Path (각 API 별로 다르겠다.)
method (필수) HTTP Method (GET, POST, PUT, DELETE 등을 지정해주는 부분!)
task (필수) request에 사용되는 파라미터를 설정해주는 부분 (이 부분은 아래 3️⃣에서 더 추가로 설명한다)
headers (필수) HTTP headers (Request Header에 해당)
sampleData 테스트용 Mock Data (테스트를 위한 목업 데이터를 제공할 때 사용) 
validationType 허용할 response의 타입 
extension CardService: TargetType {
    
    var baseURL: URL { return 기본 URL주소 }
    
    var path: String {
        switch self {
        case .cardDetailFetch(let cardID):
            return "/card/\\(cardID)"
        case .cardCreation:
            return "/card"
        case .cardListEdit:
            return "/cards"
        case .cardDelete(let cardID):
            return "/card/\\(cardID)"
        }
    }
    
    var method: Moya.Method {
        switch self {
        case .cardDetailFetch:
            return .get
        case .cardCreation:
            return .post
        case .cardListEdit:
            return .put
        case .cardDelete:
            return .delete
        }
    }
    
    var task: Task {
        switch self {
        case .cardDetailFetch, .cardDelete:
            return .requestPlain
        case .cardCreation(let request, let image):
            var multiPartData: [Moya.MultipartFormData] = []      
            let userIDData = request.userID.data(using: .utf8) ?? Data()
            multiPartData.append(MultipartFormData(provider: .data(userIDData), name: "card.userId"))
            let defaultImageData = Int(request.frontCard.defaultImage).description.data(using: .utf8) ?? Data()
            multiPartData.append(MultipartFormData(provider: .data(defaultImageData), name: "card.defaultImage"))
             "card.thirdTMI"))
        
            return .uploadMultipart(multiPartData)
        case .cardListEdit(let requestModel):
            return .requestJSONEncodable(requestModel)
        }
    }
    
    var headers: [String: String]? {
        switch self {
        case .cardDetailFetch, .cardDelete:
            return .none
        case .cardCreation:
            return ["Content-Type": "multipart/form-data"]
        case .cardListEdit:
            return ["Content-Type": "application/json"]
        }
    }
}

 

 

3️⃣ 잠깐! TargetType 속 task에 대해서도 자세하게 알아보고 갈까?


TargetType 프로토콜에서 지정해 준 속성들에 대해 가장 중요하면서, 위의 내용만으로는 설명이 부족한 부분이 바로 Task일 것이다.

Task를 위에서는 request에 사용되는 파라미터를 설정해 주는 부분이라고 설명했는데, 더 쉽게 말하자면 "Request-body와 Request-query 데이터를 어떻게 전송할 것인지"를 설정하는 부분이라고 할 수 있다.
나도 아직 자세하게는 모르는 부분이 있지만, task로 보내줄 수 있는 형태가 어떤 것이 있는지 아래에서 확인해 보자! (주로 사용하는 부분을 빨간색으로 표시했다.)

.requestPlain 추가로 보낼 데이터가 없을 경우 (보낼 내용이 없거나, request 파라미터 방식으로 이미 URl에 데이터를 보냈을 때)
.requestData(Data) 데이터가 전달될 것으로 예상할 때 사용
.requestJSONEncodable(Encodable) JSON 인코딩 가능한 개체가 전달될 것으로 예상할 때 사용.
.requestCustomJSONEncodable(Encodable, encoder: JSONEncoder) JSON 인코딩 가능한 개체가 전달되고, 자신만의 인코딩 유형을 정의할 때 사용
.requestParameters(parameters: [String: Any], encoding: ParameterEncoding) 자신만의 인코딩 유형대로 정의된 파라미터를 전달할 때 사용
*query로 요청할 때, encoding 유형을 URLEncoding.queryString으로 지정하게 될 것이다.
.requestCompositeData(bodyData: Data, urlParameters: [String: Any]) 데이터를 request body로 전달하려는 경우와 urlParameters를 동시에 전달하려는 경우에 사용
*request query와 body 두 개 모두가 필요할 때 사용할 수 있을 것이다.
.requestCompositeParameters(bodyParameters: [String: Any], bodyEncoding: ParameterEncoding, urlParameters: [String: Any]) 위와 동일하지만 대신 bodyParameter를 사용하는 경우
.uploadFile(URL) URL로 되어있는 파일을 업로드할 때 사용
.uploadMultipartFormData(MultipartFormData)
multipart/form-data를 업로드할 때 사용
uploadCompositeMultipartFormData(MultipartFormData, urlParameters: [String: Any])
매개 변수가 있는 멀티파트를 업로드하는 데 사용
downloadDestination(DownloadDestination)
대상에 대한 파일 다운로드 작업에서 사용
downloadParameters(parameters: [String: Any], encoding: ParameterEncoding, destination: DownloadDestination) 이름에서 알 수 있듯이 필요한 경우 매개 변수를 다운로드하고 전달하는 데 사용

 

 

4️⃣ ~API 파일 : 서버 통신을 위해 호출하는 함수들이 모여있는 곳


위에서 만들었던 TargetType 프로토콜을 준수하는 열거형으로 선언된 API 각각은 이제 서버 통신을 위한 모든 준비가 끝난 상태다.
이제는 서버 통신을 할 때 호출할 함수만 만들어주기만 하면 된다.

서버 통신을 할 때 호출하는 함수들을 "~API" 이름으로 끝나는 클래스에다가 모아둘 것이다. 지금부터 설명하는 코드들은 public 수준의 클래스에서 작성되는 내용들이다. 한 줄씩 친절하게 설명해 주겠다.

 

<4-1. 인스턴스에서 공통으로 사용되는 부분> 

먼저, static을 사용한 shared 이름의 CardAPI 객체의 싱글턴 인스턴스를 만들어줬다. 
그 이유인즉슨, 정작 서버 통신을 해야 하는 뷰컨트롤러에서 해당 인스턴스에 아래에서 만들어줄 네트워크 통신 메서드에 접근을 할 수 있도록 만들기 위해서다. (아래 뷰컨트롤러 코드 부분에서 사용되는 것을 확인할 수 있을 거다.)

import Foundation
import Moya

public class CardAPI {
    static let shared = CardAPI()    
    var cardProvider = MoyaProvider<CardService>(plugins: [MoyaLoggerPlugin()])
    
    public init() { }
    
    ...
}

다음으로 MoyaProvider 인스턴스를 생성해 줬다.
Moya Provider는 제네릭 타입 (어떤 타입이라도 들어올 수 있는 공간)으로 위에서 만들었던 TargetType 프로토콜을 준수하는 열거형을 받고 있으며, 플러그인을 파라미터로 받고 있다는 것을 확인할 수 있다.

플러그인이란 쉽게 말해, prepare, willSend, didReceive, process 등 네트워크 과정에서 발생하는 모든 작업들을 사용자가 알아볼 수 있게 콘솔에 기록해 주는 것이다.
Moya에서 제공해 주는 메서드는 아래 링크에 있으니 확인해 볼 수 있도록 하자.

 

GitHub - Moya/Moya: Network abstraction layer written in Swift.

Network abstraction layer written in Swift. Contribute to Moya/Moya development by creating an account on GitHub.

github.com

아무튼 이 Plugin을 아래와 같이 커스텀해서 사용한다면,
네트워크 통신 중(Request를 보낼 때, Response가 왔을 때, 통신이 성공했을 때, 통신이 실패했을 때와 같이)에 발생하는 모든 작업을 로그에서 확인할 수 있다는 것이 특징이다. Moya 장점에 해당하는 부분이라고 볼 수 있다!

import Foundation
import Moya
import UIKit

final class MoyaLoggerPlugin: PluginType {
    // Request를 보낼 때 호출
    func willSend(_ request: RequestType, target: TargetType) {
        guard let httpRequest = request.request else {
            print("--> 유효하지 않은 요청")
            return
        }
        let url = httpRequest.description
        let method = httpRequest.httpMethod ?? "unknown method"
        var log = "----------------------------------------------------\n[\(method)] \(url)\n----------------------------------------------------\n"
        log.append("API: \(target)\n")
        if let headers = httpRequest.allHTTPHeaderFields, !headers.isEmpty {
            log.append("header: \(headers)\n")
        }
        if let body = httpRequest.httpBody, let bodyString = String(bytes: body, encoding: String.Encoding.utf8) {
            log.append("\(bodyString)\n")
        }
        log.append("------------------- END \(method) --------------------------")
        print(log)
    }
    
    // Response가 왔을 때 호출
    func didReceive(_ result: Result<Response, MoyaError>, target: TargetType) {
        switch result {
        case let .success(response):
            onSuceed(response, target: target, isFromError: false)
        case let .failure(error):
            onFail(error, target: target)
        }
    }
    
    // Network 통신이 성공했을 때 호출
    func onSuceed(_ response: Response, target: TargetType, isFromError: Bool) {
        let request = response.request
        let url = request?.url?.absoluteString ?? "nil"
        let statusCode = response.statusCode
        
        var log = "------------------- 네트워크 통신 성공(isFromError: \(isFromError)) -------------------"
        log.append("\n[\(statusCode)] \(url)\n----------------------------------------------------\n")
        log.append("API: \(target)\n")
        response.response?.allHeaderFields.forEach {
            log.append("\($0): \($1)\n")
        }
        if let reString = String(bytes: response.data, encoding: String.Encoding.utf8) {
            log.append("\(reString)\n")
        }
        log.append("------------------- END HTTP (\(response.data.count)-byte body) -------------------")
        print(log)
    }
    
    // Network 통신이 실패했을 때 호출
    func onFail(_ error: MoyaError, target: TargetType) {
        if let response = error.response {
            onSuceed(response, target: target, isFromError: true)
            return
        }
        var log = "네트워크 오류"
        log.append("<-- \(error.errorCode) \(target)\n")
        log.append("\(error.failureReason ?? error.errorDescription ?? "unknown error")\n")
        log.append("<-- END HTTP")
        print(log)
        
        let alertViewController = UIAlertController(title: "네트워크 연결 실패", message: "네트워크 환경을 한번 더 확인해주세요.", preferredStyle: .alert)
        alertViewController.addAction(UIAlertAction(title: "확인", style: .default, handler: nil))
        
    }
}

 

<4-2. 서버 통신 함수> 

카드의 상세정보를 불러오는 get 네트워크 통신 메서드 cardDetailFetch를 구현한다고 가정하고 설명해 보겠다. (POST든 PUT이든 DELETE든 이 과정의 큰 틀은 동일하고, 자료형과 파라미터 정도만 편한다고 생각하면 된다!)

먼저 completion을 @escaping (escaping closure)로 정의하고 있다는 것을 확인할 수 있다. (아래아래 코드 확인!)
탈출 클로저를 사용한다는 것은 해당 네트워크 통신이 끝났을 때 (그니까 cardDetailFetch 함수가 종료되었을 때) completion 클로저에 네트워크의 결과(NetworkResult 바로 아래 코드 첨부)를 담아 호출한다는 것이라고 생각하면 된다. 

탈출 클로저에 대한 자세한 내용이 궁금하다면, 아래 써둔 Closure 글을 참고하길 바란다.

 

[Swift] #2 - Closure 완전 정복하기: 다양한 작성법부터 @escaping까지

1️⃣ 클로저(Closure)란? 솝트에서 서버 통신을 배우다가 마주친 어려운 개념 2개가 있다. 그중 하나가 Escaping Closure(탈출 클로저)였는데 (당연히, 클로저를 모르는데 탈출 클로저를 듣는다고 이해

mini-min-dev.tistory.com

import Foundation

enum NetworkResult<T> {
    case success(T)                 // 서버 통신 성공
    case requestErr(T)              // 요청 에러 발생
    case pathErr                    // 경로 에러
    case serverErr                  // 서버의 내부적 에러
    case networkFail                // 네트워크 연결 실패
}

자 이제 진짜 서버 통신 함수를 살펴보자.
Moya는 provider.request로 요청을 보낸다. (다시 위의 내용 가져오면, Moya Provider는 제네릭 타입 (어떤 타입이라도 들어올 수 있는 공간)으로 위에서 만들었던 TaegetType 프로토콜을 준수하는 열거형이었다!)

통신이 완료되면, 클로저를 통해 result라는 이름으로 결과가 도착하게 되고,
success일 경우에는 response에 들어있는 statusCode와 data, 그리고 원하는 타입까지 다시 한번 judgeStatus 함수에 실어서 보내게 될 것이다.
failure일 경우에는 가차 없이 err를 담아서 뷰컨으로 날려주기만 하면 된다. 

import Foundation
import Moya

public class CardAPI {
	...
    func cardDetailFetch(cardID: String, completion: @escaping (NetworkResult<Any>) -> Void) {
        cardProvider.request(.cardDetailFetch(cardID: cardID)) { (result) in
            switch result {
            case .success(let response):
                let statusCode = response.statusCode
                let data = response.data
                let networkResult = self.judgeStatus(by: statusCode, data: data, type: Card.self)
                completion(networkResult)
                
            case .failure(let err):
                print(err)
            }
        }
    }
	...
}

 

<4-3. judgeStatus 함수> 

성공했을 경우 넘어오는 judgeStatus 함수를 살펴보자.
위에서도 봤듯이 statusCode와 data를 받아와서 그에 맞는 NetworkResult를 반환하는 함수라는 것을 확인할 수 있다.

다시 기본 개념으로 돌아오면, 서버에서 클라로 넘어오는 데이터는 JSON 형태이므로 이를 해독하기 위한 JSONDecoder() 인스턴스를 하나 선언해줘야 한다. 그리고 해당 decoder를 이용해서 내가 원하는 타입(제네릭 타입으로 들어온 값)으로 decoder를 해주는 것이다.
*이 코드에 써있는 GenericResponse는 공통으로 전해지는 형태 code, message, data를 struct형으로 선언해 준 거에 불과하다.

그리고 각 statusCode에 따라 통신 결과를 반환해 주면 된다!
성공의 경우 decodedData.data로 JSON 형태로 전달받은 값이 내가 원하는 stuct로 반환된 상태까지 만들어져서 return 된다.

 

import Foundation
import Moya

public class CardAPI {
	...
    private func judgeStatus<T: Codable>(by statusCode: Int, data: Data, type: T.Type) -> NetworkResult<Any> {
        let decoder = JSONDecoder()
        guard let decodedData = try? decoder.decode(GenericResponse<T>.self, from: data)
        else { return .pathErr }
     
        switch statusCode {
        case 200:
            return .success(decodedData.data ?? "None-Data")
        case 400..<500:
            return .requestErr(decodedData.msg)
        case 500:
            return .serverErr
        default:
            return .networkFail
        }
    }
}

 

 

5️⃣ ViewController에서는 어떻게 사용할까?


이제 뷰컨에서 최종적으로 만들어준 함수를 어떻게 호출하는지 코드로 확인해보자!

아까 static을 사용한 shared 이름의 CardAPI 객체의 싱글턴 인스턴스를 통해 서버 통신 메서드까지 뷰컨에서 접근이 가능해진 것을 확인할 수 있다.
함수를 호출하면, response값을 받아올 수 있고, response의 success가 반환될 경우에 이제부터 이 아래 부분은 자유롭게 데이터 바인딩을 해주는 식으로 처리를 해주기만 하면 끝이다!

  private func cardDetailFetchWithAPI(cardID: String, completion: @escaping (Card) -> Void) {
        CardAPI.shared.cardDetailFetch(cardID: cardID) { response in
            switch response {
            case .success(let data):
                if let cardDataModel = data as? Card {
                    // 자유롭게 원하는 동작을 추가
                }
                print("cardDetailFetchWithAPI - success")
            case .requestErr(let message):
                print("cardDetailFetchWithAPI - requestErr", message)
            case .pathErr:
                print("cardDetailFetchWithAPI - pathErr")
            case .serverErr:
                print("cardDetailFetchWithAPI - serverErr")
            case .networkFail:
                print("deleteGroupWithAPI - networkFail")
            }
        }
    }