단방향 아키텍처에 대한 고찰

GUNDY·2025년 6월 4일

iOS

목록 보기
5/5

오늘 할 이야기는 아키텍처, 그 중에서도 단방향 아키텍처에 대한 이야기.

위의 이미지는 2019년도 WWDC의 Data Flow Through SwiftUI 세션에 나오는 한 장면이다. SwiftUI의 데이터 흐름을 설명하며 나온 이 이미지는 데이터가 항상 한 방향으로 흐르는 모델을 보여준다.


단방향 아키텍처

단방향 아키텍처라고 하는 것은 데이터 흐름이 역행없이 단방향인 아키텍처를 의미하며, Apple이 기본적으로 얘기하는 SwiftUI MV 패턴 구조도 단방향 데이터 흐름을 가진 아키텍처라고 할 수 있다.

그래서 SwiftUI에 MVVM을 적용하는 것이 의미가 있는지 없는지에 대한 담론이 있지만 오늘 얘기할 주제는 아니라고 생각한다.

MVC

Model-View-Controller 문서에 나오는 역할군 간 데이터 흐름을 설명하는 그림이다.

  • Model: 데이터의 원천이며, 상태가 변경되면 View나 Controller에 알림을 주는 역할을 한다.
  • View: 사용자 입력을 Controller에 전달하고, Model의 상태를 시각적으로 표현한다.
  • Controller: 사용자 입력을 받아 Model을 수정하며, 변경된 Model의 데이터를 기반으로 View를 갱신한다.

데이터 흐름이 단방향이라는 것은, 각 역할 간의 데이터 전달 경로가 고정되어 있어 흐름을 쉽게 추적하고 예측할 수 있다는 것을 의미한다. 하지만 MVC에서는 Controller가 View와 Model을 모두 직접 참조하고 다루므로, 데이터가 View → Controller → Model 뿐 아니라 다양한 경로로 흐를 수 있다. 이처럼 흐름이 명확하지 않기 때문에 MVC는 양방향 구조로 간주된다.

결과적으로 Controller의 책임이 커지면서 Massive View Controller가 되기 쉽고, 데이터의 변경 지점을 파악하거나 UI 상태와 동기화하는 것이 점점 어려워진다.

MVVM

MVVM은 뷰와 로직을 명확하게 분리하여, UI 코드와 비즈니스 로직이 서로 간섭하지 않도록 돕는 아키텍처 패턴이다. View와 ViewModel 간에는 데이터 바인딩이 존재하여 상태 변화에 따라 UI가 자동으로 갱신될 수 있도록 한다.

  • Model: 앱의 핵심 데이터 구조와 상태를 관리한다. ViewModel에 의해 조작되며, 변경된 데이터는 ViewModel을 통해 View에 전달된다.
  • View: 사용자에게 정보를 표시하고, 사용자 입력을 ViewModel에 전달한다. ViewModel이 제공하는 상태를 바인딩하여 UI를 갱신한다.
  • ViewModel: View로부터 받은 사용자 입력을 처리하고, 필요한 로직을 수행하여 Model을 수정한다. 이후 변경된 상태를 다시 View에 전달한다.

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 등으로 뷰에서 선언하는 것이 가장 일반적인 패턴이다.

정상 동작한다. 하지만 이 코드에는 몇 가지 수정할 부분이 있다.

  1. 뷰가 직접 뷰모델의 상태값을 수정한다.
    • 뷰가 직접 값을 수정하면 안 된다. 뷰모델은 캡슐화된 메서드를 제공하고 뷰는 그 메서드를 호출해야 한다.
  2. 뷰에서 특정 뷰모델에 대해 강하게 의존하고 있다.
    • 뷰와 로직을 분리하여 테스트하기 어렵다. 의존성을 추상화하고 구체 타입에 대한 의존성을 주입받아야 한다.

가장 기본적인 버전

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 프로토콜을 정의하였다. CounterViewModelCounterStateCounterEvent를 연관 타입으로 사용하므로 CounterView에 주입해 쓸 수 있다. 물론 ViewModel을 준수하며 연관 타입 조건이 맞는 다른 뷰모델을 만들어도 주입할 수 있을 것이다.

