iOS ReactorKit 과 TCA (Unidirectional pattern)

백상휘·2023년 4월 22일
2

Combine+Swift Concurrency(option) => TCA, RxSwift => ReactorKit

Swift 로 iOS 앱 개발을 처음 배울 때는 대부분 MVC 를 가르친 것 같다. 참고로 나는 Stanford CS193P 수업 영상을 그대로 Youtube 에 올려준 것을 시작으로 공부했는데, 거기에서도 MVC 만 가르쳐 주었다. 진짜 영어 자막이 없어서 쉽지 않았던 기억이 있다.

https://cs193p.sites.stanford.edu/

오랜만에 들어가보니 지금은 MVVM+SwiftUI 를 가르치고 있다.

왜 사람들은 MVC 를 위험하다고 표현할까?

결론만 말하자면 굳이 그럴 필요는 없다고 본다. 현재 서비스되고 있는 레거시 코드에도 MVC 로 작성된 것이 분명 있다. 내가 근무했던 회사, 프리랜서로 합류한 프로젝트 모두 MVC 를 쓰고 있다. 그 프로젝트를 전부 아키텍처를 엎는다? 화이팅이다.

흔히 아키텍처를 나누는 기준 중 하나는 데이터의 방향이다. 방향은 두 개로 나뉜다.

  • 단방향 (Unidirectional)
  • 양방향 (Bidirectional)

이 중에서 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 가 가진 문제점을 아래와 같이 제시하고 싶다.

  • 유지보수성 = 커플링이 강한 ViewController-Model 을 수정하기에는 둘 중 하나만 고려하기에는 부족한 상황이 너무 많다.
  • 테스트 = 많은 역할의 객체들이 프로젝트 각처에 퍼져있어 테스트 타겟이 너무 많다.
  • 코드 재사용성 = 커플링이 강한 ViewController-Model 을 재사용하기란 쉽지 않다.
  • 캡슐화의 부재 = iOS 의 MVC 모델에서 ViewController 는 모델의 상태를 참조해야 하는 일이 많을 수 밖에 없다. ViewController 는 필연적으로 Model 에 대해 알아야 하고, Model 도 ViewController 를 염두에 두고 제작될 수 밖에 없다.

물론 MVC 도 잘 쓰면 위의 사항들을 모두 만족할 수 있다고 생각한다. 하지만 그 이전에 새로운 아키텍처를 고민해보는 것도 좋지 않을까?

단방향 아키텍처?

태초에 Flux 가 있으라 하였더니 Redux 가 나타났고, RxSwift 를 보시고는 ReactorKit 에 만족하셨다.

MVC 를 사용하던 유명한 기업이 하나 있었으니 지금은 이름이 바뀐 Facebook(현 Meta) 이다. Facebook 은 자사의 서비스를 MVC 로 개발하며 복잡성이 올라가는 현상을 보고 Flux 를 이용해 복잡성을 많이 줄이게 된다.

이후, Flux 를 본따 Redux 가 만들어진다. Redux 는 쉽게 말해 Flux + Functional Programming 이다.

Flux, Redux 는 MVC 의 복잡성 증가로 인해 탄생한 것이다. 나는 여기서 일정한 패턴을 발견했고, 이는 ReactorKit / TCA 에서도 반복된다.

  • 사용자 Action 을 미리 정의하는 일급 객체가 존재한다.
  • 사용자 Action 에 대한 응답을 Store 하는 일급 객체가 존재한다.
  • Action -> Response 를 정의하고 관리하는 객체가 따로 존재한다.
  • 모든 객체들이 각각의 상태를 직접 참조하는 대신 메시지를 매개체로 하여 소통한다.

일급 객체 : 파라미터로 전달될 수 있는 객체 혹은 값을 말한다. Swift 에서는 클로저, 값, 클래스 등이 있고 함수는 일급 객체가 아니다.

참고로 나도 MVC 말고 다른 건 없나 찾아보다 예전 부트캠프에서 Flux 배운 것이 기억나서 이를 구현한 라이브러리를 찾아보니 ReactorKit 을 만나게 된 것이다.

그 때 내가 RxSwift 를 배워놓지 않았더라면 여기까지 오지도 못했을 것 같다.

ReactorKit

반응형, 단방향 Swift 아키텍처이다. 구조 자체는 Flux + Reactive Programming 이라고 정의되어 있다.

살짝 RxSwift 섞기

모든 설명을 하기 전에 RxSwift 의 Observable 을 설명해야 할 것 같다. Observable 은 데이터 제공자의 역할로 Observer 객체들에 값을 비동기적으로 제공하게 된다. 이를 위해 Observer 는 Observable 을 구독해야 한다.

