Swift를 사용하여 iOS 앱을 개발할 때 아주 유용한 라이브러리인 Moya에 대해 알아보자. 네트워크 작업을 처리하는 것은 앱 개발에서 필수적인 부분이지만, 이를 효율적이고 간단하게 처리하는 것은 쉽지 않다.
Moya 라이브러리를 사용해서 네트워크 Layer을 함축 시킬 수 있는데 어떻게 생겼는지 살펴보자.

Moya

Moya 인터페이스
Moya 인터페이스

JSON API 통신 규격을 잡아놓고, Swift에서 구조적으로 처리를 하고 싶은데 어떻게 해야할까 검색을 하다가 Moya라는 라이브리러 까지 오게되었다.
왼쪽 사진이 Moya을 적용하기 전 사진이다.
NetworkLayer 부분을 개발자가 구현해줘야한다.
이미 회사에서 사용중인 Layer가 있다면 그대로 따라하면 되지만, 처음부터 구성을 시작하다면 꽤 어려운 작업이다.

Moya는 네트워크 인터페이스 통신규격을 잡아놓고 사용한다고 생각하면 된다.
Moya를 사용하기 위해, 기본적인 규격을 생성해보자.

Swift API 기본 통신 규격을 만들어보자.

서버에서 받을 JSON API 규격은 다음과 같다.

{
	code: "",
	message: "",
	data: []
}

code, message는 String 타입으로,

data는 Swift에서 [String: Any]로 받는다고 생각한다.


호출 순서도

네트워크 기본 구조도

진행 작업

  1. ReturnCode Enum 구현: 서버에서 전달해주는 ReturnCode
  2. APIError Enum 구현: API통신 실패 시 클라이언트에서 사용할 Error 들
  3. APIResult Enum 구현: API통신 실패, 성공 시 전달하려는 result값
    1. APIDataType 구현: [String:Any]
  4. API Domain, EndpointAPI Enum, typealias 구현
  5. CommonApiModel 구현
  6. AlamofireManager 구현
  7. 모든 API통신에 사용되는 requestPost 구현
  8. 비지니스 Model 구현
  9. 도메인별 파라미터를 전달받는 메서드 구현
  10. 사용 예시
  11. 로딩 indicator 추가하기
  12. 로그 추가하기

1. ReturnCode Enum 구현

enum ApiReturnCode {
	static let case Success = "0000" // 성공 
	static let case Error   = "9999" // 실패 
}

2. APIError Enum 구현

enum ApiError {
	case error(String)

  var message: String {
		switch self {
			case let .error(msg):
				return msg
    }
	}
}

3. APIResult, APIDataType, typealias 구현

public typealias ApiDataType = [String: Any]
public typealias Parameters = [String: Any]
// typealias Completion = (_ result: Bool, _ data: ApiDataType?, _ error: ApiError?) -> Void
typealias Completion = (_ result: ApiResult<ApiDataType?>) -> Void)

enum ApiResult<T> {
	case success(T)
  case failure(ApiError)
}

4. API Domain, EndpointAPI Enum 구현

enum Api: String {
	case checkVersion, login
}

enum Domain: String {
	case real = "",
  case dev  = ""
}

5. CommonApiModel 구현

import UIKit

class CommonApiModel: Decodable {
	var data: ApiDataType?
  var resultCd = ""
  var msg = ""
 
  /* decodable을 사용하면 필요없음.
  func initWithResponseData(_ responseData: Dictionary<String, Any>?) {
        if let response = responseData {
            if let data = response["data"] as? Dictionary<String, Any> {
                self.data = data
            }
            
            if let resultCd = response["resultCd"] as? String {
                self.resultCd = resultCd
            }
            
            if let msg = response["msg"] as? String{
                self.msg = msg
            }
        }
    }
  */
}

6. AlamofireManager 구현

struct AlamofireManager {
    static let shared: SessionManager = {
        let configuration = URLSessionConfiguration.default
        configuration.timeoutIntervalForRequest = 10
        let session = Alamofire.SessionManager(configuration: configuration)
        return session
    }()
}

7. requestPost 공통 메서드 구현

private func requestPost(uri: Api, parameter: Parameter completion: @escaping Completion) {

  // uri  
  // parameter 설정
  // parameter 암호화
 
  // log 설정
  // alamofire 통신
  // response 데이터 복호화
  // response 데이터 Dictionary타입으로 변환
  let requestUri: String = self.domainUrl + uri.rawValue
 
	AlamofireManager.shared.request(requestUri,
                          method: .post,
                          parameters: parameter,
                          encoding: URLEncoding.httpBody,
                          headers: nil).responseJSON {
                            switch $0.result {
                            case .success(let data):
                                // let responseObject = CommonApiModel()
                                // responseObject.initWithResponseData(data as? Dictionary<String, Any>)
                                /// decodable 코드로 변경
                                do { 
	                                let response = try JSONDecoder().decode(CommonApiModel.self, from: data)
	
	                                if responseObject.resultCd == ApiReturnCode.Success {
	                                    completion(.success(responseObject.data))
	                                } else if responseObject.resultCd == ApiReturnCode.ErrorSession {
	                                    completion(.failure(.loginSessionError))
	                                } else {
	                                    completion(.failure(.kfcError(responseObject.resultCd, responseObject.msg)))
	                                }
                                } catch {
                                   completion(.failure(.error("try 구문 실패")))
                                }
                                break
                            case .failure(let error):
                                let error = error as NSError
                                completion(.failure(.httpError(error.code)))
                                print("Request failed with error: \\(error)")
                                break
                            }  
        }
 
}

