티스토리 뷰

 

 

API 호출 모듈화를 통해 코드의 재사용성을 높이고 유지보수를 용이하게 할 수 있습니다.

 

 

기존에는 이런식으로  API를 작성하여 호출해주었습니다

 

//
//  RequsetAPI.swift
//  부산 사회 복지 정보를 제공하는 어플
//  https://apps.apple.com/kr/app/id1588773594
//  Created by jh on 2021/09/17.
//

import Foundation
import Alamofire


struct fetchAPI {
    private init() { }
    static let shared = fetchAPI()
    func getData(numOfRows: Int, PageNo: Int, completion: @escaping (_ data: [Item]) -> Void) {
        
        guard let encodingKey = Bundle.main.object(forInfoDictionaryKey: "API_KEY") as? String else { return }
        let url = "https://apis.data.go.kr/6260000/SocialWelfareCenterProgramsService/getProgramInfoList?ServiceKey=\(encodingKey)&numOfRows=\(numOfRows)&pageNo=\(PageNo)&resultType=json"
        
        AF.request(url, method: .get).responseJSON { response in
            switch response.result {
            case .success(_):
                guard let data = response.data else { return }
                print(data)
                do {
                    let decoder = JSONDecoder()
                    let welfareData = try decoder.decode(DataModel.self, from: data)
                    completion(welfareData.response.body.items.item)
                } catch { print("error \(error)") }
                
            case .failure(let error):
                print("errorCode: \(error._code)")
                print("errorDescription: \(error.errorDescription!)")
            }
        }.resume()
    }
}

 

 

만약 API 호출이 N개가 될 경우 매번 아래 request 호출을 작성해주어야 합니다.

 

 

 

모듈화하는 이유로는

url로 network 통신하는 코드가 동일하므로 하나의 모듈화된 코드로 재사용해주기 위함입니다.

 

 

 

모듈화 고려사항으로

1. completion 부분의 data: [Item] 형태를 Generic 형태 T로 하면 효용성을 높일 수 있습니다.

2. fetchData 말고도 postData, deleteData 등 다양한 경우를 고려할 수 있습니다

 

 

 

먼저

API의 역할에 따른 구조를 먼저 이렇게 나눌 수 있습니다

 

1. Endpoints: API 엔드포인트를 관리하는 구조체

2. APIClient: API 호출을 처리하는 기본 클래스 또는 구조체

3. APIService: 특정 API를 호출하기 위한 메서드가 정의된 프로토콜 또는 클래스

4. Models: API 응답 데이터를 매핑하기 위한 모델들

 

 

각 구조로 모듈화하여 코드를 작성해보겠습니다

 

먼저 Endpoint 입니다.

 

//
//  EndPoint.swift
//  URLSessionTest_240813
//
//  Created by jh on 8/14/24.
//

import Foundation

struct Endpoint {
    private var basePath: String
    var path: String {
        return basePath.hasPrefix("/") ? basePath : "/" + basePath
    }
    var method: HTTPMethod
    var headers: [String: String]
    var queryItems: [URLQueryItem]

    var url: URL? {
        var components = URLComponents()
        components.scheme = "https"
        components.host = "apis.data.go.kr"
        components.path = path
        components.queryItems = queryItems.isEmpty ? nil : queryItems
        components.percentEncodedQuery = components.percentEncodedQuery?
            .replacingOccurrences(of: "+", with: "%2B")
        return components.url
    }
    
    init(path: String, method: HTTPMethod, headers: [String: String] = [:], queryItems: [URLQueryItem] = []) {
            self.basePath = path
            self.method = method
            self.headers = headers
            self.queryItems = queryItems
        }
}

enum HTTPMethod: String {
    case get = "GET"
    case post = "POST"
    // 다른 HTTP 메서드 추가
}



enum NetworkError: Error {
    case invalidURL
    case requestFailed(description: String)
    case noData
    case decodingFailed
    case httpResponseNotOK(statusCode: Int)
    case unknownError
}

