나의 기록, 현진록

[iOS] ViewModel 단방향 흐름으로 리팩토링하기 본문

iOS

[iOS] ViewModel 단방향 흐름으로 리팩토링하기

guswlsdk 2025. 4. 4. 16:33
반응형

MVC와 MVVM

MVC는 Massive Controller라는 문제를 가진다. 실제로 MVC 구조로 코드를 작성하다 보면 결국 Model과 View가 아닌 영역은 ViewController에 작성하기 때문이다. 그 대안으로 iOS에서는 MVVM 디자인 패턴을 사용한다. ViewModel을 추가하여 ViewController의 역할과 책임을 분리시킨다. 프로젝트의 규모나 기능의 복잡성이 크지 않다면 ViewModel을 추가하는 것만으로도  Massive Controller라는 문제에 대해서 해소할 수 있을 것이다. 

 

MVVM의 문제

iOS에서 처음 MVC의 문제점을 느끼고 MVVM을 경험 했을 때 당시 느껴왔던 문제들이 해소되었다. ViewController의 크기는 줄었으며 그만큼 ViewModel로 책임을 분리하여 무언가 "멋진" 코드로 프로젝트를 만들고 있다는 느낌이 들었다. 

 

그러나 기능이 많아지고 프로젝트의 규모가 커짐에 따라 ViewModel에 작성해야 하는 코드도 비례해서 증가하게 된다. MVC에서도 그래왔듯 MVVM에서 더 진화한 디자인 패턴이 있어야 하는 것 아닌가? 그러나 다들 MVVM을 사용한다. 그럼 ViewModel이 커지고 복잡해지는 문제점을 해소하기 위한 방법은 무엇일까라는 고민을 하게 되었다. 그렇게 나는 ReactorKit을 사용하여 ViewModel을 단방향 흐름과 함께 Action Mutation State라는 세 가지의 역할을 나누어 ViewModel을 작성할 수 있었다. 나에겐 엄청난 센세이션이었다. 

 

ReactorKit의 단방향 흐름

View에서 Action이 발생하면 reactor가 Action에 대한 로직을 처리하고 State를 방출하여 View는 State 값에 따라 업데이트 된다.

 

 

기억나는 ViewModel 관련 면접 질문

ReactorKit을 사용하여 ViewModel의 복잡성과 흐름을 관리하여 Massive ViewModel 문제를 해결한 나는 어깨가 높아져 있었다. 그렇게 면접에서 ReactorKit을 왜 사용했고 어떤 점이 개선되었는지에 관한 질문에 신나게 답변하였다. 하지만 문제는 다음 질문이었다.

 

"ReactorKit을 사용하지 않고 ViewModel을 어떻게 개선할 수 있는가"

 

 

분명히 나는 ViewModel에 대한 문제점도 느꼈고, 개선하고자 고민도 하였고, ReactorKit을 통해서 해결을 하였으나 ReactorKit을 사용하지 않고 ViewModel을 해결한 경험이 없었다.

 

나는 이 질문에 "protocol을 활용하여 추상화하면 해결 할 수 있을 것 같다"로 요약할 수 있는 자신감 없는 답변을 늘어 놓았던 것 같다. 

내가 직접 경험하지 않은 부분이라 그런지 자신감 있게 답변할 수 없었다.

 

면접이 끝난 이후 고민을 해보면서 ViewModel을 리팩토링한 여러 블로그를 찾아보았다. 그러나 ReactorKit을 이미 경험해서 그런건지 ReactorKit 만큼 코드가 간결해지거나 복잡한 코드를 관리하기 편할 것 같진 않았다.

 

다른 개발자 분들이 ViewModel을 여러 가지 방법으로 표현하는 것도 내가 보기엔 문제점들이 보였다.

 

그냥 내가 만들어보기로 결심했다. 내가 만든 방식 또한 다른 개발자 분들 눈에는 문제가 보일 수도 있기 때문이다.

 

ReactorKit은 Rx와 같이 사용해야 하기 때문에 Rx를 사용하지 않은 프로젝트에서도 사용할 수 있도록 만들어보자.

 

ViewModel 리팩토링 해보기

ReactorKit에서 사용했던 기억대로 추상화하였다.

 

Action - 이벤트 행위

Mutation - 이벤트에 대한 로직

State - 로직 이후 변경된 값을 업데이트

세 가지의 타입으로 역할을 구분한다.

protocol ViewModelProtocol{
    associatedtype Action
    associatedtype Mutation
    associatedtype State
    
    var action: ((Action) -> Void)? { get }
    func mutate(action: Action)
    func reduce(mutation: Mutation)
}

 

 

 

Action

Action은 다음과 같은 형태로 작성한다.

Action을 case별로 구분한다.

// 이벤트
enum Action{
    case loadBooks
    case loadSummaryStates
    case toggleButton(Int)
    case selectedSeriesNumber(Int)
}

 

 

Mutation

Mutation은 이벤트에 대한 로직 실행시킨다.

예를 들어 loadBooks이라는 Action이 발생하면 Mutation 중 setBooks을 실행시킨다.

