ReactorKit 시작하기

스타일쉐어(StyleShare) / 조회수 : 2819

ReactorKit 시작하기


오늘은 StyleShare에서 ReactorKit을 사용한지 딱 1년이 되는 날입니다. ReactorKit은 반응형 단방향 앱을 위한 프레임워크로, StyleShare와 Kakao를 비롯한 여러 기업에서 사용하고 있는 기술입니다.

StyleShare의 iOS 프로젝트 첫 커밋은 2011년 8월 23일입니다. 그 뒤로 약 7년간 크고 작은 기능을 추가하며 굉장히 큰 코드베이스를 가지게 되었습니다. 특히 2015년에는 스토어 기능을 런칭하면서 기존 서비스 만큼이나 많은 코드를 작성했습니다. 서비스 복잡도는 점점 높아졌고, 지속 가능한 코드베이스를 위해 많은 개선이 필요했습니다.

ReactorKit은 많은 부분에 있어서 StyleShare가 가진 고민을 해결해주었습니다. Flux와 Reactive Programming의 개념을 결합하여 만들어진 ReactorKit에서는 사용자 인터랙션과 뷰 상태가 관찰 가능한 스트림을 통해 단방향으로 전달됩니다. 뷰와 비즈니스 로직을 분리할 수 있게 되면서 모듈간 결합도가 낮아지고 테스트하기 쉬워졌습니다. 또한, 자칫 복잡해질 수 있는 비동기 코드를 일관되게 작성할 수 있게 되었습니다.

이 글에서는 ReactorKit의 기본 개념과 테스트를 위한 기법을 소개 합니다.


데이터 흐름



ReactorKit에는 뷰(View)와 리액터(Reactor)라는 개념이 존재합니다. 뷰는 상태를 표현합니다. 뷰 컨트롤러나 셀도 모두 뷰에 해당합니다. 뷰는 사용자 인터랙션을 추상화하여 리액터에 전달하고, 리액터에서 전달받은 상태를 각각의 뷰 컴포넌트에 바인드합니다. 뷰는 비즈니스 로직을 수행하지 않습니다.

반대로, 리액터는 뷰의 상태를 관리합니다. 뷰에서 액션을 전달받으면 비즈니스 로직을 수행한 뒤 상태를 변경하여 다시 뷰에 전달합니다. 리액터는 UI 레이어에서 독립적이기 때문에 비교적 테스트하기 쉽습니다.

View

View 프로토콜을 적용하면 뷰를 정의할 수 있습니다. DisposeBag 속성과 bind(reactor:) 메서드를 필수로 정의해야 합니다.

import ReactorKit
 import RxSwift 

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

 func bind(reactor: UserViewReactor) {  
  } 
}


<iframe width="700" height="250" data-src="/media/78a16e327ba4eb073cc5bdbb703c81f9?postId=c7b52fbb131a" data-media-id="78a16e327ba4eb073cc5bdbb703c81f9" data-thumbnail="https://i.embed.ly/1/image?url=https://avatars2.githubusercontent.com/u/931655?s=400&v=4&key=a19fcc184b9711e1b4764040d3dc5c07" class="progressiveMedia-iframe js-progressiveMedia-iframe" allowfullscreen="" frameborder="0" src="https://medium.com/media/78a16e327ba4eb073cc5bdbb703c81f9?postId=c7b52fbb131a" style="display: block; position: absolute; margin: auto; max-width: 100%; box-sizing: border-box; transform: translateZ(0px); top: 0px; left: 0px; width: 700px; height: 236.984px;">

이 프로토콜을 정의하면 reactor 속성이 자동으로 생성됩니다. 이 속성에 새로운 값이 지정되면 bind(reactor:) 메서드가 자동으로 호출됩니다. 이곳에는 사용자 인터랙션을 리액터에 바인드하거나, 리액터의 상태를 각각의 뷰 컴포넌트에 바인드하는 코드를 작성합니다.

func bind(reactor: UserViewReactor) { 
 // Action  
self.followButton.rx.tap    
.map { Reactor.Action.follow }    
.bind(to: reactor.action)    
.disposed(by: self.disposeBag)   

// State  reactor.state.map { $0.isFollowing }    
.distinctUntilChanged()    
.bind(to: self.followButton.rx.isSelected)    
.disposed(by: self.disposeBag)
 }


<iframe width="700" height="250" data-src="/media/6a6d5aa66b156cae7d4475f6ed13efb0?postId=c7b52fbb131a" data-media-id="6a6d5aa66b156cae7d4475f6ed13efb0" data-thumbnail="https://i.embed.ly/1/image?url=https://avatars2.githubusercontent.com/u/931655?s=400&v=4&key=a19fcc184b9711e1b4764040d3dc5c07" class="progressiveMedia-iframe js-progressiveMedia-iframe" allowfullscreen="" frameborder="0" src="https://medium.com/media/6a6d5aa66b156cae7d4475f6ed13efb0?postId=c7b52fbb131a" style="display: block; position: absolute; margin: auto; max-width: 100%; box-sizing: border-box; transform: translateZ(0px); top: 0px; left: 0px; width: 700px; height: 325px;">

