 Apple Lover Developer & Artist

영속적인 디자인에 현대의 공감을 채워넣는 공방입니다

 Apple/iOS Dev Challenges

[Challenge] 🛠️ iOS 앱 설계 퓨전 레시피 7부 - 네트워킹 코드 모델링

singularis7 2023. 2. 28. 14:32
반응형

Overview

음식점 서버 API와의 네트워크 통신 기능을 구현해 보고 공통 작업을 추상화시킨다. 들어가기 앞서 Swift Concurrency, Protocol, Generic 개념을 활용하고 있기에 이를 익히고 보면 포스팅을 재미있게 즐길 수 있다!

Networking

Common Process

서버 API를 활용해 클라이언트가 데이터를 요청하고 응답받을 수 있도록 도와주는 객체가 있다. Foundation 프레임워크의 URLSession 이 그 주인공이다. Fetching Website Data into Memory- Apple Developer 아티클을 통해 URLSession을 활용하여 통신 코드를 작성하는 예시를 확인할 수 있다. 쉽게 말하자면 통신 코드가 구현하는 작업은 크게 3가지로 구분할 수 있다.

  1. 웹서비스의 URL을 담은 객체를 생성한다.
  2. URL 객체를 활용해 서버에 데이터를 요청한다.
  3. 데이터 요청 작업의 성공 및 실패 여부를 확인하고 알맞은 작업을 수행한다.
    • 성공한 경우 응답 데이터를 (필요시) 특정 타입으로 디코딩하여 반환한다.
    • 실패한 경우 에러를 던진다.

Problem

모델링 포스팅에서 서버 API의 명세를 확인할 수 있었다. 이에 대응하는 클라이언트 메서드를 구현할 때 발생할 수 있는 문제점이 있다. 서버의 명세와 통신용 공통 작업이 혼합되어 구현된다는 점이다.

예를 들어 API 명세가 변경될 때를 생각해보자. 이는 다분히 발생할 수 있는 경우이나 책임이 혼합된 클라이언트 코드에서는 명확한 수정 지점을 찾기 어렵다. 만약 통신 공통 로직을 수정하기라도 하면 최악의 경우 모든 개별 통신 메서드를 수정해야 할 수도 있다.

Modeling

책임을 분리해주는 네트워크 중간 추상화 계층을 설계하여 문제를 해결해 본다. 목표는 공통 통신 작업과 API 명세에 책임을 분리하여 관리할 수 있는 구조를 설계하는 것이다.

Outline

다시 한번 책임의 두가지 축을 생각해 본다. 네트워크 공통 작업을 전문적으로 담당해 줄 객체와 API 명세 정보를 관리해 줄 객체가 필요하다.

네트워크 처리 객체를 통해 통신 과정에 필요한 공통 절차를 추상화한다. API 명세 관리 객체의 경우 변화 가능성이 높기 때문에 API 명세 객체와 소통할 객체가 특정 API 명세의 구상 클래스와 직접적인 의존성을 갖지 않도록 역전시켜 준다.

코드의 사용자 관점에서 생각해 본다. 캡슐화된 API 명세 객체를 통신 담당 객체의 메서드에 전달하는 방식으로 사용할 수 있도록 설계할 필요가 있다.

Code Implement

API 명세 객체

API 명세 객체는 API 명세 정보를 관리하고 서버로부터 받은 응답 타입에 맞춰 디코딩해줄 수 있어야 한다. API 혹은 Http에서 제공하는 상태 코드를 해석해서 오류를 감지 시 외부로 던질 수 있어야 한다.

구현

Swift 프로토콜을 활용하여 외부 객체가 구상 클래스가 아닌 추상화된 API 명세에 의존하도록 DIP 메커니즘을 활용한다. 새롭게 연동해야 할 API가 생겨도 다형성의 원리로 손쉽게 확장할 수 있다. API의 반환 타입의 경우 개별적으로 변경될 수 있다. 반환 타입을 일반화된 형태로 프로토콜에 도입할 수 있도록 Associated type 개념을 활용하였다. 구현된 코드는 다음과 같다.

protocol APIRequest {
    associatedtype Response

    var urlRequest: URLRequest? { get }
    func decodeResponse(data: Data) throws -> Response

    @discardableResult
    func verify(response: URLResponse) throws -> Bool
}

API Request에 정리된 API 명세 정보는 URLRequest로 캡슐화되어 네트워크 관리 객체에 전달된다. 네트워크 관리 객체의 경우 앞서 정의한 공통 작업을 구현하여 서버와 실질적으로 통신할 수 있도록 한다. Network Controller로 네이밍 하였으며 다음과 같이 구현하였다.