8. 도메인별 파라미터를 전달받는 메서드 구현

func requestCheckVersion(osType: String, appVersion: String, completion: @escaping Completion) {
    let param: Parameters = ["osType": osType, "appVersion": appVersion]
    requestPOST(uri: .checkVersion, parameter: param, completion: completion)
}

→ 그럼 이제 NetworkManager.shared.requestCheckVersion을 호출해서,

  1. 전달받은 completion에 있는 Dictionary 데이터를 잘 쪼개서 도메인 Model Struct에 집어 넣어줌.(init 에서 dict: [String:Any]로 받아 처리해도됨.)
  2. Decodable이 가능한가?..
  3. 애초에 Dictionary를 전달하는 것이 아니라, 사용하는 도메인 Model을 던져주는 방법도 있음
func requestCheckVersion(osType: String, appVersion: String, completion: @escaping _ result: Appversion -> void) {
    let param: Parameters = ["osType": osType, "appVersion": appVersion]
    requestPOST(uri: .checkVersion, parameter: param, completion: completion)
}

— 여기까지가 기본적인 방법임. Moya를 사용하면 어떻게 되는지 확인을 해봐야겠다.

또다른 사용예시

RxSwift에서 <AppVersion>이렇게 콕 찝어서 정의해줘야하는가

