📕 iOS

[iOS] Intent의 의도는 무엇일까? MVI 패턴 도입기

이오🐥 2025. 3. 6. 01:22

🔖 MVI 도입기

MVI = 모스트 밸류어블 이오

 

호랑이티비ㄴ스 팀에서 MVI패턴을 채택해 프로젝트를 설계하기로 했다.

가장 큰 이유는 학습에 있었고,

양방향 아키텍처인 MVC, MVVM 패턴을 경험했던 팀원들이 있어서, 단방향으로 가보자고 했다.

그리고 추가로 이번 프로젝트가 규모가 매우 작기 때문에,

다른 외부 라이브러리를 사용하지 않고 우리가 직접 구성해 보자는 의견이 모였다.

 

그런데 MVI 패턴.. 나는 사실 작년에 이해하다가 포기한 패턴이다.

단방향 흐름과 Intent의 의도를 파악하지 못했던 것이 가장 큰 이유이다.

하지만 이번에 설계해보고, 조금이나마 이해를 해보기로 했다!

 

🔖 iOS에서 MVI는 어떻게 적용되고 있는가..

MVI를 검색하면 Android와 관련된 글이 아주 많다.

사실 안드로이드 개발 경험이 없어서 잘 모르지만,

개발 환경 속에 Intent라는 용어가 쓰이는 것 같다.

그래서 '그 Intent'가 아닙니다.라는 문장도 종종 봤다.

 

그리고 단방향 흐름을 구현하기 위해 Redux도 아주 많이 언급된다.

Redux는 자바스크립트에서 State를 관리하기 위한,

단방향 데이터 흐름을 따르도록 도와주는 라이브러리라고 한다.

 

iOS에서는 유사하게 TCA가 있다. TCA는 SwiftUI와 함께 주로 쓰인다.

UIKit과는 ReactorKit, RxSwift와 함께 쓰인다.

결국 TCA도 ReactorKit도 단방향 데이터 흐름을 위한 라이브러리인 것이다.

(MVI를 구성하면서 TCA와 ReactorKit을 정말 많이 참고했다.)

 

🔖 아니 그래서 MVI가 뭔데?

사실 개념적으로 MVI를 설명하는 건 이제 할 수 있을 것 같다.

State를 관리하기 위해 단방향 데이터 흐름을 유지할 수 있게 하는 패턴.

사용자가 View에서 의도한 Intent에 따라 State를 변화시키고,

State 변화에 맞춰 View를 업데이트 하는 것.. 아닐 수도 있지만 이 정도 이해는 했다.

 

하지만 막상 구현하려고 보니 이곳 저곳 애매한 부분이 너무 많았다.

일단 많이 봤던 형태로 구성을 해봤었다.

(최종 아님 !!!!!!!!!)

 

그런데 고려해야 할 부분들을 떠올리면서 점점 MVI 자체에 대한 고민이 되기 시작했다.

고려할 부분 중에는,

- State의 Setter를 View에서 접근하지 못하게 해야 한다.

- State, Action, 그리고 Action의 정의를 묶어둔 이것을 Intent라고 볼 수 있는가?

- 실제 사용자의 '의도'와 Side Effect 요청이나 State 변경과 같은 동작을 같다고 볼 수 있는가?

와 같은 것들이 있었다.

 

처음에는 단순히 State와 Action을 가지고, 동작을 할 수 있게 해주는 역할 정도로 생각했다.

물론 Side Effect는 늘 있을 수 있다는 전제로 생각한 흐름이다.

그런데 어느 순간 "Intent"의 의미를 생각하게 되었다.

 

그래서 Intent와 Action을 분리해 생각하기 시작했다.

오른쪽 아래 그림과 같이 생각했는데,

View -> Intent: 사용자가 화면에서 버튼을 눌렀다거나 특정 의도를 가진 동작을 한다.

Intent -> Action: 사용자의 의도에 따라 구체적으로 동작해야 할 Action을 실행한다.

Action -> State: 동작 속에서 필요한 State 변경을 처리한다.

State -> View: State 변경에 따른 View를 업데이트한다.

 

왜 Intent라고 부르는 걸까? Action과 동일하다면, State를 변하게 해주는 아이라면..

굳이 Intent라고 쓰일 필요가 없지 않았을까?

 

나는 Intent와 Action이 동일하다고 생각하기도 했다.

(물론 지금은 동일하다고도, 동일하지 않다고도 할 수 없는 상태가 되었다.)

"사용자는 1-이 버튼을 눌러 2-특정 동작이 실행되고 3-화면이 변경되기를 원한다"

고 생각해 보면, 이 모든 과정이 사용자의 Intent, 즉 의도가 될 수 있을 것 같았다.

 

이와 별개로 화면 전환 로직은 Intent에 포함되어야 할까,

