올리브영 테크블로그 포스팅 iOS ReactorKit 톺아보기
iOS

iOS ReactorKit 톺아보기

ReactorKit 사용 방법에 대해서 간단히 살펴봅시다💪

2023.05.20

안녕하세요. 오랜만에 돌아왔습니다.

올리브영 모바일앱 개발팀에서 근무하고있는 럭셔Lee💍 입니다.

그동안, 올리브영 앱의 내부에선 많은 변화가 있었는데요. 이번에는 올리브영 앱에 도입한 프레임워크 ReactorKit에 대해서 소개해보겠습니다..🏃🏃‍♀




왜 ReactorKit을 사용했나요?


모바일앱 개발팀에서는 자체적으로 필요한 내용을 공부하고 공유하는 자리를 갖고 있습니다.

이번에 공부할 주제를 찾아보던 중, 저의 이목을 끄는 한 가지가 있었습니다.

사용법도 간단하고, 유지보수 및 테스트에 강점이 있는 아키텍처 구조인 ReactorKit 프레임워크인데요. 제가 ReactorKit을 사랑할 수 밖에 없는 이유를 공개해 보겠습니다.


[ ReactorKit이 최애인 이유💚 ]

  1. 사용법이 간단하다.
  2. 특정 부분에만 적용할 수 있음. (전체 적용이 전제되지 않아요.)
  3. 테스트하기 쉬움. (Reactor와 View에 대해 의존성이 없기 때문이에요.)
  4. 유지보수하기 쉬움. (상태값 관리를 용이하게 할 수 있으니까!)
  5. 코드가 간결해짐 (ViewController에 있는 로직을 분리하면 간결해지겠죠?)

이렇게 장점이 많은 ReactorKit, 사용하지 않을 이유가 없잖아요? 올리브영 앱의 네이티브 영역을 리팩토링하면서, 바로 실전에 적용해 보았습니다.




ReactorKit이 뭔데?



iOS 앱 개발 트렌드를 살펴보면, ReactiveX Programming이 거의 필수적으로 사용되고 있습니다.

ReactorKit은 RxSwift와 함께 사용되는 프레임워크에요. 따라서, ReactorKit을 사용하려면 RxSwift에 대한 이해와 Swift 언어의 기본 지식은 필수적으로 선행되어야 합니다.


ReactorKit의 핵심 개념은 Reactor입니다. Reactor는 Rx의 개념인 Observable과 함께 사용하여 비동기적으로 데이터를 처리하고, UI와의 상호 작용을 쉽게 관리할 수 있는 구조를 만듭니다.

즉, MVVM 모델의 뷰 모델(ViewModel)과 같은 역할을 하며, 비즈니스 로직을 처리하고 뷰(View)와 상호 작용하는 방법을 제공합니다.

이를 통해 UI 관련 코드와 비즈니스 로직을 분리하고, 코드의 재사용성을 높일 수 있습니다.


다음 그림을 통해, ReactorKit의 작동 방식을 간단하게 알아봅시다.




flow1

사용자의 Action은 Reactor로, Reactor에서 방출된 State는 View로 Observable 스트림을 통해 전달됩니다.

이러한 흐름은 단방향적입니다.

View는 Action만 방출 할 수 있으며, Reactor는 State만 방출 할 수 있습니다.

단방향적 흐름으로, View와 비즈니스 로직을 분리 할 수 있으며 모듈 간 결합도가 낮게 개발할 수 있습니다.



다음 그림을 통해 , ReactorKit의 작동 방식과 사용법을 상세하게 알아봅시다~~!



flow2

View

View는 Reactor로 받는State를 통해 UI를 보여주는 역할과, 사용자 인터랙션을 추상화하여 Reactor로 전달하는 역할을 수행합니다.

그림을 통해 보시면, View는 Reactor를 향해 Action을 방출하고 있으며, Reactor는 View를 향해 State를 방출해주고 있습니다.

이를 적용하기 위해선, View 프로토콜을 적용해야하며, 그 안에 DisposeBag 프로퍼티와 bind(reactor:) 메서드를 반드시 정의해주어야 합니다.

