카테고리 없음

[iOS] MVI 패턴에 대해서 알아보자

게게겍 2025. 6. 9. 00:53
반응형

SwiftUI를 도입하고나서, 기존 MVVM이 맞다 틀리다,, 수많은 논쟁이 있는데요,,

https://qiita.com/karamage/items/8a9c76caff187d3eb838

 

「SwiftUIでMVVMを採用するのは止めよう」と思い至った理由 - Qiita

※2022/04/23 追記 本記事の続編として、以下の記事を書きましたので、合わせて御覧ください。 仕事でSwiftUIでTCAを使ってみて、かなり知見がたまったので、その解説です。 MVVMからTCAへの移行

qiita.com

 

https://www.reddit.com/r/swift/comments/m60pv7/is_mvvm_an_antipattern_in_swiftui/

 

From the swift community on Reddit: Is MVVM an anti-pattern in SwiftUI?

Explore this post and more from the swift community

www.reddit.com

 

https://www.youtube.com/watch?v=SOA0IT7sxvc

 

전 세계에서 싸우는중

MVVM이 옳다 그르다를 떠나서 파편화된 상태관리를 단방향으로 사용하기 위해 MVVM이 아닌 MV패턴 혹은 MVI 패턴을 도입하려는 경우가 많은것 같습니다.

TCA도 좋은 선택지이지만 러닝커브가 높기도 하고 단순 아키텍쳐를 Third-party 라이브러리에 의존해야하는 상황을 않좋아 하는경우도 있는것 같습니다.

그래서 대부분 MVI 아키텍쳐를 도입하는 경우가 많은것같은데 (사실 제가그럼) MVI와 관련된 정보들이 파편화되는 곤란한경우가 많은것같아 정리해 보았습니다.

MVI 아키텍처란?

MVI는 단방향 데이터 흐름을 통해 상태 관리를 명확하게 하는 아키텍처 패턴입니다. 사용자의 의도(Intent)가 모델(Model)의 상태를 변경하고, 그 상태가 뷰(View)를 업데이트하는 순환 구조를 가집니다.

MVI는 반응형 프로그래밍 환경에 적합하며, 단방향 데이터 흐름을 강제하여 예측 가능한 상태 관리를 돕습니다.

4가지 MVI 구조 개요

우리가 분석할 4가지 구조는 크게 두 가지로 나누어집니다:

안드로이드식 접근법 (구조 A, B): ViewModel이 중심이 되어 모든 것을 관리하는 방식 TCA식 접근법 (구조 C, D): 역할을 명확히 분리하여 각 컴포넌트가 단일 책임을 갖는 방식


구조 A: 안드로이드식 + Effect

개념 설명

구조 A는 안드로이드의 전통적인 ViewModel 패턴에 Effect라는 개념을 추가한 방식입니다. 여기서 Effect는 비동기 작업의 결과를 별도의 Action으로 처리하는 Redux의 Saga나 Epic과 비슷한 개념입니다.

데이터 흐름

사용자의 액션이 ViewModel로 전달되면, ViewModel은 상태를 직접 변경하거나 Effect를 발생시킵니다. Effect는 비동기 작업을 수행한 후 그 결과를 새로운 Action으로 다시 ViewModel에 전달합니다.

코드 예시

// ViewModel이 모든 것을 관리
class ViewModelA: ObservableObject {
    @Published var state: State = State()

    struct State {
        var isLoading: Bool = false
        var data: [String] = []
        var error: String? = nil
    }

    enum Action {
        case loadData
        case dataLoaded([String])  // Effect의 결과
        case dataLoadFailed(String)
    }

    func send(_ action: Action) {
        switch action {
        case .loadData:
            state.isLoading = true
            // Effect 실행
            performLoadData()
        case .dataLoaded(let data):
            state.isLoading = false
            state.data = data
        case .dataLoadFailed(let error):
            state.isLoading = false
            state.error = error
        }
    }

    private func performLoadData() {
        // 비동기 작업 수행 후 결과를 Action으로 전달
        Task {
            do {
                let data = try await apiCall()
                await MainActor.run {
                    send(.dataLoaded(data))
                }
            } catch {
                await MainActor.run {
                    send(.dataLoadFailed(error.localizedDescription))
                }
            }
        }
    }
}

 

구조 A 데이터 흐름 다이어그램

장단점

장점:

  • Effect를 통한 명확한 사이드 이펙트 분리
  • 비동기 작업의 흐름을 Action으로 추적 가능

단점:

  • Action이 많아져서 복잡해짐 (원본 Action + 결과 Action들)
  • ViewModel이 여전히 많은 책임을 가짐

구조 B: 안드로이드식 단순형

개념 설명

구조 B는 Effect를 제거하고 비동기 작업을 Action 내부에서 직접 처리하는 가장 형태입니다. 

데이터 흐름

사용자 액션이 바로 ViewModel로 전달되고, ViewModel은 액션을 처리하면서 상태를 직접 변경합니다. 비동기 작업도 액션 내부에서 Task로 처리합니다.

코드 예시

class ViewModelB: ObservableObject {
    @Published var state: State = State()

    struct State {
        var isLoading: Bool = false
        var data: [String] = []
        var error: String? = nil
    }

    enum Action {
        case loadData
        case clearError
    }

    func send(_ action: Action) {
        switch action {
        case .loadData:
            state.isLoading = true
            state.error = nil

            // Task로 비동기 작업 직접 처리
            Task {
                do {
                    let data = try await apiCall()
                    await MainActor.run {
                        self.state.isLoading = false
                        self.state.data = data
                    }
                } catch {
                    await MainActor.run {
                        self.state.isLoading = false
                        self.state.error = error.localizedDescription
                    }
                }
            }

        case .clearError:
            state.error = nil
        }
    }
}

 