UseCase(Domain)에 포함되어야 할까? 고민하기도 했다.

 

🔖 일단 구성해 보자..!

iOS에서 많이 사용되는 TCA와 ReactorKit 사용 예시를 많이 찾아봤다.

두 라이브러리 모두에게서 영향을 많이 받았고,

그렇게 아래와 같은 구성이 짜이게 되었다.

// MVI - Intent

import Foundation

enum LocationPermissionStatus {
    case notDetermined
    case denied
    case authorizedAlways
    case authorizedWhenInUse
}

@Observable
class OnboardingIntent {
    // MARK: 화면에 사용되는 상태, 밖에서는 setter에 접근 불가능
    private(set) var state: State = .init()
    struct State {
        var locationPermissionStatus: LocationPermissionStatus?
    }
    
    // MARK: 사용자가 화면에서 하고자 하는 '의도'
    enum Intent {
        case nextButtonTapped  // 다음 버튼 탭
    }
    
    // MARK: Intent를 수행하기 위해 필요한 동작
    private enum Action {
        case requestLocationPermission       // 사용자 위치 정보 요청
        case updateLocationPermissionStatus  // 위치 허용 권한 업데이트
        case navigateToMainScreen            // 메인 스크린으로 이동
    }
    
    
    func intent(_ userIntent: Intent) {
        switch userIntent {
        case .nextButtonTapped:
            // 사용자에게 위치 정보 허용 요청
            _ = reduce(state, .requestLocationPermission)
            
            // 사용자의 허용 정보에 따라 State 변경
            state = reduce(state, .updateLocationPermissionStatus)
            
            // 다음 화면으로 이동
            _ = reduce(state, .navigateToMainScreen)
        }
    }
    
    // State가 불변 상태를 유지할 수 있도록 - 새로운 객체를 생성하여 대체한다
    // 이전 상태와 액션을 입력 받아 새로운 상태를 반환
    private func reduce(_ state: State, _ action: Action) -> State {
        var newState = state
        switch action {
        case .requestLocationPermission:
            print("요청 요청")
            
        case .updateLocationPermissionStatus:
            newState.locationPermissionStatus = .authorizedAlways
            
        case .navigateToMainScreen:
            print("메인화면으로 이동")
        }
        return newState
    }
}

 

우선 앞서 많은 고민을 했지만, Intent와 Action을 분리하기로 했다.

여기서 나오는 용어는 우리 팀 내의 약속으로 어쩌면 다른 곳에서는 다른 의미로 쓰일지도 모르겠다.

하지만 적어도 우리는 이렇게 쓰기로 했다.

(ReactorKit에서 많은 영감을 받았다.)

 

Intent는 사용자의 의도다. 화면에서 사용자가 처리되길 바랐던 의도.

그래서 네이밍을 좀 더 View의 Event에 가깝게 정하려고 했다.

(TCA에서 많은 영감을 받았다.)

 

Action은 사용자의 의도를 처리하기 위한 동작이다.

메서드의 역할이 더 두드러지는 네이밍을 할 필요가 있다.

 

intent 메서드에서는 Intent를 처리한다. (그런데 메소드 이름을 아직 고민 중이다.)

reduce에서는 Action을 처리한다.

 

그리고 State는 불변 상태를 유지하고, 업데이트하는 경우 새로운 값으로 교체될 수 있도록 했다.

intent method 내에서는 직접 state에 접근하고 있는데, 이 부분은 여전히 고민 중이다.

State를 파라미터로 주입해야 테스트 및 불변 상태 유지에 조금 더 도움이 될 텐데..

intent는 state를 변경시키는 주체가 되도록 했기에, 우선 저렇게 두기로 했다.

 

네이밍에 대한 언급을 조금 했는데, 호랑과 함께 네이밍에 대한 고민을 꽤 했다.

과연 Intent가 View에서 발생하는 동작 중심으로 표현되어도 괜찮을까?

Intent는 단순한 UI event보다는 사용자의 기대, 의도를 더욱 표현해야 하는 것 아닌가?

 

여기서 이 의문에 대해 무척이나 공감했다.

특히 method의 이름이 가지는 책임과 무게가 다르다고 느껴져서 더 공감했다.

하지만 우리는 Action을 분리하기로 했고,

Intent가 조금 더 View 중심으로 쓰여 명확하게 표현되기를 바랐다.

 

🔖 Combine.. 그건 내가 잘 모르는데.. async/await로 우선 해보자!

MVI로 하기로 하고, 많은 레퍼런스들을 참고했을 때 Combine을 많이 쓰고 있었다.

ReactorKit + RxSwift, TCA + Combine이라고 불리는 것처럼..

MVI는 반응형 프로그래밍, 즉 단방향 데이터 흐름을 유지하기 위해

데이터 스트림을 한 방향으로 흐를 수 있도록 도와주는 라이브러리/프레임워크를 쓰고 있었다.

 