class NetworkController {
    func send<Request: APIRequest>(request: Request) async throws -> Request.Response {
        guard let urlRequest = request.urlRequest else {
            throw APIRequestError.invalidApiURL
        }

        let (data, response) = try await URLSession.shared.data(for: urlRequest)
        try request.verify(response: response)
        let decodedResponse = try request.decodeResponse(data: data)

        return decodedResponse
    }

    enum APIRequestError: Error {
        case invalidApiURL
    }
}

Network Controller의 Send는 제네릭 메서드로 정의되었다. APIRequest 프로토콜을 준수하는 타입만 받을 수 있도록 타입 제약을 걸어두었다. 내부적으론 기본 URLSession을 활용해 서버에 비동기 네트워크 요청을 보낸다.

APIRequest 프로토콜을 통해 서버 엔드포인트 경로 및 각종 파라미터 정보를 urlRequest를 통해 받을 수 있고 검증 코드를 통해 오류를 검증할 수 있으며 문제가 없다면 디코딩한 응답 데이터를 반환할 수 있게 되었다. (참조: 템플릿 메서드 디자인 패턴)

API 연동

APIRequest를 활용해 음식점 서버 API를 연동해 본다. 음식점 서버에서 제공하는 api 엔드 포인트를 연동할 것이기 때문에 APIRequest 프로토콜을 상속받은 RestaurantAPIRequest를 정의하여 baseURL을 정의해 준다.

protocol RestaurantAPIRequest: APIRequest {
    var baseURL: URL? { get }
}

extension RestaurantAPIRequest {
    var baseURL: URL? {
        URL(string: "http://localhost:8080/")
    }
}

extension을 통해 프로토콜 기본 구현을 붙여주는 방식으로 베이스 서버 경로를 붙여주었다. 프로토콜 기본 구현은 코드를 프로토콜로 묶어서 공유할 수 있는 좋은 도구이다. 예시 코드에서 프로토콜 기본 구현을 활용하는 방식으로 Restaurant API 서버의 베이스 경로를 한 곳에서 관리할 수 있게 되었다.

이번 프로젝트에서는 하나의 서버 프로그램을 통해서만 API를 서비스하고 있다. 만약 연동할 베이스 서버가 여러 개라면 RestaurantAPIRequest의 네이밍을 일반화된 형태로 고쳐보는 것도 방법일 수 있다.

음식점 서버의 카테고리 명단을 불러올 수 있는 /categories Get api를 RestaurantAPIRequest를 통해 연동 보자! 방법은 간단하다. 새로운 구조체 타입을 만들 때, RestaurantAPIRequest 프로토콜을 채택하면 된다. 프로토콜의 요구사항은 Xcode 자동 완성 기능을 통해 자동으로 채워 넣을 수 있다. 구현이 완료되면 다음과 같다.

struct RestaurantCategoriesGetAPIRequest: RestaurantAPIRequest {
    typealias Response = CategoriesResponse

    private var baseCategoryURL: URL? {
        baseURL?.appendingPathComponent("categories")
    }

    var urlRequest: URLRequest? {
        guard let baseCategoryURL = baseCategoryURL else {
            return nil
        }
        return URLRequest(url: baseCategoryURL)
    }

    func decodeResponse(data: Data) throws -> Response {
        let categories = try JSONDecoder().decode(Response.self, from: data)
        return categories
    }

    func verify(response: URLResponse) throws -> Bool {
        guard let httpResponse = response as? HTTPURLResponse,
              httpResponse.statusCode == 200 else {
            throw ResponseError.categoriesNotFound
        }
        return true
    }

    enum ResponseError: Error, LocalizedError {
        case categoriesNotFound
    }
}

프로토콜 연관 타입인 Response에 타입 별칭으로 네트워크 응답 모델을 연결시켜 주었다. Api 경로를 만들기 위해 baseURL에 path component를 붙여주는 방식으로 경로를 확장하였다. 이 URL을 가지고 URL Request를 만들게 된다. 서버의 응답 데이터는 JSON 표현법으로 넘어오기 때문에 JSONDecoder를 활용해 Response 타입으로 디코딩해 주었다. 각 api 별로 발생할 수 있는 에러 케이스를 nested type으로 정의하여 다른 api와 네임스페이스가 곂치지 않도록 처리해 주었다. 검증 메서드에서 에러 케이스를 활용해 http 상태 코드를 뜯어봤을 때 오류가 발생하면 관련 오류 케이스를 외부에 던진다.

핵심은 API 엔드 포인트 단위로 응답 타입, 디코딩, 검증, 에로 등의 정보를 모아두어 한 곳에서 관리한다는 것이다. 앞서 소개한 문제점 중 API가 수정되었을 때 코드에서 수정할 부분이 명확하지 않아 생산성이 떨어지는 문제점을 지적했는데 이제는 프로퍼티별 명확히 구분되어 있어 수정 지점을 직관적으로 찾을 수 있다.

