URLProtocol을 통해 Mock 데이터로 서버 의존도 줄이기

서론

  • 거의 모든 앱은 서버 의존도가 필수불가결적으로 높습니다. 앱 개발자로써 서버가 다운되거나, 점검 중이거나, 배포 전일 때에도 계속 개발을 지속할 수 있어야합니다. 그래서 서버의 응답이 완료 될 떄에만 앱이 실행되는 것은 굉장히 불리한 조건을 갖게 됩니다.
  • 우리 편행 프로젝트에서도 이 기술을 활용하여 서버 의존도를 낮추고 효과적으로 개발에 임할 수 있었습니다.
  • 이번 시간에는 URLProtocol을 이용해 서버 없이도 개발을 효과적으로 진행하는 방법을 소개해드리겠습니다.

 

URLProtocol

  • URLProtocol은 네트워크 요청을 실제 데이터로 받지 않고 Mock 데이터로 받을 수 있게 할 수 있는 객체입니다.
  • URLSession의 Configuration을 통해 주입이 되어야합니다.

일반적인 네트워크 통신
URLProtocol을 이용한 네트워크 통신

  • 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, dataerror를 전달할 수 있습니다. 결과값과 에러를 커스텀할 수 있게되니 네트워크를 테스팅하기에 좋은 요건을 갖추고 있습니다.

 

테스팅 하기

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")
    }
}

 

Reference