iOS ํ๋ก์ ํธ์์ ๋คํธ์ํฌ ํต์ ์ ๋ ํธํ๊ฒ ๊ด๋ฆฌํ๊ธฐ ์ํด ์ฌ์ฉํ๋ Moya ๋ผ์ด๋ธ๋ฌ๋ฆฌ!
ํด๋น ๊ธ์ ํจ๊ป ํ๋ก์ ํธ๋ฅผ ์งํํ๋ ํ์๋ค์ ์ํด ์์ฑ๋์์ต๋๋ค โ๏ธ
Moya๊ฐ ๋ชจ์ผ? ๐คทโ๏ธ
๊ฐ๋จํ ๋งํด์… URLSession์ ๋ ๊น๋ํ๊ณ ๊ตฌ์กฐ์ ์ผ๋ก ์ฐ๊ฒ ํด์ฃผ๋ ๋คํธ์ํฌ ์ถ์ํ ๋ผ์ด๋ธ๋ฌ๋ฆฌ์์!
์์ฒญ ๋ง๋ค๊ธฐ / ์๋ต ์ฒ๋ฆฌ / ์๋ฌ ํธ๋ค๋ง / ๋ก๊ทธ ์ถ๋ ฅ ๊ฐ์ ๊ฒ๋ค์ ๋ณด๊ธฐ ์ข๊ณ ์ผ๊ด์ฑ ์๊ฒ ๊ด๋ฆฌํ ์ ์์ด์.
๊ทธ๋ผ ์ด์ ๋ถํฐ ๋คํธ์ํฌ ์ธํ
๊ตฌ์กฐ๋ฅผ ํ๋์ฉ ์ดํด๋ณผ๊น์ ? ๐
๐ 1. BaseTargetType — ๊ณตํต ์ค์ ๋ชจ์๋๊ธฐ
//
// BaseTargetType.swift
//
import Foundation
import Moya
protocol BaseTargetType: TargetType { }
extension BaseTargetType{
var baseURL: URL {
return URL(string:"<https://melon.dhxxn.dev>")!
}
var headers: [String : String]? {
let header = [
"Content-Type": "application/json"
]
return header
}
var sampleData: Data {
return Data()
}
}
๋ชจ๋ API ์์ฒญ์ ๊ธฐ๋ณธ URL, ํค๋, sampleData๋ฅผ ํ ๋ฒ์ ์ ์ํด๋๊ณ ๊ฐ API enum์ด ์ ํ๋กํ ์ฝ์ ์ฑํํด์ ์ฌ์ฉํฉ๋๋ค!
๐ 2. GenericResponse — ๋ชจ๋ API ๊ณตํต ์๋ต ๊ตฌ์กฐ
//
// GenericResponse.swift
//
import Foundation
struct GenericResponse<T: Decodable>: Decodable {
let isSuccess: Bool
let code: String
let message: String
let result: T
}
์ฐ๋ฆฌ ์๋ฒ๋ ๋ชจ๋ ์๋ต์ด ์ด ๊ตฌ์กฐ๋ก ์ค๊ธฐ ๋๋ฌธ์ result ์์ ์ฐ๋ฆฌ๊ฐ ์ํ๋ ์ค์ ๋ฐ์ดํฐ๊ฐ ๋ค์ด ์์ด์.
๐ 3. NetworkError — ์๋ฌ ํ์ ์ ์
//
// NetworkError.swift
//
import Foundation
public enum NetworkError: Error {
case decoding
case unauthorized
case forbidden
case notFound
case serverError(String)
case networkFail
case unknown
}
์ฝ๋๊ฐ ๊ธธ์ด์ง๋ ๊ฑธ ๋ฐฉ์งํ๊ธฐ ์ํด ์์ฃผ ๋์ค๋ ์๋ฌ๋ฅผ Enum์ผ๋ก ๋ฑ ์ ๋ฆฌํด๋ ๊ตฌ์กฐ์์.
๋ก๊ทธ ์ถ๋ ฅํ ๋๋ ์ ์ฉํ๊ฒ ์ฐ์ธ๋ต๋๋น
๐ 4. NetworkLogger — API ์์ฒญ/์๋ต ๋ก๊ทธ ์ถ๋ ฅ
//
// NetworkLogger.swift
//
import Foundation
import Moya
final class NetworkLogger: PluginType {
// MARK: - API Calls
func willSend(_ request: RequestType, target: TargetType) {
guard let req = request.request else {
print("โ [REQUEST] Invalid request")
return
}
print("\\n====== ๐ค Request ======")
print("โก๏ธ URL: \\(req.url?.absoluteString ?? "nil")")
print("โก๏ธ METHOD: \\(req.httpMethod ?? "nil")")
if let headers = req.allHTTPHeaderFields, !headers.isEmpty {
print("โก๏ธ HEADERS:")
headers.forEach { key, value in
print(" \\(key): \\(value)")
}
}
if let body = req.httpBody,
let pretty = prettyJSONString(from: body) {
print("โก๏ธ BODY:\\n\\(pretty)")
}
print("========================\\n")
}
func didReceive(_ result: Result<Response, MoyaError>, target: TargetType) {
print("\\n====== ๐ฅ Response ======")
switch result {
case .success(let response):
print("โฌ
๏ธ STATUS: \\(response.statusCode)")
print("โฌ
๏ธ URL: \\(response.request?.url?.absoluteString ?? "nil")")
if let pretty = prettyJSONString(from: response.data) {
print("โฌ
๏ธ BODY:\\n\\(pretty)")
} else {
print("โฌ
๏ธ BODY: (empty or non-readable)")
}
case .failure(let error):
print("โ ERROR:", error.localizedDescription)
if let data = error.response?.data,
let pretty = prettyJSONString(from: data) {
print("โ ERROR BODY:\\n\\(pretty)")
}
}
print("========================\\n")
}
// MARK: - Private Methods
private func prettyJSONString(from data: Data) -> String? {
guard
let object = try? JSONSerialization.jsonObject(with: data),
let prettyData = try? JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted]),
let prettyString = String(data: prettyData, encoding: .utf8)
else { return nil }
return prettyString
}
}
์ด๋ ๊ฒ ํ๋ฉด ์ฝ์์์ ์๋์ฒ๋ผ ๊ฐ๋
์ฑ ์ข์ ๋คํธ์ํฌ ๋ก๊ทธ๋ฅผ ๋ณผ ์ ์์ด์ ๐


๐ 5. NetworkProvider — ์ค์ ์์ฒญ์ ๋ด๋นํ๋ ํต์ฌ ํด๋์ค
//
// NetworkProvider.swift
//
import Foundation
import Moya
final class NetworkProvider<API: TargetType> {
// MARK: - API Calls
static func request<T: Decodable>(
_ target: API,
type: T.Type,
completion: @escaping (Result<T, NetworkError>) -> Void
) {
let provider = MoyaProvider<API>(plugins: [NetworkLogger()])
provider.request(target) { result in
switch result {
case .success(let response):
switch response.statusCode {
case 200...299:
break
case 401:
completion(.failure(.unauthorized))
return
case 403:
completion(.failure(.forbidden))
return
case 404:
completion(.failure(.notFound))
return
case 500...599:
let msg = String(data: response.data, encoding: .utf8) ?? "Server Error"
completion(.failure(.serverError(msg)))
return
default:
completion(.failure(.unknown))
return
}
do {
let decoded = try JSONDecoder().decode(GenericResponse<T>.self, from: response.data)
completion(.success(decoded.result))
} catch {
completion(.failure(.decoding))
}
case .failure:
completion(.failure(.networkFail))
}
}
}
}
์ฌ๊ธฐ์ ํ๋ ์ผ์ ๋ฑ ๋ ๊ฐ์ง:
- MoyaProvider๋ฅผ ์์ฑํ๊ณ
- ์๋ต์ ๋์ฝ๋ฉ → ์ฑ๊ณต/์๋ฌ๋ก ๋๊ฒจ์ค
๋คํธ์ํฌ ๊ณ์ธต์์ ๊ฐ์ฅ ์ค์ํ ํ์ผ์ด๊ณ , ์๋น์ค ๋จ์์๋ ์ด Provider๋ง ๋ถ๋ฌ์ ๋ฐ์ดํฐ ์์ฒญํ๋ฉด ๋์ด์์.
๊ทธ๋์ API ์ฐ๊ฒฐ ์ด์ผ ํ๋๋์ ใ ใ …

ใด ๋ผ๊ณ ์๊ฐํ์ค ์ฌ๋ฌ๋ถ์ ์ํ ๊ฐ์ด๋๋ผ์ธ์ ๋๋ค!
์ฐจ๊ทผ์ฐจ๊ทผ ํ๋์ฉ ์์๋ด ์๋น ใ ใ
์๋ฒ ํํธ์์ด ์ ๊ณตํด์ค API ๋ช ์ธ์๋ Swagger๋ฅผ ํตํด ๊ฐ API์ path, method, request/response ๊ตฌ์กฐ๋ฅผ ๋จผ์ ํ์ธํด์ค๋๋ค!
//
// MixUpAPI.swift
//
import Foundation
import Moya
enum MixUpAPI {
case fetchMixUp
}
extension MixUpAPI: BaseTargetType {
var path: String {
switch self {
case .fetchMixUp:
return "/api/v1/music/mixup"
}
}
var method: Moya.Method {
return .get
}
var task: Task {
return .requestPlain
}
}
์ฌ๋ฌ API๊ฐ ์์ฌ ์๋ ํ๋ฉด์ด๋ผ๋ฉด… ์ ๋ API ํ์ผ์ ์ฌ๋ฌ๊ฐ ๋ง๋ค์ง ๋ง..
๋์ ํ๋ฉด ์ ์ฉ API enum ํ๋ ๋ง๋ค๊ณ → ๊ทธ ์์์ ํ์ํ API๋ค์ case๋ก ๋๋ ์ ๊ด๋ฆฌํด๋ณด์ธ์!
์๋๋ ์์ ์ฝ๋์
๋๋ค!!!
//
// HomeAPI.swift
//
import Foundation
import Moya
enum HomeAPI {
case fetchBanner
case fetchPopular
case fetchLatest
case fetchPersonalized(userId: Int)
}
extension HomeAPI: BaseTargetType {
var path: String {
switch self {
case .fetchBanner:
return "/api/v1/home/banner"
case .fetchPopular:
return "/api/v1/home/popular"
case .fetchLatest:
return "/api/v1/home/latest"
case .fetchPersonalized(let userId):
return "/api/v1/users/\\(userId)/personalized"
}
}
var method: Moya.Method {
switch self {
case .fetchBanner, .fetchPopular, .fetchLatest, .fetchPersonalized:
return .get
}
}
var task: Task {
switch self {
case .fetchPersonalized:
return .requestPlain
default:
return .requestPlain
}
}
}
์ด์ ๋ ์๊น ๋งํ DTO ์์ฑํ๊ธฐ~~ ๋ช ์ธ๋ณด๊ณ ์์ฑํด์ฃผ๋ฉด ๋ฉ๋๋ค!
//
// MixUpDTO.swift
//
import UIKit
struct MixUpDTO: Decodable {
let id: Int
let title: String
let artistName: String
let playCount: Int
let country: String
let imageUrl: String
}
๊ทธ๋ฆฌ๊ณ ์ด์ ์ค์ํ Service ํ์ผ์ ๋๋ค~!
์ด ์น๊ตฌ๋ ๋ง ๊ทธ๋๋ก "API ํธ์ถ๋ง ๋ด๋นํ๋ ๋ก์ง"์ ๋ชจ์๋๋ ๊ณณ์ด์์.
//
// MixUpService.swift
//
import Foundation
final class MixUpService {
func fetchSongs() async throws -> [MixUpDTO] {
return try await withCheckedThrowingContinuation { continuation in
NetworkProvider<MixUpAPI>.request(
.fetchMixUp,
type: [MixUpDTO].self
) { result in
switch result {
case .success(let data):
continuation.resume(returning: data)
case .failure(let error):
continuation.resume(throwing: error)
}
}
}
}
}
์์์ฝ๋๊ฐ ์ฐ์ธ ํ๋ก์ ํธ์์๋ MVC ํจํด์ ์ฌ์ฉํ๋๋ฐ์~
๊ทธ๋์ ๋ทฐ์ปจ์์๋ UI ๋ก์ง๋ง ๋ด๋นํ๊ณ , ๋คํธ์ํฌ ํต์ ์ ์ ๋ถ Service๊ฐ ๋์ ์ฒ๋ฆฌํด์ฃผ๊ณ ์์ต๋๋ค!
์ด์ ๋คํธ์ํฌ ํด๋์์ ๊ตฌํํ ํ์ผ๋ค์ ์ ๋ถ ๊ตฌํํ์ต๋๋ค…!!
๋ค์ presentation ํด๋์ VC๋ก ๋์์๋ณผ๊น์?
๋ทฐ์ปจ์ ์ ์ฝ๋๋ค์ ์ถ๊ฐํด๋ด
์๋ท
1) Service ์ ์ธ + ๋ฐ์ดํฐ ์ ์ฅ์ฉ ํ๋กํผํฐ
private let service = MixUpService()
// ์ด๋ค ํ์
์ด๋ OK (๋จ์ผ๊ฐ / ๋ฐฐ์ด / DTO ๋ฑ)
private var data: [MixUpDTO] = []
๋ฐ์ดํฐ๊ฐ ๋ฐฐ์ด์ด ์๋ ๊ฒฝ์ฐ์๋ ์ด๋ ๊ฒ ๋ฐ๊พธ๋ฉด ๋ฉ๋๋ค์
private var data: MixUpDetailDTO? // ๋จ์ผ ๊ฐ์ฒด
private var items: [HomeDTO] = [] // ์ฌ๋ฌ ๊ฐ
private var count: Int = 0 // ์ซ์์ผ ์๋ ์์
2) API ํธ์ถ ํจ์
private func loadData() {
Task {
do {
let result = try await service.fetchSongs()
// 1) ๋ฐ์ดํฐ ์ ์ฅ
self.data = result
// 2) UI ์
๋ฐ์ดํธ
self.updateUI()
} catch {
print("โ API Error:", error)
}
}
}
3) UI ๋ฐ์ดํฐ ๋ฐ์ธ๋ฉ (API ์ฐ๋ ํ)
API๋ฅผ ํตํด ๋ฐ์ดํฐ๋ฅผ ๋ฐ์์ค๋ฉด, ๊ทธ ๋ฐ์ดํฐ๋ฅผ UI์ ๋ฐ์ธ๋ฉํด์ค์ผ ํ๊ฒ ์ฃ ?
UI ํํ๊ฐ ๋ผ๋ฒจ ํ๋ ๊ฐ์ผ ์๋ ์๊ณ , ์ปฌ๋ ์ ๋ทฐ๋ ํ ์ด๋ธ๋ทฐ์ฒ๋ผ ๋ฆฌ์คํธ UI์ผ ์๋ ์์ต๋๋ค!
ํ๋์ฉ ์ผ์ด์ค๋ฅผ ์ดํด๋ด
์๋ค~
1) ๋จ์ผ ๋ฐ์ดํฐ ๋ฐ์ธ๋ฉ
API์์ ํน์ ์์ดํ ํ๋๋ง ๋ฐ์์ค๋ ๊ฒฝ์ฐ์๋ ์๋ ๊ฒ UI ์์์ ์ง์ ๊ฐ์ ๋ฃ์ด์ฃผ๋ฉด ๋ฉ๋๋น
titleLabel.text = data.first?.title
thumbnailImageView.kf.setImage(
with: URL(string: data.first?.imageUrl ?? "")
)
2) ์ปฌ๋ ์ ๋ทฐ์ ๋ฐ์ธ๋ฉ
Uikit ํ๋ก์ ํธ์์ ์์ฃผ ์ฐ์ด๋ ์ปฌ๋ทฐ!
์๋ API๋ก ๋ฐ์์จ data ๋ฐฐ์ด์ ์ปฌ๋ ์ ๋ทฐ ์ ์ ๋งคํํ๋ ๋ฐฉ์์ ์๋๋น~
์ปฌ๋ ์ ๋ทฐ์ ํต์ฌ์ item → cell.configure() ํ๋ฆ์ด์์!!
extension MixUpViewController: UICollectionViewDataSource {
func collectionView(_ collectionView: UICollectionView,
numberOfItemsInSection section: Int) -> Int {
return data.count // ๐ API ์๋ต ๊ฐ์๋งํผ ์
์์ฑ
}
func collectionView(_ collectionView: UICollectionView,
cellForItemAt indexPath: IndexPath
) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(
withReuseIdentifier: MixUpListViewCell.reuseIdentifier,
for: indexPath
) as! MixUpListViewCell
let item = data[indexPath.item] // ๐ ํน์ ๋ฐ์ดํฐ ์ ํ
// ๐ ์
๋ด๋ถ๋ก ๋ฐ์ดํฐ ์ ๋ฌ(๋ฐ์ธ๋ฉ)
cell.configure(
title: item.title,
artist: item.artistName,
imageUrl: item.imageUrl
)
return cell
}
}
3) ํ ์ด๋ธ๋ทฐ์ ๋ฐ์ธ๋ฉ
ํ
์ด๋ธ๋ทฐ๋ ์ปฌ๋ ์
๋ทฐ์ ํ๋ฆ์ด ๊ฐ์ต๋๋ค!
๋ฑ ํ๋!!!!! ์ธ๋ฑ์ค ์ ๊ทผ์ด row๋ผ๋ ๊ฒ๋ง ๋ฌ๋ผ์.
extension MixUpViewController: UITableViewDataSource {
func tableView(_ tableView: UITableView,
numberOfRowsInSection section: Int) -> Int {
return data.count // ๐ ์๋ฒ์์ ๋ฐ์ ๋ฐ์ดํฐ ๊ฐ์
}
func tableView(_ tableView: UITableView,
cellForRowAt indexPath: IndexPath
) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(
withIdentifier: MixUpCell.reuseIdentifier,
for: indexPath
) as! MixUpCell
let item = data[indexPath.row] // ๐ row์ ํด๋นํ๋ ๋ฐ์ดํฐ
// ๐ ์
ํ๋์ ๋ฐ์ดํฐ ๋ฐ์ธ๋ฉ
cell.configure(with: item)
return cell
}
}
์ด๋ ๊ฒ ๋คํธ์ํฌ ์ฐ๊ฒฐ์ ๋ํ ์ค๋ช ์ด ๋๋ฌ์ต๋๋ค….

๋คํธ์ํฌ ์ฐ๊ฒฐ ์ฝ๋ค์ฌ์!!!!
'๐ถ๐ข๐ฆ > ๐ iOS ์ค์ ํธ' ์นดํ ๊ณ ๋ฆฌ์ ๋ค๋ฅธ ๊ธ
| [iOS / Uikit] ๋๋๊ทธ ์ค ๋๋ ๊ตฌํํ๊ธฐ :interactiveMovement (0) | 2025.12.04 |
|---|