iOS 애플리케이션에서 클라이언트-서버 상호작용 로직을 분리하는 방법

게시 됨: 2022-03-11

오늘날 대부분의 모바일 애플리케이션은 클라이언트-서버 상호 작용에 크게 의존합니다. 이는 대부분의 무거운 작업을 백엔드 서버로 오프로드할 수 있음을 의미할 뿐만 아니라 이러한 모바일 애플리케이션이 인터넷을 통해서만 사용할 수 있는 모든 종류의 기능을 제공할 수 있도록 합니다.

백엔드 서버는 일반적으로 RESTful API를 통해 서비스를 제공하도록 설계되었습니다. 더 간단한 응용 프로그램의 경우 우리는 종종 스파게티 코드를 생성하고 싶은 유혹을 느낍니다. API를 호출하는 코드를 나머지 애플리케이션 로직과 혼합합니다. 그러나 애플리케이션이 복잡해지고 점점 더 많은 API를 처리함에 따라 이러한 API와 구조화되지 않은 계획되지 않은 방식으로 상호 작용하는 것은 성가신 일이 될 수 있습니다.

잘 설계된 REST 클라이언트 네트워킹 모듈로 iOS 애플리케이션 코드를 깔끔하게 유지하세요.

잘 설계된 REST 클라이언트 네트워킹 모듈로 iOS 애플리케이션 코드를 깔끔하게 유지하세요.
트위터

이 기사에서는 모든 클라이언트-서버 상호 작용 논리를 나머지 애플리케이션 코드와 격리할 수 있는 iOS 애플리케이션용 깨끗한 REST 클라이언트 네트워킹 모듈을 빌드하기 위한 아키텍처 접근 방식에 대해 설명합니다.

클라이언트-서버 애플리케이션

일반적인 클라이언트-서버 상호 작용은 다음과 같습니다.

  1. 사용자가 어떤 동작을 수행합니다(예: 일부 버튼을 탭하거나 화면에서 다른 제스처 수행).
  2. 애플리케이션은 사용자 작업에 대한 응답으로 HTTP/REST 요청을 준비하고 보냅니다.
  3. 서버는 요청을 처리하고 애플리케이션에 따라 응답합니다.
  4. 애플리케이션은 응답을 수신하고 이를 기반으로 사용자 인터페이스를 업데이트합니다.

얼핏 보면 전체 프로세스가 단순해 보이지만 세부 사항에 대해 생각해야 합니다.

백엔드 서버 API가 알려진 대로 작동한다고 가정하더라도(항상 그런 것은 아닙니다 !) 잘못 설계되어 사용하기가 비효율적이거나 심지어 어려울 수 있습니다. 한 가지 일반적인 문제는 API에 대한 모든 호출에서 호출자가 동일한 정보(예: 요청 데이터 형식, 서버가 현재 로그인한 사용자를 식별하는 데 사용할 수 있는 액세스 토큰 등)를 중복으로 제공해야 한다는 것입니다.

모바일 애플리케이션은 다른 목적을 위해 동시에 여러 백엔드 서버를 활용해야 할 수도 있습니다. 예를 들어 한 서버는 사용자 인증 전용이고 다른 서버는 분석 수집만 처리할 수 있습니다.

또한 일반적인 REST 클라이언트는 원격 API를 호출하는 것 이상의 작업을 수행해야 합니다. 보류 중인 요청을 취소하는 기능 또는 오류 처리에 대한 명확하고 관리 가능한 접근 방식은 강력한 모바일 애플리케이션에 구축해야 하는 기능의 예입니다.

아키텍처 개요

