You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
이전에 저도 SwiftUI에 MVI를 적용해 보았는데 사람마다 이해한게 다르고 다른 프로젝트 레퍼런스도 안드로이드에서 착안한 MVI인지 아니면 TCA에서 착안한 MVI인지가 다르더라구요.
일단 저는 TCA에서 착안한 MVI 구조를 가져가면 좋을것같아요. TCA에서 레퍼런스삼아서 흐름도를 잘 가져갈수 있는게 큰 장점인것 같아서 이 구조가 제일 적합하다고 생각합니다.
일단 우리 플젝이 Clean Architecture의 기반으로 UseCase, Repository, Network 계층이 명확히 분리되어 있어서, 이 부분은 그대로 활용하면서 프레젠테이션 계층만 MVI로 교체할 수 있을것 같아요
MVI 패턴의 기본 구성
Model
화면에 표시되는 모든 데이터, UI상태를 담는 불변객체
로딩 상태, 에러 메시지, 표시할 데이터 목록 등 포함
View
Model을 받아서 UI로 렌더링하고 사용자의 행동을 Intent로 변환 및 시스템 전달
View는 상태 직접변경 x Intent 통해서만 시스템과 소통
Intent
사용자의 모든 행동이나 시스템 이벤트 표현하는 데이터 타입
사용자가 버튼을 탭하거나, 텍스트를 입력하거나, 화면이 나타날 때마다 이런 행동들이 Intent로 변환
이런 단방향 흐름 덕분에 데이터가 어디서 어떻게 변경되는지 항상 명확하게 추적이 가능해집니다.
일단 우리 플젝이 Clean Architecture의 기반으로 UseCase, Repository, Network 계층이 명확히 분리되어 있어서, 이 부분은 그대로 활용하면서 프레젠테이션 계층만 MVI로 교체할 수 있을것 같아요
기존 구조에서 View가 직접 UseCase를 호출하던 방식을 , View가 Intent를 발생시키고 이 Intent가 UseCase를 호출하는 방식으로 변경하는 식으로 잡을 수 있을것 같아요!
Intent, Effect, State의 정의와 역할
Intent(Action)
Intent는 시스템에서 발생할 수 있는 모든 사건을 표현하는 열거형
// Intent(Action) 정의 - 시스템에서 발생할 수 있는 모든 사건을 표현
enumHomeIntent{
// 사용자 액션 - 직접적으로 사용자가 발생한 이벤트
case viewDidLoad // 화면이 처음 로드될 때
case refreshRequested // 사용자가 새로고침을 요청할 때
case searchTextChanged(String) // 검색어가 변경될 때
case eventTapped(Event) // 특정 이벤트를 탭할 때
case createEventTapped // 새 이벤트 생성 버튼을 탭할 때
// 시스템 액션 - Effect의 결과로 발생하는 내부 액션
case eventsLoaded([Event]) // 이벤트 로딩이 성공했을 때
case loadingFailed(String) // 이벤트 로딩이 실패했을 때
case eventCreated(Event) // 새 이벤트가 생성되었을 때
}
사용자 액션은 UI 이벤트에서 직접 발생하는 것들이고, 시스템 액션은 Effect의 결과로 내부적으로 생성되는 것들입니다. 이렇게 나누어야 SideEffect 관리와 순수성을 보장할 수 있게 됩니다.
State
State는 화면의 모든 상태를 하나의 구조체로 통합한 것
// State 정의 - 화면의 모든 상태를 하나로 통합
structHomeState{
// 기본 데이터
varevents:[Event]=[]varsearchText:String=""
// UI 상태
varisLoading:Bool=falsevarerror:String?=nil
// 계산된 속성 - UI 로직을 State 내부로 캡슐화
varfilteredEvents:[Event]{guard !searchText.isEmpty else{return events }return events.filter{ event in
event.title.localizedCaseInsensitiveContains(searchText)}}
// 복합 상태 - 여러 기본 상태를 조합한 UI 판단 로직
varshouldShowEmptyState:Bool{return !isLoading && filteredEvents.isEmpty && error ==nil}varshouldShowErrorState:Bool{return !isLoading && error !=nil}}
State를 설계할 때는 모든 UI 상태를 하나의 구조체에 몰아 넣어야 한ㄷ다고 생각합니다.
계산속성을 활용하여 파생되는 UI 상태들도 State 내부에서 관리하면, View는 단순히 이 State를 표시하는 역할만 할수 있어요
Effect
비동기 작업이나 Side Effect를 처리하는 부분입니다.
Effect는 Intent를 받아서 외부 시스템과 상호작용을 한 후, 그 결과를 새로운 Intent로 변환합니다
// Effect 정의 - 비동기 작업을 Intent로 변환
privatefunc createEffect(for intent:HomeIntent)->AnyPublisher<HomeIntent,Never>{switch intent {case.viewDidLoad,.refreshRequested:
// 이벤트 로딩 Effect - 기존 UseCase 활용
returnloadEventsEffect()case.createEventTapped:
// 이벤트 생성 Effect
returncreateEventEffect()default:returnEmpty().eraseToAnyPublisher()}}privatefunc loadEventsEffect()->AnyPublisher<HomeIntent,Never>{
// 컴바인을 사용하기에 Future를 사용하여 async/await 코드를 Publisher로 변환
Future{[weak self] promise inguardlet self =selfelse{return}Task{do{
// 기존 UseCase를 그대로 활용 - Clean Architecture 재사용
letevents=tryawaitself.eventUseCase.getEvents()
// 성공 결과를 새로운 Intent로 변환
promise(.success(.eventsLoaded(events)))}catch{
// 실패 결과도 Intent로 변환하여 일관된 에러 처리
promise(.success(.loadingFailed(error.localizedDescription)))}}}.eraseToAnyPublisher()}
Effect는 모든 결과를 Intent로 변환합니다.
성공과 실패 모두 Intent로 표현하여, Reducer가 똑같은 방식으로 처리할 수 있게 하면서 기존 UseCase를 동일하게 사용합니다.
적용해보자
지금코드
// 지금방식
structHomeView:View{
//상태관리 귀찬핑
@Stateprivatevarevents:[Event]=[]@StateprivatevarisLoading=false@StateprivatevarsearchText=""@StateprivatevarerrorMessage:String?=nil
// UseCase 직접 의존성 - View에 비즈니스 로직이 섞여있음
privateleteventUseCase:EventUseCaseProtocolvarbody:someView{VStack{SearchBar(text: $searchText)if isLoading {ProgressView("Loading...")}elseiflet error = errorMessage {ErrorView(message: error)}else{EventList(events: filteredEvents)}}.onAppear{
// View에서 직접 비즈니스 로직 호출 - 테스트하기 어려움
loadEvents()}.refreshable{loadEvents()}}
// View 내부에 비즈니스 로직과 상태 변경 로직이 혼재
privatefunc loadEvents(){
isLoading =true
errorMessage =nil // 여러 상태를 수동으로 관리해야 함
Task{do{
events =tryawait eventUseCase.getEvents()
isLoading =false}catch{
errorMessage = error.localizedDescription
isLoading =false}}}
// UI 로직이 View에 분산되어 있음
privatevarfilteredEvents:[Event]{guard !searchText.isEmpty else{return events }return events.filter{ $0.title.localizedCaseInsensitiveContains(searchText)}}}
// MVI 적용
// 1. Intent - 사용자 액션 명시
enumHomeIntent{case onAppear
case refresh
case searchChanged(String)case eventTapped(Event)
// 시스템 액션 (Effect 결과)
case eventsLoaded([Event])case loadingFailed(String)}
// 2. State - 모든 상태 통합
// 비즈니스 로직이 들어가는게 아닌 로직없이 데이터나 상태(스냅샷) 정도만 담겨있는 위치
structHomeState{varevents:[Event]=[]varsearchText=""varisLoading=falsevarerrorMessage:String?=nil
// UI 로직도 State에서 관리
varfilteredEvents:[Event]{guard !searchText.isEmpty else{return events }return events.filter{ $0.title.localizedCaseInsensitiveContains(searchText)}}}
// 3. Store - 상태와 로직을 분리 + 비즈니스 모델 구현
classHomeStore:ObservableObject{@Publishedvarstate=HomeState()privateleteventUseCase:EventUseCaseProtocolinit(eventUseCase:EventUseCaseProtocol){self.eventUseCase = eventUseCase
}
//모든 액션 처리
func send(_ intent:HomeIntent){switch intent {case.onAppear,.refresh:
state.isLoading =true
state.errorMessage =nilloadEvents()case.searchChanged(let text):
state.searchText = text
case.eventsLoaded(let events):
state.events = events
state.isLoading =falsecase.loadingFailed(let error):
state.errorMessage = error
state.isLoading =falsecase.eventTapped(let event):print("Event tapped: \(event.title)")}}
// 비즈니스 로직을 Store로 분리
privatefunc loadEvents(){Task{@MainActorindo{letevents=tryawait eventUseCase.getEvents()send(.eventsLoaded(events)) // 결과를 Intent로 순환
}catch{send(.loadingFailed(error.localizedDescription))}}}}
// 4. View - 순수하게 UI만 담당
structHomeView:View{@StateObjectprivatevarstore:HomeStoreinit(store:HomeStore){self._store =StateObject(wrappedValue: store)}varbody:someView{VStack{
// State 구독하고 Intent 전송만
SearchBar(
text: store.state.searchText,
onTextChanged:{ store.send(.searchChanged($0))})if store.state.isLoading {ProgressView("Loading...")}elseiflet error = store.state.errorMessage {ErrorView(message: error)}else{EventList(
events: store.state.filteredEvents,
onEventTapped:{ store.send(.eventTapped($0))})}}.onAppear{
store.send(.viewDidLoad) // 액션을 Intent로 표현
}.refreshable{
store.send(.refresh)}}}
graph TB
%% 단순화된 MVI 핵심 흐름 (실제 코드와 일치)
View[👀 View] -->|send| Intent[📨 Intent]
Intent -->|switch| Reducer[⚙️ Reducer]
Reducer -->|update| State[📊 State]
State -->|Published| View
%% Effect 처리
Reducer -->|Task async| Effect[🌐 Effect]
Effect -->|await| UseCase[UseCase Layer]
UseCase -->|결과| Effect
Effect -->|send result| Intent
%% Store 컨테이너
subgraph Store [🏪 Store Container]
Intent
State
Reducer
Effect
end
%% 기존 Clean Architecture (변경 없음)
subgraph "Clean Architecture (유지)"
UseCase --> Repository[Repository]
Repository --> Network[Network]
Repository --> Storage[Storage]
end
%% 다크/라이트 모드 호환 색상
classDef viewStyle fill:#4A90E2,stroke:#2171b5,stroke-width:2px,color:#fff
classDef intentStyle fill:#F5A623,stroke:#e8930c,stroke-width:2px,color:#000
classDef reducerStyle fill:#7ED321,stroke:#5cb516,stroke-width:2px,color:#000
classDef stateStyle fill:#D0021B,stroke:#b8001a,stroke-width:2px,color:#fff
classDef effectStyle fill:#9013FE,stroke:#7c0fe3,stroke-width:2px,color:#fff
classDef storeStyle fill:#6C7B7F,stroke:#576064,stroke-width:3px,color:#fff
class View viewStyle
class Intent intentStyle
class Reducer reducerStyle
class State stateStyle
class Effect effectStyle
class Store storeStyle
reacted with thumbs up emoji reacted with thumbs down emoji reacted with laugh emoji reacted with hooray emoji reacted with confused emoji reacted with heart emoji reacted with rocket emoji reacted with eyes emoji
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
-
작성중
MVI(Model-View-Intent)는 Redux 패턴에서 영감을 받은 단방향 아키텍처입니다
redux 흐름
출처
상태 변화를 완전히 예측 가능하게 만드는 것을 목표로 하고있어요
이전에 저도 SwiftUI에 MVI를 적용해 보았는데 사람마다 이해한게 다르고 다른 프로젝트 레퍼런스도 안드로이드에서 착안한 MVI인지 아니면 TCA에서 착안한 MVI인지가 다르더라구요.
일단 저는 TCA에서 착안한 MVI 구조를 가져가면 좋을것같아요. TCA에서 레퍼런스삼아서 흐름도를 잘 가져갈수 있는게 큰 장점인것 같아서 이 구조가 제일 적합하다고 생각합니다.
일단 우리 플젝이 Clean Architecture의 기반으로 UseCase, Repository, Network 계층이 명확히 분리되어 있어서, 이 부분은 그대로 활용하면서 프레젠테이션 계층만 MVI로 교체할 수 있을것 같아요
MVI 패턴의 기본 구성
이런 단방향 흐름 덕분에 데이터가 어디서 어떻게 변경되는지 항상 명확하게 추적이 가능해집니다.
일단 우리 플젝이 Clean Architecture의 기반으로 UseCase, Repository, Network 계층이 명확히 분리되어 있어서, 이 부분은 그대로 활용하면서 프레젠테이션 계층만 MVI로 교체할 수 있을것 같아요
기존 구조에서 View가 직접 UseCase를 호출하던 방식을 , View가 Intent를 발생시키고 이 Intent가 UseCase를 호출하는 방식으로 변경하는 식으로 잡을 수 있을것 같아요!
Intent, Effect, State의 정의와 역할
Intent(Action)
사용자 액션은 UI 이벤트에서 직접 발생하는 것들이고, 시스템 액션은 Effect의 결과로 내부적으로 생성되는 것들입니다. 이렇게 나누어야 SideEffect 관리와 순수성을 보장할 수 있게 됩니다.
State
State는 화면의 모든 상태를 하나의 구조체로 통합한 것
State를 설계할 때는 모든 UI 상태를 하나의 구조체에 몰아 넣어야 한ㄷ다고 생각합니다.
계산속성을 활용하여 파생되는 UI 상태들도 State 내부에서 관리하면, View는 단순히 이 State를 표시하는 역할만 할수 있어요
Effect
비동기 작업이나 Side Effect를 처리하는 부분입니다.
Effect는 Intent를 받아서 외부 시스템과 상호작용을 한 후, 그 결과를 새로운 Intent로 변환합니다
Effect는 모든 결과를 Intent로 변환합니다.
성공과 실패 모두 Intent로 표현하여, Reducer가 똑같은 방식으로 처리할 수 있게 하면서 기존 UseCase를 동일하게 사용합니다.
적용해보자
지금코드
graph TB %% 단순화된 MVI 핵심 흐름 (실제 코드와 일치) View[👀 View] -->|send| Intent[📨 Intent] Intent -->|switch| Reducer[⚙️ Reducer] Reducer -->|update| State[📊 State] State -->|Published| View %% Effect 처리 Reducer -->|Task async| Effect[🌐 Effect] Effect -->|await| UseCase[UseCase Layer] UseCase -->|결과| Effect Effect -->|send result| Intent %% Store 컨테이너 subgraph Store [🏪 Store Container] Intent State Reducer Effect end %% 기존 Clean Architecture (변경 없음) subgraph "Clean Architecture (유지)" UseCase --> Repository[Repository] Repository --> Network[Network] Repository --> Storage[Storage] end %% 다크/라이트 모드 호환 색상 classDef viewStyle fill:#4A90E2,stroke:#2171b5,stroke-width:2px,color:#fff classDef intentStyle fill:#F5A623,stroke:#e8930c,stroke-width:2px,color:#000 classDef reducerStyle fill:#7ED321,stroke:#5cb516,stroke-width:2px,color:#000 classDef stateStyle fill:#D0021B,stroke:#b8001a,stroke-width:2px,color:#fff classDef effectStyle fill:#9013FE,stroke:#7c0fe3,stroke-width:2px,color:#fff classDef storeStyle fill:#6C7B7F,stroke:#576064,stroke-width:3px,color:#fff class View viewStyle class Intent intentStyle class Reducer reducerStyle class State stateStyle class Effect effectStyle class Store storeStyleBeta Was this translation helpful? Give feedback.
All reactions