이말은 즉슨, View에 필요한 StateEvent만 정의하면 로직과 독립적으로 UI를 짤 수 있다는 것이다.

추상화, 의존성 분리, 테스트 가능성까지 갖춘 코드가 되었지만 보일러 플레이트 코드가 많아졌고, 기능에 비해 과할 수 있는 코드가 되었다.

결정적으로 iOS는 아직 UIKit만 쓰거나 SwiftUI와 혼재되어 있는 경우가 많은데, 이 MVVM으로는 SwiftUI만 대응할 수 있게 된다. UI 프레임워크에 대해 의존적이지 않은 아키텍처가 필요하다고 생각된다.

이쯤되면 TCA를 쓰는 게 낫겠는데? 하는 생각이 든다.

The Composable Architecture

The Composable Architecture
컴포저블 아키텍처는 구성, 테스트, 그리고 편의성을 고려하여 일관되고 이해하기 쉬운 방식으로 애플리케이션을 빌드하기 위한 라이브러리이다. SwiftUI, UIKit 등 모든 Apple 플랫폼에서 사용할 수 있다.

쉽게 말하면 Point-Free에서 만든 Swift 환경에서 사용할 수 있는 TCA 패턴을 지원하는 라이브러리이다.

TCA를 사용할 때 반드시 정의해야 하는 타입들이 있다. 아래는 Point-Free의 설명을 번역한 것.

  • State: 기능이 로직을 수행하고 UI를 렌더링하는 데 필요한 데이터를 설명하는 타입입니다.
  • Action: 사용자 작업, 알림, 이벤트 소스 등 기능에서 발생할 수 있는 모든 작업을 나타내는 타입입니다.
  • Reducer: 액션이 주어졌을 때 앱의 현재 상태를 다음 상태로 어떻게 전환할지 설명하는 함수입니다. 리듀서는 API 요청과 같이 실행해야 하는 모든 효과를 반환하는 역할도 하며, 이는 Effect 값을 반환하여 수행할 수 있습니다.
  • Store: 실제로 기능을 구동하는 런타임입니다. 모든 사용자 동작을 스토어로 전송하여 스토어에서 리듀서와 이펙트를 실행하고, 스토어의 상태 변화를 관찰하여 UI를 업데이트할 수 있습니다.

마찬가지로 아까의 카운터를 만들어보겠다.

@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가 사용하는 StateAction에 대한 의존성이므로 로직을 담당하는 구체적인 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>을 직접 명시하여 구체 타입 의존을 분리하는 방식이 더 유연하고 확장성 있는 선택이 될 수 있다.

UI 프레임워크 의존성

앞서 Point-Free의 말을 인용한 것처럼 TCA는 UIKit, SwiftUI 등 UI 프레임워크에 대해 의존하지 않고 작업할 수 있다.

어떤 칼이든 쓸 수 있다는 것


마무리

예전부터 좋은 아키텍처에 대한 고민을 해왔고 기존 아키텍처를 잘 사용하는 방법에 대해 고민했다.

최근 새로운 사이드 프로젝트를 시작하며 TCA를 공부해 도입하고 있는데, TCA는 좋은 선택이 될 것 같다. 공부 전에는 Flutter의 Bloc 패턴이랑 비슷할 거라고 생각했는데, 마냥 비슷하지만도 않다. 특히 조합이나 의존성 주입 등에 대해서 훨씬 깊은 고민이 있었다고 생각한다.

뭐 단점이 있다면 서드파티라는 거?

서드파티가 아니면 더 좋겠지만 가장 중요한 것은 구조니까, 만약 TCA가 서비스 중단되더라도 이러한 유형의 아키텍처를 사용할 수 있을 것이다.

그리고 TCA를 공부하니, 저마다 만드는 법이 조금은 다른 MVVM도 더 고민해서 만들면 보다 나은 구조로 설계할 수 있다고 느꼈다.

profile
개발자할건디?

0개의 댓글