
오늘 할 이야기는 아키텍처, 그 중에서도 단방향 아키텍처에 대한 이야기.
위의 이미지는 2019년도 WWDC의 Data Flow Through SwiftUI 세션에 나오는 한 장면이다. SwiftUI의 데이터 흐름을 설명하며 나온 이 이미지는 데이터가 항상 한 방향으로 흐르는 모델을 보여준다.
단방향 아키텍처라고 하는 것은 데이터 흐름이 역행없이 단방향인 아키텍처를 의미하며, Apple이 기본적으로 얘기하는 SwiftUI MV 패턴 구조도 단방향 데이터 흐름을 가진 아키텍처라고 할 수 있다.
그래서 SwiftUI에 MVVM을 적용하는 것이 의미가 있는지 없는지에 대한 담론이 있지만 오늘 얘기할 주제는 아니라고 생각한다.

Model-View-Controller 문서에 나오는 역할군 간 데이터 흐름을 설명하는 그림이다.
데이터 흐름이 단방향이라는 것은, 각 역할 간의 데이터 전달 경로가 고정되어 있어 흐름을 쉽게 추적하고 예측할 수 있다는 것을 의미한다. 하지만 MVC에서는 Controller가 View와 Model을 모두 직접 참조하고 다루므로, 데이터가 View → Controller → Model 뿐 아니라 다양한 경로로 흐를 수 있다. 이처럼 흐름이 명확하지 않기 때문에 MVC는 양방향 구조로 간주된다.
결과적으로 Controller의 책임이 커지면서 Massive View Controller가 되기 쉽고, 데이터의 변경 지점을 파악하거나 UI 상태와 동기화하는 것이 점점 어려워진다.
MVVM은 뷰와 로직을 명확하게 분리하여, UI 코드와 비즈니스 로직이 서로 간섭하지 않도록 돕는 아키텍처 패턴이다. View와 ViewModel 간에는 데이터 바인딩이 존재하여 상태 변화에 따라 UI가 자동으로 갱신될 수 있도록 한다.
MVVM에서 데이터 바인딩의 구현 방식에 따라 단방향 또는 양방향으로 구성될 수 있지만, iOS에서는 RxSwift, Combine, Swift Concurrency 등을 사용하여 View → ViewModel → Model → ViewModel → View 형태의 단방향 흐름을 유지하는 방식이 일반적이다.
이러한 단방향 흐름 덕분에 상태 추적이 명확하고 사이드이펙트가 줄어들며, 테스트와 유지보수가 용이한 구조를 만들 수 있다.
이번에 고민했던 것은 이 MVVM 패턴을 어떻게 하면 iOS에서 가장 깔끔하게 적용할 수 있을지에 대해 고민했다.
'+', '-' 버튼을 통해 화면의 숫자를 바꾸는 카운터를 예시로 알아보겠다.
final class CounterViewModel: ObservableObject {
@Published var count = 0
}
struct CounterView: View {
@StateObject var viewModel = CounterViewModel()
var body: some View {
VStack {
Text(String(viewModel.count))
.font(.largeTitle)
HStack {
decreaseButton
increaseButton
}
}
.padding()
}
private var decreaseButton: some View {
Button {
viewModel.count -= 1
} label: {
Text("-")
}
}
private var increaseButton: some View {
Button {
viewModel.count += 1
} label: {
Text("+")
}
}
}
SwiftUI에서는 ObservableObject를 통해 뷰모델을 만들고 @StateObject 등으로 뷰에서 선언하는 것이 가장 일반적인 패턴이다.

정상 동작한다. 하지만 이 코드에는 몇 가지 수정할 부분이 있다.
final class CounterViewModel: ObservableObject {
@Published private(set) var count = 0
func increaseButtonTapped() {
count += 1
}
func decreaseButtonTapped() {
count -= 1
}
}
struct CounterView: View {
@StateObject var viewModel = CounterViewModel()
var body: some View {
VStack {
Text(String(viewModel.count))
.font(.largeTitle)
HStack {
decreaseButton
increaseButton
}
}
.padding()
}
private var decreaseButton: some View {
Button {
viewModel.decreaseButtonTapped()
} label: {
Text("-")
}
}
private var increaseButton: some View {
Button {
viewModel.increaseButtonTapped()
} label: {
Text("+")
}
}
}
뷰모델의 상태값을 private(set)으로 선언하여 get-only로 만들고 이벤트에 대응하는 메서드를 선언하여 제공하도록 하였다.

