Skip to content

[iOS] Concurrency와 Combine의 공존

TaeHyun edited this page Dec 14, 2023 · 9 revisions

✅ Concurrency + Combine

서비스를 구현하면서 CombineConcurrency의 사용에 대해서 고민하게 되었습니다.

팀원들과 같이 고민하다보니 Combine은 데이터 스트리밍을 조작하고 구독하는 것에 최적화 되어있다는 느낌을,
Concurrency 는 단발성 비동기 응답을 한 번 받는 것에 집중한다는 느낌을 받게 되었습니다.

그래서 이번 프로젝트에서는 각 장점을 살려 Combine은 UI Binding에, Concurrency는 네트워크 통신에 활용해보기로 했습니다.


🤔 언제 어떻게 변환하고 연결할 것이냐 ?

우리는 ViewModel에서 단방향 Flow를 구현하기 위해 Combine을 통해 Action → SideEffect → State의 스트림을 만들어놓은 상태였는데, 그래서 네트워크 통신에서 async로 받아온 데이터를 ViewModel에서 편하게 스트림으로 연결하려면 이를 Combine Publisher로 변환하는 작업이 필요했습니다.

Note

설계하는 과정에서 후보는 2가지가 있었습니다.

  1. ViewModel에서 UseCase에 async로 요청하고, 변환을 ViewModel에서 한다.
  2. 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으로 변환하는 역할을 맡겼는데, 아래와 같은 사용하기 불편한 보일러플레이트가 지속적으로 발생했습니다. 🥲

스크린샷 2023-12-14 오전 11 35 39

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에서 좀 더 간결하고 직관적으로 변환해줄 수 있게 되었어요!

스크린샷 2023-12-14 오전 11 12 53

확실히 뎁스가 줄어드니 사용하고 읽는데 도움이 많이 되는 것 같습니다 :)

Clone this wiki locally