최근 프로젝트에서 적용한 ReactorKit에 대하여 정리해보고자 한다.
이전에 RxSwift + MVVM Input-Output 패턴을 적용하여 개발했는데, ReactorKit 구조와 비슷하다는 이야기를 들어서 한 번 적용해보았다.
특징
- 단방향 흐름을 가진 반응형 프레임워크이다.
- RxSwift와 필수적으로 함께 사용된다. RxSwift의 Observable을 통해 비동기적으로 데이터를 처리하고, 사용자의 이벤트 처리와 UI 업데이트 쉽게 관리할 수 있다.
- View와 Reactor로 구성되어있고, Reactor가 ViewModel과 같은 역할을 한다. 네트워크 통신이나 db 접근 등 비즈니스 로직을 구성하게 된다.
기본 동작(➡️단방향 흐름➡️)
- Reactor에는 View에서 받은 Action과 작업(Mutation)을 미리 정의해둔다.
- View에서 발생하는 이벤트는 Reactor에 정의된 Action으로 바인딩하여 Reactor에 전달한다.
- Reactor에서는 Action을 전달받아
mutate()
와reduce()
를 거쳐 작업 결과를 State에 담아 View로 방출한다. - View에서는 State를 구독하고 있기 때문에 데이터가 변경되면 전달 받은 데이터를 UI에 업데이트 한다.
예제
ReactorKit을 적용한 프로젝트 코드를 통해 자세히 알아보자!
예제 코드는 마이페이지에서 프로필 사진을 변경하는 과정이다.
프로필 사진을 변경하면 서버에 변경 요청을 전달한다.
성공 응답을 받으면 프로필 사진을 변경하고, 하단에 성공 토스트 메세지를 띄우게 된다.
ReactorKit 구조 설명이기 때문에 다른 과정은 코드에서 생략함
View
사용자 이벤트를 Reactor에 전달하고, Reactor에서 전달받은 State를 반영을 담당한다.
필수 구현은 bind(reactor: Reactor)
메서드이지만 가독성을 위해 bind를 Action과 State를 나누어 정의한다.
bind 메서드에 모두 정의하여도 상관 없다.
bindAction()
- View에서 일어나는 Action을 Reactor에 전달
bindState()
- Reactor에서 방출하는 State를 구독
final class MyProfileViewController: BaseViewController, View { // ** View Protocol 채택
var disposeBag = DisposeBag() // ** 필수 구현
override func viewDidLoad() {
super.viewDidLoad()
self.reactor = MyProfileReactor() // **
}
func bind(reactor: MyProfileReactor) { // **
bindAction(reactor: reactor)
bindState(reactor: reactor)
}
private func bindAction(reactor: MyProfileReactor) {
changeProfileImage
.map { Reactor.Action.changeProfileImage(data: $0)}
.bind(to: reactor.action)
.disposed(by: disposeBag)
}
private func bindState(reactor: MyProfileReactor) {
reactor.state
.map { $0.msg }
.distinctUntilChanged()
.asDriver(onErrorJustReturn: nil)
.filter { $0 != .none }
.drive(with: self) { owner, value in
if let value = value {
owner.showToastMessage(message: value, position: .bottom)
}
}
.disposed(by: disposeBag)
reactor.state
.map { $0.profileImage }
.distinctUntilChanged()
.asDriver(onErrorJustReturn: nil)
.drive(with: self) { owner, value in
owner.mainView.setProfileImage(value: value)
}
.disposed(by: disposeBag)
}
}
프로필 사진 변경 이벤트가 발생하면 변경할 사진을 Reactor에게 전달하게 된다.bindState()
에서 state를 구독하여 변경 이미지와 토스트 메세지를 전달 받으면 UI에 업데이트 하게 된다.
Reactor
View에서 전달받은 이벤트(action)에 따라 비즈니스 로직을 수행하는 역할을 담당한다.
Action
- View에서 받을 Action 정의
Mutation
- 받은 Action에 대해 처리해야 할 작업
State
- View에게 전달한 State 값을 관리하고, 값이 변경되면 View에 방출
Reactor 내부에서 작업을 처리하기 위한 필수 메서드인 mutate()와 reduce()가 있다.
mutate(action: Action) → Observable<Mutation>
- View에서 Action을 받아 수행해야 하는 작업(Mutation)을 Observable로 방출한다.
- 비동기 작업과 같은 작업을 mutate에서 수행하게 된다.
reduce(state: State, mutation: Mutation) → State
- 수행한 작업(mutation)을 받아서 state를 변경한다.
- 변경된 State를 구독하고 있는 View에게 전달한다.
import Foundation
import ReactorKit
final class MyProfileReactor: Reactor {
// State 초기화
var initialState: State = State(
msg: nil,
profileImage: nil
)
enum Action {
case changeProfileImage(data: SelectImage)
}
enum Mutation {
case msg(msg: String)
case profileImage(data: String?)
}
struct State {
var msg: String?
var profileImage: String?
}
func mutate(action: Action) -> Observable<Mutation> {
switch action {
case .changeProfileImage(let data):
return requestChangeImage(img: data)
}
}
func reduce(state: State, mutation: Mutation) -> State {
var newState = state
switch mutation {
case .msg(let msg):
newState.msg = msg
case .profileImage(let data):
newState.profileImage = data
}
return newState
}
// 프로필 변경을 위한 서버와의 비동기 통신 코드
private func requestChangeImage(img: SelectImage) -> Observable<Mutation> {
if let img = img.img, let imgData = img.imageToData() {
return UsersAPIManager.shared.request(api: .profile(data: ProfileImageReqDTO(image: imgData)), responseType: MyProfileResDto.self)
.asObservable()
.flatMap { result -> Observable<Mutation> in
switch result {
case .success(let response):
if let response = response {
return .concat( // 여러 Mutation을 한 번에 리턴
.just(.profileImage(data: response.profileImage)),
.just(.msg(msg: "")),
.just(.msg(msg: UserToastMessage.changeProfileImage.message))
)
}
return .just(.msg(msg: UserToastMessage.loadFail.message))
case .failure(let error):
if let error = UserError(rawValue: error.errorCode) {
return .just(.msg(msg: error.localizedDescription))
} else if let error = CommonError(rawValue: error.errorCode) {
return .just(.msg(msg: error.localizedDescription))
} else {
return .just(.loginRequest)
}
}
}
} else {
return .just(.msg(msg: UserToastMessage.otherError.message))
}
}
}
네트워크 통신과 같은 작업은 mutate 메서드 내에 구현하지만 복잡한 작업을 처리하게 되면 mutate 메서드가 길어지고 가독성이 좋지 않아져서 작업 별로 메서드를 따로 구현하여 사용하였다.
- 하나의 액션에 여러 작업을 연속적으로 수행해야 할 경우에 좋다.
- → 흐름이 명확해지기 때문에 가독성이 좋아짐!
- → 유지보수 작업에 용이해짐
통신 작업을 완료하게 되면 이미지 데이터와 토스트 메세지를 띄울 텍스트도 전달해야 하기 때문에 .concat() 연산자를 통해 여러 Observable을 묶어서 전달한다.
장점
- 프레임워크를 프로젝트 전체에 적용하지 않아도 사용이 가능하다.
- 특정 부분에만 사용할 수 있고, 사용하지 않을 수도 있는 것!
- 테스트하기 쉬움
- View와 Reactor사이의 의존성이 없기 때문!
- 유지보수하기 쉬움
- 코드의 가독성이 좋음
- 단방향이기 때문에 복잡한 로직을 구성하게 되더라도 흐름이 명확하여 코드를 파악하기에 편리하다.
- 기본으로 정의된 구조를 따르기 때문에 협업할 때 유리할 것 같다.
- 비즈니스 로직을 분리하고, 코드의 재사용성을 높일 수 있다.
느낀 점
MVVM + Input-Output 패턴을 사용할 때는 View와 ViewModel이 순환되는 구조로 코드를 작성하였다. 때문에 나중에 다시 코드를 볼 때 흐름을 파악하는 데에 시간이 좀 걸렸었다.
하지만 ReactorKit은 단방향 흐름이기 때문에 코드를 파악하는데 시간이 줄어들었다.
또, 코드가 구조화되어 있기 때문에 코드가 간결해지고 가독성이 매우 좋아지는 점이 굉장히 마음에 들었다!
다만 어려웠던 점은 값이 하나가 변경될 때 모든 State값이 전부 전달되기 때문에 .distinctUntilChange() 처리를 잘 하지 않으면 API를 과호출 한다던가, 원하지 않는 타이밍에 UI가 반복되어 업데이트 되기도 한다.
이러한 부분을 꼭 고려하며 적절하게 처리해야 한다!
참고
https://velog.io/@sanghwi_back/iOS-ReactorKit-과-TCA-Unidirectional-pattern
https://oliveyoung.tech/blog/2023-05-20/OliveYoung-iOS-ReactorKit/
'iOS > RxSwift&Combine' 카테고리의 다른 글
[RxSwift] Rx 를 사용하여시스템 권한 요청받기 (카메라, 앨범, 알림) (0) | 2024.11.06 |
---|---|
RxSwift 왜 사용?? (0) | 2023.12.28 |
[iOS/RxSwift] CombineLatest vs Observable.zip (0) | 2023.11.19 |
RxSwift 정리하기 (0) | 2023.11.08 |