Network Protocols
- 네트워킹에 필요한 프로토콜을 정의합니다.
NetworkService
와 Endpoint
정도 있을것 같습니다.
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
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:
case .serverError(let statusCode):
case .decodingError:
case .customError(let message):
default:
}
} catch {
}
}
Caching
URLCache
로 캐싱하기 위해 APINetworkService
에서 설정합니다.- 해당
session
을 이용하면, 캐시가 자동으로 적용됩니다. - 다음과 같은 캐시 정책이 존재합니다.
.useProtocolCachePolicy
: 기본 정책으로 HTTP 헤더의 캐시 정책을 따릅니다..reloadIgnoringLocalCacheData
: 캐시를 무시하고 네트워크로부터 data를 로드합니다..returnCacheDataElseLoad
: 캐시에 data가 있으면 캐시에서 반환하고, 없으면 네트워크에서 로드합니다..returnCacheDataDontLoad
: Data를 캐시에서만 가져옵니다.
let memoryCapacity = 50 * 1024 * 1024
let diskCapacity = 100 * 1024 * 1024
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 {
}
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 {
} catch {
XCTFail("Expected connectivity error, received different error")
}
}
override func tearDown() {
mockNetworkService = nil
super.tearDown()
}
}