Swift의 Network Layer

Network Protocols

  • 네트워킹에 필요한 프로토콜을 정의합니다.
  • NetworkServiceEndpoint 정도 있을것 같습니다.
protocol NetworkService {
    func request<T: Decodable>(endpoint: Endpoint) async throws -> T
}

protocol Endpoint {
    var baseURL: URL { get }
    var path: String { get }
    var method: HTTPMethod { get }
    var parameters: [String: Any]? { get }
    var headers: [String: String]? { get }
}

Endpoint

  • Endpoint 프로토콜을 준수하여 API 엔드포인트를 정의합니다.
enum APIEndpoint: Endpoint {
    case fetchPosts
    case fetchUser(userId: Int)

    var baseURL: URL { URL(string: "some-working-url.com")! }

    var path: String {
        switch self {
        case .fetchPosts: return "/posts"
        case .fetchUser(let userId): return "/users/\(userId)"
        }
    }

    var method: HTTPMethod { .get }
    var parameters: [String: Any]? { nil }
    var headers: [String: String]? { nil }
}

NetworkService

  • 다음과 같이 NetworkService를 준수하는 APINetworkService를 구현합니다.
class APINetworkService: NetworkService {
    func request<T: Decodable>(endpoint: Endpoint) async throws -> T {
        let urlRequest = try buildURLRequest(for: endpoint)
        let (data, response) = try await URLSession.shared.data(for: urlRequest)

        guard let httpResponse = response as? HTTPURLResponse else {
            throw NetworkError.connectivityError
        }

        guard 200...299 contains httpResponse.statusCode else {
            throw NetworkError.serverError(statusCode: httpResponse.statusCode)
        }

        do {
            return try JSONDecoder().decode(T.self, from: data)
        } catch {
            throw NetworkError.decodingError
        }
    }

    private func buildURLRequest(for endpoint: Endpoint) throws -> URLRequest {
        var urlComponents = URLComponents(url: endpoint.baseURL.appendingPathComponent(endpoint.path), resolvingAgainstBaseURL: false)
        urlComponents.queryItems = endpoint.parameters?.map { URLQueryItem(name: $0.key, value: "\($0.value)") }

        guard let url = urlComponents?.url else { throw NetworkError.invalidEndpoint }
         
        var request = URLRequest(url: url)
        request.httpMethod = endpoint.method.rawValue
        endpoint.headers?.forEach { request.addValue($1, forHTTPHeaderField: $0) }
                
        return request
    }
}

Error Handling

  • Custom 에러 타입을 정의합니다.
enum NetworkError: Error {
    case invalidEndpoint
    case connectivityError
    case serverError(statusCode: Int)
    case decodingError
    case customError(message: String)
}
  • 정의한 에러 타입으로 에러 핸들을 추가합니다.
func fetchPosts() async {
    do {
        let posts: [Post] = try await networkService.request(endpoint: APIEndpoint.fetchPosts)
    } catch let error as NetworkError {
        switch error {
        case .connectivityError:
            // Handle connectivity error
        case .serverError(let statusCode):
            // Handle server error based on statusCode
        case .decodingError:
            // Handle JSON decoding error
        case .customError(let message):
            // Handle custom error
        default:
            // Handle other errors
        }
    } catch {
        // Handle any other errors
    }
}

Caching

  • URLCache로 캐싱하기 위해 APINetworkService에서 설정합니다.
  • 해당 session을 이용하면, 캐시가 자동으로 적용됩니다.
  • 다음과 같은 캐시 정책이 존재합니다.
    • .useProtocolCachePolicy: 기본 정책으로 HTTP 헤더의 캐시 정책을 따릅니다.
    • .reloadIgnoringLocalCacheData: 캐시를 무시하고 네트워크로부터 data를 로드합니다.
    • .returnCacheDataElseLoad: 캐시에 data가 있으면 캐시에서 반환하고, 없으면 네트워크에서 로드합니다.
    • .returnCacheDataDontLoad: Data를 캐시에서만 가져옵니다.
let memoryCapacity = 50 * 1024 * 1024 // 50 MB
let diskCapacity = 100 * 1024 * 1024 // 100 MB
let cache = URLCache(memoryCapacity: memoryCapacity, diskCapacity: diskCapacity, diskPath: "myDiskPath")

let configuration = URLSessionConfiguration.default
configuration.urlCache = cache
configuration.requestCachePolicy = .returnCacheDataElseLoad

let session = URLSession(configuration: configuration)
  • 수동으로 캐시를 사용할수도 있습니다.
  • request 함수내에 캐시를 수동으로 저장합니다.
if let data, let response {
    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 {
    // Request through 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

  • NetworkService을 준수하는 APINetworkService의 mock 버전을 생성합니다.
  • MockService는 실제 네트워크 요청을 하지 않고 사전에 정의된 응답을 반환합니다.
class MockNetworkService: NetworkService {
    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()
    }

    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 NetworkError.connectivityError {
            // Test passed
        } catch {
            XCTFail("Expected connectivity error, received different error")
        }
    }

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