Swift를 사용하여 iOS 앱을 개발할 때 아주 유용한 라이브러리인 Moya에 대해 알아보자. 네트워크 작업을 처리하는 것은 앱 개발에서 필수적인 부분이지만, 이를 효율적이고 간단하게 처리하는 것은 쉽지 않다.
Moya 라이브러리를 사용해서 네트워크 Layer을 함축 시킬 수 있는데 어떻게 생겼는지 살펴보자.
Moya
JSON API 통신 규격을 잡아놓고, Swift에서 구조적으로 처리를 하고 싶은데 어떻게 해야할까 검색을 하다가 Moya라는 라이브리러 까지 오게되었다.
왼쪽 사진이 Moya을 적용하기 전 사진이다.
NetworkLayer 부분을 개발자가 구현해줘야한다.
이미 회사에서 사용중인 Layer가 있다면 그대로 따라하면 되지만, 처음부터 구성을 시작하다면 꽤 어려운 작업이다.
Moya는 네트워크 인터페이스 통신규격을 잡아놓고 사용한다고 생각하면 된다.
Moya를 사용하기 위해, 기본적인 규격을 생성해보자.
Swift API 기본 통신 규격을 만들어보자.
서버에서 받을 JSON API 규격은 다음과 같다.
{
code: "",
message: "",
data: []
}
code, message는 String 타입으로,
data는 Swift에서 [String: Any]로 받는다고 생각한다.
호출 순서도
진행 작업
- ReturnCode Enum 구현: 서버에서 전달해주는 ReturnCode
- APIError Enum 구현: API통신 실패 시 클라이언트에서 사용할 Error 들
- APIResult Enum 구현: API통신 실패, 성공 시 전달하려는 result값
- APIDataType 구현: [String:Any]
- API Domain, EndpointAPI Enum, typealias 구현
- CommonApiModel 구현
- AlamofireManager 구현
- 모든 API통신에 사용되는 requestPost 구현
- 비지니스 Model 구현
- 도메인별 파라미터를 전달받는 메서드 구현
- 사용 예시
- 로딩 indicator 추가하기
- 로그 추가하기
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을 호출해서,
- 전달받은 completion에 있는 Dictionary 데이터를 잘 쪼개서 도메인 Model Struct에 집어 넣어줌.(init 에서 dict: [String:Any]로 받아 처리해도됨.)
- Decodable이 가능한가?..
- 애초에 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을 하면됨.
이렇게 2가지의 구조도를 비교해보면 Moya에 간단해보이긴하지만, 다시 서버 통신 규약에 맡게 파일을 구성하게 되면 똑같이 여러파일을 생성하긴 해야한다.
느낀점과 다른점이라하면,
- NetworkManager을 찢어 도메인별로 나눠놓은 느낌
- parameter를 Struct로 만들어서 관리를 한다는 점
- Auth, Logging 같은 플러그인을 사용할 수 있다는 점
- TargetType Protocol을 통해 구조가 미리 잡혀있어, 네트워크 구조 잡는게 어느정도 정해져 있긴하다.
Network 설계 주요 4가지 파일
- 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에 로딩뷰를 추가 할 수도 있다.
- 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
}
}
}
- 로딩 뷰를 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()
}
}
- 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])
}
}