REST 클라이언트의 핵심은 다음 구성 요소를 기반으로 합니다.

  • 모델: 백엔드 서버에서 수신하거나 백엔드 서버로 전송되는 데이터 구조를 반영하여 애플리케이션의 데이터 모델을 설명하는 클래스입니다.
  • 파서: 서버 응답을 디코딩하고 모델 객체를 생성하는 일을 담당합니다.
  • 오류: 잘못된 서버 응답을 나타내는 개체입니다.
  • 클라이언트: 백엔드 서버에 요청을 보내고 응답을 받습니다.
  • 서비스: 논리적으로 연결된 작업을 관리합니다(예: 인증, 사용자 관련 데이터 관리, 분석 등).

이러한 각 구성 요소가 서로 상호 작용하는 방식은 다음과 같습니다.

위 이미지의 화살표 1에서 10은 서비스를 호출하는 애플리케이션과 결국 요청된 데이터를 모델 개체로 반환하는 서비스 간의 이상적인 작업 순서를 보여줍니다. 해당 흐름의 각 구성 요소에는 모듈 내에서 우려 사항을 분리하는 특정 역할이 있습니다.

구현

현재 로그인한 사용자의 친구 목록을 로드할 가상의 소셜 네트워크 애플리케이션의 일부로 REST 클라이언트를 구현합니다. 원격 서버가 응답에 JSON을 사용한다고 가정합니다.

먼저 모델과 파서를 구현해 보겠습니다.

원시 JSON에서 모델 객체로

첫 번째 모델인 User 는 소셜 네트워크의 모든 사용자에 대한 정보 구조를 정의합니다. 일을 단순하게 유지하기 위해 이 자습서에 절대적으로 필요한 필드만 포함합니다(실제 응용 프로그램에서는 일반적으로 구조에 더 많은 속성이 있음).

 struct User { var id: String var email: String? var name: String? }

API를 통해 백엔드 서버에서 모든 사용자 데이터를 수신하므로 API 응답을 유효한 User 개체로 구문 분석하는 방법이 필요합니다. 이를 위해 구문 분석된 JSON 객체( Dictionary )를 매개변수로 받아들이는 생성자를 User 에 추가합니다. JSON 객체를 별칭 유형으로 정의합니다.

 typealias JSON = [String: Any]

그런 다음 다음과 같이 User 구조체에 생성자 함수를 추가합니다.

 extension User { init?(json: JSON) { guard let id = json["id"] as? String else { return nil } self.id = id self.email = json["email"] as? String self.name = json["name"] as? String } }

User 의 원래 기본 생성자를 유지하기 위해 User 유형의 확장을 통해 생성자를 추가합니다.

다음으로 원시 API 응답에서 User 객체를 생성하려면 다음 두 단계를 수행해야 합니다.

 // Transform raw JSON data to parsed JSON object using JSONSerializer (part of standard library) let userObject = (try? JSONSerialization.jsonObject(with: data, options: [])) as? JSON // Create an instance of `User` structure from parsed JSON object let user = userObject.flatMap(User.init)

간소화된 오류 처리

백엔드 서버와 상호 작용을 시도할 때 발생할 수 있는 다양한 오류를 나타내는 유형을 정의합니다. 이러한 모든 오류를 세 가지 기본 범주로 나눌 수 있습니다.

  • 인터넷 연결 없음
  • 응답의 일부로 보고된 오류(예: 유효성 검사 오류, 액세스 권한 부족 등)
  • 서버가 응답의 일부로 보고하지 못하는 오류(예: 서버 충돌, 응답 시간 초과 등)

오류 객체를 열거형으로 정의할 수 있습니다. 그리고 우리가 그것에 있는 동안 ServiceError 유형이 Error 프로토콜을 따르도록 하는 것이 좋습니다. 이것은 우리가 (오류를 throw 위해 throw를 사용하는 것과 같은) Swift에서 제공하는 표준 메커니즘을 사용하여 이러한 오류 값을 사용하고 처리하는 것을 허용합니다.

 enum ServiceError: Error { case noInternetConnection case custom(String) case other }

noInternetConnectionother 오류와 달리 사용자 지정 오류에는 연결된 값이 있습니다. 이렇게 하면 서버의 오류 응답을 오류 자체에 대한 관련 값으로 사용할 수 있으므로 오류에 더 많은 컨텍스트를 제공할 수 있습니다.

