서론
- 거의 모든 앱은 서버 의존도가 필수불가결적으로 높습니다. 앱 개발자로써 서버가 다운되거나, 점검 중이거나, 배포 전일 때에도 계속 개발을 지속할 수 있어야합니다. 그래서 서버의 응답이 완료 될 떄에만 앱이 실행되는 것은 굉장히 불리한 조건을 갖게 됩니다.
- 우리 편행 프로젝트에서도 이 기술을 활용하여 서버 의존도를 낮추고 효과적으로 개발에 임할 수 있었습니다.
- 이번 시간에는 URLProtocol을 이용해 서버 없이도 개발을 효과적으로 진행하는 방법을 소개해드리겠습니다.
URLProtocol
- URLProtocol은 네트워크 요청을 실제 데이터로 받지 않고 Mock 데이터로 받을 수 있게 할 수 있는 객체입니다.
- URLSession의 Configuration을 통해 주입이 되어야합니다.
- URLSessionDataTask에서 실제 서버와 통신하는 것과 달리 URLSessionConfiguration에 URLProtocol이 있다면 완전히 다른 방식으로 네트워크 통신을 진행합니다.
let configuration: URLSessionConfiguration = .ephemeral
configuration.protocolClasses?.insert(MockURLProtocol.self, at: .zero)
- 우리가 만들어줄 Mock Data를 통해 네트워크 커넥션을 열어 Request 생성, Response를 받아 처리하는 과정을 실제 서버와 통신하는 것처럼 사용할 수 있습니다.
Custom URLProtocol 구현하기
- 이제 URLProtocol을 이용해 원하는 Mock 데이터로 서버 통신을 해볼 차례입니다.
public final class HomeURLProtocol: URLProtocol {}
- 먼저 원하는 클래스 객체에 URLProtocol을 상속 받아야합니다. 이름은 Protocol인데 Class인 것을 혼동하실 수 있습니다.
- URLProtocol을 상속 받게 되면 필수적으로 구현해야할 4가지 메서드가 있습니다.
public final class HomeURLProtocol: URLProtocol {
override public class func canInit(with _: URLRequest) -> Bool {
true
}
override public class func canonicalRequest(for request: URLRequest) -> URLRequest {
request
}
override public func startLoading() {}
override public func stopLoading() {}
}
- canInit은 URLRequest에 URLProtocol을 적용할 것인지에 대한 메서드입니다. 일반적으로 true를 반환합니다.
- canonicalRequest는 URLRequest를 중간에 바꿔치기할 수 있는 메서드입니다. 이것도 일반적으로 인자로 들어온 request를 바로 반환합니다.
- startLoading은 Task가 실행될 때의 로직을 적는 곳입니다. 해당 메서드에서 대부분의 로직 처리가 일어납니다.
- stopLoading은 Task가 끝날 때의 로직을 적는 곳입니다. 일반적으로 빈 값으로 둡니다.
Mock Data 구현
- Mock Data는 다양한 방식으로 구현할 수 있습니다. 우리는 JSON파일을 따로 만들어서 관리했습니다.
- API 명세서가 나왔다면, Response 구조와 똑같이 구현하면 됩니다. 하지만 명세서가 나오기 전이라면 화면에 구성되는 데이터만을 사용하는 것도 하나의 방법입니다.
startLoading 로직 추가하기
- 이제 만들어둔 Mock Data를 가지고 startLoading에 로직을 추가하면 됩니다.
public final class HomeURLProtocol: URLProtocol {
// ...
override public func startLoading() {
defer { client?.urlProtocolDidFinishLoading(self) }
if let url = request.url,
let mockData = mockData[url.path(percentEncoded: true)],
let data = mockData,
let response = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil) {
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: data)
} else {
client?.urlProtocol(self, didFailWithError: NetworkError.urlError)
}
}
// ...
}
- 여기서 client는 URLProtocol을 상속받으면 있는 URLProtocolClient입니다.
- client는 response, data와 error를 전달할 수 있습니다. 결과값과 에러를 커스텀할 수 있게되니 네트워크를 테스팅하기에 좋은 요건을 갖추고 있습니다.
테스팅 하기
struct Services {
let homeService: HomeServiceRepresentable
init() {
let homeNetworking: Networking = {
let configuration: URLSessionConfiguration
#if DEBUG
configuration = .ephemeral
configuration.protocolClasses = [HomeURLProtocol.self]
#else
configuration = .default
#endif
let provider = NetworkProvider(session: URLSession(configuration: configuration))
return provider
}()
homeService = HomeService(network: homeNetworking)
}
}
- API Service에서 URLProtocol를 사용해 Mock Data로 앱을 실행할 것인지 정해야합니다.
- 우리는 XCode의 Flag 기능을 통해 URLSessionConfiguration을 구분했습니다.
- 실제 서버 데이터는 1000개가 넘는 데이터가 있지만, 실제로는 Mock Data에서 정의해준 24개의 데이터가 화면에 나타나는 것을 볼 수 있습니다.
- 뿐만아니라 Unit Test에도 URLProtocol은 유용하게 쓰일 수 있습니다.
Unit Tests
// UserAPITests.swift
import Foundation
import XCTest
@testable import MyApp
class UserAPITests: XCTestCase {
lazy var session: URLSession = {
let configuration = URLSessionConfiguration.ephemeral
configuration.protocolClasses = [MockURLProtocol.self]
return URLSession(configuration: configuration)
}()
lazy var api: UserAPI = {
UserAPI(session: session)
}()
override func tearDown() {
MockURLProtocol.requestHandler = nil
super.tearDown()
}
func testFetchUser() async throws{
let mockData = """
{
"firstName": "Vincent",
"lastName": "Pradeilles"
}
""".data(using: .utf8)!
MockURLProtocol.requestHandler = { request in
XCTAssertEqual(request.url?.absoluteString, "https://my-api.com/user/me")
let response = HTTPURLResponse(
url: request.url!,
statusCode: 200,
httpVersion: nil,
headerFields: nil
)!
return (response, mockData)
}
let result = try await api.fetchUser()
XCTAssertEqual(result.firstName, "Vincent")
XCTAssertEqual(result.lastName, “Pradeilles")
}
}