이 상태가 가장 기본적인 상태겠지만 여전히 앞서 이야기한 의존성 문제가 해결되지 않았다.
protocol CounterViewModel: ObservableObject {
var count: Int { get }
func increaseButtonTapped()
func decreaseButtonTapped()
}
final class DefaultCounterViewModel: CounterViewModel {
@Published private(set) var count = 0
func increaseButtonTapped() {
count += 1
}
func decreaseButtonTapped() {
count -= 1
}
}
struct CounterView<T: CounterViewModel>: View {
@ObservedObject var viewModel: T
var body: some View {
VStack {
Text(String(viewModel.count))
.font(.largeTitle)
HStack {
decreaseButton
increaseButton
}
}
.padding()
}
private var decreaseButton: some View {
Button {
viewModel.decreaseButtonTapped()
} label: {
Text("-")
}
}
private var increaseButton: some View {
Button {
viewModel.increaseButtonTapped()
} label: {
Text("+")
}
}
}
이번엔 @ObservedObject로 선언하여 의존성을 주입받도록 하였고, 구체적인 타입이 아니라 CounterViewModel 프로토콜을 의존하도록 하여 DIP를 지킬 수 있도록 하였다.
이정도 되면 조금 더 개선해서 앱 전체에서 뷰모델의 기본 동작을 정의하여 일관성을 갖고 싶다는 생각이 든다.
protocol ViewModel: ObservableObject {
associatedtype State
associatedtype Event
var state: State { get }
func recieveEvent(_ event: Event)
}
struct CounterState {
var count = 0
}
enum CounterEvent {
case increaseButtonTapped
case decreaseButtonTapped
}
final class CounterViewModel: ViewModel {
@Published private(set) var state = CounterState()
func recieveEvent(_ event: CounterEvent) {
switch event {
case .increaseButtonTapped:
increaseButtonTapped()
case .decreaseButtonTapped:
decreaseButtonTapped()
}
}
private func increaseButtonTapped() {
state.count += 1
}
private func decreaseButtonTapped() {
state.count -= 1
}
}
struct CounterView<T: ViewModel>: View where T.Event == CounterEvent, T.State == CounterState {
@ObservedObject var viewModel: T
var body: some View {
VStack {
Text(String(viewModel.state.count))
.font(.largeTitle)
HStack {
decreaseButton
increaseButton
}
}
.padding()
}
private var decreaseButton: some View {
Button {
viewModel.recieveEvent(.decreaseButtonTapped)
} label: {
Text("-")
}
}
private var increaseButton: some View {
Button {
viewModel.recieveEvent(.increaseButtonTapped)
} label: {
Text("+")
}
}
}
특정 이벤트를 수신하여 특정 상태를 수정하는 ViewModel 프로토콜을 정의하였다. CounterViewModel은 CounterState와 CounterEvent를 연관 타입으로 사용하므로 CounterView에 주입해 쓸 수 있다. 물론 ViewModel을 준수하며 연관 타입 조건이 맞는 다른 뷰모델을 만들어도 주입할 수 있을 것이다.
이말은 즉슨, View에 필요한 State와 Event만 정의하면 로직과 독립적으로 UI를 짤 수 있다는 것이다.
추상화, 의존성 분리, 테스트 가능성까지 갖춘 코드가 되었지만 보일러 플레이트 코드가 많아졌고, 기능에 비해 과할 수 있는 코드가 되었다.
결정적으로 iOS는 아직 UIKit만 쓰거나 SwiftUI와 혼재되어 있는 경우가 많은데, 이 MVVM으로는 SwiftUI만 대응할 수 있게 된다. UI 프레임워크에 대해 의존적이지 않은 아키텍처가 필요하다고 생각된다.
이쯤되면 TCA를 쓰는 게 낫겠는데? 하는 생각이 든다.
The Composable Architecture
컴포저블 아키텍처는 구성, 테스트, 그리고 편의성을 고려하여 일관되고 이해하기 쉬운 방식으로 애플리케이션을 빌드하기 위한 라이브러리이다. SwiftUI, UIKit 등 모든 Apple 플랫폼에서 사용할 수 있다.
쉽게 말하면 Point-Free에서 만든 Swift 환경에서 사용할 수 있는 TCA 패턴을 지원하는 라이브러리이다.
TCA를 사용할 때 반드시 정의해야 하는 타입들이 있다. 아래는 Point-Free의 설명을 번역한 것.
Effect 값을 반환하여 수행할 수 있습니다.마찬가지로 아까의 카운터를 만들어보겠다.
@Reducer
struct Counter {
@ObservableState
struct State: Equatable {
var count = 0
}
enum Action {
case increaseButtonTapped
case decreaseButtonTapped
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .increaseButtonTapped:
state.count += 1
return .none
case .decreaseButtonTapped:
state.count -= 1
return .none
}
}
}
}
struct CounterView: View {
let store: StoreOf<Counter>
var body: some View {
VStack {
Text(String(store.count))
.font(.largeTitle)
HStack {
decreaseButton
increaseButton
}
}
.padding()
}
private var decreaseButton: some View {
Button {
store.send(.decreaseButtonTapped)
} label: {
Text("-")
}
}
private var increaseButton: some View {
Button {
store.send(.increaseButtonTapped)
} label: {
Text("+")
}
}
}
앞서 MVVM에서 고려했던 사항들을 하나씩 확인해보자.

