프로토콜
- 네트워킹에 필요한 프로토콜을 정의합니다.
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)
}
}
}
에러 핸들링
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
let diskCapacity = 100 * 1024 * 1024
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 {
}
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")
}
}
}