-
Notifications
You must be signed in to change notification settings - Fork 0
[iOS] Concurrency와 Combine의 공존
서비스를 구현하면서 Combine
과 Concurrency
의 사용에 대해서 고민하게 되었습니다.
팀원들과 같이 고민하다보니
Combine
은 데이터 스트리밍을 조작하고 구독하는 것에 최적화 되어있다는 느낌을,
Concurrency
는 단발성 비동기 응답을 한 번 받는 것에 집중한다는 느낌을 받게 되었습니다.
그래서 이번 프로젝트에서는 각 장점을 살려 Combine
은 UI Binding에, Concurrency
는 네트워크 통신에 활용해보기로 했습니다.
우리는 ViewModel
에서 단방향 Flow를 구현하기 위해 Combine
을 통해 Action → SideEffect → State
의 스트림을 만들어놓은 상태였는데, 그래서 네트워크 통신에서 async
로 받아온 데이터를 ViewModel
에서 편하게 스트림으로 연결하려면 이를 Combine Publisher
로 변환하는 작업이 필요했습니다.
Note
설계하는 과정에서 후보는 2가지가 있었습니다.
- ViewModel에서 UseCase에 async로 요청하고, 변환을 ViewModel에서 한다.
- UseCase에서 Publisher로 변환해 ViewModel에서 연결한다.
-
1번 방법의 경우,
UseCase → Repository → Network
의 흐름이 모두Concurrency
로 이루어져 코드가 직관적이고, 깔끔하다는 장점이 있었지만
ViewModel
에서 변환하는 과정이 뎁스가 크고 사용하기 불편하다는 단점이 있었습니다. -
2번 방법의 경우,
반대로ViewModel
에서 스트림으로 연결하는 작업이 깔끔하고 편했고, 데이터의 변환(async → Combine)
역할을UseCase
로 분리할 수 있었습니다.
그래서 최종적으로 2번 방법으로 선택하기로 했고,
아래 그림처럼 UseCase
에서는 데이터를 변환하고 비즈니스 로직을 적용하는 역할을,
Repository
에게는 Domain-Data 사이의 데이터 Mapping과 데이터 소스에 접근하는 역할을 맡기게 되었습니다.
개인적으로는 Repository
에게도 “~~데이터를 가져와줘!” 라는 단발성 비동기 요청을 하는 느낌이라 처음 생각했던 Concurrency
의 역할에도 잘 맞는다고 생각이 들었고, UseCase
에서 데이터 변환을 맡는 것도 우리 팀이 생각했던 역할 분담 내에서 자연스럽다는 느낌이 들었습니다!
ViewModel
에서는 아래와 같이 응답 결과에 따라 SideEffect
를 흘려보내줄 수 있었습니다. 🙂
extension HomeViewModel {
func fetchHomeList() -> SideEffectPublisher {
return homeUseCase.fetchHomeList()
.map { travelList in
return HomeSideEffect.showHomeList(travelList)
}
.catch { error in
return Just(HomeSideEffect.loadFailed(error))
}
.eraseToAnyPublisher()
}
}
다만, Concurrency에서 Combine으로 변환하는 작업 자체가 그다지 부드럽진 않은 것 같았어요.
예를 들어, Concurrency는 Combine의 Future를 대체할 수 있다고 공식문서에서 얘기하는데 결국 Combine으로 변환하는 작업에서는 Future를 사용하게 된다던지..
저희는 UseCase에 Concurrency를 Combine으로 변환하는 역할을 맡겼는데, 아래와 같은 사용하기 불편한 보일러플레이트가 지속적으로 발생했습니다. 🥲
UseCase에서 비즈니스 로직을 적용하고 Combine으로 변환해주는 작업은 아주 빈번한 일이라 좀 더 편하고 읽기 쉽게 개선할 수 없을지 처음부터 고민이 되었습니다.
그래서 Future의 extension으로 async를 깔끔하게 변환할 수 있는 convenience init을 구현해봤어요!
extension Future where Failure == Error {
/// async 응답 결과를 Future publisher로 변환합니다.
/// - Parameter asyncFulfill: 변환할 async 응답
convenience init(_ asyncFulfill: @escaping () async throws -> Output) {
self.init { promise in
Task {
do {
let result = try await asyncFulfill()
promise(.success(result))
} catch {
promise(.failure(error))
}
}
}
}
}
기존에 매번 Task-do-catch로 결과값을 처리해줬는데, Future 생성자 내부에서 반복되는 로직을 처리해주도록 해봤습니다. 🙂
그래서 이젠 아래와 같이 UseCase에서 좀 더 간결하고 직관적으로 변환해줄 수 있게 되었어요!
확실히 뎁스가 줄어드니 사용하고 읽는데 도움이 많이 되는 것 같습니다 :)