나머지 API도 이와 유사한 방식으로 명세서 대로 구현해 주면 된다. 코드 예시는 Order-App-Toy-Project: GitHub를 참조하면 된다.

Model Controller

앞서 연동한 APIRequest 모델과 네트워크 컨트롤러를 사용하여 실제 요청을 보낼 수 있는 지점을 구현한다. 필자의 경우 RestaurantController로 네이밍 해주었다. 이 모델 컨트롤러를 통해 서버 api를 iOS 일반 비동기 메서드의 사용성과 동일하게 활용할 수 있게 된다.

class RestaurantController {
    typealias MinutesToPrepare = Int

    private let networkController = NetworkController()

    func submitOrder(forMenuIDs menuIDs: [Int]) async throws -> MinutesToPrepare {
        let apiRequest = RestaurantOrderPostAPIRequest(menuIDs: menuIDs)
        let result = try await networkController.send(request: apiRequest)
        return result.preperationTime
    }

    func fetchCategories() async throws -> [String] {
        let apiRequest = RestaurantCategoriesGetAPIRequest()
        let result = try await networkController.send(request: apiRequest)
        return result.categories
    }

    func fetchMenuItems(forCategory categoryName: String) async throws -> [MenuItem] {
        let apiRequest = RestaurantMenuItemsGetAPIRequest(categoryName: categoryName)
        let result = try await networkController.send(request: apiRequest)
        return result.items
    }

    func fetchImage(from url: URL) async throws -> Data {
        let apiRequest = ImageGetAPIRequest(baseURL: url.formatted())
        let result = try await networkController.send(request: apiRequest)
        return result
    }
}

Unit Test

이제까지 연동한 API가 정상적으로 동작하는지 검증해 본다. Xcode의 단위 테스트 기능을 활용하여 간단한 테스트 코드를 작성해 보았다. 코드를 수정할 때마다 기존의 테스트 코드를 빠르게 검증하여 오류 지점을 찾을 수 있게 되었다.

import XCTest
@testable import OrderApp

final class OrderAppTests: XCTestCase {

    private var restaurantController: RestaurantController!

    override func setUpWithError() throws {
        self.restaurantController = RestaurantController.shared
    }

    override func tearDownWithError() throws {
        self.restaurantController = nil
    }

    func test_id_is_5_submitOrder_return_int() async throws {
        let result = try await self.restaurantController.submitOrder(forMenuIDs: [5])
        XCTAssertEqual(result, 5)
    }

    func test_fetchCategories_return_string_array_with_contents() async throws {
        let result = try await self.restaurantController.fetchCategories()
        XCTAssertFalse(result.isEmpty)
    }

    func test_appetizers_fetchMenuItems_return_menuItem_array_with_contents() async throws {
        let result = try await self.restaurantController.fetchMenuItems(forCategory: "appetizers")
        XCTAssertFalse(result.isEmpty)
    }

    func test_salads_fetchMenuItems_return_menuItem_array_with_contents() async throws {
        let result = try await self.restaurantController.fetchMenuItems(forCategory: "salads")
        XCTAssertFalse(result.isEmpty)
    }

    func test_soups_fetchMenuItems_return_menuItem_array_with_contents() async throws {
        let result = try await self.restaurantController.fetchMenuItems(forCategory: "soups")
        XCTAssertFalse(result.isEmpty)
    }

    func test_sandwiches_fetchMenuItems_return_menuItem_array_with_contents() async throws {
        let result = try await self.restaurantController.fetchMenuItems(forCategory: "sandwiches")
        XCTAssertTrue(result.isEmpty)
    }

    func test_entrees_fetchMenuItems_return_menuItem_array_with_contents() async throws {
        let result = try await self.restaurantController.fetchMenuItems(forCategory: "entrees")
        XCTAssertFalse(result.isEmpty)
    }

    func test_desserts_fetchMenuItems_return_menuItem_array_with_contents() async throws {
        let result = try await self.restaurantController.fetchMenuItems(forCategory: "desserts")
        XCTAssertTrue(result.isEmpty)
    }

}

마무리

음식점 서버 API와의 네트워크 통신 기능을 구현하고 네트워크 공통 작업을 Network Controller를 통해 구현해 보았다. 발생할 수 있는 문제점을 생각해 보며 이를 개선하기 위한 디자인을 생각해 보았다. Swift Concurrency, Protocol, Generic 개념을 효율적인 비동기 처리 및 추상화 과정에서 활용해 보았다.

다음 시간에는 데이터를 UIKit View와 연동해 보는 작업을 진행해 볼 것이다. 😆

반응형