Reactor

리액터를 정의하기 위해서는 Reactor 프로토콜을 사용합니다. 사용자 인터랙션을 표현하는 Action과 뷰의 상태를 표현하는 State, 그리고 상태를 변경하는 가장 작은 단위인 Mutation을 클래스 내부에 필수로 정의해야 합니다. 또한 가장 첫 상태를 나타내는 initialState가 필요합니다.

import ReactorKit 
import RxSwift 

final class UserViewReactor: Reactor { 
 enum Action {    
case follow  
}   

enum Mutation {    
case setFollowing(Bool)  
}   

enum State {   
 var isFollowing: Bool 
 }  

 let initialState: State = State(isFollowing: false) 
}


<iframe width="700" height="250" data-src="/media/572f53fb442c67060d2a69f90a42a07b?postId=c7b52fbb131a" data-media-id="572f53fb442c67060d2a69f90a42a07b" data-thumbnail="https://i.embed.ly/1/image?url=https://avatars2.githubusercontent.com/u/931655?s=400&v=4&key=a19fcc184b9711e1b4764040d3dc5c07" class="progressiveMedia-iframe js-progressiveMedia-iframe" allowfullscreen="" frameborder="0" src="https://medium.com/media/572f53fb442c67060d2a69f90a42a07b?postId=c7b52fbb131a" style="display: block; position: absolute; margin: auto; max-width: 100%; box-sizing: border-box; transform: translateZ(0px); top: 0px; left: 0px; width: 700px; height: 435px;">

Action이나 State와 달리 Mutation은 리액터 클래스 밖으로 노출되지 않습니다. 대신, 클래스 내부에서 Action과 State를 연결하는 역할을 수행합니다. Action이 리액터에 전달되면 두 단계를 거쳐서 뷰의 상태를 변경합니다.



mutate() 함수에서는 Action 스트림을 Mutation 스트림으로 변환하는 역할을 합니다. 이곳에서 네트워킹이나 비동기로직 등의 사이드 이펙트를 처리합니다. 그 결과로 Mutation을 방출하면 그 값이 reduce() 함수로 전달됩니다. reduce() 함수는 이전 상태와 Mutation을 받아서 다음 상태를 반환합니다.


func mutate(action: Action) -> Observable { 
 switch action {   
 case .follow:      
return UserService.follow()        
.map { Mutation.setFollowing(true) }        
.catchErrorJustReturn(Mutation.setFollowing(false))     

case .unfollow:      
return UserService.unfollow()        
.map { Mutation.setFollowing(false) }        
.catchErrorJustReturn(Mutation.setFollowing(true))  
} 
}

 func reduce(state: State, mutation: Mutation) -> State { 
 var newState = state  
switch mutation {  
case let setFollowing(isFollowing):    
newState.isFollowing = isFollowing  
}  
return newState 
}


<iframe width="700" height="250" data-src="/media/dc8fbdce8314a7eba99be944241c5432?postId=c7b52fbb131a" data-media-id="dc8fbdce8314a7eba99be944241c5432" data-thumbnail="https://i.embed.ly/1/image?url=https://avatars2.githubusercontent.com/u/931655?s=400&v=4&key=a19fcc184b9711e1b4764040d3dc5c07" class="progressiveMedia-iframe js-progressiveMedia-iframe" allowfullscreen="" frameborder="0" src="https://medium.com/media/dc8fbdce8314a7eba99be944241c5432?postId=c7b52fbb131a" style="display: block; position: absolute; margin: auto; max-width: 100%; box-sizing: border-box; transform: translateZ(0px); top: 0px; left: 0px; width: 700px; height: 522.984px;">

테스팅

테스트를 위해 가장 먼저 고민하게 되는 것은 ‘무엇을 테스트할 것인가’에 대한 것입니다. ReactorKit을 사용하면 뷰와 로직이 분리되어 상대적으로 쉽게 해답을 얻을 수 있습니다.

View

  • 사용자 인터랙션이 발생했을 때 Action이 리액터로 잘 전달되는지
  • 리액터의 상태가 바뀌었을 때 뷰의 컴포넌트 속성이 잘 변경되는지

Reactor

  • Action을 받았을 때 원하는 State로 잘 변경되는지


뷰 테스팅

리액터의 stub 기능을 이용하면 뷰를 쉽게 테스트할 수 있습니다. stub 기능을 활성화하면 리액터가 받은 Action을 모두 기록하고, mutate()reduce()를 실행하는 대신 외부에서 상태를 설정할 수 있게 됩니다.

