Skip to content

[iOS] 단방향 플로우 ViewModel 구현기

Youngin Kim edited this page Dec 14, 2023 · 32 revisions


? 단방향 플로우 도입 배경

저희 둥글둥글 팀은 MVVM + CleanArchitecture 구조로 프로젝트를 진행하고 있습니다.

해당 구조를 선택한 가장 큰 이유는 traveline 서비스의 SNS라는 특성상 많은 기능을 구현해야했고, 각각의 뷰가 복잡했기 때문인데요.

뷰가 복잡하기에 뷰컨트롤러가 비대해지는 상황을 방지하고, 단순히 뷰를 그리는 역할에 대한 책임을 분리하기 위해 MVVM을 선택했습니다.

또한, 많은 기능에 대한 비즈니스 로직 및 서버 연결의 책임을 분리하여 개발 및 추후에 빠른 대응이 가능하도록 Clean Architecture를 선정했습니다.


이에 따라 MVVM을 구현하기 위해서 ViewController와 ViewModel 사이의 데이터 바인딩이 필요했는데요.

팀원들 모두 기존에 ViewModel을 사용하는 방식이 달랐기에 이를 구체화하는 과정을 거쳐야했습니다.

이 과정에서 input/output 패턴, Reactorkit, MVI 패턴 등 여러 가지 방식을 찾을 수 있었는데요.

일단 가장 중점을 두고 고려한 점은 "지금 서비스에 적합한 방식인가?", "적용하는데 러닝커브가 높지 않은가?", "오버엔지니어링이 아닌가?"였습니다.


[1] 지금 서비스에 적합한 방식인가 ?

여행 공유라는 특성 상, 검색 및 필터가 주된 기능이 되는데요.

그렇기에 총 8가지의 필터 선택, 삭제 및 키워드 검색 등의 기능이 한 화면에 들어가게 됩니다.

하나의 화면에 많은 인터렉션, 여러 번의 서버 통신, 필터 관리 로직이 있게 됩니다.

이렇게 많은 액션과 서버 및 로직의 부수효과를 보다 편하게 개발하고 디버깅 하기 위해선 단방향 방식을 사용하기 편하게 가져가는 방식이 적합하다고 생각했습니다.

먼저 단방향 플로우를 가져가면 사용자의 액션을 시작으로 부수효과를 거쳐 매번 새로운 상태를 만들어내기 때문에 상호작용이 많아지더라도 상태관리가 훨씬 편할거라고 판단했습니다.

또한 흐름이 직관적이기 때문에 개발하는 과정에서 가독성 측면도 좋을 거라고 생각했습니다.


[2] 적용하는데 러닝커브가 높지 않은가 ?

실제 팀원 대다수가 MVI 패턴이나 단방향 아키텍쳐들(ex. ReactorKit)에 대한 이해도가 없는 상황이었기 때문에 많은 고민을 했습니다.

이 상황에서 아키텍쳐 자체를 붙여 쓰는 건 러닝커브가 높을 거라고 판단했습니다.

그렇기에 현재 시점에서 필요한 기능들만 붙여서 간소화된 버전을 만들어 쓰는 방향을 선택하게 되었습니다.


[3] 오버엔지니어링이 아닌가 ?

가장 경계했던 부분이었는데요.

그냥 뷰모델을 쓰는 거에 비해서 너무 많은 파일의 생성이나 코드의 추가가 필요하는 걸 최대한 방지하고자 했습니다.

또한, 이후 확장성에 대한 고민을 초기에는 가져가지 않았고 개발해나가면서 수정하고자 했습니다.


🌏 traveline만의 ViewModel 만들기

MVI 패턴 관련 안드로이드 자료들과 iOS의 단방향 플로우 아키텍쳐인 ReactorKit을 살펴보며 구현해나갔습니다.


