[Swift] Combine + Moya + Alamofire 조합해서 Request Response 받아보기
1. 서론
iOS 개발의 세계에서 네트워킹은 거의 모든 애플리케이션의 필수적인 부분입니다. 사용자의 요구를 충족시키기 위해 데이터를 신속하게 가져오고, 적절하게 처리하며, 더 나은 사용자 경험을 제공하는 것은 모바일 앱의 성공 여부를 결정짓는 중요한 요소 중 하나입니다. 이러한 맥락에서, 우리는 다양한 네트워킹 솔루션과 그것들이 어떻게 서로 상호 작용하는지를 이해할 필요가 있습니다. 이번 글에서는 Moya, Alamofire, 그리고 Combine의 조합을 통해 어떻게 더 견고하고 관리 가능한 네트워킹 계층을 구축할 수 있는지에 대해 논의해보겠습니다.
1) Alamofire
먼저, "Alamofire"는 Swift 기반으로 작성된 HTTP 네트워킹 라이브러리입니다. 이 라이브러리는 표준적인 HTTP 메서드에 대한 지원, JSON, XML 같은 데이터 형식의 직렬화, 네트워크 통신 상태의 모니터링 등을 포함한 다양한 기능을 제공합니다. Alamofire는 그 자체로도 강력하지만, 때로는 더 높은 수준의 추상화가 필요할 수 있습니다.
Alamofire 공식 문서
"Combine"은 Apple이 제공하는 반응형 프로그래밍에 대한 Swift 프레임워크입니다. 데이터 처리와 응답, 사용자 인터페이스 업데이트 등을 스트림으로 관리할 수 있게 해주어 복잡성을 줄이고 가독성과 유지 관리의 용이성을 향상시킵니다.
2) Combine
Combine의 기본적인 사용
이제 "Moya"가 등장하는 이유에 대해 설명하겠습니다. Moya는 Alamofire를 기반으로 한 더 높은 수준의 추상화를 제공하는 네트워크 추상화 라이브러리입니다. 이는 개발자가 네트워크 계층을 더 추상적인 방식으로 처리할 수 있게 해주어, 실제 HTTP 메서드 대신 더 직관적인 API 호출에 집중할 수 있도록 도와줍니다.
3) Moya
Moya 사용 방법 및 장점
Moya를 사용함으로써, 우리는 특정 endpoint에 대한 raw한 HTTP 요청을 수동으로 구성할 필요 없이, 더 명확하고 관리하기 쉬운 방식으로 API 요청을 정의할 수 있습니다. 또한, Combine과 함께 사용하면 비동기 작업의 흐름을 더욱 명확하게 관리할 수 있어 에러 처리, 데이터 바인딩 등이 보다 편리해집니다.
이 글에서는 Moya의 구체적인 사용 사례를 통해, Alamofire의 강력한 기능을 유지하면서도 Combine의 반응형 프로그래밍 스타일과 어떻게 결합하여 사용할 수 있는지에 대해 상세히 알아보겠습니다. 이를 통해 네트워크 호출의 관리가 더욱 간결하고 직관적이며, 더 나은 사용자 경험을 제공하는 견고한 애플리케이션을 개발하는 데 도움이 될 것입니다.
모야를 왜쓰는가요? 라는 질문에 공식문서 일부를 번역해봤습니다.
임시 네트워크 계층은 iOS 앱에서 흔히 볼 수 있는데 몇 가지 이유로 좋지 않습니다:
- 새 앱을 쓰기가 어렵습니다
- 기존 앱을 유지 관리하는 것을 어렵게 합니다
- 단위 테스트를 작성하기가 어렵습니다
- 그래서 Moya의 기본 아이디어는 우리가 실제로 Alamofire를 직접 호출하는 것을 충분히 캡슐화한 네트워크 추상화 계층을 원한다는 것입니다. 일반적인 것들은 쉽지만 복잡한 것들도 쉽다는 것은 충분히 포괄적이어야 합니다.
Alamofire를 사용하여 URL 세션을 추상화하는 경우 URL, 매개 변수 등의 니티 그리티를 추상화하는 데 사용하는 것은 어떨까요?
모야의 놀라운 특징은 다음과 같습니다:
- 컴파일 시간 동안 올바른 API 엔드포인트 액세스를 확인합니다.
- 연관된 열거값으로 여러 엔드포인트의 명확한 사용을 정의할 수 있습니다.
- 테스트 스텝을 개별화 취급하여 유닛 테스트가 매우 쉽습니다.
예시 코드를 보여드리겠습니다. (자세한 설명은 코드 내 주석을 확인해주세요)
API는 [https://apifootball.com] 에서 받아와서 사용했습니다.
APIManager라는 네트워크 접근을 위한 구조체를 사용했습니다.
import Foundation
import Combine
import CombineMoya
import Moya
struct APIManager{
static var cancelable = Set<AnyCancellable>()
// https://apifootball.com/documentation/ 을 참조하여 만든 함수입니다.
static func requestPremierLeague() -> AnyPublisher<[TeamStat] , ErrorModel>{
Future<[TeamStat], ErrorModel> {promise in
let apis: ApisTeam = .premierLeague
let provider = MoyaProvider<ApisTeam>()
provider.requestPublisher(apis)
.sink(receiveCompletion: { completion in
switch completion{
case .finished:
print("RECEIVE VALUE COMPLETED")
case .failure:
print("RECEIVE VALUE FAILED")
}
}, receiveValue: { response in
let result = try? JSONDecoder().decode([TeamStat].self, from: response.data)
promise(.success(result!))
})
.store(in: &cancelable)
}.eraseToAnyPublisher()
}
}
// API 의 메서드를 정의 합니다.
enum ApisTeam{
case premierLeague
}
// Moya.TargetType의 프로토콜을 채택합니다.
extension ApisTeam: Moya.TargetType{
// Moya 프로토콜의 메서드 입니다. 기본 URL을 구현합니다. 주로 호스트 주소를 넣습니다.
var baseURL: URL{
switch self{
case .premierLeague:
return URL(string: "https://apiv3.apifootball.com")!
}
}
// Moya 프로토콜의 메서드 입니다. 엔드 포인트를 정의 합니다.
var path: String {
switch self {
case .premierLeague:
return "/"
}
}
// Moya 프로토콜의 메서드 입니다. 메서드를 정의 합니다.
var method: Moya.Method {
switch self {
case .premierLeague:
return .get
}
}
// Moya 프로토콜의 메서드 입니다. Request Body나 URL query를 주로 정의 합니다.
var task: Moya.Task {
switch self {
case .premierLeague:
var params: [String: Any] = [:]
params["action"] = "get_standings"
params["league_id"] = 152
params["APIkey"] = "fb3816f9f0f2efe99d043b02e3ba5ca263c2b8cda14e98afd7f8f76df4d7a129"
return .requestParameters(parameters: params, encoding: URLEncoding.default)
}
}
// Moya 프로토콜의 메서드 입니다. 헤더를 주로 정의 합니다
var headers: [String : String]? {
var header :[String:String] = [:]
switch self {
default:
header["Content-Type"] = "application/json"
}
return header
}
}
// 서버에서 받아온 모델을 구현 했습니다. Codable Protocol을 채택합니다. 칼럼이 상당히 많네요 :)
struct TeamStat: Codable {
let countryName: String
let leagueId: String
let leagueName: String
let teamId: String
let teamName: String
let overallPromotion: String
let overallLeaguePosition: String
let overallLeaguePayed: String
let overallLeagueW: String
let overallLeagueD: String
let overallLeagueL: String
let overallLeagueGF: String
let overallLeagueGA: String
let overallLeaguePTS: String
let homeLeaguePosition: String
let homePromotion: String
let homeLeaguePayed: String
let homeLeagueW: String
let homeLeagueD: String
let homeLeagueL: String
let homeLeagueGF: String
let homeLeagueGA: String
let homeLeaguePTS: String
let awayLeaguePosition: String
let awayPromotion: String
let awayLeaguePayed: String
let awayLeagueW: String
let awayLeagueD: String
let awayLeagueL: String
let awayLeagueGF: String
let awayLeagueGA: String
let awayLeaguePTS: String
let leagueRound: String
let teamBadge: URL
let fkStageKey: String
let stageName: String
enum CodingKeys: String, CodingKey {
case countryName = "country_name"
case leagueId = "league_id"
case leagueName = "league_name"
case teamId = "team_id"
case teamName = "team_name"
case overallPromotion = "overall_promotion"
case overallLeaguePosition = "overall_league_position"
case overallLeaguePayed = "overall_league_payed"
case overallLeagueW = "overall_league_W"
case overallLeagueD = "overall_league_D"
case overallLeagueL = "overall_league_L"
case overallLeagueGF = "overall_league_GF"
case overallLeagueGA = "overall_league_GA"
case overallLeaguePTS = "overall_league_PTS"
case homeLeaguePosition = "home_league_position"
case homePromotion = "home_promotion"
case homeLeaguePayed = "home_league_payed"
case homeLeagueW = "home_league_W"
case homeLeagueD = "home_league_D"
case homeLeagueL = "home_league_L"
case homeLeagueGF = "home_league_GF"
case homeLeagueGA = "home_league_GA"
case homeLeaguePTS = "home_league_PTS"
case awayLeaguePosition = "away_league_position"
case awayPromotion = "away_promotion"
case awayLeaguePayed = "away_league_payed"
case awayLeagueW = "away_league_W"
case awayLeagueD = "away_league_D"
case awayLeagueL = "away_league_L"
case awayLeagueGF = "away_league_GF"
case awayLeagueGA = "away_league_GA"
case awayLeaguePTS = "away_league_PTS"
case leagueRound = "league_round"
case teamBadge = "team_badge"
case fkStageKey = "fk_stage_key"
case stageName = "stage_name"
}
}
// 서버에서 받아온 모델을 에러 처리를 위해 구현 했습니다. Codable Protocol을 채택합니다.
struct ErrorModel: Codable, Error {
var code : String = ""
var msg : String? = ""
}
ViewModel을 만들어 SwiftUI의 뷰모델을 만들었습니다.
import Foundation
import Combine
class ViewModel: ObservableObject{
var cancelable = Set<AnyCancellable>()
@Published var team : [Team] = []
// 프리미어리그팀의 데이터를 불러오는 함수입니다.
func requestPremierLeagueData(){
APIManager.requestPremierLeague()
.sink(receiveCompletion: { result in
}, receiveValue: { values in
// APIManager에서 가져온 TeamStat(여기서는 values)를 Team의 배열에 넣습니다. (Mapping 하셔도 되고 For문으로 넣으셔도 상관없습니다. :)
for value in values {
let thisTeam = Team(teamName: value.teamName, stadings: value.overallLeaguePosition)
self.team.append(thisTeam)
}
})
.store(in: &cancelable)
}
}
// 제가 궁금한건 순위와 팀 이름 뿐이라 구조체를 작게 잡았습니다.
struct Team : Identifiable{
let id = UUID()
let teamName:String
let stadings:String
}
ContentView에 화면을 구현했습니다.
import SwiftUI
struct ContentView: View {
// 위에서 만들었던 뷰모델입니다.
@StateObject var vm = ViewModel()
var body: some View {
VStack {
// ForEach문으로 가져와 Text view로 렌더링 했습니다. 물론 더 이쁘게 꾸밀 수 있지만 Moya설명이 더 중요하니 대략적으로 했습니다.
ForEach(vm.team, id: \.id, content: {team in
Text("\(team.stadings) - \(team.teamName) ")
.padding(.horizontal, 20)
})
}
//뷰가 나타나면 뷰모델로부터 프리미어리그 데이터를 요청하는 함수를 실행시키도록 합니다.
.onAppear{
vm.requestPremierLeagueData()
}
.padding()
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
구현화면
결론
Moya와 Combine을 사용함으로서 더욱 코드 가독성이나, 코드 수정이 좀더 편해지지 않았을까 생각됩니다.
P.S 추가적으로 Moya를 SPM(Swift Package Manager)에서 등록을 하시면 Alamofire도 같이 포함되어있으니 따로 Alamofire를 추가 안하셔도 됩니다.