func testAction_refresh() {  
// 1. Stub 리액터를 준비합니다. 
 let reactor = MyReactor()  
reactor.stub.isEnabled = true   

// 2. Stub된 리액터를 주입한 뷰를 준비합니다.  
let view = MyView() 
 view.reactor = reactor   

// 3. 사용자 인터랙션을 발생시킵니다.  
view.refreshControl.sendActions(for: .valueChanged)  

 // 4. Reactor에 액션이 잘 전달되었는지를 검증합니다. 
 XCTAssertEqual(reactor.stub.actions.last, .refresh) 
}

 func testState_isLoading() {  
// 1. Stub 리액터를 준비합니다.  
let reactor = MyReactor()  
reactor.stub.isEnabled = true  

 // 2. Stub된 리액터를 주입한 뷰를 준비합니다. 
 let view = MyView()  view.reactor = reactor  

 // 3. 리액터의 상태를 임의로 설정합니다. 
 reactor.stub.state.value = MyReactor.State(isLoading: true)   

// 4. 그 때 뷰 컴포넌트의 속성이 잘 변하는지를 검증합니다. 
 XCTAssertEqual(view.activityIndicator.isAnimating, true) 
}


<iframe width="700" height="250" data-src="/media/9e5e0349766c69076a5081cbd680645b?postId=c7b52fbb131a" data-media-id="9e5e0349766c69076a5081cbd680645b" data-thumbnail="https://i.embed.ly/1/image?url=https://avatars2.githubusercontent.com/u/931655?s=400&v=4&key=a19fcc184b9711e1b4764040d3dc5c07" class="progressiveMedia-iframe js-progressiveMedia-iframe" allowfullscreen="" frameborder="0" src="https://medium.com/media/9e5e0349766c69076a5081cbd680645b?postId=c7b52fbb131a" style="display: block; position: absolute; margin: auto; max-width: 100%; box-sizing: border-box; transform: translateZ(0px); top: 0px; left: 0px; width: 700px; height: 721px;">

리액터 테스팅

리액터는 뷰에 비해서 상대적으로 테스트하기 쉽습니다. Action이 전달되었을 때 비즈니스 로직을 수행하여 State가 바뀌는지를 확인하면 됩니다.

func testBookmark() { 
 // 1. 리액터를 준비합니다.  
let reactor = MyReactor()   

// 2. 리액터에 액션을 전달합니다.  
reactor.action.onNext(.toggleBookmarked)   

// 3. 리액터의 상태가 변경되는지를 검증합니다.  
XCTAssertEqual(reactor.currentState.isBookmarked, true)
 }

 func testUnbookmark() {
  // 1. 리액터를 준비합니다. 액션을 미리 한 번 전달해서 테스트 환경을 만들어둡니다.  
let reactor = MyReactor()  
reactor.action.onNext(.toggleBookmarked)   

// 2. 리액터에 액션을 한 번 더 전달합니다.  
reactor.action.onNext(.toggleBookmarked)  

// 3. 리액터의 상태가 변경되는지를 검증합니다.  
XCTAssertEqual(reactor.currentState.isBookmarked, false) 
}


<iframe width="700" height="250" data-src="/media/32af3eac8c1c9646bf95ea1442ad8ff4?postId=c7b52fbb131a" data-media-id="32af3eac8c1c9646bf95ea1442ad8ff4" data-thumbnail="https://i.embed.ly/1/image?url=https://avatars2.githubusercontent.com/u/931655?s=400&v=4&key=a19fcc184b9711e1b4764040d3dc5c07" class="progressiveMedia-iframe js-progressiveMedia-iframe" allowfullscreen="" frameborder="0" src="https://medium.com/media/32af3eac8c1c9646bf95ea1442ad8ff4?postId=c7b52fbb131a" style="display: block; position: absolute; margin: auto; max-width: 100%; box-sizing: border-box; transform: translateZ(0px); top: 0px; left: 0px; width: 700px; height: 522.984px;">

마치며

ReactorKit은 지금까지 CocoaPods에서 약 3만 7천회 다운로드 되었고, 약 730개 앱에서 사용되고 있습니다. 최근에는 Wantedly에서 사용하며 일본에서도 많은 호응을 얻고 있습니다. 공개된지 1년밖에 되지 않았지만 굉장히 좋은 평을 받으며 성장하고 있는 프레임워크입니다. 만약 새로운 프로젝트를 시작하거나, StyleShare와 비슷한 고민을 하고 계신다면 ReactorKit을 강력하게 추천합니다.


#스타일쉐어 #개발팀 #개발자 #경험공유 #인사이트



관련 스택

기업문화 엿볼 때, 더팀스

로그인

/