static func checkVersion() -> Single<AppVersion> {
        guard let version = nowAppVersion() else {
            debugPrint(#fileID, #function, #line, NetworkError.noRequiredData)
            return .error(NetworkError.noRequiredData)
        }
        
        let parameters = ["OS_TYPE" : "I", "APP_VER" : version]
        debugPrint(#function, #line, " 도메인 url Endpoint:", parameters)
        return NetworkManager.shared.request(domain: NetworkManager.shared.domain, resource: "/도메인 url Endpoint", method: .post, parameters: parameters).flatMap { json in
            debugPrint(#function, #line, "도메인 url Endpoint :", json)
            guard let resultData = JSON(rawValue: json),
                  let msg = resultData["RESULT_MSG"].string else {
                      debugPrint(#fileID, #function, #line, "!", NetworkError.noRequiredData)
                      return .error(NetworkError.noRequiredData)
                  }
            
            guard let appState = resultData["APP_STATE"].string,
                  let marketUrl = resultData["MARKET_URL"].string else {
                      debugPrint(#fileID, #function, #line, ".도메인 url Endpoint")
                      return .error(NetworkError.responseNotSuccess(status: resultData["RESULT_CD"].string ?? "99999", errorMessage: msg))
                  }
            
            let appVersion = AppVersion(version: version, appState: appState, marketUrl: marketUrl, msg: msg)
            return .just(appVersion)
        }
    }

Moya는 어떻게 네트워크 통신을 구현하고 있을까

크게 protocol,class 2가지 네트워크 통신을 추상화 하고있다.

  • TargetType – protocol
  • MoyaProvider – class

TargetType: protocol에 implement 할 내용들을 적어 놨으니, 여기에 통신에 필요한 내용들을 작성해라

MoyaProvider: TargetType을 받아서, 통신을 해줄 class이다. 이 Provider을 사용하여 api call을 하면됨.

TargetType.png
TargetType
MoyaProvider.png
MoyaProvider
기본네트워크구조도.png
기본네트워크구조도
Moya 네트워크 통신 구조도.png
Moya 네트워크 통신 구조도

이렇게 2가지의 구조도를 비교해보면 Moya에 간단해보이긴하지만, 다시 서버 통신 규약에 맡게 파일을 구성하게 되면 똑같이 여러파일을 생성하긴 해야한다.

느낀점과 다른점이라하면,

  1. NetworkManager을 찢어 도메인별로 나눠놓은 느낌
  2. parameter를 Struct로 만들어서 관리를 한다는 점
  3. Auth, Logging 같은 플러그인을 사용할 수 있다는 점
  4. TargetType Protocol을 통해 구조가 미리 잡혀있어, 네트워크 구조 잡는게 어느정도 정해져 있긴하다.

Network 설계 주요 4가지 파일

Moya 네트워크 설계.png
Moya 네트워크 설계
  • NetworkLoggerPlugin: 네트워크 통신 시 MoyaProvider라는 객체를 통해 접근하는데, MoyaProvider의 파라미터 값으로 plugin객체를 넣어주면 해당 plugin기능을 사용 가능
    • authPlugin: bearer 토큰 세팅 전용의 플러그인
    • LoggerPlugin: response, request 로그를 확인할 수 있는 플러그인
  • Networkable: MoyaProvider 객체를 만들어서 리턴 (Target 타입, plugin 객체를 주입하여 생성)
  • NetworkError: Error프로토콜을 준수하고 있는 에러 정의용도 (response에서 사용)
  • ResponseData: 공통 response에 관하여 Codable로 정의하고 있고, success, failure에 관한 처리를 담당
  • BaseTargetType: Moya에서 제공하는 endpoint에 관해 enum으로 정의하여 편리하게 api호출을 할수 있게 하는 targetType의 base

Moya와 로딩뷰

플러그인을 통해 Moya에 로딩뷰를 추가 할 수도 있다.

  1. UIWindow를 받을 수 있는 Extension을 하나 만들어주자.
//
//  UIWindow+.swift
//  THFoundation
//
//  Created by inicis on 2022/07/27.
//

import UIKit

public extension UIWindow {
    
    /// [THFoundation]
    static var key: UIWindow? {
        
        if #available(iOS 13, *) {
            var window = UIApplication.shared.windows.first { $0.isKeyWindow }
            if #available(iOS 15, *) {
                if let sceneWindow = UIApplication.shared.connectedScenes
                    .compactMap({ $0 as? UIWindowScene})
                    .flatMap({ $0.windows })
                    .first(where: { $0.isKeyWindow }) {
                    window = sceneWindow
                }
                
            }

            return window
            
        }
        else {
            return UIApplication.shared.keyWindow
        }
        
    }
}
  1. 로딩 뷰를 window위에 올려주자.
//
//  LoadingView.swift
//  WaterRide
//
//  Created by Taehoon Kim on 2023/01/25.
//

import UIKit

final class LoadingView: UIView {
    static let shared = LoadingView()

    private let contentView: UIView = {
        let view = UIView()
        view.backgroundColor = .white
        view.alpha = 0
        return view
    }()
    
    private let activityIndicatorView: UIActivityIndicatorView = {
        let view = UIActivityIndicatorView(style: .large)
        view.translatesAutoresizingMaskIntoConstraints = false
        return view
    }()
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        self.backgroundColor = .black.withAlphaComponent(0.3)
        
        self.addSubview(self.contentView)
        self.contentView.addSubview(self.activityIndicatorView)
        
        self.contentView.snp.makeConstraints {
            $0.center.equalTo(self.safeAreaLayoutGuide)
        }
        self.activityIndicatorView.snp.makeConstraints {
            $0.center.equalTo(self.safeAreaLayoutGuide)
            $0.size.equalTo(300)
        }
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    func show() {
        guard let window = UIWindow.key else { return }
        guard !window.subviews.contains(where: { $0 is LoadingView }) else { return }
        
        print("로딩 뷰 호출 확인")
        window.addSubview(self)
        self.snp.makeConstraints {
            $0.edges.equalToSuperview()
        }
        self.layoutIfNeeded()
        
        self.activityIndicatorView.startAnimating()
        UIView.animate(
            withDuration: 0.7,
            animations: { self.contentView.alpha = 1 }
        )
    }
    func hide(completion: @escaping () -> () = {}) {
        self.activityIndicatorView.stopAnimating()
        self.removeFromSuperview()
        completion()
    }
}
  1. Moya에 networkActivityClosure Plugin을 추가해줍시다.
//
//  Networkable.swift
//  WaterRide
//
//  Created by Taehoon Kim on 2022/11/22.
//

import Moya

protocol Networkable {
    /// provider객체 생성 시 Moya에서 제공하는 TargetType을 명시해야 하므로 타입 필요
    associatedtype Target: TargetType
    /// DIP를 위해 protocol에 provider객체를 만드는 함수 정의
    static func makeProvider() -> MoyaProvider<Target>
}

extension Networkable {
    static func makeProvider() -> MoyaProvider<Target> {
        /// access token 세팅
        let authPlugin = AccessTokenPlugin { _ in
            return "bear-access-token-sample"
        }
        /// 로그 세팅
        let loggerPlugin = NetworkLoggerPlugin()
        
        let networkActivityClosure: NetworkActivityPlugin.NetworkActivityClosure = { change, arg  in
            switch change {
            case .began:
                DispatchQueue.main.async {
                    LoadingView.shared.show()
                }
            case .ended:
                DispatchQueue.main.async {
                    LoadingView.shared.hide()
                }
            }
        }
        let networkActivityPlugin = NetworkActivityPlugin(networkActivityClosure: networkActivityClosure)
        /// plugin객체를 주입하여 provider 객체 생성
        return MoyaProvider<Target>(plugins: [authPlugin, loggerPlugin, networkActivityPlugin])
    }
    
}

추천
2024 라즈베리 파이로 웹 서버 구축하기