본문 바로가기

TIL

[TIL] MVI 패턴

이번에 새로 시작하게 된 프로젝트에 MVI 패턴으로 도입하기로 결정하여 이번기회에 MVI 패턴에 대해 공부해보고자 이번 포스팅을 작성하게 되었습니다.

 

MVI 패턴

탄생 배경

MVI 패턴은 MVVM에서 발생하는 상태 문제부수 효과라는 두 가지 문제를 해결하기 위해 탄생했다고 합니다.

 

그렇다면 여기서 상태 문제와 부수 효과란 무엇일까요?

각각에 대해 코드 예시를 들어 설명해보겠습니다.

상태 문제

MVVM에서는 상태가 여러 ViewModel에 분산되어 있을 수 있습니다. 이는 상태 변경이 여러 곳에서 일어나기 때문에, 예기치 않은 방식으로 상태가 변할 수 있는 문제가 발생합니다.

class CounterViewModel: ObservableObject {
    @Published var counter: Int = 0
    @Published var isLoading: Bool = false

    func incrementCounter() {
        counter += 1
    }

    func decrementCounter() {
        counter -= 1
    }

    func loadData() {
        isLoading = true
        // 비동기 작업 예시
        DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
            self.isLoading = false
            self.counter = 100 // 예기치 않은 상태 변경
        }
    }
}

struct ContentView: View {
    @ObservedObject var viewModel = CounterViewModel()

    var body: some View {
        VStack {
            Text("Counter: \(viewModel.counter)")
            if viewModel.isLoading {
                ProgressView()
            } else {
                Button("Load Data") {
                    viewModel.loadData()
                }
            }
            Button("Increment") {
                viewModel.incrementCounter()
            }
            Button("Decrement") {
                viewModel.decrementCounter()
            }
        }
    }
}

 

위의 코드에서 loadData 메서드가 실행될 때, isLoading 상태가 변경되고, 2초 후에 counter가 100으로 변경됩니다. 이렇게 여러 곳에서 상태가 변경되면, 예기치 않은 방식으로 상태가 변할 수 있어 디버깅이 어려워집니다.

부수 효과

부수 효과는 비동기 작업, 서버 호출, 데이터베이스 접근 등에서 발생합니다. 이런 부수 효과는 상태 변경을 예측하기 어렵게 만듭니다.

class UserViewModel: ObservableObject {
    @Published var user: User?
    @Published var errorMessage: String?

    func fetchUser(userId: String) {
        // 서버 호출 예시
        URLSession.shared.dataTask(with: URL(string: "https://api.example.com/user/\(userId)")!) { data, response, error in
            if let error = error {
                DispatchQueue.main.async {
                    self.errorMessage = error.localizedDescription
                }
                return
            }
            guard let data = data, let fetchedUser = try? JSONDecoder().decode(User.self, from: data) else {
                DispatchQueue.main.async {
                    self.errorMessage = "Failed to load user"
                }
                return
            }
            DispatchQueue.main.async {
                self.user = fetchedUser
            }
        }.resume()
    }
}

struct UserView: View {
    @ObservedObject var viewModel = UserViewModel()

    var body: some View {
        VStack {
            if let user = viewModel.user {
                Text("User: \(user.name)")
            } else if let errorMessage = viewModel.errorMessage {
                Text("Error: \(errorMessage)")
            } else {
                Text("Loading...")
                Button("Fetch User") {
                    viewModel.fetchUser(userId: "123")
                }
            }
        }
    }
}

 

위의 코드에서 fetchUser 메서드는 서버 호출을 통해 사용자 데이터를 가져옵니다. 서버 호출은 비동기적으로 이루어지며, 호출 결과에 따라 user나 errorMessage 상태가 변경됩니다. 이처럼 비동기 작업에서 발생하는 부수 효과는 상태 변경을 예측하기 어렵게 만듭니다.

정리

코드 설명에 의해 너무 머리 속으로 정리가 안되니 한번 더 간단히 정리해보도록 하겠습니다.

 

상태 관리

  • 복잡한 상태 관리: 애플리케이션이 커질수록 상태가 복잡해지고, 여러 ViewModel 사이에서 상태를 일관성 있게 유지하는 것이 어렵습니다.
  • 예상치 못한 상태 변화: 여러 곳에서 상태가 변경될 수 있기 때문에, 예기치 않은 방식으로 상태가 변할 수 있습니다. 이는 디버깅을 어렵게 만듭니다.