가장 먼저 Action, SideEffect, State 총 3가지의 상태가 필요했습니다.

  • Action: 사용자의 액션(ex. 버튼 탭, 텍스트 입력 등)

  • SideEffect: 액션으로 인해 생기는 부수효과(ex. 서버 통신, 글자수 검증 등)

  • State: 뷰를 그리기 위해 필요한 현재 시점의 상태(ex. 서버에서 받아온 모델 등)

사용자의 액션(Action)이 들어오면 서버 통신 등의 부수효과(SideEffect)을 거쳐 뷰를 그리기 위한 상태(State)를 만들어 뷰에 업데이트하는 과정이 됩니다.


이를 위해 ViewModel에서는 이러한 흐름을 만들어줘야겠죠.

  • sendAction(Action): 뷰에서 action을 받는 메서드

  • transform(Action) → SideEffect: action을 받아 sideEffect 만드는 메서드

  • reduceState(State, SideEffect) → State: sideEffect와 이전 state를 받아 새로운 state를 생성하는 메서드


전체적인 흐름은 이와 같습니다.

image

이제 본격적으로 구현 과정을 살펴봅시다.

먼저 각 ViewModel마다 Action, SideEffect, State가 필요한데요.

보다 가독성 있게, 편하게 개발하기 위해 Action과 SideEffect는 enum으로 구현했습니다.

상태인 State는 불변성을 유지해야했기에 struct 구조체로 구현했습니다.

ViewModel 생성 시점에 위에서 말한 흐름을 연결해두고 그 후에 sendAction으로 상태를 변경할 수 있도록 했습니다.


이 때, 저희의 첫 리팩토링이 진행되었습니다.

ViewModel은 ViewController마다 만들어질텐데 그럼 생성시 흐름을 이어주기 위해선 매번 생성시점에 해당 코드가 추가된다는 상황에 직면했는데요.

이를 BaseViewModel을 만드는 것으로 해결하고자 했습니다.

BaseAction, BaseSideEffect, BaseState 프로토콜을 생성하여 각각을 채택해 구현할 수 있도록 하고,

BaseViewModel을 생성하여 ViewModel들이 상속하여 transform, reduceState 메서드를 오버라이딩하여 내부를 구현하도록 했습니다.

이를 통해 반복되는 코드를 줄일 수 있었고, 보다 일관된 구조로 뷰모델을 가져갈 수 있게 되었습니다.

결론적으로 완성된 저희의 BaseViewModel은 이와 같습니다.

(참고) 리팩토링을 거쳐 BaseViewModel이 변경되어 이 버전은 최종이 아닙니다 !

import Combine
import Foundation

class BaseViewModel<Action: BaseAction, SideEffect: BaseSideEffect, State: BaseState> {
    
    // MARK: - Types
    
    /// 사용자의 input
    typealias Action = Action

    /// 사용자의 action 후 부수효과 처리
    /// 서버 통신 등 비동기 처리
    typealias SideEffect = SideEffect

    /// State는 immutable한 struct 구조
    /// 어떠한 시점에도 현재의 시점을 표현하는 하나의 상태만 존재할 뿐
    /// 그걸 바꿀 수 있는 유일한 트리거는 새로운 State를 만드는 reduceState()의 실행
    typealias State = State
    typealias SideEffectPublisher = AnyPublisher<SideEffect, Never>
    
    // MARK: - Properties
    
    private var cancellables = Set<AnyCancellable>()
    
    // MARK: - Binding Properties
    
    private var actions = PassthroughSubject<Action, Never>()
    private var sideEffects = PassthroughSubject<SideEffect, Never>()
    
    @Published private(set) var state: State = .init()
    
    // MARK: - Initializer

