-
Notifications
You must be signed in to change notification settings - Fork 1
[Protocol] DataProviding
#Open/Closed principle #Single Responsibility principle #Dependency inversion principle
ViewModel
에서 데이터 요청 로직을 분리해 별도의 모듈로 관리합니다.
-
ViewModel
로부터 데이터 요청 로직을 분리해 책임을 분산시킵니다. - 객체 간 조합을 가능하게 해 확장성을 개선합니다.
- 객체간 의존성을 줄여 테스트를 용이하게 만듭니다.
- 재사용성을 늘려 반복되는 코드 작업을 줄입니다.
- 프로젝트는 크게 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
ViewModel
의 데이터 호출 책임이 분리되어 단일 책임 원칙을 준수하게 되었습니다.
하지만 아직 2가지 문제점을 갖고 있습니다.
-
RestaurantDataProvider
의 역할이 ‘필요한 모든 데이터를 집산해서 전달’하는 것으로 매겨지며 책임이 과중해졌습니다(단일 책임 원칙 위배). 이는ViewModel
의 책임이 다른 객체에 전가된 것에 지나지 않습니다. -
RestaurantDataProvider
가ViewModel
에 전달되는 데이터를 전담하면서 데이터의 구조가 조금이라도 바뀌면RestaurantDataProvider
의 코드가 수정되어야 하는 문제가 생깁니다. 이는 개방폐쇄 원칙에 위배됩니다.
이제 RestaurantDataProvider
의 책임을 분리해보겠습니다. 개선된 데이터 구조는 다음과 같습니다.
graph LR
subgraph DataProvider
FoodQuery --> DefaultProvider
DrinkQuery --> DefaultProvider
ReservationQuery --> DefaultProvider
DefaultProvider --Subprotocol --> RestaurantDataProvider
end
RestaurantDataProvider--> RestaurantViewModel
RestaurantViewModel --> RestaurantViewController
-
이전과는 다르게
Query
의 형태로 명령어가 전달되는 것을 볼 수 있습니다. 여기서Query
는 ‘맡은 데이터를 호출 및 사용가능하게 손질’하는 역할을 갖습니다. 이는 Command 패턴을 따른 예시입니다. -
이를 통해 Food, Drink, Reservation 등의 모델을 부르고 가공하는 로직이
Provider
로부터 분리되어 전역에서 공유할 수 있게 되었고 따라서 재사용성이 개선되었습니다. -
Provider
는 이제Query
를 처리하는 중간 레이어로서Query
의 Side-effect를 관리하는 역할을 하게 됩니다. 이를테면Query
를 외부 의존성에 따라 재가공하거나 저장소에 요청하는 등의 동작을 수행할 수 있습니다. -
현 흐름에서
Provider
는 데이터 구조가 변경된다 하더라도Query
요청과 가공이라는 본질적인 역할이 변하지 않습니다.Query
역시Provider
의 구조 변경으로부터 자유롭습니다. 이로써Provider
와Query
는 각각 단일책임 원칙과 개방폐쇄 원칙을 준수하게 되었습니다.
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
-
Query
를 조합해 새로운Query
를 만드는 것 역시 가능합니다. 본 프로젝트의 경우Query
는Queryable
프로토콜에 의해RxSwift.Single
타입을 반환하도록 강제되기 때문에 서로 다른Query
타입을merge
혹은map
하여 필요한 타입으로 변환시킬 수 있습니다. - 이는 개발자로부터 모든 상황에 해당하는
Query
를 작성해야 한다는 부담을 덜어줍니다. 또한 하위 구현이 상위 구현에 영향을 미치지 않는 구조가 되어 의존성 역전원칙 역시 준수할 수 있습니다. - 상위
DataProvier
의 Sub 프로토콜을 만들어 Type-safe한 동작을 강제할 수도 있습니다. 도표의 예시에서OperationDataProvider
는EnvironmentQuery
만을 수행할 수 있게 강제할 수 있습니다.
다음은 프로젝트 내에서 실제 구현된 예시입니다
**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
프로토콜을 준수해야합니다. -
DataProviding
은Queryable
프로토콜을 따르는Query
를 인자로 받습니다. - 예시처럼
extension
에 기본 동작을 미리 구현하면 반복되는 구현 작업을 줄일 수 있습니다.
// Default
**public final class DefaultProvider: DataProviding** {
public static let shared = DefaultProvider()
}
-
DataProviding
프로토콜에fetch
동작이 미리 정의되어 있으므로 프로토콜을 채택함과 동시에 바로 사용할 수 있게 됩니다.DefaultProvider
를 싱글톤 객체로 정의해 커스텀이 필요하지 않은 동작은 별도의 선언 없이 바로 사용할 수 있게 만드는 것도 가능합니다.
// 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
를 사용해 데이터를 저장/조회할 경우에 대한 예시입니다.
**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 패턴을 이용해 데이터의 조회 책임을 분산시키는 리팩토링을 진행한 결과입니다.
- 이로써 뷰 모델과 데이터 처리 로직이 개방폐쇄 원칙, 단일 책임 원칙, 의존성 역전의 원칙을 준수하게 되었습니다.