부수 효과

  • 비동기 작업: 서버 호출, 데이터베이스 접근 등 비동기 작업은 언제 완료될지 모르기 때문에 상태 변경을 예측하기 어렵습니다.
  • 불투명한 부수 효과: ViewModel에서 상태를 변경하면서 부수 효과가 발생할 수 있습니다. 예를 들어, 네트워크 요청 후 상태가 변경되면, 이로 인해 다른 부분의 상태가 의도치 않게 변할 수 있습니다.

 

이렇듯 이 두 가지 문제(상태 관리와 부수 효과)를 해결하기 위해 탄생한 패턴이 바로 MVI 패턴입니다.

 

MVI 패턴이란?

그렇다면 MVI 패턴이란 무엇일까요?

MVI 패턴은 Model, View, Intent의 약자로 Intent를 통해 사용자 이벤트를 받아들여 Model의 상태를 변경합니다. 그리고 변경된 Model의 상태에 따라 View에 반영합니다.

 

각 요소에 대해 좀 더 자세한 설명은 다음과 같습니다.

  • Model: 애플리케이션의 상태를 단일 상태 객체로 관리합니다.
  • View: 사용자 인터페이스를 표현하고, 사용자 상호작용을 Intent로 변환합니다.
  • Intent: 사용자의 액션이나 시스템 이벤트를 처리하는 로직입니다. Intent는 Model의 상태를 변경하고, View에 다시 반영합니다.

 

MVI에서 상태 문제 부수 효과 해결

그렇다면 MVI에서는 이 MVVM의 상태 문제부수 효과에 대해 어떻게 해결했을까요?

코드와 함께 한번 살펴보도록 하겠습니다.

상태 문제 해결 

MVI 패턴에서는 단일 상태 객체를 사용하여 상태를 일관성 있게 유지합니다. 모든 상태 변경은 Intent를 통해 이루어지며, 상태 객체를 통해 일관되게 관리됩니다.

struct AppState {
    var counter: Int = 0
    var isLoading: Bool = false
}

enum AppIntent {
    case incrementCounter
    case decrementCounter
    case startLoading
}

class AppViewModel: ObservableObject {
    @Published private(set) var state = AppState()

    func send(_ intent: AppIntent) {
        switch intent {
        case .incrementCounter:
            state.counter += 1
        case .decrementCounter:
            state.counter -= 1
        case .startLoading:
            state.isLoading = true
            // 비동기 작업 예시
            DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
                self.state.isLoading = false
            }
        }
    }
}

struct ContentView: View {
    @ObservedObject var viewModel = AppViewModel()

    var body: some View {
        VStack {
            Text("Counter: \(viewModel.state.counter)")
            Button("Increment") {
                viewModel.send(.incrementCounter)
            }
            Button("Decrement") {
                viewModel.send(.decrementCounter)
            }
            if viewModel.state.isLoading {
                ProgressView()
            } else {
                Button("Start Loading") {
                    viewModel.send(.startLoading)
                }
            }
        }
    }
}

부수 효과 해결

MVI 패턴에서는 Intent에서 상태 변경을 처리하므로, 부수 효과가 예측 가능합니다. 비동기 작업도 Intent 내에서 처리하여 상태 변경을 명확하게 합니다.

enum UserIntent {
    case fetchUser(userId: String)
    case setUser(User)
    case setError(String)
}

struct UserState {
    var user: User?
    var errorMessage: String?
    var isLoading: Bool = false
}

class UserViewModel: ObservableObject {
    @Published private(set) var state = UserState()

    func send(_ intent: UserIntent) {
        switch intent {
        case .fetchUser(let userId):
            state.isLoading = true
            // 서버 호출 예시
            URLSession.shared.dataTask(with: URL(string: "https://api.example.com/user/\(userId)")!) { data, response, error in
                if let error = error {
                    DispatchQueue.main.async {
                        self.send(.setError(error.localizedDescription))
                    }
                    return
                }
                guard let data = data, let fetchedUser = try? JSONDecoder().decode(User.self, from: data) else {
                    DispatchQueue.main.async {
                        self.send(.setError("Failed to load user"))
                    }
                    return
                }
                DispatchQueue.main.async {
                    self.send(.setUser(fetchedUser))
                }
            }.resume()
        case .setUser(let user):
            state.isLoading = false
            state.user = user
        case .setError(let errorMessage):
            state.isLoading = false
            state.errorMessage = errorMessage
        }
    }
}

struct UserView: View {
    @ObservedObject var viewModel = UserViewModel()