Books 불러오기(loadBooks) -> 불러온 Books를 프로퍼티에 저장하기(setBooks)

// 이벤트에 대한 로직
enum Mutation{
    case setBooks([Book])
    case setSummaryStates([Bool])
    case setError(Error)
    case toggleButton(Int)
    case setSelectedSeriesNumber(Int)
}

 

 

State

이벤트에 대한 로직이 수행되면 State를 업데이트한다.

// 상태 값
struct State {
    // fileprivate(set) 외부에서는 read만 허용
    fileprivate(set) var books: [Book] = []
    fileprivate(set) var summaryStates: [Bool] = []
    fileprivate(set) var selectedSeriesNumber: Int = 0

    var jsonParseErrorListenr: ((String) -> Void)?
    var successedLoadBooks: (() -> Void)?
    var successedLoadSummaryStates: (() -> Void)?
    var tapSummaryToggleButton: (() -> Void)?
}

 

위 세가지는 모두 ViewModel 내부에 작성되어 있다.

 

 

initialize

action을 클로저 형태로 선언하고 ViewModel init 내에 action이 발생할 경우 action에 대한 Mutation()가 호출될 수 있도록 정의한다.

final class MainViewModel: ViewModelProtocol{
    
    var state: State = State()
    var action: ((Action) -> Void)?

    init(){
        action = { action in
            switch action{
            case .loadBooks:
                self.mutate(action: .loadBooks)
            case .loadSummaryStates:
                self.mutate(action: .loadSummaryStates)
            case .toggleButton(let seriesNumber):
                self.mutate(action: .toggleButton(seriesNumber))
            case .selectedSeriesNumber(let number):
                self.mutate(action: .selectedSeriesNumber(number))
            }
        }
    }
}

 

 

 

mutation()

Mutation은 Action에 대한 로직을 수행하는 역할을 한다.

 

현재 작성한 코드에서는 로직이 동작하기 위한 코드가 길어짐에 따라 mutation()의 크기는 커지게 되는 문제가 발생하지만

클린아키텍처 구조에서 matation 내부의 로직은 Usecase로 간결히 표현 가능할 것이다.

 

로직으로 얻은 값을 reduce()에 파라미터로 전달하여 호출한다.

func mutate(action: Action) {
        switch action{
        case .loadBooks:
            dataService.loadBooks { [weak self] result in
                guard let self = self else { return }
                switch result {
                case .success(let books):
                    reduce(mutation: .setBooks(books))
                case .failure(let error):
                    reduce(mutation: .setError(error))
                }
            }
        case .loadSummaryStates:
            dataService.loadSummaryToggleState { [weak self] result in
                guard let self = self else { return }
                switch result{
                case .success(let states):
                    reduce(mutation: .setSummaryStates(states))
                case .failure(_):
                    // UserDefaults에서 찾을 수 없는 값이라면(앱 최초 실행 시) 디폴트 값
                    let defaultStates = Array(repeating: false, count: 7)
                    reduce(mutation: .setSummaryStates(defaultStates))
                }
            }
        case .toggleButton(let seriseNumber):
            reduce(mutation: .toggleButton(seriseNumber))
        case .selectedSeriesNumber(let number):
            reduce(mutation: .setSelectedSeriesNumber(number))
        }
    }
}

 

 

reduce()

reduce는 mutation으로 얻어진 결과를 State에 저장하는 역할을 한다.

 

rx를 사용하지 않은 조건에서 작성하였기 때문에 State 값이 변동됨을 알리는 클로저를 호출하는 방식으로 구현하였다.

func reduce(mutation: Mutation) {
    switch mutation{
    case .setBooks(let books):
        state.books = books
        state.successedLoadBooks?()
    case .setSummaryStates(let states):
        state.summaryStates = states
        state.successedLoadSummaryStates?()
    case .setError(let error):
        state.jsonParseErrorListenr?(error.localizedDescription)
    case .toggleButton(let seriseNumber):
        state.summaryStates[seriseNumber].toggle()
        state.tapSummaryToggleButton?()
        dataService.uploadSummaryToggleState(states: state.summaryStates)
    case .setSelectedSeriesNumber(let number):
        state.selectedSeriesNumber = number
    }
}

 

 

 

 

전체 코드

protocol ViewModelProtocol{
    associatedtype Action
    associatedtype Mutation
    associatedtype State
    
    var action: ((Action) -> Void)? { get }
    func mutate(action: Action)
    func reduce(mutation: Mutation)
}

final class MainViewModel: ViewModelProtocol{
    
    private let dataService = DataService()

    // 이벤트
    enum Action{
        case loadBooks
        case loadSummaryStates
        case toggleButton(Int)
        case selectedSeriesNumber(Int)
    }
    
    // 이벤트에 대한 로직
    enum Mutation{
        case setBooks([Book])
        case setSummaryStates([Bool])
        case setError(Error)
        case toggleButton(Int)
        case setSelectedSeriesNumber(Int)
    }
    
    // 상태 값
    struct State {
        // fileprivate(set) 외부에서는 read만 허용
        fileprivate(set) var books: [Book] = []
        fileprivate(set) var summaryStates: [Bool] = []
        fileprivate(set) var selectedSeriesNumber: Int = 0
        