extension NetworkError: LocalizedError {
    public var errorDescription: String? {
        switch self {
        case .invalidURL:
            return "The URL provided was invalid."
        case .requestFailed(let description):
            return "The network request failed: \(description)"
        case .noData:
            return "No data was returned by the server."
        case .decodingFailed:
            return "Failed to decode the data from the server."
        case .httpResponseNotOK(let statusCode):
            return "HTTP request returned an unsuccessful status code: \(statusCode)"
        case .unknownError:
            return "An unknown error occurred."
        }
    }
}

 

 

EndPoint는

path, method, header, query(numOfRows = 5, pageNo = 1) 를 받아서 URLRequest로 만들어주는 역할

 

let url = "https://apis.data.go.kr/6260000/SocialWelfareCenterProgramsService/getProgramInfoList?ServiceKey=\(encodingKey)&numOfRows=\(numOfRows)&pageNo=\(PageNo)&resultType=json"

 

path는 위 url에서 /SocialWelfareCenterProgramsService/getProgramInfoList 에 해당합니다

(path 맨앞에 "/"를 안 붙일 경우 url 에러나서 basePath로 rapping해줬음)

 

 

그리고 API KEY를 query item component 로 만들때, encode가 제대로 되지 않습니다

그래서 + 부분을 %2B 로 교체해주는 코드

components.percentEncodedQuery = components.percentEncodedQuery?.replacingOccurrences(of: "+", with: "%2B")

 

를 작성하여 query encode를 작업해줍니다.

 

출처 https://stackoverflow.com/questions/43052657/encode-using-urlcomponents-in-swift

 

 

 

 

그 다음

네트워크 요청을 처리하고 반환하는 APIClient 입니다.

 

import Foundation

class APIClient {
    static let shared = APIClient()

    private init() {}

    func request<T: Decodable>(_ endpoint: Endpoint) async throws -> T {
        guard let url = endpoint.url else {
            throw NetworkError.invalidURL
        }

        var urlRequest = URLRequest(url: url)
        urlRequest.httpMethod = endpoint.method.rawValue
        urlRequest.allHTTPHeaderFields = endpoint.headers

        do {
            let (data, response) = try await URLSession.shared.data(for: urlRequest)
            guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
                throw NetworkError.httpResponseNotOK(statusCode: (response as? HTTPURLResponse)?.statusCode ?? 0)
            }
            do {
                let decodedData = try JSONDecoder().decode(T.self, from: data)
                return decodedData
            } catch {
                throw NetworkError.decodingFailed
            }
        } catch {
            throw NetworkError.requestFailed(description: error.localizedDescription)
        }
    }
    
    func postRequest<T: Decodable, U: Encodable>(to endpoint: Endpoint, body: U) async throws -> T {
        guard let url = endpoint.url else {
            throw NetworkError.invalidURL
        }
        
        var urlRequest = URLRequest(url: url)
        urlRequest.httpMethod = endpoint.method.rawValue
        urlRequest.allHTTPHeaderFields = endpoint.headers
        urlRequest.httpBody = try JSONEncoder().encode(body)
        urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type")
        
        do {
            let (data, response) = try await URLSession.shared.data(for: urlRequest)
            guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
                throw NetworkError.httpResponseNotOK(statusCode: (response as? HTTPURLResponse)?.statusCode ?? 0)
            }
            do {
                let decodedData = try JSONDecoder().decode(T.self, from: data)
                return decodedData
            } catch {
                throw NetworkError.decodingFailed
            }
        } catch {
            throw NetworkError.requestFailed(description: error.localizedDescription)
        }
    }
}

 

1. DataModel 부분을 Generic 형태로 바꿔주었습니다

 

2. 그리고 endPoint를 URLRequest에 넣고

let (data, response) = try await URLSession.shared.data(for: urlRequest)를 수행합니다

 

3. iOS15 부터 지원하는 swift concurrency 문법 async await를 사용하여, escaping closure를 refactoring 해주었습니다

 

4. 그리고 각 error를 Endpoint 구조체 아래에 작성해주었던 네트워크 에러 Enum 타입으로 반환해주었습니다.

 

 

 

 

 

 

그리고 API 명세 프로토콜을 작성해 줍니다

 

protocol APIService {
    func fetchData<T: Decodable>(from endpoint: Endpoint) async throws -> T
    func postData<T: Decodable, U: Encodable>(to endpoint: Endpoint, body: U) async throws -> T
    // func deleteData(from endpoint: Endpoint) async throws
}

 

 

 