    /// Action -> SideEffect -> State 스트림 만드는 곳
    /// sideEffect를 기준으로 reduceState()를 호출하며 새로운 State로 계속 갱신
    init() {
        self.actions
            .flatMap { [weak owner = self] action -> SideEffectPublisher in
                guard let owner else { return Empty().eraseToAnyPublisher() }
                return owner.transform(action: action)
            }
            .receive(on: DispatchQueue.global())
            .sink(receiveValue: { [weak owner = self] sideEffect in
                owner?.sideEffects.send(sideEffect)
            })
            .store(in: &cancellables)
        
        self.sideEffects
            .scan(state, reduceState)
            .receive(on: DispatchQueue.main)
            .assign(to: \.state, on: self)
            .store(in: &cancellables)
    }
    
    /// View에서 action을 받는 메서드
    func sendAction(_ action: Action) {
        actions.send(action)
    }
    
    func transform(action: Action) -> SideEffectPublisher {
        return Empty().eraseToAnyPublisher()
    }
    
    func reduceState(state: State, effect: SideEffect) -> State {
        return state
    }
}

🚨 하나의 Action에서 여러 SideEffect가 발생한다면?

개발을 하는 도중에 사용자에 Action에 대해서 여러 개의 SideEffect가 발생하는 경우가 생겼습니다.

처음 직면하게 된 상황은 버튼을 탭했을 때,

  1. 화면을 리셋하는 작업

  2. 서버에서 값을 불러오는 작업

두 가지 모두가 수행되어야 했던 상황이었습니다.

하나의 뷰를 재사용하고 있었기 때문애 바로 서버에서 값을 불러와 업데이트하면 앞선 뷰가 보이기 때문에 화면을 리셋하고, 서버에서 값을 불러와야 했습니다.

이를 해결하기 위해 transform시에 하나의 Action에 대해 여러 개의 SideEffect를 방출할 수 있도록 Publishers.Merge 메서드를 사용했습니다.

이 후에도 이러한 상황을 종종 마주했기에 두 개일때는 Merge, 두 개 이상일 때는 MergeMany를 사용하여 구현해나갔습니다.

func fetchTimeline() -> SideEffectPublisher {
    Publishers.MergeMany(
        .just(.popToTimeline),
        fetchTimelineCardInfo(currentState.day),
        fetchTravelInfo()
    ).eraseToAnyPublisher()
}

🚨 SideEffect에서 다시 Action이 생길 수 있다고?

Merge 메서드를 통해 여러 개의 SideEffect를 방출하는 것은 원하는대로 구현해냈지만 여전히 문제 상황이 존재했습니다.

바로 순서를 보장할 수 없다는 점이었는데요.

위에서 언급했던 작업을 다시 보자면,

  1. 화면을 리셋하는 작업

  2. 서버에서 값을 불러오는 작업

이렇게 되는데 만약 2번이 1번보다 선행된다면 어떻게 될까요 ?

서버에서 값이 불러져왔는데도 불구하고 사용자는 빈 화면을 보게 되겠죠.

그렇기에 이 상황의 경우 1번이 수행되고 난 후 2번이 수행된다는 순서의 보장이 필요했습니다.

이를 해결하기 위해서 1번의 SideEffect의 수행이 끝난 후, Action을 보내줘서 흐름을 다시 한 번 이어가도록 하여 문제 상황을 해결했습니다.




🚨 State가 바로 업데이트되지 않는다고?

검색 및 필터쪽을 구현하면서 state를 바인딩하는 시점에서 해당 state의 필터 딕셔너리의 최근값을 가지고 로직을 구현해야하는 상황이 있었습니다.

하지만 왜인지 바로 업데이트가 되지 않고, 다음 바인딩 시점에서 값이 업데이트되는 것을 발견할 수 있었습니다.

이런 이유로 인해 ViewModel의 state에 바로 접근하지 않고, 매번 바인딩해서 받은 값을 넘겨주는 방식으로 단편적을 해결하고 넘어갔었는데요.

이후에 state에 접근해 값을 사용하는 경우가 늘어났고, 한 박자씩 밀리는 이슈로 원하는 상황을 개발하기 위해 많은 보일러 플레이트 코드가 작성되었습니다.

그래서 해당 이슈의 원인을 찾고 해결하고자 했습니다.


