Swift의 Network Layer

프로토콜

  • 네트워킹에 필요한 프로토콜을 정의합니다.
  • URLSessionProtocol, NetworkProtocol, EndpointProtocol 정도 있을것 같습니다.
protocol URLSessionProtocol {
    func data(for request: URLRequest) async throws -> (Data, URLResponse)
}

extension URLSession: URLSessionProtocol { }

protocol NetworkProtocol {
    var session: URLSessionProtocol { get }
    func request<T: Decodable>(endpoint: EndpointProtocol) async throws -> T
}

protocol EndpointProtocol {
    var baseURL: URL? { get }
    var path: String { get }
    var method: NetworkMethod { get }
    var parameters: [URLQueryItem]? { get }
    var headers: [String: String]? { get }
    var body: Data? { get }
}
  • NetworkMethod 열거형도 정의합니다.
enum NetworkMethod: String {
    case get = "GET"
    case post = "POST"
    case put = "PUT"
    case delete = "DELETE"
}

Endpoint

  • EndpointProtocol 프로토콜을 준수하여 API 엔드포인트를 정의합니다.
enum ApiEndpoint: EndpointProtocol {
    case station(x: Double, y: Double)
    case air(name: String)

    var baseURL: URL? { URL(string: "http://apis.data.go.kr") }

    var path: String {
        switch self {
        case .station: "/station"
        case .air: "/air"
        }
    }

    var method: NetworkMethod {
        switch self {
        case .station, .air: .get
        }
    }

    var parameters: [URLQueryItem]? {
        switch self {
        case let .station(x, y):
            [
                URLQueryItem(name: "tmX", value: "\(x)"),
                URLQueryItem(name: "tmY", value: "\(y)"),
                ...
            ]
        case let .air(name):
            [
                URLQueryItem(name: "stationName", value: name),
                ...
            ]
        }
    }

    var headers: [String: String]? {
        switch self {
        case .station, .air: nil
        }
    }
    
    var body: Data? {
        switch self {
        case .station, .air: nil
        }
    }
}

NetworkService

  • 다음과 같이 NetworkProtocol를 준수하는 NetworkService를 구현합니다.
struct NetworkService: NetworkProtocol {
    let session: URLSessionProtocol
    
    static let shared: NetworkService = {
        let config = URLSessionConfiguration.default
        let session = URLSession(configuration: config)
        return NetworkService(session: session)
    }()
    
    init(session: URLSessionProtocol) {
        self.session = session
    }
    
    func request<T: Decodable>(endpoint: EndpointProtocol) async throws -> T {
        guard let url = configUrl(endpoint: endpoint) else {
            throw NetworkError.invalidUrl
        }
        
        let request = configRequest(url: url, endpoint: endpoint)
        let (data, response) = try await session.data(for: request)
        
        return try processResponse(data: data, response: response)
    }
}

extension NetworkService {
    private func configUrl(endpoint: EndpointProtocol) -> URL? {
        guard let url = endpoint.baseURL?.appendingPathComponent(endpoint.path) else {
            return nil
        }
        
        guard var components = URLComponents(url: url, resolvingAgainstBaseURL: true) else {
            return nil
        }
        
        components.queryItems = endpoint.parameters
        
        return components.url
    }
    
    private func configRequest(url: URL, endpoint: EndpointProtocol) -> URLRequest {
        var request = URLRequest(url: url)
        request.httpMethod = endpoint.method.rawValue
        
        if let headers = endpoint.headers {
            for (key, value) in headers {
                request.setValue(value, forHTTPHeaderField: key)
            }
        }
        
        if let body = endpoint.body {
            request.httpBody = try? JSONEncoder().encode(body)
        }
        
        return request
    }
    
    private func processResponse<T: Decodable>(data: Data, response: URLResponse) throws -> T {
        guard let httpResponse = response as? HTTPURLResponse else {
            throw NetworkError.invalidResponse
        }
        
        guard (200...299).contains(httpResponse.statusCode) else {
            throw NetworkError.serverError(statusCode: httpResponse.statusCode)
        }
        
        do {
            let decoder = JSONDecoder()
            decoder.keyDecodingStrategy = .convertFromSnakeCase
            
            return try decoder.decode(T.self, from: data)
        } catch {
            throw NetworkError.decodingError(error.localizedDescription)
        }
    }
}

에러 핸들링

  • Custom 에러 타입을 정의합니다.
enum NetworkError: Error {
    case invalidUrl
    case invalidResponse
    case decodingError(String)
    case serverError(statusCode: Int)
}
  • 각 케이스에 대한 error message도 정의합니다.
extension NetworkError {
    var errorMessage: String {
        switch self {
        case .invalidUrl:
            return Constants.Error.url
        case .invalidResponse:
            return Constants.Error.response
        case .decodingError(let description):
            return Constants.Error.decoding + description
        case .serverError(let statusCode):
            return Constants.Error.server + String(statusCode)
        }
    }
}

