Notice
Recent Posts
Recent Comments
Link
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | |||||
3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 | 19 | 20 | 21 | 22 | 23 |
24 | 25 | 26 | 27 | 28 | 29 | 30 |
Tags
- Clean Code
- combine
- UITextView
- Xcode
- Observable
- 리팩토링
- map
- 클린 코드
- Protocol
- uiscrollview
- rxswift
- uitableview
- SWIFT
- HIG
- UICollectionView
- Human interface guide
- Refactoring
- MVVM
- swiftUI
- tableView
- swift documentation
- 리펙토링
- 애니메이션
- clean architecture
- ios
- collectionview
- 리펙터링
- RxCocoa
- ribs
- 스위프트
Archives
- Today
- Total
김종권의 iOS 앱 개발 알아가기
[iOS - swift] 네트워크(network) - testable한 URLSession 설계 (Endpoint, Provider) 본문
iOS 응용 (swift)
[iOS - swift] 네트워크(network) - testable한 URLSession 설계 (Endpoint, Provider)
jake-kim 2021. 10. 2. 16:06* URLSession 기본 개념은 여기 참고
준비
- 구현된 해당 소스 코드 사용 시, 아래 작업 사용 후 실행
- https://unsplash.com/developers 가입 후 api키 획득
- Contants에 accessKey입력
struct Constants {
static let accessKey = "Your accessKey"
}
Network 레이어 설계
- 네트워크의 핵심 모듈
- Endpoint: path, queryPramameters, bodyParameter등의 데이터 객체
- Provider: URLSession, DataTask를 이용하여 network호출이 이루어 지는 곳
- Endpoint는 요청, 응답 protocol을 준수하는 상태
- requestable에는 baseURL, path, method, parameters, ... 같은 정보가 존재
- Responsable은 단순히 아래 코드
- Request하는 곳인, Provider에서 Response의 타입을 알아야 제네릭을 적용할 수 있는데, 여기서 Endpoint객체 하나만 넘기면 따로 request할 때 Response타입을 넘기지 않아도 되게끔 설계
// Responsable.swift
protocol Responsable {
associatedtype Response
}
// Endpoint.swift
// Endpoint 객체를 만들때 Response타입을 명시
class Endpoint<R>: RequesteResponsable {
typealias Response = R
...
// Provider.swift
// Provider에서 Endpoint객체를 받으면 따로 Response 타입을 넘기지 않아도 되도록 설계
protocol Provider {
func request<R: Decodable, E: RequesteResponsable>(with endpoint: E, completion: @escaping (Result<R, Error>) -> Void) where E.Response == R
...
Network 구현
- 핵심:
1) Request, Response가 Generic하여 하드코딩되지 않은 형태
2) URLSession의 dataTask메소드를 protocol로 선언하여 request/response를 testable하도록 구현
공통 Error 타입 정의
enum NetworkError: LocalizedError {
case unknownError
case invalidHttpStatusCode(Int)
case components
case urlRequest(Error)
case parsing(Error)
case emptyData
case decodeError
var errorDescription: String? {
switch self {
case .unknownError: return "알수 없는 에러입니다."
case .invalidHttpStatusCode: return "status코드가 200~299가 아닙니다."
case .components: return "components를 생성 에러가 발생했습니다."
case .urlRequest: return "URL request 관련 에러가 발생했습니다."
case .parsing: return "데이터 parsing 중에 에러가 발생했습니다."
case .emptyData: return "data가 비어있습니다."
case .decodeError: return "decode 에러가 발생했습니다."
}
}
}
Endpoint 정의
- Requestable
protocol Requestable {
var baseURL: String { get }
var path: String { get }
var method: HttpMethod { get }
var queryParameters: Encodable? { get }
var bodyParameters: Encodable? { get }
var headers: [String: String]? { get }
var sampleData: Data? { get }
}
extension Requestable {
func getUrlRequest() throws -> URLRequest {
let url = try url()
var urlRequest = URLRequest(url: url)
// httpBody
if let bodyParameters = try bodyParameters?.toDictionary() {
if !bodyParameters.isEmpty {
urlRequest.httpBody = try? JSONSerialization.data(withJSONObject: bodyParameters)
}
}
// httpMethod
urlRequest.httpMethod = method.rawValue
// header
headers?.forEach { urlRequest.setValue($1, forHTTPHeaderField: $0) }
return urlRequest
}
func url() throws -> URL {
// baseURL + path
let fullPath = "\(baseURL)\(path)"
guard var urlComponents = URLComponents(string: fullPath) else { throw NetworkError.components }
// (baseURL + path) + queryParameters
var urlQueryItems = [URLQueryItem]()
if let queryParameters = try queryParameters?.toDictionary() {
queryParameters.forEach {
urlQueryItems.append(URLQueryItem(name: $0.key, value: "\($0.value)"))
}
}
urlComponents.queryItems = !urlQueryItems.isEmpty ? urlQueryItems : nil
guard let url = urlComponents.url else { throw NetworkError.components }
return url
}
}
- Responsable: Endpoint를 생성할 때 타입을 주입하여, 뒤에 나올 provider의 request제네릭에 적용
protocol Responsable {
associatedtype Response
}
// Endpoint.swift
class Endpoint<R>: RequesteResponsable {
// Response타입을 미리 정의하여, Endpoint객체 하나만 request쪽에 넘기면 request함수의 Response제네릭에 적용
typealias Response = R
...
}
- Endpoint 구현: Requestable, Responsable의 성격을 가지고 있는 클래스
protocol RequesteResponsable: Requestable, Responsable {}
class Endpoint<R>: RequesteResponsable {
typealias Response = R
var baseURL: String
var path: String
var method: HttpMethod
var queryParameters: Encodable?
var bodyParameters: Encodable?
var headers: [String: String]?
var sampleData: Data?
init(baseURL: String,
path: String = "",
method: HttpMethod = .get,
queryParameters: Encodable? = nil,
bodyParameters: Encodable? = nil,
headers: [String: String]? = [:],
sampleData: Data? = nil) {
self.baseURL = baseURL
self.path = path
self.method = method
self.queryParameters = queryParameters
self.bodyParameters = bodyParameters
self.headers = headers
self.sampleData = sampleData
}
}
enum HttpMethod: String {
case get = "GET"
case post = "POST"
case put = "PUT"
case delete = "DELETE"
}
Provider 정의
- request는 2가지 종류
- 1) Encodable한 Request모델을 통해서 Decodable한 Response를 받는 request
- 2) 단순히 URL을 request로 주어, Data를 얻는 request (ex - 이미지 url을 넣고 이미지 Data 얻어오는 경우 사용)
protocol Provider {
/// 특정 responsable이 존재하는 request
func request<R: Decodable, E: RequesteResponsable>(with endpoint: E, completion: @escaping (Result<R, Error>) -> Void) where E.Response == R
/// data를 얻는 request
func request(_ url: URL, completion: @escaping (Result<Data, Error>) -> ())
}
class ProviderImpl: Provider {
let session: URLSessionable
init(session: URLSessionable = URLSession.shared) {
self.session = session
}
func request<R: Decodable, E: RequesteResponsable>(with endpoint: E, completion: @escaping (Result<R, Error>) -> Void) where E.Response == R {
do {
let urlRequest = try endpoint.getUrlRequest()
session.dataTask(with: urlRequest) { [weak self] data, response, error in
self?.checkError(with: data, response, error) { result in
guard let `self` = self else { return }
switch result {
case .success(let data):
completion(`self`.decode(data: data))
case .failure(let error):
completion(.failure(error))
}
}
}.resume()
} catch {
completion(.failure(NetworkError.urlRequest(error)))
}
}
func request(_ url: URL, completion: @escaping (Result<Data, Error>) -> ()) {
session.dataTask(with: url) { [weak self] data, response, error in
self?.checkError(with: data, response, error, completion: { result in
completion(result)
})
}.resume()
}
// Private
private func checkError(with data: Data?, _ response: URLResponse?, _ error: Error?, completion: @escaping (Result<Data, Error>) -> ()) {
if let error = error {
completion(.failure(error))
return
}
guard let response = response as? HTTPURLResponse else {
completion(.failure(NetworkError.unknownError))
return
}
guard (200...299).contains(response.statusCode) else {
completion(.failure(NetworkError.invalidHttpStatusCode(response.statusCode)))
return
}
guard let data = data else {
completion(.failure(NetworkError.emptyData))
return
}
completion(.success((data)))
}
private func decode<T: Decodable>(data: Data) -> Result<T, Error> {
do {
let decoded = try JSONDecoder().decode(T.self, from: data)
return .success(decoded)
} catch {
return .failure(NetworkError.emptyData)
}
}
}
extension Encodable {
func toDictionary() throws -> [String: Any]? {
let data = try JSONEncoder().encode(self)
let jsonData = try JSONSerialization.jsonObject(with: data)
return jsonData as? [String: Any]
}
}
사용하는 쪽
- APIEndpoints를 정의하여 도메인에 종속된 baseURL, path등을 정의
struct APIEndpoints {
static func getPhotosInfo(with photoListRequestDTO: PhotoListRequestDTO) -> Endpoint<[PhotoListResponseDTO]> {
return Endpoint(baseURL: "https://api.unsplash.com/",
path: "photos",
method: .get,
queryParameters: photoListRequestDTO,
headers: ["Authorization": "Client-ID \(Constants.accessKey)"],
sampleData: NetworkResponseMock.photoList)
}
static func getImages(with url: String) -> Endpoint<Data> {
return Endpoint(baseURL: url, sampleData: NetworkResponseMock.image)
}
}
- 호출하여 사용
let endpoint = APIEndpoints.getPhotosInfo(with: photoListRequestDTO)
provider.request(with: endpoint) { result in
switch result {
case .success(let response)
print(response)
case .failure(let error):
print(error)
}
}
네트워크 Test 코드
- mock response로 사용될 데이터를 APIEndpoints에서 sampleData에 사용되는 데이터 미리 준비
// NetworkResponseMock.swift
struct NetworkResponseMock {
static let photoList: Data = """
[
{
"id":"z3htkdHUh5w",
...
},
]
""".data(using: .utf8)!
static let image: Data = UIImage(systemName: "square")!.pngData()!
}
// APIEndpoints.swift
struct APIEndpoints {
static func getPhotosInfo(with photoListRequestDTO: PhotoListRequestDTO) -> Endpoint<[PhotoListResponseDTO]> {
return Endpoint(baseURL: "https://api.unsplash.com/",
path: "photos",
method: .get,
queryParameters: photoListRequestDTO,
headers: ["Authorization": "Client-ID \(Constants.accessKey)"],
sampleData: NetworkResponseMock.photoList) // <- 여기서사용
}
static func getImages(with url: String) -> Endpoint<Data> {
return Endpoint(baseURL: url, sampleData: NetworkResponseMock.image) // <- 여기서사용
}
}
- 설계할때 URLSession을 protocol을 따르도록 구현했으므로, URLSession에 mock을 넣어서 response데이터를 조작할 수 있으므로 testable 성격 확보
// URLSessionable.swift
protocol URLSessionable {
func dataTask(with request: URLRequest,
completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTask
func dataTask(with url: URL,
completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTask
}
extension URLSession: URLSessionable {}
// Provider.swift
// 사용하는 곳
class ProviderImpl: Provider {
let session: URLSessionable
init(session: URLSessionable = URLSession.shared) {
self.session = session
}
...
}
- URLSessionDataTask를 준수하는 테스트용 MockURLSessionDataTask 정의
- resumeDidCall: resume이 불리면 실행되는 closure블록 (외부에서 정의하면, resume이 불리는 타이밍에 바로 실행)
class MockURLSessionDataTask: URLSessionDataTask {
var resumeDidCall: (() -> ())?
override func resume() {
// 주의: super.resume()호출하면 실제 resume()이 호출되므로 주의
resumeDidCall?()
}
}
- URLSessionable을 준수하는 MockURLSession정의
- 미리 성공, 실패에 대한 response를 정의하여 넘길 수 있는 역할
class MockURLSession: URLSessionable {
var makeRequestFail = false
init(makeRequestFail: Bool = false) {
self.makeRequestFail = makeRequestFail
}
var sessionDataTask: MockURLSessionDataTask?
func dataTask(with request: URLRequest,
completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTask {
let endpoint = APIEndpoints.getPhotosInfo(with: .init(page: 1))
// 성공 callback
let successResponse = HTTPURLResponse(url: try! endpoint.url(),
statusCode: 200,
httpVersion: "2",
headerFields: nil)
// 실패 callback
let failureResponse = HTTPURLResponse(url: try! endpoint.url(),
statusCode: 301,
httpVersion: "2",
headerFields: nil)
let sessionDataTask = MockURLSessionDataTask()
// resume() 이 호출되면 completionHandler()가 호출
sessionDataTask.resumeDidCall = {
if self.makeRequestFail {
completionHandler(nil, failureResponse, nil)
} else {
completionHandler(endpoint.sampleData!, successResponse, nil)
}
}
self.sessionDataTask = sessionDataTask
return sessionDataTask
}
}
- 테스트 코드 작성
class PhotoAPITests: XCTestCase {
var sut: Provider!
override func setUpWithError() throws {
// mock데이터 주입
sut = ProviderImpl(session: MockURLSession())
}
func test_fetchPhotoList_whenSuccess_thenProcessRight() {
// async테스트를 위해서 XCTestExpectation 사용
let expectation = XCTestExpectation()
let endpoint = APIEndpoints.getPhotosInfo(with: .init(page: 1))
let responseMock = try? JSONDecoder().decode([PhotoListResponseDTO].self, from: endpoint.sampleData!)
sut.request(with: endpoint) { result in
switch result {
case .success(let response):
XCTAssertEqual(response.first?.id, responseMock?.first?.id)
case .failure:
XCTFail()
}
expectation.fulfill()
}
wait(for: [expectation], timeout: 2.0)
}
func test_fetchPhotoList_whenFailed_thenProcessRight() {
sut = ProviderImpl(session: MockURLSession(makeRequestFail: true))
let expectation = XCTestExpectation()
let endpoint = APIEndpoints.getPhotosInfo(with: .init(page: 1))
sut.request(with: endpoint) { result in
switch result {
case .success:
XCTFail()
case .failure(let error):
XCTAssertEqual(error.localizedDescription, "status코드가 200~299가 아닙니다.")
}
expectation.fulfill()
}
wait(for: [expectation], timeout: 2.0)
}
}
전체 코드, Pagination/Infrastructure/Network 하위 파일 참고: https://github.com/JK0369/PaginationExample
* 참고
- https://developer.apple.com/documentation/foundation/urlsession
'iOS 응용 (swift)' 카테고리의 다른 글
Comments