state를 바인딩하는 시점에서 발생한 이슈였기 때문에 BaseViewModel의 state 변수를 의심하게 되었고 문제점을 발견할 수 있었습니다.

저희가 state를 구조체로 관리하기 때문에 이를 $키워드만 붙여서 방출된 값을 받을 수 있는 @Published를 통해 state를 선언했었는데요.

이는 프로퍼티 래퍼의 형태로 willSet 시점에 이벤트를 방출합니다.

즉, 값이 변경되기 직전에 호출되므로 저희가 예상한대로 동작하지 않고 한 단계 늦게 반영되었던 것입니다.


이를 해결하기 위해 CurrentValueSubject를 사용해 state를 관리하도록 수정했습니다.

CurrentValueSubject는 didSet 시점에 호출되기에 업데이트된 값을 바라볼 수 있게 됩니다.

하지만 해당 subject는 private(set)이나 let을 통해 선언해도 어디서든 send() 메서드를 통해 state가 변경될 가능성이 존재했는데요.

이를 위해 AnyPubliser<State, Never> 형태로 방출된 값을 받을 수만 있는 state를 생성했습니다.


이러한 과정들을 거쳐 최종적으로 저희가 사용하고 있는 ViewModel을 완성할 수 있었습니다.

import Combine
import Foundation

class BaseViewModel<Action: BaseAction, SideEffect: BaseSideEffect, State: BaseState> {
    
    // MARK: - Types
    
    typealias Action = Action
    typealias SideEffect = SideEffect
    typealias State = State
    typealias SideEffectPublisher = AnyPublisher<SideEffect, Never>
    
    // MARK: - Properties
    
    private var cancellables = Set<AnyCancellable>()
    
    // MARK: - Binding Properties
    
    private var actions = PassthroughSubject<Action, Never>()
    private var sideEffects = PassthroughSubject<SideEffect, Never>()
    
    private let stateSubject: CurrentValueSubject<State, Never> = .init(.init())
    
    var state: AnyPublisher<State, Never> {
        return stateSubject.eraseToAnyPublisher()
    }
    
    var currentState: State {
        return stateSubject.value
    }
    
    // MARK: - Initializer
    
    init() {
        self.actions
            .flatMap { [weak owner = self] action -> SideEffectPublisher in
                guard let owner else { return Empty().eraseToAnyPublisher() }
                return owner.transform(action: action)
            }
            .receive(on: DispatchQueue.global())
            .sink(receiveValue: { [weak owner = self] sideEffect in
                owner?.sideEffects.send(sideEffect)
            })
            .store(in: &cancellables)
        
        self.sideEffects
            .scan(stateSubject.value, reduceState)
            .receive(on: DispatchQueue.main)
            .assign(to: \.stateSubject.value, on: self)
            .store(in: &cancellables)
    }
    
    func sendAction(_ action: Action) {
        actions.send(action)
    }
    
    func transform(action: Action) -> SideEffectPublisher {
        return Empty().eraseToAnyPublisher()
    }
    
    func reduceState(state: State, effect: SideEffect) -> State {
        return state
    }
}

물론 지금 이 구조가 완벽하다고 할 수 없고 또 어떤 다른 문제 상황을 직면하게 될지도 모르지만

여기까지가 저희 둥글둥글팀의 iOS 팀원들이

  • 직접 서비스를 바탕으로 구현하고,

  • 문제 상황을 직면하면서 해결하고,

  • 더 나은 방향으로 개선하면서

구축해온 단방향 ViewModel의 이야기입니다.


실제로 개발을 진행하면서 상태를 디버깅하여 여러 복잡한 상황들을 해결할 수 있었고,

반대로 간단한 화면전환에서도 다른 상태를 고려해야하는 복잡한 상황도 마주할 수 있었습니다.

앞으로도 더 좋은 서비스를 개발하기 위해 더 나은 개발 방향을 고민하는 traveline 이 되겠습니다 :)

Clone this wiki locally