๐—ถ๐—ข๐—ฆ/๐ŸŽ iOS ์‹ค์ „ํŽธ

[iOS / Uikit] ๋„คํŠธ์›Œํฌ ์—ฐ๊ฒฐ์„ ํ•ด๋ณด์ž! (MoyaํŽธ)

z_ero 2025. 12. 4. 14:54

 

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))
            }
        }
    }
}


์—ฌ๊ธฐ์„œ ํ•˜๋Š” ์ผ์€ ๋”ฑ ๋‘ ๊ฐ€์ง€:

  1. MoyaProvider๋ฅผ ์ƒ์„ฑํ•˜๊ณ 
  2. ์‘๋‹ต์„ ๋””์ฝ”๋”ฉ → ์„ฑ๊ณต/์—๋Ÿฌ๋กœ ๋„˜๊ฒจ์คŒ

๋„คํŠธ์›Œํฌ ๊ณ„์ธต์—์„œ ๊ฐ€์žฅ ์ค‘์š”ํ•œ ํŒŒ์ผ์ด๊ณ , ์„œ๋น„์Šค ๋‹จ์—์„œ๋Š” ์ด 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
    }
}

 

 

์ด๋ ‡๊ฒŒ ๋„คํŠธ์›Œํฌ ์—ฐ๊ฒฐ์— ๋Œ€ํ•œ ์„ค๋ช…์ด ๋๋‚ฌ์Šต๋‹ˆ๋‹ค….

 

 

๋„คํŠธ์›Œํฌ ์—ฐ๊ฒฐ ์‰ฝ๋‹ค์‰ฌ์›Œ!!!!

๋ฐ˜์‘ํ˜•