티스토리 뷰
1. 개요
예전에 TCA 를 사용해본적이 있는데 내가 느끼기에 장단점이 뚜렷하다고 생각이 들었다.
장점
- 단방향 플로우로 디버깅과 사이드 이펙트 처리가 편했음
- MVVM 이나 MVP 같은 패턴은 사용하는 사람이나 회사 따라 사실상 구조가 다 다른데 TCA 는 모두가 비슷한 구조의 코드를 강제(?)할 수 있음
단점
- 러닝커브가 있음
- 외부 라이브러리에 의존해야 함
- 컴파일러가 일을 잘 못하는 포인트가 있는듯
새로운 바닐라 프로젝트에서 시작했기 때문에 적용에 크게 애를 먹지는 않았고, 개발할때의 경험도 나쁘지 않았다. 특히 단방향 플로우의 아키텍쳐를 처음 경험해봐서 그런지 모르겠지만, 유저의 액션을 통합해 관리하고 결과 혹은 사이드이펙트로 처리하는 플로우가 잘 읽힌다고 느꼇었다.
하지만 아키텍쳐를 외부 라이브러리에 의존해야 한다는 부분은 물음표였다. 만약 TCA 를 빼야 하는 경우가 생긴다면 다바꿔야하는데? 건물을 세웠는데 기둥을 바꿀 수 있을까? 그리고 TCA 라이브러리를 다 분석해 이해할 수 있을까?
한편 회사의 안드로이드 개발자님과 함께 개발 얘기들을 막 하다보니, 안드로이드 개발 진영에서는 MVI 패턴을 Compose 와 함께 잘 사용한다고 한다. 특이하게도 여기도 MVI 패턴 라이브러리를 사용한다고 한다. 그래서 이거 괜찮은거에요? 라고 물어봤더니 Slack 에서 만든 MVI 라이브러리가 핫하다고 하더라. (그럼 슬랙이 망하지 않는이상 유지보수는 계속 되는건가?!) iOS 에서는 TCA 가 있어서 그런지 MVI 가 엄청 활발하게 사용되지는 않는 상황같다.
나도 그 당시 사이드프로젝트 팀에서 새로운 프로젝트를 시작하려던 참인데, MVI 패턴을 한번 적용해보고 싶어서 이런저런 자료를 찾아보고 적용해보게 되었다.
2. MVI 패턴이란
먼저 MVI 패턴의 흐름을 살펴보면 다음과 같다.
- 클라이언트에서는 유저의 행동 혹은 뷰의 생명주기에 따른 Event 를 intent 로 전달한다.
- intent 는 그에 따른 로직을 가지고 있다. 연산처리를 하거나 API 를 호출하거나, 여러 비즈니스 로직들을 처리한 뒤, Model 로 input 을 보낸다.
- Model 에서는 들어온 데이터를 State 로 변환해 데이터를 변경시킨다.
- State 를 구독하고 있는 View 는 UI 를 업데이트한다.
MVI(Model-View-Intent) 자체는 그 자체로 특별하거나 특이하거나? 한 디자인 패턴은 아니다. 기존에 iOS 에서 사용되던 단방향 아키텍처인 ReactorKit 이나 TCA 와 큰 구조는 동일하다. UIKit 에서 많이 사용되던 MVVM 패턴이 SwiftUI 에서도 효율적일까? 에 대한 의문들이 iOS 개발자들 사이에 생기면서, 그에 따라 여러가지 대안들이 제시되었고, TCA와 MVI 도 그 중 하나이다.
3. SwiftUI와 함께 적용하기
3-1. MVI Container
iOS 에 적용하려면 일단 시작부터 문제가 있다. MVI 에서는 view 가 intent 를 참조해야 한다. 하지만 SwiftUI 의 view 는 Struct 이기 때문에 불가능하다. 그렇다고 intent 를 @observable 로 만들수도 없다. 자료를 찾아보니 iOS에서는 이에 대한 대안으로 MVIContainer 를 만들어 사용한다.
import SwiftUI
import Combine
final class MVIContainer<Intent, Model>: ObservableObject {
// MARK: Public
let intent: Intent
let model: Model
// MARK: private
private var cancellable: Set<AnyCancellable> = []
// MARK: Life cycle
init(intent: Intent, model: Model, modelChangePublisher: ObjectWillChangePublisher) {
self.intent = intent
self.model = model
modelChangePublisher
.receive(on: RunLoop.main)
.sink(receiveValue: objectWillChange.send)
.store(in: &cancellable)
}
}
MVIContainer 는 Intent 와 Model 을 가지고 있는다. 그리고 Container 를 ObservableObject
로 만든것을 볼 수 있다. 또한 modelChangePublisher
를 외부에서 주입받는데, 이것을 Container 의 objectWillChange
로 바인딩 해준다. 이렇게 되면 modelChangePublisher 로 받은 이벤트가 MVIContainer 의 변경사항으로 적용되고, View 에서 container 를 ObservableObject
혹은 StateObject
로 사용할 경우 UI 업데이트에 사용할 수 있게 될 것이다!
VAnsimov/MVI-SwiftUI 레포를 참고해 내 프로젝트에 맞는 MVI 패턴 구조를 더 만들어보았다.
3-2. Model
final class ExampleModel: ObservableObject {
//MARK: Stateful
protocol Stateful {
// content
var userInfo: UserInfo? { get }
var selectedDistanceType: DreamPartnerDistanceType? { get }
var isValidated: Bool { get }
var myRegionString: String { get }
// default
var isLoading: Bool { get }
// error
var showErrorView: ErrorModel? { get }
var showErrorAlert: ErrorModel? { get }
}
//MARK: State Properties
// content
@Published var userInfo: UserInfo?
@Published var selectedDistanceType: DreamPartnerDistanceType?
var isValidated: Bool {
if selectedDistanceType == nil {
return false
}
if userInfo?.dreamPartner.distanceType == selectedDistanceType {
return false
}
return true
}
var myRegionString: String {
if let location = userInfo?.profile.locations {
return location
.compactMap { $0.name }
.joined(separator: ", ")
}
return "-"
}
// default
@Published var isLoading: Bool = false
// error
@Published var showErrorView: ErrorModel?
@Published var showErrorAlert: ErrorModel?
}
extension ExampleModel: ExampleModel.Stateful {}
//MARK: - Actionable
protocol ExampleModelActionable: AnyObject {
// content
func setUserInfo(_ userInfo: UserInfo)
func setDistanceType(_ type: DreamPartnerDistanceType)
// default
func setLoading(status: Bool)
// error
func showErrorView(error: ErrorModel)
func showErrorAlert(error: ErrorModel)
func resetError()
}
extension ExampleModel: ExampleModelActionable {
// content
func setDistanceType(_ type: DreamPartnerDistanceType) {
selectedDistanceType = type
}
func setUserInfo(_ userInfo: UserInfo) {
self.userInfo = userInfo
}
// default
func setLoading(status: Bool) {
isLoading = status
}
// error
func showErrorView(error: ErrorModel) {
showErrorView = error
}
func showErrorAlert(error: ErrorModel) {
showErrorAlert = error
}
func resetError() {
showErrorView = nil
showErrorAlert = nil
}
}
사용하던 MVI 방식으로 예제 코드를 만들어 보았다. 먼저 Model 은 두가지의 역할이 있다.
- state - view 에서 데이터 변경에 따른 UI 업데이트
- action - intent 에서 비즈니스 로직을 거친 데이터를 input
따라서 각각 Stateful 과 Actionable 두가지의 프로토콜을 만들어 채택하고 있음을 볼 수 있다.
- Stateful 은 state 로 가질 변수들을 선언하고 있으며 외부에서 변경이 불가능하도록 get 만 허용한다.
- Actionable 은 데이터를 받아 state 를 변경해주는 로직을 가지고 있다.
MVI 에서 Model 은 view 혹은 intent 에서 직접 접근해 데이터를 변경하지 않는 것이 중요하기 때문에 Protocol 로 만들어 각각 view 와 intent 에서 사용한다.
3-3. Intent
//MARK: - Intent
class ExampleIntent {
private weak var model: ExampleModelActionable?
private let profileService: ProfileServiceProtocol
// MARK: Life cycle
init(
model: ExampleModelActionable,
input: UserInfo,
service: ProfileServiceProtocol = ProfileService.shared
) {
self.model = model
self.profileService = service
model.setUserInfo(input)
}
}
//MARK: - Intentable
extension ExampleIntent {
protocol Intentable {
// content
func onTapDistanceType(_ type: DreamPartnerDistanceType)
func onTapNextButton(
type: DreamPartnerDistanceType?,
userInfo: UserInfo?
)
// default
func onAppear()
func task() async
}
}
//MARK: - Intentable
extension ExampleIntent: ExampleIntent.Intentable {
func onTapDistanceType(_ type: DreamPartnerDistanceType) {
model?.setDistanceType(type)
}
func onTapNextButton(
type: DreamPartnerDistanceType?,
userInfo: UserInfo?
) {
Task {
do {
guard let selectedType = type,
let userInfo else { return }
model?.setLoading(status: true)
var newUserInfo = userInfo
newUserInfo.dreamPartner.distanceType = selectedType
try await requestUpdatePartnerInfo(newUserInfo: newUserInfo)
model?.setLoading(status: false)
await MainActor.run {
AppCoordinator.shared.pop()
}
} catch {
model?.setLoading(status: false)
ToastHelper.showErrorMessage(error.localizedDescription)
}
}
}
func requestUpdatePartnerInfo(newUserInfo: UserInfo) async throws {
try await profileService.requestPutPartnerInfo(userInfo: newUserInfo)
}
// default
func onAppear() {}
func task() async {}
}
MVIContainer 에서 model 을 강하게 참조하고 있기 때문에 Intent 에서는 model 을 약한 참조로 가지고 있는 것을 볼 수 있다. 중요한 부분은 Intentable Protocol 이다. view 에서 들어오는 유저의 인터랙션이나 라이프사이클에 해당하는 이벤트를 Intentable 로 가지고 있는 것을 볼 수 있다.
위 예시에서는 크게 두가지의 이벤트가 있는데, 항목 선택과 다음 버튼 클릭 이벤트이다. 다음 버튼 클릭시 API 호출에 따른 비동기 처리도 담당하고 있으며, 상태에 따라 model 에서 만들어두었던 Actionable
로 setDistanceType()
과 setLoading()
에서 input
으로 보내는 것을 확인할 수 있다. 또한 네트워크 요청이 완료되면 AppCoordinator
를 호출해 화면을 닫는 것까지 담당하도록 구현했다.
3-4. View
public struct ExampleView: View {
@StateObject var container: MVIContainer<ExampleIntent.Intentable, ExampleModel.Stateful>
private var intent: ExampleIntent.Intentable { container.intent }
private var state: ExampleModel.Stateful { container.model }
public init(_ userInfo: UserInfo) {
let model = ExampleModel()
let intent = ExampleIntent(
model: model,
input: userInfo
)
let container = MVIContainer(
intent: intent as ExampleIntent.Intentable,
model: model as ExampleModel.Stateful,
modelChangePublisher: model.objectWillChange
)
self._container = StateObject(wrappedValue: container)
}
public var body: some View {
VStack {
VStack(spacing: 12) {
HStack {
Text("🏢 내 활동 지역")
.pretendard(weight: ._400, size: 14)
Text(state.myRegionString)
.typography(.semibold_14)
}
.foregroundStyle(DesignCore.Colors.grey400)
.padding(.horizontal, 20)
.padding(.vertical, 10)
.background {
Capsule()
.inset(by: 1)
.stroke(Color(hex: 0xEDE9C1), lineWidth: 2)
.fill(DesignCore.Colors.yellow50)
}
.padding(.bottom, 10)
ForEach(DreamPartnerDistanceType.allCases, id: \.self) { type in
HorizontalButtonView(
text: type.description,
isSelected: state.selectedDistanceType == type
) {
withAnimation {
intent.onTapDistanceType(type)
}
}
}
.animation(.default, value: state.selectedDistanceType)
}
.padding(.vertical, 20)
.padding(.horizontal, 28)
Spacer()
CTABottomButton(
title: "다음",
isActive: state.isValidated
) {
intent.onTapNextButton(
type: state.selectedDistanceType,
userInfo: state.userInfo
)
}
}
.task {
await intent.task()
}
.onAppear {
intent.onAppear()
}
.navigationTitle("선호 거리 수정")
.ignoresSafeArea(.keyboard)
.textureBackground()
.setPopNavigation {
AppCoordinator.shared.pop()
}
.setLoading(state.isLoading)
}
}
#Preview {
NavigationView {
EditDateProfileDistanceView(.mock)
}
}
View 는 container 를 @StateObject 로 가지고 있는다. View 가 재생성 되어도 변함없이 유지되어야 하기 때문이다. 그리고 view 의 init 함수에서 Model 과 intent 객체를 각각 생성해 Container 로 넣어준다. ObservableObject 인 Model 의 objectWillChange publisher 도 Container 로 넣어주게 된다.
MVIContainer 로 객체를 넣어줄 때 제넬릭으로 타입을 Intentable 과 Stateful 프로토콜로 지정하여 넣었기 때문에 View 에서 intentable 과 stateful 을 연산 프로퍼티로 꺼내주어 사용한다.
CTABottomButton(
title: "다음",
isActive: state.isValidated
) {
intent.onTapNextButton(
type: state.selectedDistanceType,
userInfo: state.userInfo
)
}
위의 코드만 부분적으로 보면 state 의 isValidated 값에 따라 버튼이 재렌더링 될 것이고, active 된 버튼의 클릭 이벤트는 intent 의 onTapNextButton() 으로 들어가며 비즈니스 로직이 처리된다.
추가로 아까 intent 에서 API 호출 시 intent 에서 model 로 setLoading action 이 들어갔었는데, View 에서는 이렇게 받아서 처리할 수 있다. 뷰에서 사용하는 쪽은 여러 방법이 있을 것 같음
뷰 ...
.setLoading(state.isLoading)
// --
private struct LoadingViewModifier: ViewModifier {
let isLoading: Bool
func body(content: Content) -> some View {
content
.overlay {
if isLoading {
FullScreenLoadingOverlay()
}
}
}
}
public extension View {
func setLoading(_ isLoading: Bool) -> some View {
modifier(LoadingViewModifier(isLoading: isLoading))
}
}
이렇게 예시 코드와 함께 MVI 패턴을 적용해보았다. 여러가지 Github 샘플과 블로그 포스팅 등을 살펴보니 정형화된 구조가 없고 각각 다르다. 하지만 단방향의 흐름으로써 상태를 한 곳에서 원활하게 관리를 한다는 측면에서 MVI 의 의미가 있다고 생각한다.
4. 테스트코드 적용하기
이번 프로젝트를 진행하면서 MVI 와 함께 테스트코드도 함께 적용해보았다. MVI 라서 테스트가 유리하다고 말하기는 조금 애매하긴 하다. 기존 MVVM 에서도 Testable 한 구조를 만든다면 로직들에 대한 테스트 코드를 충분히 작성 가능하다고 생각한다. Intent 와 State 의 각각 역할이 명확하게 나뉘어진다는 점 덕분에 뭔가 코드를 쓰고 읽기가 수월하다는 느낌은 있었다.
struct WidgetUnitTest {
let selectionState: WidgetSelectionModel
let selectionIntent: WidgetSelectionIntent
let writeState: WidgetWritingModel
let writeIntent: WidgetWritingIntent
init () {
self.selectionState = WidgetSelectionModel()
self.selectionIntent = WidgetSelectionIntent(
model: selectionState,
input: .init(
successHandler: {
}
)
)
self.writeState = WidgetWritingModel()
self.writeIntent = WidgetWritingIntent(
model: writeState,
input: .init(
widgetType: .body,
content: nil,
successHandler: {
}
),
service: ProfileServiceMock()
)
selectionIntent.onAppear()
writeIntent.onAppear()
}
@Test func selectWidget() async throws {
selectionIntent.onTapWidget(.body)
#expect(selectionState.selectedWidget == .body)
#expect(selectionState.isPushWriteContentView == true)
}
@Test func writeWidgetContent() async throws {
let content: String = "Hello, World!"
writeIntent.onChangedBodyText(content, maxCount: 15)
#expect(writeState.widgetBodyText == content)
let longContent: String = "Hello World!! This is Hipster Text! Oh YEAH!!"
writeIntent.onChangedBodyText(longContent, maxCount: 15)
#expect(writeState.widgetBodyText == "Hello World!! T")
#expect(writeState.widgetBodyText.count == 15)
}
@Test func onTapBackButton() async throws {
writeIntent.onTapBackButton()
writeState.isPushedWriteContentView = false
}
@Test func onTapDismissButton() async throws {
writeIntent.onTapDismissButton()
writeState.isPushedWriteContentView = false
}
@Test func keyboardFocusState() async throws {
#expect(writeState.isFocused == true)
}
@Test func onTapNextButton() async throws {
writeIntent.onTapNextButton(state: writeState)
// 3초 후 실행
try await Task.sleep(for: .seconds(1))
#expect(writeState.isModalPresented == false)
}
}
위는 위젯 입력 기능에 대한 테스트 코드이다.
큰 흐름은 다음과 같다
- 테스트코드에서 유저의 액션과 라이프사이클에 해당하는 intentable 을 실행
- 원하는 동작대로 state 가 변경되었는지 확인
즉, 비즈니스 로직을 담당하는 Intent 와 그 로직에 의해 변화하는 결과값인 State 를 테스트한다.
@Test func selectWidget() async throws {
selectionIntent.onTapWidget(.body)
#expect(selectionState.selectedWidget == .body)
#expect(selectionState.isPushWriteContentView == true)
}
예를 들어 위 코드에서 .body 위젯을 선택했을 때, state의 selectedWidget 이 정상적으로 .body 로 선택 되었는지, 그리고 isPushWriteContentView
가 true 로 바뀌는지 체크한다.
실제 사용하는 뷰 에서는 isPushWriteContentView
가 바뀌어야 다음 로직으로 진행되기 때문에 중요한 비즈니스 로직이라고 생각해 테스트코드를 넣었다.
5. 결론
TCA 와 ReactorKit, MVI 와 같은 단방향 아키텍쳐는 코드를 쓰고 읽을 때 흐름을 명확하게 볼 수 있다는게 참 좋다. 하지만 간단한 기능임에도 코드를 많이 작성해야 한다는 점은 트레이드 오프로 작용하는 것 같다.
일부 커스텀해 만든 MVI 구조에 추가로 고민해봐야 할 부분을 몇개 생각해보았다.
- Side-Effect 관리
Android 같은 경우는 Intent 단계에서 Side-Effect 를 관리하지만, 현재의 구조는 State 에서 error 를 들고 있는 상황이다. TCA 에서 Side-Effect 를 우아하게 처리했던 경험이 있는데, 마찬가지로 global 하고 깔끔한 방향으로 처리하는 쪽으로 보완해보고 싶다. - 양방향 binding 처리
대표적으로 TextField 는Binding<String>
이 들어가므로 양방향 바인딩으로 구현이 된다. 그런데 MVI 에서는 view 가 state 를 직접 접근해 변경하는 것을 지양하기 때문에 옳은 방향이 아니라고 생각한다.
그래서 복잡하게 사용하긴 했는데, View 에 별개의 @State 를 만든 뒤, 해당 State 에 .onChange
를 달아 intent
로 전달하여서 동기화 시켰다.
// 저 위에
@State var inputText = String()
// View
TextField(
"김위브",
text: $inputText
)
.pretendard(weight: ._400, size: 28)
.onChange(of: inputText) {
intent.onChangeInputText(text: inputText)
}
.onChange(of: state.inputText) {
inputText = state.inputText
}
이렇게 쓰면 view 의 @State 와 model(state) 의 text 가 동기화 된다. intent 에서 수행하는 validation 이나 trim 등 도 적용 가능하다.
그런데 불편하긴 하다. 뭔가 다른 방법이 있을 지 고민이 필요하다고 생각한다.
추가로, 해당 프로젝트에서는 MVI 뷰를 Xcode Template 으로 만들어서 3개의 파일을 생성하도록 하니까 생산성이 크게 늘었다.
참고한 자료
'iOS' 카테고리의 다른 글
Github Action self-hosted-runner 도입기 (0) | 2025.05.22 |
---|---|
[iOS] STOMP 로 채팅 구현하기 (0) | 2025.05.11 |
[iOS] String Catalog 로 Localization, 다국어 지원하기 (0) | 2025.04.19 |
[iOS] SwiftUI 에서 TapGesture 가 동작하지 않을 때 (HighPriorityGesture) (1) | 2025.03.23 |
[iOS] Swift로 오디오 다루기 (AudioKit) (0) | 2025.03.16 |
- Total
- Today
- Yesterday
- SwiftUI
- Swift
- swift audio
- highprioritygesture
- flo
- swiftui 탭
- keyboardtype
- AVFoundation
- ios채팅
- open-api-generator
- avplayer
- openapi-generator
- demical
- 맥북에어 m4
- swiftui 제스처
- swift날짜
- 애플워치 데이터 전송
- watch connectivity
- ios 다국어
- Xcode15
- audiokit
- DateFormatter
- audio kit
- onTapGesture
- ios웹소켓
- IOS
- Github action
- easy cue
- self-hosted-runner
- string catalog
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | |||||
3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 | 19 | 20 | 21 | 22 | 23 |
24 | 25 | 26 | 27 | 28 | 29 | 30 |
31 |