구조 B 데이터 흐름 다이어그램

장단점

장점:

  • 매우 단순하고 직관적
  • Action 수가 적어 관리하기 쉬움
  • 안드로이드 개발자들에게 익숙(?)

단점:

  • Action이 순수하지 않음 (사이드 이펙트 포함)
  • 비동기 로직이 Action 내부에 섞여 있어 테스트하기 어려움

구조 C: TCA식 + Effect

개념 설명

구조 C는 TCA에서 착안하여 Effect를 따로 분리하는 패턴입니다.

Store, Reducer, State로 명확히 분리되어 있고, Effect를 통해 사이드 이펙트를 관리합니다.

데이터 흐름

View에서 Action을 Store로 전달하면, Store는 이를 Reducer에게 넘깁니다. Reducer는 순수 함수로서 현재 State와 Action을 받아 새로운 State를 만들거나 Effect를 반환합니다.

코드 예시

// State와 Action을 정의하는 Reducer
struct FeatureReducer: Reducer {
    struct State: Equatable {
        var isLoading: Bool = false
        var data: [String] = []
        var error: String? = nil
    }

    enum Action {
        case loadData
        case dataResponse(Result<[String], Error>)
        case clearError
    }

    var body: some ReducerOf<Self> {
        Reduce { state, action in
            switch action {
            case .loadData:
                state.isLoading = true
                state.error = nil
                // Effect 반환
                return .task {
                    await .dataResponse(Result {
                        try await apiCall()
                    })
                }

            case .dataResponse(.success(let data)):
                state.isLoading = false
                state.data = data
                return .none

            case .dataResponse(.failure(let error)):
                state.isLoading = false
                state.error = error.localizedDescription
                return .none

            case .clearError:
                state.error = nil
                return .none
            }
        }
    }
}

// Store 사용
struct ContentView: View {
    let store: StoreOf<FeatureReducer>

    var body: some View {
        WithViewStore(store, observe: { $0 }) { viewStore in
            VStack {
                if viewStore.isLoading {
                    ProgressView()
                } else {
                    List(viewStore.data, id: \\.self) { item in
                        Text(item)
                    }
                }
            }
            .onAppear {
                viewStore.send(.loadData)
            }
        }
    }
}

 

구조 C 데이터 흐름 다이어그램

장단점

장점:

  • 명확한 역할 분리로 높은 테스트 가능성을 용이하게 해준다.
  • 순수 함수인 Reducer로 인한 예측 가능한 상태 변화.
  • Effect를 통한 체계적인 사이드 이펙트 관리.

단점:

  • 학습 곡선이 높고 단순한 앱인데도 보일러 플레이트 코드가 많아진다.

구조 D: TCA식 - Effect

개념 설명

구조 D는 TCA의 명확한 역할 분리는 유지하면서 Effect를 제거한 접근법입니다. TCA의 컴포지셔널 특성을 활용하여 독립적인 엔티티들이 함께 시스템으로 작동할 수 있게 하면서도, 복잡성을 줄인버전입니다.

데이터 흐름

구조 C와 비슷하지만 Effect 없이 비동기 작업을 Reducer 내부에서 직접 처리합니다.

코드 예시

// Reducer 프로토콜 정의
protocol Reducerable {
    associatedtype State
    associatedtype Action

    var state: State { get }
    func reduce(state: inout State, action: Action)
}

// Store 구현
@dynamicMemberLookup
@Observable
final class Store<State, Action> {
    private let _reduce: (inout State, Action) -> Void
    public private(set) var state: State

    // @dynamicMemberLookup으로 state의 프로퍼티에 직접 접근 가능
    subscript<T>(dynamicMember keyPath: KeyPath<State, T>) -> T {
        state[keyPath: keyPath]
    }

    init<R: Reducerable>(reducer: R) where R.State == State, R.Action == Action {
        self._reduce = reducer.reduce
        self.state = reducer.state
    }

    func send(_ action: Action) {
        _reduce(&state, action)
    }
}

// 구체적인 Reducer 구현
final class FeatureReducer: Reducerable {
    @Published var state: State = State()

    struct State {
        var isLoading: Bool = false
        var data: [String] = []
        var error: String? = nil
    }

    enum Action {
        case loadData
        case clearError
    }

    func reduce(state: inout State, action: Action) {
        switch action {
        case .loadData:
            state.isLoading = true
            state.error = nil

            // 비동기 작업을 Action 내부에서 직접 처리
            Task {
                do {
                    let data = try await apiCall()
                    await MainActor.run {
                        // 직접 state 변경
                        self.state.isLoading = false
                        self.state.data = data
                    }
                } catch {
                    await MainActor.run {
                        self.state.isLoading = false
                        self.state.error = error.localizedDescription
                    }
                }
            }

        case .clearError:
            state.error = nil
        }
    }
}

// View에서 사용
struct ContentView: View {
    @EnvironmentObject var store: Store<FeatureReducer.State, FeatureReducer.Action>

    var body: some View {
        VStack {
            // @dynamicMemberLookup 덕분에 store.isLoading으로 직접 접근 가능
            if store.isLoading {
                ProgressView()
            } else {
                List(store.data, id: \\.self) { item in
                    Text(item)
                }
            }

            Button("Load Data") {
                store.send(.loadData)
            }
        }
    }
}

구조 D 데이터 흐름 다이어그램

장단점

장점:

  • TCA의 명확한 역할 분리 유지
  • Effect 제거로 복잡성 감소
  • @dynamicMemberLookup으로 상태 접근
  • 적절한 수준의 추상화

단점:

  • 여전히 구조 B보다는 복잡함

참고 자료

반응형