private extension Constants {
    struct Error {
        static let url = "유효하지 않은 URL입니다"
        static let response = "유효하지 않은 응답입니다"
        static let decoding = "디코딩 에러입니다: "
        static let server = "서버 에러입니다: "
    }
}
  • 정의한 에러 타입으로 에러 핸들을 추가합니다.
private func fetchStation(tmX: Double, tmY: Double) async -> [MunziModel.StationModel.Item] {
    do {
        let station: MunziModel.StationModel = try await NetworkService.shared.request(endpoint: APIEndpoint.station(x: tmX, y: tmY))
        let result = station.response.body.items
        return result
    } catch let error as NetworkError {
        print(error.errorMessage)
        return []
    } catch {
        print(error.localizedDescription)
        return []
    }
}

캐싱

  • URLCache로 캐싱하기 위해 NetworkService에서 설정합니다.
static let shared: NetworkService = {
    let config = URLSessionConfiguration.default
    let memoryCapacity = 50 * 1024 * 1024 // 50 MB
    let diskCapacity = 100 * 1024 * 1024 // 100 MB
    let cache = DurationService(memoryCapacity: memoryCapacity, diskCapacity: diskCapacity, directory: nil)
    config.urlCache = cache
    let session = URLSession(configuration: config)
    return NetworkService(session: session)
}()
  • 수동으로 캐시를 사용할수도 있습니다.
  • request 함수내에 캐시를 수동으로 저장합니다.
let cachedData = CachedURLResponse(response: response, data: data)
URLCache.shared.storeCachedResponse(cachedData, for: urlRequest)
  • 캐시를 수동으로 사용하기 위해서는 다음과 같이합니다.
if let cachedResponse = URLCache.shared.cachedResponse(for: request) {
    let data = cachedResponse.data
} else {
    // Network 통해서 데이터 가져오기
}
  • 캐시를 삭제해야 할때 사용하는 코드입니다.
URLCache.shared.removeAllCachedResponses()
URLCache.shared.removeCachedResponse(for: request)

Combine 방법

  • Async/await 대신 Combine을 선호한다면, 이렇게도 사용할수 있습니다.
import Combine

protocol NetworkService {
    func request<T: Decodable>(endpoint: Endpoint) -> AnyPublisher<T, Error>
}

class APINetworkService: NetworkService {
    func request<T>(endpoint: Endpoint) -> AnyPublisher<T, Error> where T: Decodable {
        let urlRequest = try! buildURLRequest(for: endpoint)

        return URLSession.shared.dataTaskPublisher(for: urlRequest)
            .map(\.data)
            .decode(type: T.self, decoder: JSONDecoder())
            .receive(on: DispatchQueue.main)
            .eraseToAnyPublisher()
    }
}

Unit Test

  • NetworkProtocol을 준수하는 NetworkService의 mock 버전을 생성합니다.
  • MockService는 실제 네트워크 요청을 하지 않고 사전에 정의된 응답을 반환합니다.
class MockNetworkService: NetworkProtocol {
    var mockData: Data?
    var mockError: Error?

    func request<T>(endpoint: Endpoint) async throws -> T where T: Decodable {
        if let error = mockError {
            throw error
        }
        
        guard let data = mockData else {
            throw NetworkError.noData
        }
        
        return try JSONDecoder().decode(T.self, from: data)
    }
}
  • XCTest를 사용하여 이러한 시나리오에 대한 테스트를 작성합니다.
  • 성공적으로 데이터를 가져오는 테스트와 네트워크 문제에 대한 테스트 케이스입니다.
@testable import AppName
import XCTest

class NetworkServiceTests: XCTestCase {
    var mockNetworkService: MockNetworkService!
    var dataToReturn: Data!
    var errorToReturn: Error!

    override func setUp() {
        super.setUp()
        mockNetworkService = MockNetworkService()
    }
    
    override func tearDown() {
        mockNetworkService = nil
        super.tearDown()
    }

    func testFetchPostsSuccess() async {
        let posts = [Post(id: 1, title: "Test Post", body: "This is a test")]
        dataToReturn = try! JSONEncoder().encode(posts)
        mockNetworkService.mockData = dataToReturn

        do {
            let fetchedPosts: [Post] = try await mockNetworkService.request(endpoint: APIEndpoint.fetchPosts)
            XCTAssertEqual(fetchedPosts.count, posts.count)
            XCTAssertEqual(fetchedPosts.first?.id, posts.first?.id)
        } catch {
            XCTFail("Expected successful fetch, received error")
        }
    }

    func testFetchPostsWithConnectivityError() async {
        errorToReturn = NetworkError.connectivityError
        mockNetworkService.mockError = errorToReturn

        do {
            let _: [Post] = try await mockNetworkService.request(endpoint: APIEndpoint.fetchPosts)
            XCTFail("Expected connectivity error, received successful fetch")
        } catch {
            XCTFail("Expected connectivity error, received different error")
        }
    }
}