Skip to content

[Protocol] DataProviding

YoungBin Lee edited this page Feb 7, 2023 · 1 revision

DataProviding- 프로토콜을 이용해 확장성 있는 데이터 불러오기

#Open/Closed principle #Single Responsibility principle #Dependency inversion principle

목표

ViewModel에서 데이터 요청 로직을 분리해 별도의 모듈로 관리합니다.

개선하기

  1. ViewModel로부터 데이터 요청 로직을 분리해 책임을 분산시킵니다.
  2. 객체 간 조합을 가능하게 해 확장성을 개선합니다.
  3. 객체간 의존성을 줄여 테스트를 용이하게 만듭니다.
  4. 재사용성을 늘려 반복되는 코드 작업을 줄입니다.

아이디어

동기

  • 프로젝트는 크게 3가지(FileManager, Firebase, App server(Oracle))의 저장소를 사용하고 있습니다. 다루는 모델이 많지 않았던 개발 초기에는 호출 로직이 복잡하지 않아 ViewModel 등에서 직접적으로 각 저장소에 데이터를 요청해도 문제가 없었습니다.
  • 그러나 모델이 다양해짐에 따라 데이터 호출 로직이 복잡해졌고 코드의 복잡도가 증가하는 문제가 발생했습니다. 이를테면, Firebase에서 이미지 호출을 기다린 후 이를 App server에서 받아온 데이터에 조합해 뷰에 전달하는 작업을 예로 들 수 있습니다.
  • 그에 따라 저장소 호출 로직을 ViewModel로부터 분리하여 사용할 수 있는 방식이 필요했습니다.

문제

먼저 단순히 호출 로직만을 분리해보겠습니다. 구조는 다음과 같습니다.

graph LR

subgraph DataProvider
	dp[RestaurantDataProvider]
	id1([Food, Drink, Reservation, etc.])

end
dp--> RestaurantViewModel 
RestaurantViewModel --> RestaurantViewController
Loading

ViewModel의 데이터 호출 책임이 분리되어 단일 책임 원칙을 준수하게 되었습니다.

하지만 아직 2가지 문제점을 갖고 있습니다.

  1. RestaurantDataProvider의 역할이 ‘필요한 모든 데이터를 집산해서 전달’하는 것으로 매겨지며 책임이 과중해졌습니다(단일 책임 원칙 위배). 이는 ViewModel의 책임이 다른 객체에 전가된 것에 지나지 않습니다.
  2. RestaurantDataProviderViewModel에 전달되는 데이터를 전담하면서 데이터의 구조가 조금이라도 바뀌면 RestaurantDataProvider 의 코드가 수정되어야 하는 문제가 생깁니다. 이는 개방폐쇄 원칙에 위배됩니다.

개선

이제 RestaurantDataProvider 의 책임을 분리해보겠습니다. 개선된 데이터 구조는 다음과 같습니다.

graph LR

subgraph DataProvider
		FoodQuery --> DefaultProvider
		DrinkQuery --> DefaultProvider
		ReservationQuery --> DefaultProvider

DefaultProvider --Subprotocol --> RestaurantDataProvider
end

RestaurantDataProvider--> RestaurantViewModel 

RestaurantViewModel --> RestaurantViewController
Loading
  • 이전과는 다르게 Query의 형태로 명령어가 전달되는 것을 볼 수 있습니다. 여기서 Query는 ‘맡은 데이터를 호출 및 사용가능하게 손질’하는 역할을 갖습니다. 이는 Command 패턴을 따른 예시입니다.

  • 이를 통해 Food, Drink, Reservation 등의 모델을 부르고 가공하는 로직이 Provider로부터 분리되어 전역에서 공유할 수 있게 되었고 따라서 재사용성이 개선되었습니다.

  • Provider는 이제 Query를 처리하는 중간 레이어로서 Query의 Side-effect를 관리하는 역할을 하게 됩니다. 이를테면 Query를 외부 의존성에 따라 재가공하거나 저장소에 요청하는 등의 동작을 수행할 수 있습니다.

  • 현 흐름에서 Provider는 데이터 구조가 변경된다 하더라도 Query 요청과 가공이라는 본질적인 역할이 변하지 않습니다. Query 역시 Provider의 구조 변경으로부터 자유롭습니다. 이로써 ProviderQuery는 각각 단일책임 원칙과 개방폐쇄 원칙을 준수하게 되었습니다.

graph LR

subgraph DataProvider

	subgraph Menu
		FoodQuery
		DrinkQuery
	end
	
	FoodQuery --> MenuQuery 
	DrinkQuery --> MenuQuery
	MenuQuery --> RestuarntDataProvider
	
	subgraph Environment
		ReservationQuery
		AvailableEmployeeQuery
	end
	
	ReservationQuery --> EnvironmentQuery
	AvailableEmployeeQuery --> EnvironmentQuery

	EnvironmentQuery --> RestuarntDataProvider 
	EnvironmentQuery --> OperationDataProvider

end

OperationDataProvider --> ...