이제 ServiceError 열거형에 errorDescription 속성을 추가하여 오류를 보다 자세히 설명하겠습니다. noInternetConnectionother 오류에 대해 하드코딩된 메시지를 추가하고 관련 값을 custom 오류에 대한 메시지로 사용합니다.

 extension ServiceError: LocalizedError { var errorDescription: String? { switch self { case .noInternetConnection: return "No Internet connection" case .other: return "Something went wrong" case .custom(let message): return message } } }

ServiceError 열거에서 구현해야 할 것이 한 가지 더 있습니다. custom 오류의 경우 서버 JSON 데이터를 오류 개체로 변환해야 합니다. 이를 위해 모델의 경우에 사용한 것과 동일한 접근 방식을 사용합니다.

 extension ServiceError { init(json: JSON) { if let message = json["message"] as? String { self = .custom(message) } else { self = .other } } }

애플리케이션과 백엔드 서버 간의 격차 해소

클라이언트 구성 요소는 애플리케이션과 백엔드 서버 사이의 중개자가 됩니다. 애플리케이션과 서버가 통신하는 방법을 정의하는 중요한 구성 요소이지만 데이터 모델과 해당 구조에 대해서는 아무것도 모릅니다. 클라이언트는 제공된 매개변수로 특정 URL을 호출하고 JSON 객체로 구문 분석된 수신 JSON 데이터를 반환해야 합니다.

 enum RequestMethod: String { case get = "GET" case post = "POST" case put = "PUT" case delete = "DELETE" } final class WebClient { private var baseUrl: String init(baseUrl: String) { self.baseUrl = baseUrl } func load(path: String, method: RequestMethod, params: JSON, completion: @escaping (Any?, ServiceError?) -> ()) -> URLSessionDataTask? { // TODO: Add implementation } }

위의 코드에서 무슨 일이 일어나고 있는지 살펴봅시다...

먼저 네 가지 일반적인 HTTP 메서드를 설명하는 열거형 RequestMethod 를 선언했습니다. REST API에서 사용되는 메소드 중 하나입니다.

WebClient 클래스에는 수신하는 모든 상대 URL을 확인하는 데 사용되는 baseURL 속성이 포함되어 있습니다. 애플리케이션이 여러 서버와 상호 작용해야 하는 경우 baseURL 에 대해 각각 다른 값을 사용하여 WebClient 의 여러 인스턴스를 만들 수 있습니다.

클라이언트에는 매개변수, 요청 메서드, 요청 매개변수 및 완료 클로저로 baseURL 에 대한 상대 경로를 사용하는 단일 메서드 load 가 있습니다. 완료 클로저는 구문 분석된 JSON 및 ServiceError 를 매개변수로 사용하여 호출됩니다. 현재로서는 위의 방법에 구현이 부족하므로 곧 다루게 될 것입니다.

load 메서드를 구현하기 전에 메서드에서 사용할 수 있는 모든 정보에서 URL 을 만드는 방법이 필요합니다. 우리는 이 목적을 위해 URL 클래스를 확장할 것입니다:

 extension URL { init(baseUrl: String, path: String, params: JSON, method: RequestMethod) { var components = URLComponents(string: baseUrl)! components.path += path switch method { case .get, .delete: components.queryItems = params.map { URLQueryItem(name: $0.key, value: String(describing: $0.value)) } default: break } self = components.url! } }

여기에 기본 URL에 대한 경로를 추가하기만 하면 됩니다. GET 및 DELETE HTTP 메서드의 경우 쿼리 매개변수도 URL 문자열에 추가합니다.

다음으로 주어진 매개변수에서 URLRequest 의 인스턴스를 생성할 수 있어야 합니다. 이를 위해 URL 에 대해 수행한 것과 유사한 작업을 수행합니다.

 extension URLRequest { init(baseUrl: String, path: String, method: RequestMethod, params: JSON) { let url = URL(baseUrl: baseUrl, path: path, params: params, method: method) self.init(url: url) httpMethod = method.rawValue setValue("application/json", forHTTPHeaderField: "Accept") setValue("application/json", forHTTPHeaderField: "Content-Type") switch method { case .post, .put: httpBody = try! JSONSerialization.data(withJSONObject: params, options: []) default: break } } }

여기에서 먼저 확장의 생성자를 사용하여 URL 을 만듭니다. 그런 다음 이 URL 을 사용하여 URLRequest 의 인스턴스를 초기화하고 필요에 따라 몇 가지 HTTP 헤더를 설정한 다음 POST 또는 PUT HTTP 메서드의 경우 요청 본문에 매개변수를 추가합니다.

이제 모든 전제 조건을 다루었으므로 load 메서드를 구현할 수 있습니다.

 final class WebClient { private var baseUrl: String init(baseUrl: String) { self.baseUrl = baseUrl } func load(path: String, method: RequestMethod, params: JSON, completion: @escaping (Any?, ServiceError?) -> ()) -> URLSessionDataTask? { // Checking internet connection availability if !Reachability.isConnectedToNetwork() { completion(nil, ServiceError.noInternetConnection) return nil } // Adding common parameters var parameters = params if let token = KeychainWrapper.itemForKey("application_token") { parameters["token"] = token } // Creating the URLRequest object let request = URLRequest(baseUrl: baseUrl, path: path, method: method, params: params) // Sending request to the server. let task = URLSession.shared.dataTask(with: request) { data, response, error in // Parsing incoming data var object: Any? = nil if let data = data { object = try? JSONSerialization.jsonObject(with: data, options: []) } if let httpResponse = response as? HTTPURLResponse, (200..<300) ~= httpResponse.statusCode { completion(object, nil) } else { let error = (object as? JSON).flatMap(ServiceError.init) ?? ServiceError.other completion(nil, error) } } task.resume() return task } }

위의 load 방법은 다음 단계를 수행합니다.

  1. 인터넷 연결의 가용성을 확인하십시오. 인터넷 연결을 사용할 수 noInternetConnection 오류를 매개변수로 사용하여 즉시 완료 클로저를 호출합니다. (참고: 코드의 Reachability 은 인터넷 연결을 확인하기 위해 일반적인 접근 방식 중 하나를 사용하는 사용자 지정 클래스입니다.)
  2. 공통 매개변수를 추가합니다. . 여기에는 애플리케이션 토큰 또는 사용자 ID와 같은 공통 매개변수가 포함될 수 있습니다.
  3. 확장의 생성자를 사용하여 URLRequest 객체를 만듭니다 .
  4. 서버에 요청을 보냅니다. URLSession 객체를 사용하여 서버에 데이터를 보냅니다.
  5. 들어오는 데이터를 구문 분석합니다. 서버가 응답하면 먼저 JSONSerialization 을 사용하여 응답 페이로드를 JSON 객체로 구문 분석합니다. 그런 다음 응답의 상태 코드를 확인합니다. 성공 코드(예: 200에서 299 사이)인 경우 JSON 개체로 완료 클로저를 호출합니다. 그렇지 않으면 JSON 객체를 ServiceError 객체로 변환하고 해당 오류 객체로 완료 클로저를 호출합니다.

논리적으로 연결된 작업을 위한 서비스 정의

우리 응용 프로그램의 경우 사용자의 친구와 관련된 작업을 처리하는 서비스가 필요합니다. 이를 위해 FriendsService 클래스를 만듭니다. 이상적으로는 이와 같은 클래스가 친구 목록 가져오기, 새 친구 추가, 친구 제거, 일부 친구를 범주로 그룹화 등과 같은 작업을 담당할 것입니다. 이 자습서에서는 단순성을 위해 하나의 메서드만 구현합니다. :

 final class FriendsService { private let client = WebClient(baseUrl: "https://your_server_host/api/v1") @discardableResult func loadFriends(forUser user: User, completion: @escaping ([User]?, ServiceError?) -> ()) -> URLSessionDataTask? { let params: JSON = ["user_id": user.id] return client.load(path: "/friends", method: .get, params: params) { result, error in let dictionaries = result as? [JSON] completion(dictionaries?.flatMap(User.init), error) } } }

FriendsService 클래스에는 WebClient 유형의 client 속성이 포함되어 있습니다. 친구 관리를 담당하는 원격 서버의 기본 URL로 초기화됩니다. 앞서 언급했듯이 다른 서비스 클래스에서는 필요한 경우 다른 URL로 초기화된 WebClient 의 다른 인스턴스를 가질 수 있습니다.

하나의 서버에서만 작동하는 애플리케이션의 경우 WebClient 클래스에 해당 서버의 URL로 초기화하는 생성자가 제공될 수 있습니다.

 final class WebClient { // ... init() { self.baseUrl = "https://your_server_base_url" } // ... }

loadFriends 메서드가 호출되면 필요한 모든 매개변수를 준비하고 FriendServiceWebClient 인스턴스를 사용하여 API 요청을 만듭니다. WebClient 를 통해 서버로부터 응답을 받은 후 JSON 객체를 User 모델로 변환하고 이를 매개변수로 사용하여 완료 클로저를 호출합니다.

FriendService 의 일반적인 사용법은 다음과 같습니다.

 let friendsTask: URLSessionDataTask! let activityIndicator: UIActivityIndicatorView! var friends: [User] = [] func friendsButtonTapped() { friendsTask?.cancel() //Cancel previous loading task. activityIndicator.startAnimating() //Show loading indicator friendsTask = FriendsService().loadFriends(forUser: currentUser) {[weak self] friends, error in DispatchQueue.main.async { self?.activityIndicator.stopAnimating() //Stop loading indicators if let error = error { print(error.localizedDescription) //Handle service error } else if let friends = friends { self?.friends = friends //Update friends property self?.updateUI() //Update user interface } } } }

위의 예에서 우리는 사용자가 네트워크에서 친구 목록을 보여주기 위한 버튼을 누를 때마다 friendsButtonTapped 함수가 호출된다고 가정합니다. 또한 friendsTask?.cancel() 을 호출하여 언제든지 요청을 취소할 수 있도록 friendsTask 속성에 작업에 대한 참조를 유지합니다.

이를 통해 보류 중인 요청의 수명 주기를 더 잘 제어할 수 있으므로 관련성이 없다고 판단되면 요청을 종료할 수 있습니다.

결론

이 기사에서는 구현하기 쉽고 대부분의 iOS 애플리케이션의 복잡한 네트워킹 요구 사항에 맞게 조정할 수 있는 iOS 애플리케이션용 네트워킹 모듈의 간단한 아키텍처를 공유했습니다. 그러나 여기서 중요한 점은 적절하게 설계된 REST 클라이언트와 그에 수반되는 구성 요소(나머지 애플리케이션 로직과 분리되어 있음)는 애플리케이션 자체가 점점 더 복잡해지는 경우에도 애플리케이션의 클라이언트-서버 상호 작용 코드를 단순하게 유지하는 데 도움이 될 수 있다는 것입니다. .

이 기사가 다음 iOS 애플리케이션을 구축하는 데 도움이 되기를 바랍니다. GitHub에서 이 네트워킹 모듈의 소스 코드를 찾을 수 있습니다. 코드를 확인하고, 분기하고, 변경하고, 가지고 놀아보세요.

귀하와 귀하의 프로젝트에 더 적합한 다른 아키텍처를 찾으면 아래 의견 섹션에서 세부 정보를 공유하십시오.

관련 항목: Mantle 및 Realm을 사용하여 iOS에서 RESTful API 사용 및 데이터 지속성 간소화