슬프게도 ReactorKit 에 RxSwift 가 필수다. 이미 라이브러리 의존성에 RxSwift 가 추가되어 있다. 이름도 예전에는 RxMVVM 이었을 정도이니 진입장벽이 일부 존재한다고 볼 수 있겠다.

구성요소

  • View
    View 는 데이터를 표시한다. 예를 들어 UIViewController, UITableViewCell 등이View에 속한다.
    View 에는 사용자 입력을 Action stream 으로 바꿔서 Reactor 에 전달할 뿐이다.
    어떠한 비즈니스 로직도 정의되어 있지 않다.
  • Action
    사용자와의 상호작용과 뷰의 상태를 정의한 State 를 소유한다.
    Mutation 은 Action, State 을 브릿징한다.
    View 에서 전달된 Action stream 은 State stream 으로 바뀌게 되는데 mutate(), reduce() 를 거쳐야 한다.
  • Reactor
    UI 와 완전히 분리된 영역으로 State 를 관리한다.
    View 에게서 제어권을 완전히 가져오는데 그 존재의미가 있다.
    모든 View 는 자신만의 Reactor 를 가지고 있고 모든 비즈니스 로직을 Reactor 에게 Delegate 한다.
    Reactor 자체에는 View 에 대한 의존성이 없기 때문에 더욱 testable 한 특성을 갖는다.
  • State
    View 가 표시하거나 표시되는 데 사용해야 할 현재 상태를 저장하고 있는 타입이다.

데이터 흐름

ReactorKit 의 구조를 데이터 흐름을 통해 설명하자면 아래와 같다.

  1. View 는 Reacotr 와 UI Element 의 이벤트(터치, 스크롤, 입력 등)를 통해 바인딩 된다. (주로 UIResponder, UIControl 타입의 UIKit 클래스들이다)
  2. View 와 Reactor 를 바인딩 할 때는 reactor 의 action 프로퍼티와 바인딩 된다.
  3. Reactor 에는 Action, Mutation, State 가 미리 정의되어 있고 Action 에 따라 실제 비즈니스 로직을 수행하는 mutate(:Action) 과 그 결과를 상태에 반영하는 reduce(:State:Mutation) 을 정의해 놓는다.
  4. View 의 UI Element 중 Reactor 와 바인딩 된 이벤트가 발생하면 정해진 액션으로 바꿔 전달하고, Reactor 는 액션에 따라 정해진 역할만을 수행한다.

예제

따로 모델은 만들지 않았다.

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 프로퍼티에 참조값을 반영하는 타이밍을 조절하면 된다.

TCA(The Composable Architecture)

또 하나의 단방향 아키텍처이다. 상태값을 저장하고 Action과 Store 에 따라 비즈니스 로직을 돌리는 것은 같다. 개인적인 느낌은 SPM 을 사용하는 프로젝트에 적합하다. 애초에 설치 자체를 SPM 에서밖에 제공하지 않는다.

즉, Combine + Swift Concurrency(async/await) 에 아주 적합하다고 할 수 있다. 그리고 GitHub 페이지의 예시도 SwiftUI 위주이다. UIKit 도 가능하지만, 애초에 README.md 에서 fold 되어 있는 상태이다.

특징

  • State management
    struct 로 선언된다. 여러 화면에서 공유할 수 있도록 제작되어 있기 때문에 하나의 상태 변환으로 여러 화면을 변경하는 것 또한 가능하다.
  • Composition
    큰 기능을 컴포넌트 단위로 관리하여 조합하는 형태를 취한다.
  • Side effects
    외부 환경과 연결되는데 가장 testable, understandable 한 방법을 제시한다.
  • Testing
    이 구조에서 한 개의 기능을 유닛 테스트, 여러 개의 기능을 통합 테스트, End-to-End 테스트할 수 있다. 이를 통해 Side effect 의 영향도를 테스트할 수 있다.
  • Ergonomics
    위의 4 개를 간단한 컨셉의 API 와 유연한 구조를 통해 구현 가능하다.

구성요소

  • State
    기능을 실행하고 UI 렌더링에 필요한 데이터
  • Action
    사용자의 제스처, 알림, 이벤트 등 미리 정의된 모든 행동
  • Reducer
    상태값을 바꾸는 함수. 기능 동작을 위한 API 요청 후 Effect 값을 반환하는 것도 여기서 진행한다.
  • Store
    실제 기능을 실행하는 런타임이다. 사용자 액션을 Store 로 보내면 Store 는 Reducer, Effect 를 실행한다. 이를 통해 State 의 상태 변화를 유도하여 UI 를 업데이트 한다.

예시

슬프게도 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()
				)
			)
		}
	}
}

참조 (Reference)

profile
plug-compatible programming unit

0개의 댓글