MVI 아키텍처로 단방향 데이터 흐름 만들기

2025.04.03 - [◽️ Programming/T I L] - 단방향 데이터 흐름이 왜 좋은데!?

 

단방향 데이터 흐름이 왜 좋은데!?

스터디를 진행하면서 토론을 했던 주제가 TCA에서 얘기하는 단방향 데이터 흐름에 대한 이해를 도울 수 있는 토론이었던 것 같아 끝나고 개인적으로 다시 정리해서 글을 남기려고 한다. 매주 하

dongdida.tistory.com

 

프로젝트를 진행하면서 단방향 아키텍처에 관한 관심이 생겼다 이전에는 클린아키텍처를 가지고 Repository, UseCase, ViewModel, View 를 구성해 관리했지만 이전에 블로그 글을 남겼던 내용과 같이 양방향 아키텍처를 충분히 사용해본 지금 상태에서 단방향 아키텍처는 어떤 방식으로 진행되는지 궁금했기 때문..

 

대표적으로 SwiftUI를 사용하면서 단방향 아키텍처를 설계한다고 했을때 TCA를 채택해서 사용 하는 경우가 많이 보였다. 이번 프로젝트에서는 TCA가 나름 러닝커브가 높은 편이기 때문에 한정된 시간에 TCA까지 적용하는건 완성까지 가는데 무리가 있을 것 같다고 생각해서 이번에는 MVI 패턴을 활용해 단방향 아키텍처를 구현해보기로 결정!

 

먼저 MVI 아키텍처는 크게 Model, View, Intent(의도)를 바탕으로 구성되어있다고 할 수 있는데 기존의 양방향 데이터 흐름을 가진 것과는 다르게 단방향 데이터 흐름을 가져갈 수 있도록 하는 패턴 중 하나이다.

 

Model과 View는 동일하게 가져가는 개념이니까 일단 Intent에 대해서 알아보자!

 

Intent

단어의 뜻대로 사용자의 ‘의도’를 뜻한다. 사용자가 UI에서 발생시키는 액션과 같은.. 예를 들어 버튼 클릭, 텍스트 입력 등을 의미한다. Intent는 View에서 발생하고 , Intent가 처리된 후 Model의 상태를 변경하는 방식으로 이전에 알아봤던 TCA와 비슷한 흐름으로 동작한다!!

다시 정리하자면

View에서 사용자가 인터페이스와 상호작용 (버튼 클릭 등) →

View는 이 상호작용을 Intent로 변환하여 전달 →

Intent는 Model에 의해서 처리된 후 상태를 변경 →

모델에서 상태가 변경되면, 변경된 상태를 View로 다시 전달 →

View는 새 상태를 반영하여 UI를 업데이트!

 

이런 방식으로 상태가 한 방향으로 흐르고, 각 컴포넌트가 명확한 역할을 하므로 코드가 어떤 상태에서 문제가 발생하거나 어떤 상호 작용을 진행했을때 흐름 파악이 매우 용이하다! 단방향 아키텍처의 장점은 이전 블로그 글에 작성해 뒀으니 요정도만 하고 넘어가도록 하자

 

예시

먼저 Model은 앱의 상태를 정의하기 때문에 현재 사용자 로그인 상태를 저장해보는 AppState를 만들어보자

struct AppState {
	var isLoggedIn: Bool
	var username: String?
}

Intent는 사용자 인터페이스에서 발생하는 액션을 정의하면 된다.

enum AppIntent {
    case login(username: String, password: String)
    case logout
}

여기서 나는 개인적으로 ViewModel을 사용하는 방식보다 Store를 사용하는 방식을 적용해보려고 하는데. Store를 사용하는 이유는 앱의 상태를 중앙에서 관리하고 업데이트할 수 있도록 해주고 싶기 때문!! Store가 모든 상태 변화를 처리하고 이를 View에 전달하는 방식으로 한 곳에서 집중되어 관리될 수 있도록 하고싶다.

 

그리고 Store는 UI와 직접적으로 연결되지 않아 상태와 Intent를 처리하는 로직을 View와 분리하여 더 깔끔한 구조를 유지할 수 있다.

class AppStore: ObservableObject {
    @Published var state: AppState
    
    init(state: AppState) {
        self.state = state
    }

    func send(intent: AppIntent) {
        switch intent {
        case .login(let username, let password):
            login(username: username, password: password)
        case .logout:
            logout()
        }
    }

    private func login(username: String, password: String) {
        // 여기에 로그인 로직을 처리합니다.
        // 예시로 바로 로그인 상태로 설정합니다.
        state = AppState(isLoggedIn: true, username: username)
    }

    private func logout() {
        state = AppState(isLoggedIn: false, username: nil)
    }
}

이렇게 구성하면 Store에서 Intent를 전달받아 State를 변경해주고 해당 변경 사항은 View에 적용되도록 하면 된다!

import SwiftUI

struct ContentView: View {
    @ObservedObject var store: AppStore

    @State private var username: String = ""
    @State private var password: String = ""

    var body: some View {
        VStack {
            if store.state.isLoggedIn {
                Text("Welcome, \\(store.state.username ?? "")!")
                Button("Logout") {
                    store.send(intent: .logout)
                }
            } else {
                TextField("Username", text: $username)
                TextField("Password", text: $password)
                Button("Login") {
                    store.send(intent: .login(username: username, password: password))
                }
            }
        }
    }
}

결론적으로 이렇게 구성하게 되면 Store에서는 상태만 관리하게 되고 Intent 처리만 담당하게 되면서 뷰와 결합이 적어지기 떄문에 View가 변경되더라도 Store가 영향을 받지 않는 상태가 될 수 있다.

 

이렇게 됐을 때 새로운 상태를 추가하거나 변경을 관리하는데 용이해지고 단위 테스트를 진행하기 수월해지는 장점이 있다!

 

이렇게 MVI 아키텍처의 장점은 명호가한 역할을 분담하고 단방향 데이터 흐름을 가져갈 수 있게 되면서 다소 프로젝트가 복잡하게 구성되어 있어도 테스트가 용이해지고 어떤 문제가 발생했을때 어디가 문제인지 빠르게 흐름을 파악할 수 있는 구조를 가지게 된다!

 

이런 구조를 바탕으로 이번 프로젝트에 적용해봐야지!

오늘은 여기까지!