스터디를 진행하면서 토론을 했던 주제가 TCA에서 얘기하는 단방향 데이터 흐름에 대한 이해를 도울 수 있는 토론이었던 것 같아 끝나고 개인적으로 다시 정리해서 글을 남기려고 한다. 매주 하나씩 이런 형태의 글을 꼭 써야지!!
오늘은 그 첫번째로 단방향 데이터 흐름이 왜 좋은건데? 라는 주제로 토론한 내용을 토대로 정리한 내용이다.
단방향 데이터 흐름이란 말 그대로 데이터가 하나의 방향으로만 흐른다는 의미이다.
TCA에서 데이터 흐름을 보면 다음과 같다
사용자입력(Action) -> Reducer -> State -> View
이 흐름은 언제나 한 방향으로만 일어나고 반대로 거슬러 올라가는 흐름이 없기 때문에 단방향이라고 말한다.
그렇다면 단방향 데이터 흐름이 왜 좋을까?
이걸 따져보기 위해 먼저 양방향 데이터 흐름의 문제점을 짚고 넘어가보면 양방향 흐름은 데이터를 View와 모델이 서로 변경 가능한 상태로 만든다는것을 의미한다.
View ↔ ViewModel 간 데이터가 양방향으로 변경된다. 이렇게 됐을때 데이터의 변화가 명확히 어디서 시작되었는지 추적하기가 어려운 상태가 될 수 있다. 사이즈가 큰 복잡한 앱에서는 의도치 않은 Side Effect와 버그가 증가할 수 있다.
import SwiftUI struct ContentView: View { @State private var name: String = "" var body: some View { VStack { TextField("이름 입력", text: $name) .textFieldStyle(RoundedBorderTextFieldStyle()) .padding() Button("이름 초기화") { name = "" } } } }
이 코드에서 TextField는 사용자 입력을 바로바로 name 상태에 양방향으로 연결하고 있다 (two-way binding)
사용자가 텍스트를 입력하면 → 상태인 (name) 이 변경되고 상태가 변경되면 뷰( TextField )가 업데이트 되는 형태
그렇다면 ViewModel에 로직을 몰아 넣어 StateObject 혹은 ObservedObject를 활용해 viewModel에서만 바인딩해 사용해보면? 해결 될 수 있을까
class ProfileViewModel: ObservableObject { @Published var name: String = "" @Published var isEditing: Bool = false func toggleEditing() { isEditing.toggle() } func resetName() { name = "" } func updateName(_ newName: String) { name = newName } }
struct ProfileView: View { @StateObject private var viewModel = ProfileViewModel() var body: some View { VStack { if viewModel.isEditing { TextField("이름 입력", text: $viewModel.name) } else { Text("이름: \\(viewModel.name)") } Button(viewModel.isEditing ? "완료" : "수정") { viewModel.toggleEditing() } Button("이름 초기화") { viewModel.resetName() } } } }
예를 들어 뷰모델에 변경될 name에 대한 로직을 구성하고 이 내용을 @Published 해 해당 값을 View에 넣어 변경값을 사용하는 방향으로 수정한다면 해당 문제는 해결 될까
이렇게 View + ViewModel 형태를 통해 SwiftUI + Combine을 활용해 ViewModel에 로직을 넣어 View에 상태 변화를 억제할 수 있는 방법을 생각해 볼 수 있지만 프로젝트의 크기가 점점 더 커진다는 가정하에 ViewModel은 View를 위한 상태 관리 뿐 만 아니라, 비즈니스 로직, 데이터 처리, 네트워크 로직 등 많은 로직이 담겨 더욱 많은 책임을 담당해 이거에 대한 문제점이 다시 생기게 된다.
단순하게 책임과 역할이 모호해져 관리가 어려워지고 어떤 문제가 발생했을 때 명확한 원인을 추적하기 어려워 질 수 있다 뿐 만 아니라 로직이 얽히면서 예측 불가능한 상황이 발생 할 수도.. 테스트가 어려워진다는 것까지 덤으로..
그럼 ViewModel에 그저 View에 바인딩하기 위해 모든 로직들을 몰아 넣는 단순 MVVM 구조가 아닌 각 책임을 나눠 Claen Architecture 구조를 도입해 책임을 나누고 테스트가 용이한 구조로 변경한다고 생각해보자
그렇다면 구조는 단순 View - ViewModel에서 아래와 같이 변경되겠지
View -> ViewModel -> UseCase -> Repository -> Data (NetWork, DB 등)
ViewModel은 주로 상태 관리와 UI 바인딩을 위한 최소한의 로직만을 담당하게 되고 UseCase가 핵심 비즈니스 로직을 처리 그 외 데이터 처리 관련 로직은 Data 레이어에서 담당하게 되면 어떨까
물론 현재도 이런 구조를 통해 책임소재를 명확하게 나눠 코드의 수정 및 새로운 기능 추가에 이전보다 훨씬 수월하고 의존성을 낮출 수 있어서 주로 사용하는 방식이다.
그렇기 때문에 무조건 단방향 데이터 흐름이 답이다 라는 내용을 얘기하려는 것은 아니고 “양방향 데이터 흐름이 왜 추적이 어려운지” 에 관점에서 접근 해보려고 한다.
그렇다면 양방향 데이터 흐름 문제는 왜 여전히 남아있을까
- 상태 변경의 출처가 여전히 모호하다.
View가 직접 바인딩으로 ViewModel의 상태를 바꿀 때 ViewModel의 입장에서는 변화의 원인(Action)이 뭔지 알 수 없다는 것 . 단지 상태의 값만 바뀌는 것 뿐
클린 아키텍처를 적용해 책임을 나누고 단순 MVVM 구조에서 나타날 수 있는 문제들을 어느정도 보완을 할 수 있다고 했을때에도 결국엔 상태 변화가 View ↔ ViewModel 사이에서 직접 바인딩(@Published, @Binding)을 통해 일어나면, ViewModel은 여전히 “ 어떤 상태가 왜 바뀌었는지? “ 에 대한 명확한 이 상태 변화의 원인을 기록하지 못한다 - 양방향 데이터 흐름의 상태 변화 추적의 어려움을 가지고 있다.
여전히 View , ViewModel 간 양방향 바인딩을 통해 데이터 흐름이 진행 중이라면 위에 설명헀던 내용과 같이 어떤 상태가 왜 바뀌었는지 명확하게 알 수 없기 때문에 어떤 문제가 발생했을때 추적이 어려워지고 사이트 이펙트를 일으키기 수월해진다.
간단한 코드를 보자면
class ProfileViewModel: ObservableObject { @Published var name: String = "" @Published var isEditing: Bool = false private let updateProfileUseCase: UpdateProfileUseCase init(updateProfileUseCase: UpdateProfileUseCase) { self.updateProfileUseCase = updateProfileUseCase } func saveName() { updateProfileUseCase.execute(name: name) } }
지금 이 ViewModel은 UseCase를 통해 텍스트 필드에 입력된 Name을 저장하는 로직을 주입 받아 사용하고 있다
struct ProfileView: View { @ObservedObject var viewModel: ProfileViewModel var body: some View { VStack { TextField("이름 입력", text: $viewModel.name) Button("저장") { viewModel.saveName() } } } }
그리고 저 ViewModel을 사용하는 View는 ViewModel을 바인딩해 사용하게 되면서 로직은 명확히 분리되어 있다고 볼 순 있지만 TextField가 직접적으로 ViewModel의 상태를 변경하는 양방향 바인딩을 사용하게 되면서 ViewModel 입장에서는 상태 변경의 출처가 직접 View가 변경했는지 또는 다른 로직의 결과인지 판단하기 어려워 진다는 것
혹시 이 여러 View에서 이 ViewModel을 공유한다면, 어떤 View가 언제 왜 상태를 바꿨는지 추적하기가 어려워진다!
이런 상태 변화에 대한 근본적인 투명성은 클린아키텍처를 써도 완전히 해결되지는 않는다는 점
그럼 TCA를 활용한 단방향 데이터 플로우는 어떻게 “상태 추적이 용이한” 상태가 되는 것일까