        var jsonParseErrorListenr: ((String) -> Void)?
        var successedLoadBooks: (() -> Void)?
        var successedLoadSummaryStates: (() -> Void)?
        var tapSummaryToggleButton: (() -> Void)?
    }
    
    var state: State = State()
    var action: ((Action) -> Void)?

    init(){
        action = { action in
            switch action{
            case .loadBooks:
                self.mutate(action: .loadBooks)
            case .loadSummaryStates:
                self.mutate(action: .loadSummaryStates)
            case .toggleButton(let seriesNumber):
                self.mutate(action: .toggleButton(seriesNumber))
            case .selectedSeriesNumber(let number):
                self.mutate(action: .selectedSeriesNumber(number))
            }
        }
    }
    
    func mutate(action: Action) {
        switch action{
        case .loadBooks:
            dataService.loadBooks { [weak self] result in
                guard let self = self else { return }
                switch result {
                case .success(let books):
                    reduce(mutation: .setBooks(books))
                case .failure(let error):
                    reduce(mutation: .setError(error))
                }
            }
        case .loadSummaryStates:
            dataService.loadSummaryToggleState { [weak self] result in
                guard let self = self else { return }
                switch result{
                case .success(let states):
                    reduce(mutation: .setSummaryStates(states))
                case .failure(_):
                    // UserDefaults에서 찾을 수 없는 값이라면(앱 최초 실행 시) 디폴트 값
                    let defaultStates = Array(repeating: false, count: 7)
                    reduce(mutation: .setSummaryStates(defaultStates))
                }
            }
        case .toggleButton(let seriseNumber):
            reduce(mutation: .toggleButton(seriseNumber))
        case .selectedSeriesNumber(let number):
            reduce(mutation: .setSelectedSeriesNumber(number))
        }
    }
    
    func reduce(mutation: Mutation) {
        switch mutation{
        case .setBooks(let books):
            state.books = books
            state.successedLoadBooks?()
        case .setSummaryStates(let states):
            state.summaryStates = states
            state.successedLoadSummaryStates?()
        case .setError(let error):
            state.jsonParseErrorListenr?(error.localizedDescription)
        case .toggleButton(let seriseNumber):
            state.summaryStates[seriseNumber].toggle()
            state.tapSummaryToggleButton?()
            dataService.uploadSummaryToggleState(states: state.summaryStates)
        case .setSelectedSeriesNumber(let number):
            state.selectedSeriesNumber = number
        }
    }
}

 

VC에서 활용

final class MainViewController: UIViewController {
    
    private let viewModel: MainViewModel = MainViewModel()
    
    private func bindViewModel(){
        viewModel.action?(.loadSummaryStates) // summaryStates 불러오기 
        
        viewModel.state.successedLoadBooks = { [weak self] in
            guard let self = self else { return }
			// successedLoadBooks 호출될 경우 UI 업데이트
        }
        
        viewModel.state.jsonParseErrorListenr = { [weak self] error in
            guard let self = self else { return }
			// jsonParseErrorListenr 호출될 경우 UI 업데이트
        }
        
        viewModel.state.tapSummaryToggleButton = { [weak self] in
            guard let self = self else { return }
            // tapSummaryToggleButton 호출될 경우 UI 업데이트
        }
        viewModel.action?(.loadBooks) // book 불러오기
    }
    
}

 

 

단방향 흐름 구조의 이점

단방향 흐름이 가지는 이점은 여러 가지이다. 그러나 내가 이러한 코드를 작성하면서 가장 체감할 수 있었던 이점만 나열해 보자면 다음과 같다.

 

UI 업데이트  관리 및 디버깅 용이

View는 ViewModel의 State에만 응답한다. 개발자가 State 값을 적절한 시점에만 업데이트한다면 불필요하거나 의도치 않은 시점에 UI가 업데이트 되는 상황을 방지할 수 있으며, 비정상적으로 UI가 동작할 경우 State 값만 추적하면 되기 때문에 오류를 제어하기도 매우 용이할 것이다.

 

 

예측가능성 및 유지보수성 향상

단방향 흐름을 가지는 ViewModel로 인하여 특정 행위(이벤트)가 발생할 경우 어떠한 상태 변화가 일어날지 쉽게 이해할 수 있다. 

Action -> Mutation -> State 흐름을 가지기 때문에 코드를 이해하는데도 편리하며 새로운 기능을 추가하는 경우에도 추상화 되어 있는 구조로 인하여 유지보수성이 향상될 것이다.

 

 

 

https://github.com/dbguswls030/haza/tree/main/HarryPotterBook/HarryPotterBook

 

반응형

'iOS' 카테고리의 다른 글

[iOS] Main Event Loop & UI Update Cycle  (0) 2025.03.11
[iOS] WKWebView로 웹뷰 만들기  (0) 2025.03.11
[Swift] RxSwift  (0) 2025.02.24
[iOS] App Launch Sequence  (0) 2025.02.20
[iOS] UIView & CALayer  (0) 2025.02.18