bind 내부에는, Reactor로 보낼 Action과, Reactor로부터 수신할 State를 작성하면 됩니다. 그렇게 하면 다음과 같은 형태가 됩니다.


import ReactorKit
import RxGesture
import RxSwift

class MyViewController: UIViewController, View {
 var disposeBag = DisposeBag()

 func bind(reactor: MyReactor) {
		// Reactor로 보낼 Action
		self.testButton.rx.tapGesture().when(.recognized).map { _ in
							Reactor.Action.testAction }.bind(to: reactor.action).disposed(by: disposeBag)
		
		// Reactor로 부터 수신할 State
		reactor.state.map { $0.testState }.distinctUntilChanged().bind(to: testLabel.rx.text).disposed(by: disposeBag)
  }
}

Reactor

Reactor야 말로 이름에서도 알 수 있듯이, ReactorKit의 핵심입니다!😎

그림을 보시면, Reactor는 View로 부터 Action Stream을 전달 받아, 내부에서 mutate()reduce() 과정을 거쳐서 State Stream으로 바꾸어 다시 View로 전달해주는 역할을 합니다.

이를 적용하기 위해선, Reactor 프로토콜을 적용해야하며, 그 내부에는 사용자 인터랙션을 의미하는 Action, Action과 State의 매개체인 Mutation, View가 가질 상태를 의미하는 State 를 반드시 정의해야 합니다. 또한, State의 초기값을 설정하기 위해 initialState가 필요합니다.

mutate() 함수는 Action 스트림을 Mutation 스트림으로 변환하고, 변환된 Mutation 스트림은 reduce() 함수로 전달됩니다.

reduce() 함수는 이전 State와 Mutation을 활용하여 새로운 State를 반환합니다. 이 State를 View에서 구독을 하고 있었다면, State가 변경됨에 따라서, UI가 업데이트 될 것입니다.


import ReactorKit
import RxSwift

class MyViewReactor: Reactor {
	enum Action {
		case test
	}

	enum Mutation {
		case setTest(Int)
	}

	struct State {
		var testValue: Int = 0
	}
	
	let initialState: State = State()
}

extension myReactor {
  func mutate(action: Action) -> Observable<Mutation> {
    switch action {
      case .test:
      return Observable.just(Mutation.setTest(1))
        }
    }

  func reduce(state: State, mutation: Mutation) -> State {
    var newstate = state
    switch mutation {
      case let .setTest(testValue):
      newstate.testValue = testValue
    }
    return newstate
  }
}



인생은 실전! 예제 코드로 확인해봅시다. 👨‍💻

화면 터치시, 숫자가 올라가는 간단한 예제입니다.


  • ViewController
class MyViewController: UIViewController {
  var disposeBag = DisposeBag()
  
  let increaseNumberTouchView: UIView = .init().then {
    $0.backgroundColor = .black
    }
  
  let numberLabel: UILabel = .init().then {
    $0.text = "0"
		}
  // .... 추가적으로 필요한 UI 요소 구현

  override func viewDidLoad() {
    super.viewDidLoad()
    self.reactor = MyReactor()
    addSubViews()
    setLayout()
    }
}

extension MyViewController {
  func addSubViews() {
    view.addSubview(increaseNumberTouchView)
    touchView.addSubview(numberLabel)
    // .... 추가적으로 필요한 UI 요소 addSubView
  }

  func setLayout() {
    increaseNumberTouchView.snp.makeConstraints {
      $0.top.left.bottom.right.equalToSuperview()
    }
    numberLabel.snp.makeConstraints {
      $0.centerX.centerY.equalToSuperview()
    }
    // .... 추가적으로 필요한 UI 요소 Constraints 설정
  }
}

