Combine+Swift Concurrency(option) => TCA, RxSwift => ReactorKit
Swift 로 iOS 앱 개발을 처음 배울 때는 대부분 MVC 를 가르친 것 같다. 참고로 나는 Stanford CS193P 수업 영상을 그대로 Youtube 에 올려준 것을 시작으로 공부했는데, 거기에서도 MVC 만 가르쳐 주었다. 진짜 영어 자막이 없어서 쉽지 않았던 기억이 있다.
https://cs193p.sites.stanford.edu/
오랜만에 들어가보니 지금은 MVVM+SwiftUI 를 가르치고 있다.
결론만 말하자면 굳이 그럴 필요는 없다고 본다. 현재 서비스되고 있는 레거시 코드에도 MVC 로 작성된 것이 분명 있다. 내가 근무했던 회사, 프리랜서로 합류한 프로젝트 모두 MVC 를 쓰고 있다. 그 프로젝트를 전부 아키텍처를 엎는다? 화이팅이다.
흔히 아키텍처를 나누는 기준 중 하나는 데이터의 방향이다. 방향은 두 개로 나뉜다.
이 중에서 MVC 는 양방향에 속한다. 왜 그럴까?
이건 애플에서 제시하는 MVC 도식이다. 상당히 오래되었지만 잘못되었다고 생각하지는 않는다.
여기서 중요한 것은 모델이다. MVVM, MVC, ReactorKit, TCA 모두 모델을 ViewController 가 어떻게 바라보느냐에 따라 정해진다.
MVC 에서 모델을 호출하려면 ViewController 는 Model 을 소유해야 한다. ViewController 와 Model 의 커플링을 느슨하게 해야 하는 것도 맞다. 하지만 커플링이 없는 아키텍처가, 애플리케이션이 존재할 수 있는가? 외부 의존성을 주입한다고 해도 커플링을 숨겼을 뿐, 커플링이 일어나지 않은 것이 아니다. 아키텍처를 이해하기 위해서는 결국 이 커플링을 이해해야 하는 것이다.
그럼 ViewController 이 소유한 Model 은 무엇일까? 나는 MVC 의 Model 이 각 ViewController 에 최적화된 Model
이 될 수 밖에 없다고 생각한다.
이제 앞에서 말한 MVC 가 양방향인 이유가 나온다. MVC 의 Model 은 ViewController 를 위한 Model 이고, Model 과 ViewController 의 협력도 ViewController 의 실행 Context 를 벗어나기 어렵다.
커플링이 없을 수는 없지만 이건 너무 강한 것이다.
모든 아키텍처를 선택할 때 가장 중요한 것은 아래의 3 가지라고 생각한다.
그리고 나는 이 기준에 대해 MVC 가 가진 문제점을 아래와 같이 제시하고 싶다.
물론 MVC 도 잘 쓰면 위의 사항들을 모두 만족할 수 있다고 생각한다. 하지만 그 이전에 새로운 아키텍처를 고민해보는 것도 좋지 않을까?
태초에 Flux 가 있으라 하였더니 Redux 가 나타났고, RxSwift 를 보시고는 ReactorKit 에 만족하셨다.
MVC 를 사용하던 유명한 기업이 하나 있었으니 지금은 이름이 바뀐 Facebook(현 Meta) 이다. Facebook 은 자사의 서비스를 MVC 로 개발하며 복잡성이 올라가는 현상을 보고 Flux 를 이용해 복잡성을 많이 줄이게 된다.
이후, Flux 를 본따 Redux 가 만들어진다. Redux 는 쉽게 말해 Flux + Functional Programming 이다.
Flux, Redux 는 MVC 의 복잡성 증가로 인해 탄생한 것이다. 나는 여기서 일정한 패턴을 발견했고, 이는 ReactorKit / TCA 에서도 반복된다.
일급 객체 : 파라미터로 전달될 수 있는 객체 혹은 값을 말한다. Swift 에서는 클로저, 값, 클래스 등이 있고 함수는 일급 객체가 아니다.
참고로 나도 MVC 말고 다른 건 없나 찾아보다 예전 부트캠프에서 Flux 배운 것이 기억나서 이를 구현한 라이브러리를 찾아보니 ReactorKit 을 만나게 된 것이다.
그 때 내가 RxSwift 를 배워놓지 않았더라면 여기까지 오지도 못했을 것 같다.
반응형, 단방향 Swift 아키텍처이다. 구조 자체는 Flux + Reactive Programming 이라고 정의되어 있다.
모든 설명을 하기 전에 RxSwift 의 Observable 을 설명해야 할 것 같다. Observable 은 데이터 제공자의 역할로 Observer 객체들에 값을 비동기적으로 제공하게 된다. 이를 위해 Observer 는 Observable 을 구독
해야 한다.
슬프게도 ReactorKit 에 RxSwift 가 필수
다. 이미 라이브러리 의존성에 RxSwift 가 추가되어 있다. 이름도 예전에는 RxMVVM 이었을 정도이니 진입장벽이 일부 존재한다고 볼 수 있겠다.
ReactorKit 의 구조를 데이터 흐름을 통해 설명하자면 아래와 같다.
따로 모델은 만들지 않았다.
import ReactorKit
import UIKit
import RxSwift
import RxCocoa
class ViewController: View {
typealias Reactor = CustomReactor
typealias CELL = CustomTableViewCell
let tableVieew: UITableView {
let view = UITableView()
view.rowHeight = 80
return view
}
let refreshButton = UIButton()
let submitButton = UIButton()
let textField = UITextField()
private var disposeBag = DisposeBag()
override viewDidLoad() {
super.viewDidLoad()
// Omit all layout codes.
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
reactor = Reactor()
}
func bind(reactor: Reactor) {
reacotr.state
.map({ $0.entities })
.bind(tableView.rx.items(
cellIdentifier: String(describing: CELL.self),
cellType: CELL.self,
)) { row, entity, cell in
cell.titleLabel.text = entity.name
}
.disposed(by: disposeBag)
tableView.rx.modelSelected(Entity.self)
.bind(onNext: { [weak self] in self?.goNextView(with: $0) })
.disposed(by: disposeBag)
textField.rx.text
.skip(1)
.map({ Reactor.Action.textChanged($0) })
.bind(to: reactor.action)
.disposed(by: disposeBag)
refreshButton.rx.tap
.map({ Reactor.Action.refreshData })
.bind(to: reactor.action)
.disposed(by: disposeBag)
}
func goNextView(with entity: Entity) {
let nextView = SubViewController()
nextView.entity = entity
navigationController?.pushViewController(nextView, animated: true)
}
}
class CustomReactor: Reactor {
enum Action {
case refreshData
case textChanged(String)
}
enum Mutation {
case refreshEntities([Entity])
case setText(String)
}
struct State {
var entities: [Entity] = []
var text: String = ""
}
let initialState = State()
func mutate(action: Action) -> Observable<Mutation>
switch action {
case refreshData:
return URLSession.shared.rx.data(request: URLRequest.GetAPI) // static GetAPI.
.compactMap({ try? JSONDecoder().decode([Entity].self, from: $0) })
.map({ Mutation.refreshEntities($0) })
case textChanged(let text):
return Observable.just(Mutation.setText(text))
}
}
func reduce(state: State, mutation: Mutation) -> State {
var state = state
switch mutation {
case .refreshEntities(let entities):
state.entities = entities
case .setText(let text)
state.text = text
}
return state
}
}
Pulse, transform 등 추가 기능들이 많지만 최대한 주요 기능만을 표현하려 하였다.
참고로 View 프로토콜에는 reactor 프로퍼티가 선언되어 있는데, didSet 에 reactor 객체의 bind() 함수를 실행하는 코드가 이미 들어가 있다.
만약 CollectionView(테이블 뷰, 컬렉션 뷰) 에 DataSource 가 반영되는 타이밍이 중요하다면 reactor 프로퍼티에 참조값을 반영하는 타이밍을 조절하면 된다.
또 하나의 단방향 아키텍처이다. 상태값을 저장하고 Action과 Store 에 따라 비즈니스 로직을 돌리는 것은 같다. 개인적인 느낌은 SPM 을 사용하는 프로젝트에 적합하다. 애초에 설치 자체를 SPM 에서밖에 제공하지 않는다.
즉, Combine + Swift Concurrency(async/await) 에 아주 적합하다고 할 수 있다. 그리고 GitHub 페이지의 예시도 SwiftUI 위주이다. UIKit 도 가능하지만, 애초에 README.md 에서 fold 되어 있는 상태이다.
슬프게도 TCA Github 에서 직접 가져오는 수밖에 없었다.
+, - 버튼을 통해 숫자를 더하고 뺀다. 추가로 API 요청을 통해 어떠한 숫자에 대한 어떤 사실을 가져와서 Alert 창에 보여주는 기능도 더한다.
이를 위해 도메인과 기능을 정의하는 ReducerProtocol 구현체를 만들 것이다.
import ComposableArchitecture
// MARK: - Reducer
struct Feature: ReducerProtocol {
// MARK: - State
struct State: Equatable {
var count = 0
var numberFactAlert: String? // Optional 여부로 Alert 표시여부를 정의할 예정
}
// MARK: - Action
enum Action: Equatable {
case factAlertDismissed, decrementButtonTapped, incrementButtonTapped, numberFactButtonTapped
case numberFactResponse(TaskResult<String>)
} // 미리 정의된 액션. +,-,Fact 버튼 탭과 Fact 알럿 창 닫힘 그리고 Fact 관련 정보 수신이 정해져 있다.
// MARK: - Reduce
func reduce( // reduce 는 Effect 를 반환하지 않아도 되고 해도 된다.ㅋ
into state: inout State,
action: Action) -> EffectTask<Action> {
switch action {
case .factAlertDismissed: state.numberFactAlert = nil
return .none
case .decrementButtonTapped: state.count -= 1
return .none
case .numberFactButtonTapped:
return .task { [count = state.count] in
await .numberFactResponse(
TaskResult {
String(
decoding: try await URLSession.shared.data(from URL("..")!).0,
as: UTF8.self
)
}
)
}
case .numberFactResponse(.success(let fact)): state.numberFactAlert = fact
return .none
case .numberFactResponse(.failure): state.numberFactAlert = "Colud not load a number fact :("
return .none
}
}
}
// MARK: - View
struct FeatureView: View {
// MARK: - Store
let store: StoreOf<Feature>
var body: some View {
WithViewStore(self.store, observe { $0 }) { viewStore in
VStack {
HStack {
Button("-") { viewStore.send(.decrementButtonTapped) }
Text("\(viewStore.count)")
Button("+") { viewStore.send(.incrementButtonTapped) }
}
Button("Number fact") { viewStore.send(.numberFactButtonTapped) }
}
.alert(
item: viewStore.binding(
get: { $0.numberFactAlert.map(FactAlert.init(title:)) },
send: .factAlertDismissed
),
content: { Alert(title: Text($0.title)) }
)
}
}
}
struct FactAlert: Identifiable {
var title: String
var id: String { self.title }
}
// MARK: - App
@main
struct MyApp: App {
var body: some View {
WindowGroup {
FeatureView(
store: Store(
initialState: Feature.State(),
reducer: Feature()
)
)
}
}
}