    var body: some View {
        VStack {
            if let user = viewModel.state.user {
                Text("User: \(user.name)")
            } else if let errorMessage = viewModel.state.errorMessage {
                Text("Error: \(errorMessage)")
            } else if viewModel.state.isLoading {
                Text("Loading...")
            } else {
                Button("Fetch User") {
                    viewModel.send(.fetchUser(userId: "123"))
                }
            }
        }
    }
}

 

정리보자면 MVI 패턴에서 MVVM의 상태 문제State란 하나의 단일 객체에서 상태를 관리해주어 해결하고, 부수 효과오로지 Intent를 통한 상태 변경으로 변경 시점을 명확히 해주어 부수 효과의 단점을 극복할 수 있습니다.

 

구현

그럼 이제 제가 실제 프로젝트에 적용하였던 MVI 패턴의 형태에 대해 설명해보겠습니다.

 

MVI 패턴의 기본적인 흐름은 위 그림과 같은데요. 위 그림을 토대로 코드를 구현해보겠습니다.

 

 

위 코드는 단순히 뷰에 관한 코드입니다. 버튼 클릭 시 fetchData 이벤트가 발생합니다 .

 

 

fetchData 이벤트를 받은 Intent를 통해 비지니스 로직(네트워크 통신)을 시작합니다.

 

 

위 코드는 비지니스 로직을 구현한 함수 내부 모습입니다.

결과적으로 네트워크 요청이 성공하면 새로운 상태값을 State에 반영하여 View를 업데이트 시켜줍니다.

전체 코드

// DataView.swift

struct DataView: View {
    
    @ObservedObject var intent: DataIntent
    
    var body: some View {
        VStack {
            if intent.state.isLoading {
                ProgressView("Loading...")
            } else if let errorMessage = intent.state.errorMessage {
                Text("Error: \(errorMessage)")
                    .foregroundColor(.red)
            } else {
                List(intent.state.data, id: \.self) { item in
                    Text(item)
                }
            }
            
            // 버튼 클릭 시 fetchData 이벤트 발생
            Button(action: {
                intent.send(.fetchData)
            }) {
                Text("Fetch Data")
                    .padding()
                    .background(Color.blue)
                    .foregroundColor(.white)
                    .cornerRadius(8)
            }
        }
        .padding()
    }
}

// DataIntent.swift
import Foundation
import Combine

final class DataIntent: ObservableObject {
    @Published private(set) var state = AppState()
    private var cancellables = Set<AnyCancellable>()
    
    func send(_ action: DataAction) {
        switch action {
        case .fetchData:
            fetchData()
        }
    }
    
    private func fetchData() {
        state.isLoading = true
        state.errorMessage = nil
        
        // 네트워크 호출 시뮬레이션
        Just(["Item 1", "Item 2", "Item 3"])
            .delay(for: 2.0, scheduler: DispatchQueue.main)
            .sink { completion in
                if case .failure(let error) = completion {
                    self.state.errorMessage = "Failed to fetch data: \(error.localizedDescription)"
                }
                self.state.isLoading = false
            } receiveValue: { fetchedData in
                self.state.data = fetchedData
            }
            .store(in: &cancellables)
    }
}

enum DataAction {
    case fetchData
}

// AppState.swift
struct AppState {
    var data: [String] = []
    var isLoading: Bool = false
    var errorMessage: String? = nil
}

 

끝까지 읽어주셔서 감사합니다.

 

참고 사이트

https://medium.com/@kimdohun0104/mvi-%ED%8C%A8%ED%84%B4%EC%97%90-%EB%8C%80%ED%95%9C-%EA%B3%A0%EC%B0%B0-%EC%9D%B4%EC%9C%A0%EC%99%80-%EB%B0%A9%EB%B2%95-%EA%B7%B8%EB%A6%AC%EA%B3%A0-%ED%95%9C%EA%B3%84-767cc9973c98 (MVI 패턴 개념 정리 1)

https://medium.com/@Jager-yoo/ios-mvi-%ED%8C%A8%ED%84%B4-%EC%95%8C%EC%95%84%EB%B3%B4%EA%B8%B0-ea24d29cac9f (MVI 패턴 개념 정리 2)

https://superohinsung.tistory.com/148 (MVI 패턴 개념 정리 3)

 

https://f-lab.kr/insight/understanding-and-applying-mvvm-and-mvi-patterns (MVVM과 MVI 패턴 비교)

https://f-lab.kr/insight/understanding-mvc-mvvm-mvi-20240618 (MVC, MVVM, MVI 패턴 비교)

 

https://velog.io/@jakkujakku98/MVI-%ED%8C%A8%ED%84%B4 (MVI 패턴 구현 참고)