extension MyViewController: View {
  func bind(reactor: MyReactor) {
    increaseNumberTouchView.rx.tapGesture().when(.recognized)
    .map { _ in Reactor.Action.increaseNumber }
    .bind(to: reactor.action)
    .disposed(by: disposeBag)
    // .... 추가적으로 필요한 Action 설정

    reactor.state.map { $0.currentNumber }
    .distinctUntilChanged()
    .map{ String($0)}
    .bind(to: numberLabel.rx.text)
    .disposed(by: disposeBag)
    // .... 추가적으로 바라보고자 하는 State 설정
  }
}

  • Reactor

class MyReactor: Reactor {
  enum Action {
    case increaseNumber
    // .... 추가적으로 필요한 Action 정의
  }

  enum Mutation {
    case increaseNumber
    // .... 추가적으로 필요한 Mutation 정의
  }

  struct State {
    var currentNumber: Int = 0
    // .... 추가적으로 필요한 State 정의
  }

  let initialState: State = State()
}

extension MyReactor {
  func mutate(action: Action) -> Observable<Mutation> {
    switch action {
      case .increaseNumber:
      	return .just(Mutation.increaseNumber)
      	// .... 추가적으로 필요한 Action Case 정의
    }
  }
}

extension MyReactor {
  func reduce(state: State, mutation: Mutation) -> State {
    var newstate = state
    switch mutation {
      case .increaseNumber:
      	newstate.currentNumber += 1
      	// .... 추가적으로 필요한 Reduce Case 정의
    }
    return newstate
  }
}



꿀팁 한 스푼 추가요 🍯

bind함수를 구현할 때 subscribe를 자주 사용하게 되는데, ViewController의 메소드를 활용하면서 [weak self] 를 함께 사용하게 되는 경우가 많습니다.

subscribe는 closure를 인자로 받게 되는데, [weak self]를 사용하지 않을 경우, closure에서 순환 참조로 인한 메모리 누수가 발생하게 되기 때문인데요, 먼저 이 코드를 살펴 봅시다.

initNumberTouchView.rx.tapGesture().when(.recognized).subscribe(onNext: { [weak self] _ in 
	self?.initNumber() }).disposed(by: disposeBag)

initNumberTouchView에서 tap을 인식하면, 해당 ViewController에 정의되어있는 initNumber함수를 호출하는 코드인데요. 이러면 self를 사용할 때 일일히 ?를 넣어줘야 하는 번거로움이 있습니다. 따라서, 이렇게도 사용합니다.

initNumberTouchView.rx.tapGesture().when(.recognized).subscribe(onNext: { [weak self] _ in 
	guard let self = self else { return }
  self.initNumber() }).disposed(by: disposeBag)	

이렇게 guard let을 활용하면, ?를 넣어주지 않아도 되서 조금은 코드가 간결해집니다. 하지만 이것 보다 더 좋은 방법이 있습니다.

initNumberTouchView.rx.tapGesture().when(.recognized).withUnretained(self).subscribe(onNext: { owner, _ in 
	owner.initNumber() }).disposed(by: disposeBag)	

Tada🎉

RxSwift에서 제공해주는 withUnretained Operator를 사용하면 조금 더 간결하게 표현이 가능합니다.



마무리 하며

여러분도 보셨다시피, ReactorKit은 사용법이 간단합니다. 저는 상태값을 용이하게 관리 할 수 있는 ReactorKit의 장점을 적극 활용하여, 원하는 동작을 쉽게 만들수 있었습니다. 또한, 파일의 분리가 이루어지기는 했지만, 코드 자체가 굉장히 간결해졌다는 느낌을 받았습니다.

특히, RxSwift를 단독으로 사용하였을 때 상태 값 관리에 어려움을 느꼈었는데, ReactorKit을 도입하면서 상태 값 관리를 용이한 구조로 구현할 수 있었습니다.

정말 좋은 ReactorKit! 여러분들도 꼭 써보셨으면 좋겠습니다!

그럼 이만 물러나겠습니다~ 다음에 만나요 안녕!🖐



참고자료

AppiOSReactorKit
올리브영 테크 블로그 작성 iOS ReactorKit 톺아보기
💍
럭셔Lee |
iOS App Engineer
안녕하세요~! 럭셔리한 삶을 지향하는 iOS 신입 개발자입니다.