하지만 당장에 나와 팀원들의 Combine에 대한 지식이 많이 없어서,

비동기 처리가 필요한 경우에는 async, await 키워드를 활용해 보기로 했다.

// async, await를 사용하는 일부 코드!
func intent(_ action: Intent) async {
    switch action {
    case .nextButtonTapped:
        // 사용자에게 위치 정보 허용 요청
        _ = reduce(state, .requestLocationPermission)

        // 사용자의 허용 정보에 따라 State 변경
        let permissionStatus = await requestLocationPermission()
        state = reduce(state, .updateLocationPermissionStatus(permissionStatus))

        // 다음 화면으로 이동
        _ = reduce(state, .navigateToMainScreen)
    }
}

 

이 부분은 개발을 하면서 조금 더 공부하고 적용할 필요가 있어 보인다.

그리고 Combine을 활용한 코드를 추가하는 것도 매우 매우 고려 중이다.

 

🔖 앞으로 해야 할 일

프로젝트를 하면서 생각하는 것들을 글로 적는 것이 익숙하지 않은데,

그래서 더더욱 이 글이 두서없는 글처럼 느껴진다.

해야 할 말을 못 하고 넘어간 것 같기도 하고, 부족함이 많아서..

올려도 되나 고민이 많이 되었지만 ㅎㅎ 이왕 쓴 거 그냥 올려본다!!

 

이제 여기에 화면 전환을 위한 로직을 구성하고,

View에 필요한 Intent를 비롯한 다양한 의존성을 주입하는 객체를 만들 예정이다.

아마도 그런 것들이 추가되면 위에 구성한 내용들이 변경될 여지도 보인다!

그리고 개발하면서 어쩌면 많은 것들이 추가되고 빠질 수도 있겠다는 생각이 든다.

 

이론적으로 구상하더라도 실제 사용하면서 느끼는 바가 있을 수 있으니!

호랑이티비ㄴ스 파이팅!!

 


(추가)

🔖 MVVM과의 차이는 무엇일까?

내가 간과했던 부분이 하나 있었다. 내가 MVVM에 대한 정의를 완벽히 이해하지 못했다는 것.

VM과 Intent와의 차이에 대한 질문을 받았을 때,

나는 너무 당당하게 VM은 양방향 데이터 흐름이고, Intent는 단방향 데이터 흐름 아니야?

이렇게만 이야기 해버렸다.

 

나름 내가 Intent를 구성하면서 View에서 접근하는 Intent로 트리거 되어

새로운 State로 교체하며 State의 불변성을 유지하는 것에 신경을 많이 쓰려고 했는데,

이로써 Intent를 단방향으로 흐르도록 하는 것은 꽤 신경 써서 너무 자신있게 말했던 것이다.

 

추가로 내가 State, Intent, Action을 가지고 있는 객체의 이름도 Intent라고 지어버려서, 더욱 혼란을 가중시켰던 것 같다.

가장 먼저 적어도 이 객체의 이름을 변경하고 명확하게 Intent를 명시하도록 해야겠다.

이 데이터 흐름을 책임지는 객체의 이름은 고민 중이다. 아마도 IntentContainer..? 정도가 아닐까.

TCA의 영향으로 Store를 쓰거나 그저 ViewModel이라고도 할 수 있을 것 같다.

 

물론 VM을 써버리면 이제 머리가 폭발하는 것이다. 그래서 ViewModel이 뭐냐..

아무튼 MVVM과의 차이. 사실 잘 모르겠다. 이게 정답인 것 같다.

지금 당장 내가 인지하는 VM에서는 View와 데이터가 바인딩되어, 서로 접근이 가능한 형태.

이 정도라고만 생각을 하게 된다.

 

VM에 많이 쓰이는 Input, Output 형태로 쓰였을때..?

만약 VM에서도 데이터가 한 방향으로만 흐르도록 했다면?

그러면 뭐 VM이 아니라고 할 수는 없는 거 아닌가?

아무튼 아직 잘 모르겠다. 누가 그냥 딱 알려주면 좋겠다는 생각도 든다. 하하.

 

🔖 참고자료

[SyncSwift 2022] MVI패턴과 어울리는 SwiftUI 화면 이동 라이브러리 만들기

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

 

MVI 패턴에 대한 고찰, 이유와 방법 그리고 한계

MVI 패턴은 무엇을 해결할까요? MVI 패턴을 사용하는 이유를 살펴본 뒤, 문제를 어떻게 우아하게 풀어나가는지 확인해봅시다. 물론 한계점도 존재할 것입니다. 그에 대한 저의 해결 방법도 확인해

medium.com

 

 

[iOS] ReactorKit정리와 MVI 설계

ReactorKit 적용과 MVI를 직접 설계해보며

medium.com