간단하게 설명하자면 TCA는 다음과 같이 동작한다.
기본적으로 사용자의 인터랙션 등 액션이 발생하면 뷰는 store.send를 호출해 적절한 액션을 전달한다. 그럼 스토어는 리듀서의 로직에 따라 상태 갱신, 비동기 작업 등을 수행할 수 있게 된다. 결과적으로 갱신된 상태값이 뷰에 반영된다.

외부에 get-only로만 제공하기 때문에 뷰에서 직접 값을 수정할 수 없다. 이것은 store.state.count로 좌변을 바꿔도 마찬가지다.
StoreOf<Counter>로 선언되어있어 Counter에 대한 의존으로 볼 수도 있다.
public typealias StoreOf<R: Reducer> = Store<R.State, R.Action>
하지만 사실 이것은 Reducer 자체가 아니라 그 Reducer가 사용하는 State와 Action에 대한 의존성이므로 로직을 담당하는 구체적인 Reducer에 대한 의존성이 아니다.
따라서 다음과 같은 DoubleCounter를 만들어서 주입하더라도 정상적으로 빌드가 된다.
@Reducer
struct DoubleCounter {
typealias State = Counter.State
typealias Action = Counter.Action
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .increaseButtonTapped:
state.count += 2
return .none
case .decreaseButtonTapped:
state.count -= 2
return .none
}
}
}
}

CounterView를 하나도 수정하지 않았음에도 새로운 로직의 DoubleCounter를 만들어 넣을 수 있었다. 이는 곧 구체적인 리듀서의 로직에는 의존하지 않고, 단지 상태와 액션의 인터페이스에만 의존하고 있다는 걸 보여준다.
그래도 StoreOf<Counter>라는 표현이 Counter 리듀서 타입을 뷰가 알아야 하는 것처럼 보이므로 다음과 같이 분리하면 더 구체타입 의존성을 떼어낼 수 있다.
@ObservableState
struct CounterState: Equatable {
var count = 0
}
enum CounterAction {
case increaseButtonTapped
case decreaseButtonTapped
}
@Reducer
struct Counter {
typealias State = CounterState
typealias Action = CounterAction
var body: some ReducerOf<Self> {
...
}
}
struct CounterView: View {
let store: Store<CounterState, CounterAction>
var body: some View {
...
}
}
이렇게 하면 View는 Counter 리듀서 자체에 대한 의존 없이, 순수하게 데이터 타입과 이벤트에만 관심을 갖는 구조가 된다.
그렇지만 대부분의 경우 StoreOf<Reducer>를 쓰는 것이 간단하고 명확하며, 실제로는 State와 Action에만 의존하므로 큰 문제가 되지 않는다.
하지만 경우에 따라 여러 리듀서가 동일한 상태/액션을 공유하거나, 리듀서를 교체 가능하게 설계하고 싶을 때는 Store<SomeState, SomeAction>을 직접 명시하여 구체 타입 의존을 분리하는 방식이 더 유연하고 확장성 있는 선택이 될 수 있다.
앞서 Point-Free의 말을 인용한 것처럼 TCA는 UIKit, SwiftUI 등 UI 프레임워크에 대해 의존하지 않고 작업할 수 있다.

어떤 칼이든 쓸 수 있다는 것
예전부터 좋은 아키텍처에 대한 고민을 해왔고 기존 아키텍처를 잘 사용하는 방법에 대해 고민했다.
최근 새로운 사이드 프로젝트를 시작하며 TCA를 공부해 도입하고 있는데, TCA는 좋은 선택이 될 것 같다. 공부 전에는 Flutter의 Bloc 패턴이랑 비슷할 거라고 생각했는데, 마냥 비슷하지만도 않다. 특히 조합이나 의존성 주입 등에 대해서 훨씬 깊은 고민이 있었다고 생각한다.
뭐 단점이 있다면 서드파티라는 거?

서드파티가 아니면 더 좋겠지만 가장 중요한 것은 구조니까, 만약 TCA가 서비스 중단되더라도 이러한 유형의 아키텍처를 사용할 수 있을 것이다.
그리고 TCA를 공부하니, 저마다 만드는 법이 조금은 다른 MVVM도 더 고민해서 만들면 보다 나은 구조로 설계할 수 있다고 느꼈다.