[iOS] MVI Architecture

Emily·2025년 8월 18일

MVI(Model-View-Intent) 패턴은 상태(State) 관리를 통해 UI를 업데이트하는 아키텍처다. 단방향 데이터 흐름이 특징이며, SwiftUI 같은 선언형 UI 프레임워크에 걸맞다.

여기서 Intent의 사전적인 의미는 의도인데, 사용자 상호작용의 의도를 말한다. 의도에 따라 데이터가 변하고, 그에 따라 변하는 상태가 UI를 그리는 흐름이라고 보면 된다.

MVI 아키텍처 다이어그램
출처 - 나. 내가 그림.

MVI의 흐름은 다음과 같다.

  1. View는 사용자의 상호작용(User Event)을 Intent로 변환한다.
  2. Intent는 상호작용의 의도에 따라 Model이 데이터를 처리(Process)하도록 한다.
  3. Model은 처리 결과를 새로운 상태(State)로 갱신(Update)한다.
  4. 갱신된 State를 바탕으로 View가 렌더링(Render)된다.

State

State는 앱의 현재 상태를 표현하는 구조체로, UI를 표현할 순수 데이터를 포함한다.

struct MainState {
    var todos: [Todo] = []
    var error: DataError?
    
    var hasError: Bool {
        error != nil
    }
}

ViewState를 보관하는 Store 객체를 통해 State에 접근하여 UI를 그린다.

struct MainView: View {
	@StateObject private var store: MainStore
    private let repository: TodoRepository
    
    init(repository: TodoRepository) {
        self.repository = repository
        _store = StateObject(wrappedValue: MainStore(repository: repository))
    }
    
    var body: some View {
    	// ... //
        
        // "Todo" List : MainState.todos로 렌더링
        List {
        	ForEach(store.state.todos) { todo in
            	Text(todo.text)
            }
        }
        // error 발생 시 alert : MainState.hasError, error 값으로 렌더링
        .alert("Error", isPresented: .constant(store.state.hasError), actions: {
            Button("OK") {
                if let error = store.state.error {
                    print(error.errorDescription)
                }
            }
        }, message: {
            Text("Data could not be loaded. Please try again later.")
        })
    }
}

Intent(Action)

IntentUser Event가 의도하는 액션을 가리키므로, Action이라는 이름으로 정의되기도 한다. 인터랙션(상호작용) 발생 시 Storesend 메소드로 Reducer에 전달되어 필요한 데이터 처리를 유도한다. (Reducer는 하나의 객체가 아니라 함수다. 상태를 업데이트하는 역할과 책임을 가졌기 때문에 이를 나타내기 위한 개념적인 용어다.)

enum MainAction {
	case createTodo(input: String)
    case deleteTodo(id: UUID)
}
struct MainView: View {
	@StateObject private var store: MainStore
    private let repository: TodoRepository
    
    init(repository: TodoRepository) {
        self.repository = repository
        _store = StateObject(wrappedValue: MainStore(repository: repository))
    }
    
    @State private var textFieldText: String = ""
    
    var body: some View {
		// ... //
        
        List {
        	ForEach(store.state.todos) { todo in
				Text(todo.text)
                	// 스와이프 시 todo 삭제를 의도
                    .swipeActions {
                        Button {
							store.send(.deleteTodo(id: todo.id)
                        } label: {
                            Image(systemName: "trash")
                        }
                    }
            }
        }

        Button {
        	// 버튼을 눌렀을 때 todo 생성을 의도
            store.send(.createTodo(input: textFieldText))
        } label: {
        	Text("Create")
        }
    }
}

Store

StoreIntent를 받아 Reducer를 통해 액션을 처리하고, 그에 따른 상태(State) 변경을 관리하는 객체로, MVI 아키텍처에서 중심 역할을 맡는다. ViewStore를 구독하여 상태 변화에 따라 자동으로 UI를 갱신하게 된다. 어떻게 보면 MVVM에서의 ViewModel의 역할과 비슷한데, Intent/Reducer/State의 명확한 역할 분리로 그보다 좀더 구조화된 객체라고 보면 된다.

final class MainStore: ObservableObject {
	private let repository: TodoRepository
    
    private var cancellables = Set<AnyCancellable>()
    
    init(repository: TodoRepository) {
        self.repository = repository
        bind()
    }
    
    @Published private(set) var state = MainState()
    
    private func bind() {
        Publishers.CombineLatest(
            repository.$todos,
            repository.$error
        )
        .receive(on: DispatchQueue.main)
        .sink { [weak self] todos, error in
        	// 상태(State) 업데이트
            self?.state = MainState(todos: todos, error: error)
        }
        .store(in: &cancellables)
    }
    
    // view에서 호출되어 Action(Intent)을 Reducer로 전달
    func send(_ action: MainAction) {
        reduce(action)
    }
    
    // Reducer : Action 처리
    private func reduce(_ action: MainAction) {
        switch action {
        case .createTodo(let input):
        	repository.createTodo(with: input)
        case .deleteTodo(let id):
            repository.deleteTodo(with: id)
        }
    }
}
profile
iOS Junior Developer

0개의 댓글