RestuarntDataProvider --> RestaurantViewModel 
RestaurantViewModel --> RestaurantViewController
Loading
  • Query를 조합해 새로운 Query를 만드는 것 역시 가능합니다. 본 프로젝트의 경우 QueryQueryable 프로토콜에 의해 RxSwift.Single 타입을 반환하도록 강제되기 때문에 서로 다른 Query 타입을 merge 혹은 map 하여 필요한 타입으로 변환시킬 수 있습니다.
  • 이는 개발자로부터 모든 상황에 해당하는 Query를 작성해야 한다는 부담을 덜어줍니다. 또한 하위 구현이 상위 구현에 영향을 미치지 않는 구조가 되어 의존성 역전원칙 역시 준수할 수 있습니다.
  • 상위 DataProvier의 Sub 프로토콜을 만들어 Type-safe한 동작을 강제할 수도 있습니다. 도표의 예시에서 OperationDataProviderEnvironmentQuery만을 수행할 수 있게 강제할 수 있습니다.

예시

다음은 프로젝트 내에서 실제 구현된 예시입니다

Root protocol

**public protocol DataProviding** {
    func fetch<Query: Queryable>(_ model: Query) -> Single<Query.ResultType>
}

**extension DataProviding** {
    public func fetch<Query>(_ model: Query) -> Single<Query.ResultType> where Query : Queryable {
        return model.singleTrait
    }
}

**public protocol Queryable** {
    associatedtype ResultType
    
    var singleTrait: Single<ResultType> { get }
}
  • 모든 DataProvider 들은 DataProviding 프로토콜을 준수해야합니다.
  • DataProvidingQueryable 프로토콜을 따르는 Query를 인자로 받습니다.
  • 예시처럼 extension에 기본 동작을 미리 구현하면 반복되는 구현 작업을 줄일 수 있습니다.

DataProvier - Default

// Default
**public final class DefaultProvider: DataProviding** {
    public static let shared = DefaultProvider()
}
  • DataProviding 프로토콜에 fetch 동작이 미리 정의되어 있으므로 프로토콜을 채택함과 동시에 바로 사용할 수 있게 됩니다. DefaultProvider를 싱글톤 객체로 정의해 커스텀이 필요하지 않은 동작은 별도의 선언 없이 바로 사용할 수 있게 만드는 것도 가능합니다.

DataProvier - Custom

// Custom
**public struct HomeViewDataProvider: DataProviding** {
    private let localStorage: UserDefaults
    
    public init(localStorage: UserDefaults = .standard) {
        self.localStorage = localStorage
    }

		public func fetch<Query>(_ model: Query) -> Single<Query.ResultType> where Query : Queryable {
        let isSafe = self.localStorage.bool(forKey: "safetyFlag")
        if isSafe {
            return model.singleTrait
        } else {
            return .error(SomeError)
        }
    }
}
  • DataProvider가 외부에서 참조할 데이터가 필요하거나 side-effect를 발생시킬 경우 새로운 객체를 구현할 수 있습니다. 위 경우 처리 로직이 UserDefault를 사용해 데이터를 저장/조회할 경우에 대한 예시입니다.

View Model

**class HomeViewModel<Provider: DataProviding>** {
		private var dataProvider: HomeViewDataProvider
	   ...

    **func fetchRemoteData()** {
        dataProvider.fetch(BadgeQuery())
            .subscribe(on: ConcurrentDispatchQueueScheduler(qos: .userInitiated))
            .subscribe(
                onSuccess: { [weak self] data in
                    guard let self else { return }
                    
                    self.badges = data
                }
            )
            .disposed(by: bag)
        
        dataProvider.fetch(ArticleQuery(size: 10))
            .subscribe(on: ConcurrentDispatchQueueScheduler(qos: .userInitiated))
            .subscribe(onSuccess: { [weak self] articles in
                guard let self else { return }
                
                self.articles = articles
            })
            .disposed(by: bag)

				...
    }

	...

}
  • ViewModel에서는 DataProvider를 선언하여 사용합니다. fetch 메소드는 Single 타입을 반환하므로 ViewModel 혹은 ViewController에서 이를 subscribe하여 사용합니다.
**public struct HomeQuery<T: DataProviding>: Queryable** {
    let dataProvider: T
    
    public var singleTrait: Single<([Badge], MyInfo, [RecentMorning], [Article])> {
        let badges = dataProvider.fetch(BadgeQuery())
        let myInfo = dataProvider.fetch(MyInfoQuery())
        let recentMorning = dataProvider.fetch(MyMorningQuery())
        let articles = dataProvider.fetch(ArticleQuery(size: 10))
        
        return Single.zip(badges, myInfo, recentMorning, articles)
    }
    
    public init(_ dataProvider: T = HomeViewDataProvider()) {
        self.dataProvider = dataProvider
    }
}

// View Model
**func fetchRemoteData()** {
        dataProvider.fetch(HomeQuery())
            .subscribe(on: ConcurrentDispatchQueueScheduler(qos: .userInitiated))
            .subscribe(
                onSuccess: { [weak self] badges, info, mornings, articles in
                    guard let self else { return }

										...

                }
            )
            .disposed(by: bag)

		...
}
  • zip 연산자를 이용해 여러 개의 Query를 조합할 수도 있습니다. 단 zip을 사용할 경우 구성 Query의 결과가 한 개라도 실패할 경우 모두 실패한 것으로 간주되니 주의해야 합니다.

정리

  • 이상 Command 패턴을 이용해 데이터의 조회 책임을 분산시키는 리팩토링을 진행한 결과입니다.
  • 이로써 뷰 모델과 데이터 처리 로직이 개방폐쇄 원칙, 단일 책임 원칙, 의존성 역전의 원칙을 준수하게 되었습니다.

참고

Commad pattern

https://github.com/apollographql/apollo-ios