TCA는 이 문제를 어떻게 해결하는지 살펴보면 상태 변경을 반드시 Action을 통해서만 Reducer로 전달한다. Reducer가 오직 순수 함수로 상태를 변경하기 때문에 모든 상태 변화는 액션과 연결되어 투명하게 추적이 가능해진다.
enum ProfileAction: Equatable { case nameChanged(String) case saveButtonTapped } let reducer = Reducer<ProfileState, ProfileAction, Void> { state, action, _ in switch action { case let .nameChanged(newName): state.name = newName return .none case .saveButtonTapped: // 비즈니스 로직을 여기에 넣거나 Effect로 처리 가능 return .none } }
이 형태에서 View는 상태를 바꾸지 않고 특정 액션만 발생시키게 된다.
TextField( "이름 입력", text: viewStore.binding( get: \\.name, send: ProfileAction.nameChanged ) )
이런 형태로 어떤 액션이 발생했는지 명확하게 명시하게 때문에 상태 변화에 대한 값을 추적할 수 있게 되는 형태로 흐름을 투명하게 관리할 수 있게 해준다는 장점이 있다.
클린아키텍처와 비교를 다시 해보자면 클린아키텍처는 책임과 로직을 명확히 분리하지만 상태 변화 흐름의 투명성을 근본적으로 해결하지 못하는 한계가 있다. 단방향 플로우를 바탕으로 TCA는 투명성과 추적 가능성을 제공하는 구조라는 차이가 있고 이런 투명성은 어떤 문제가 발생했을때 문제점을 빠르게 찾아 낼 수 있다는 장점이 있다.
'◽️ Programming > T I L' 카테고리의 다른 글
모듈화 아키텍처를 적용해 재사용성 높히기 (0) | 2025.03.27 |
---|---|
모듈화 아키텍처를 활용한 프로젝트 관리 (0) | 2025.03.20 |
Moya를 활용해서 API 호출해 챗봇 기능 구현하기 (0) | 2025.03.17 |
WebSocket과 Starstream에 대해서 알아보자. (0) | 2025.02.17 |
fastlane를 활용한 CI/CD 환경 세팅하기 (0) | 2025.02.05 |