그리고 ViewModel 에서 API를 호출할때 APIService로 의존성을 주입을 해주기 위해 NetworkSerivce 를 작성해줍니다.

 

NetworkService에서는 해당 APIService의 fetchData를 정의해주고,

그리고 fetchData에서 APIClient의 싱글톤 request 메소드를 실행하는 함수입니다.

import Foundation

class NetworkService: APIService {
    func fetchData<T>(from endpoint: Endpoint) async throws -> T where T : Decodable {
        try await APIClient.shared.request(endpoint)
    }
    func postData<T: Decodable, U: Encodable>(to endpoint: Endpoint, body: U) async throws -> T {
        try await APIClient.shared.postRequest(to: endpoint, body: body)
    }
}

 

 

 

 

ViewModel 내부에서는 numOfRows와 pageNo 같은 쿼리문을 인자로 받아 endpoint로 작성해주며,

APIService의 fetchData를 호출해줍니다.

 

//
//  ViewModel.swift
//  URLSessionTest_240813
//
//  Created by jh on 8/14/24.
//

import Foundation

class NetworkViewModel {
    private var apiService: APIService
    var data: DataModel?

    init(apiService: APIService) {
        self.apiService = apiService
    }
    
    func loadData(numOfRows: Int, pageNo: Int) async {
        guard let decodedKey = Bundle.main.object(forInfoDictionaryKey: "API_KEY") as? String else {
            print("API Key is not found.")
            return
        }
        
        let queryItems = [
            URLQueryItem(name: "ServiceKey", value: decodedKey),
            URLQueryItem(name: "numOfRows", value: "\(numOfRows)"),
            URLQueryItem(name: "pageNo", value: "\(pageNo)"),
            URLQueryItem(name: "resultType", value: "json")
        ]
        
        let endpoint = Endpoint(path: "6260000/SocialWelfareCenterProgramsService/getProgramInfoList", method: .get, queryItems: queryItems)
        
        do {
            let data: DataModel = try await apiService.fetchData(from: endpoint)
            DispatchQueue.main.async {
                self.data = data
                print("Data loaded successfully: \(data)")
            }
        } catch {
            DispatchQueue.main.async {
                print("Failed to load data: \(error)")
            }
        }
    }
}

 

그리고 DI 형태로 APIService를 init의 파라미터로 받아서

APIService의 fetchData를 호출해주고있습니다.

 

 

이렇게 작성해주면 테스트가 용이해지는데,

이런식으로 MockService 를 생성하여 테스트 할 수 있습니다.

 

class MockService: APIService {
    func fetchData<T>(from endpoint: Endpoint) async throws -> T where T: Decodable {
        // 여기서 테스트에 사용할 가짜 데이터를 반환
        let mockData = ... // T 타입의 가짜 데이터 생성
        return mockData
    }
}

// 테스트 시:
let mockService = MockService()
let viewModel = NetworkViewModel(apiService: mockService)
await viewModel.loadData(numOfRows: 5, pageNo: 1)

 

 

 

 

APIKey는 내부 번들키로 빼두어 두었습니다.

 

빼두는 방법은

 

 

Configuration Settings File 작성해주고, 내부에 API KEY를 작성해줍니다

 

 

 

그리고 Project info의 debug release Configurations에 위에서 작성한 APIKey 파일을 추가합니다.

 

 

 

그 다음 Targets Info Plist에

키값 API_KEY 에  $(API_KEY)를 추가해주면 됩니다.

 

 

 

(위 부분이 git에 올라가지 않도록 해주려면 gitignore에 추가해주면 됩니다

https://ggasoon2.tistory.com/18 )

 

 

추가로 url을 호출하여 networking할때 보안관련하여 ATS를 작성해주어야합니다. 

App Transport Security Settings 를 생성해주고 하위값으로 YES를 넣어주면 됩니다.

 

 

마지막으로 

위에서 작성한 ViewModel을 ViewController에서 호출하게 되면

 

 

 

이렇게 호출한 API response data가 잘 나오는 모습입니다.

 

 

 

 

 

 

전체 코드입니다

https://github.com/JangJaeHyung1/NetworkModule

 

 

댓글