RxSwift와 MVVM으로 iOS앱 만들기
2018.10.09
by Navdeep Singh, translated by pilgwon
출처: icetime17
RxSwift는 많은 작업을 수행하고 코드를 빠르게 작성할 수 있으며 따라하기 쉽게 만들어져있습니다.
이 글은 중급이상의 Swift 개발자들에게 RxSwift와 MVVM에 대한 기본적인 지식을 전달하기 위해 작성되었습니다. 초보자라면 제가 쓴 다른 글인 RxSwift 파운데이션과 기본 컴포넌트를 추천드립니다. 그리고 RxSwift에서 테스트하기까지 읽으신 후에 돌아오시길 바랍니다.
MVVM과 RxSwift는 iOS 개발자들 사이에서도 쉽지 않은 주제이며 둘 중 하나에도 혼란스러워 하는 개발자들을 많이 봐왔습니다. 이 글에서는 이 둘에 대해 최대한 쉽게 설명한 예제를 알려드리고 원한다면 둘 중 하나를 선택해서 더 높은 단계로 끌어올릴 수도 있게 될 것입니다.
오늘날의 앱 개발은 원격 서버와 상호작용이 없으면 많은 것을 할 수 없게 되었습니다. 그래서 API와 상호작용해서 데이터를 파싱하고 뷰에 그리는 iOS 앱을 만들어 볼 것입니다. iOS 앱의 기본 컴포넌트 중에서도 많이 쓰이는 것들을 사용할 예정입니다.
오늘은 사용자 ID로 GitHub 저장소를 검색하고 실시간으로 결과를 보여주는 검색 앱을 만들어 볼 것입니다. 할 일이 많으니 바로 시작하겠습니다.
프로젝트 설정하기
테이블 뷰 가 있는 싱글 뷰 앱으로 시작하겠습니다. 이 저장소에 있는 프로젝트를 clone 하세요. 그러면 제가 만들어놓은 searchController 에서 configureSearchController() 로 searchController 의 searchBar 를 tableView 의 tableHeaderView 로 설정한 것을 확인하실 수 있을 것입니다.
func configureSearchController() {
searchController.obscuresBackgroundDuringPresentation = false
searchBar.showsCancelButton = true
searchBar.text = "scotteg"
searchBar.placeholder = "Enter GitHub ID, e.g., \"navdeepsinghh\""
tableView.tableHeaderView = searchController.searchBar
definesPresentationContext = true
}
또 viewModel 의 데이터 시퀀스를 tableView 에 바인딩해놓았습니다.
viewModel.data
.drive(tableView.rx.items(cellIdentifier: "Cell")) { _, repository, cell in
cell.textLabel?.text = repository.name
cell.detailTextLabel?.text = repository.url
}
.addDisposableTo(disposeBag)
repoName 과 repoURL 로 이루어진 struct도 만들어두었습니다.
struct Repository {
let repoName: String
let repoURL: String
}
그리고 스트링 값을 가지는 searchText 가 들어있는 viewModel 클래스도 만들었습니다.
class ViewModel {
let searchText = Variable("")
lazy var data: Driver<[Repository]> = {
return Observable.of([Repository]()).asDriver(onErrorJustReturn: [])
}()
}
viewModel 의 목적은 UI에 바인딩할 때 처럼 viewController 에 데이터를 준비하는 코드를 추상화하기 위함입니다. 데이터 속성은 placeholder를 가지고 있어서 프로젝트는 컴파일 잘됩니다. 이 부분은 나중에 바꿀 것입니다. 이 데이터 시퀀스는 저장소로 이루어진 배열의 Driver 로 구현되었습니다. (Driver<[Repository]>) 그리고 viewController 에서 볼 수 있듯이 이는 테이블뷰를 다룰(drive)것입니다. Driver 는 에러를 발생하지 않고 자동으로 메인 스레드에 이벤트를 전달한다는 사실을 기억하세요.
바쁜 분들을 위해
완성된 프로젝트 코드는 RxSwift_MVVM_Finished 저장소에서 볼 수 있지만 저는 이 글을 따라가면서 직접 완성시키는 것을 제안드립니다.
프로젝트 구현하기
이제 GitHub ID 파라미터를 받아서 Repository 의 배열의 Observable 을 반환하는 헬퍼 메소드인 repositoriesBy() 를 구현해보겠습니다. 실제 앱에선 APIManager 에 넣어야겠지만 이해를 위해 한 파일안에 모두 넣는걸로 하겠습니다. 먼저 GitHub ID가 비지는 않았는지 확인하고(guard) fetch request를 보낼 URL을 만들겠습니다.
static func repositoriesBy(_ githubID: String) -> Observable<[Repository]> {
guard !githubID.isEmpty,
let url = URL(string: "https://api.github.com/users/\(githubID)/repos") else {
return Observable.just([])
}
}
URL 생성이 실패하면 빈 Observable 배열이 반환될 것입니다. GitHub API 문서에서도 확인 가능합니다.
그 URL에서 오는 리스폰스를 테스트하려면 브라우저를 사용하면 됩니다. 예를 들면 아래는 제 GitHub ID인 NavdeepSinghh 를 검색하는 URL입니다.
https://api.github.com/users/NavdeepSinghh/repos
이 URL을 브라우저에서 열어보겠습니다. 아래 이미지에서 확인할 수 있듯이 제 GitHub ID 아래에 저장소의 목록이 내려옵니다.
결과로 오는 딕셔너리의 배열 중에는 우리가 눈여겨 볼만한 두 데이터가 내려옵니다.
- “name”: “book-notes”
- “html_url”: “https://github.com/NavdeepSinghh/book-notes”
Xcode로 돌아가서 이 데이터를 가져오도록 한 후에 우리 앱에서 사용해보겠습니다.
데이터 불러오고(Fetching) 파싱하기(Parsing)
다음으론 URLSession 의 싱글톤(shared singleton)에서 rx.json 의 익스텐션을 사용해서 URL의 결과를 반환하겠습니다.
guard !githubID.isEmpty,
let url = URL(string: "https://api.github.com/users/\(githubID)/repos") else {
return Observable.just([])
}
return URLSession.shared.rx.json(url: url)
rx.json 은 리스폰스 JSON의 Observable 시퀀스를 반환합니다. 네트워킹 도중에는 에러가 발생할 수 있으니 최대 재시도 횟수를 3회로 지정해서 두 번 더 재시도 하도록 하겠습니다. 세 번째 에러가 발생헀을 때는 catchErrorJustReturn 를 사용해서 빈 배열을 반환하도록 하겠습니다.
return URLSession.shared.rx.json(url: url)
.retry(3)
.catchErrorJustReturn([])
RxSwift에서 에러 핸들링하는 방법에 대해 더 알고싶다면 저의 다른 글인 RxSwift에서 테스트하기를 참고해주세요.
이 글에서 에러 핸들링에 대해 자세히 다루지 않는 것은 Driver 때문입니다. Driver 는 에러를 발생시킬 수 없습니다. Driver 를 쓰지않으면 이 에러가 생기는 시점을 잡을 수(catch) 있습니다. 일단 넘어가겠습니다.
URLSession 이 이미 반환을 했기 때문에 백그라운드 큐에서 파싱 작업을 할 필요가 없습니다. 이것을 특정 큐에서 작동하도록 하고싶다면 observeOn 연산자를 사용하면 됩니다.
다음엔 Observable sequence 를 변환하기 위해 map 을 사용해보겠습니다. 하지만 map 연산자에서 매핑 코드를 작성하는 것이 아니라, json 을 받아서 저장소 배열을 반환하는 parseJson() 메소드에 추상화해보겠습니다. 먼저 json 을 스트링으로 된 키와 아무 값을 가지는 딕셔너리로 이루어진 배열로 캐스팅하도록 하겠습니다. 이게 실패한다면 빈 배열을 반환하겠습니다.
static func parse(json: Any) -> [Repository] {
guard let items = json as? [[String: Any]] else {
return []
}
}
그리고 json에서 만든 각 인스턴스들을 넣을 저장소의 배열을 만들겠습니다. 이제 이 배열을 forEach 순환(iterate)하면서 guard를 사용해서 딕셔너리에서 이름과 URL을 추출해보겠습니다.
guard let items = json as? [[String: Any]] else {
return []
}
var repositories = [Repository]()
items.forEach{
guard let repoName = $0["name"] as? String,
let repoURL = $0["html_url"] as? String else {
return
}
}
다음엔 새 저장소 인스턴스를 만들어서 저장소 배열에 추가하고 마지막에 반환하겠습니다.
var repositories = [Repository]()
items.forEach{
guard let repoName = $0["name"] as? String,
let repoURL = $0["html_url"] as? String else {
return
}
repositories.append(Repository(repoName: repoName, repoURL: repoURL))
}
return repositories
다시 repositoriesBy() 메소드로 돌아가서 이 메소드를 map 연산자에 넘기겠습니다.
함수 타입을 직접 파라미터로 전달할 땐 이름만 적고 파라미터 목록은 적지 않습니다. 이는 하나의 파라미터를 받는 함수에만 적용됩니다. 이 경우엔 map 이 되겠습니다.
이걸 구독(subscribe)하고 있지는 않으니 입력에서 출력으로 바로 직진할 것입니다.
static func repositoriesBy(_ githubID: String) -> Observable<[Repository]> {
guard !githubID.isEmpty,
let url = URL(string: "https://api.github.com/users/\(githubID)/repos") else {
return Observable.just([])
}
return URLSession.shared.rx.json(url: url)
.retry(3)
//.catchErrorJustReturn([])
.map(parse)
}
여긴 저장소 목록을 가져오고 생성하는 것을 담당하는 부분입니다.
이제 repositoriesBy() 메소드를 사용해보겠습니다.
불러온 데이터를 뷰에 바인딩하기
먼저 placeholder를 반환하는 곳을 삭제하겠습니다. 여기선 searchText 를 저장소 배열로 변환하고자 합니다. 우리가 사용하는 GitHub API는 인증되지 않았기 때문에 1분에 10번의 제한이 있단 사실을 기억해주세요.
우리는 throttle operator 를 사용해서 가장 마지막 값을 받을 것입니다. 이는 쓸데없는 네트워크 리퀘스트를 방지할 수 있는 방법입니다. 그리고 distinctUntilChanged 연산자를 사용해서 값이 변경됐을 때만 넘기도록 하겠습니다.
searchText 가 변경되면 이 앱의 연산이 시작되기 때문에 필수는 아니지만 검색 버튼을 추가해서 클릭하면 명시적으로 검색 결과를 받을 수 있게 만들겠습니다. 이렇게 만들면 사용자가 버튼을 여러번 누르더라도 검색 키워드는 그대로라서 필요없는 리퀘스트가 생기지 않을 것입니다.
lazy var data: Driver<[Repository]> = {
return self.searchText.asObservable()
.throttle(0.3, scheduler: MainScheduler.instance)
.distinctUntilChanged()
}()
이제는 flatMapLatest를 사용할 것입니다. 이전 블로그 글에서 말했었는데 flatMapLatest는 가장 마지막 Observable sequence로 가면서 각 요소들에 변환 작업을 합니다. 우리는 viewModel.repositoriesBy() 메소드를 flatMapLatest 에 직접 전달해서 검색 결과가 Observable 배열로 변경되게 하고 마지막으로 asDriver(onErrorJustReturn) 를 사용해서 Driver 로 변경하는 작업을 하겠습니다. 에러가 발생하면 빈 배열을 반환합니다.
lazy var data: Driver<[Repository]> = {
return self.searchText.asObservable()
.throttle(0.3, scheduler: MainScheduler.instance)
.distinctUntilChanged()
.flatMapLatest(ViewModel.repositoriesBy)
.asDriver(onErrorJustReturn: [])
}()
viewController 로 돌아가보겠습니다. 여기선 viewModel 의 searchText 시퀀스의 searchBar 에 rx.text 를 바인딩하고 텍스트 값이 nil일 경우를 대비해 orEmpty 를 사용하겠습니다.
viewModel.data
.drive(tableView.rx.items(cellIdentifier: "Cell")) { _, repository, cell in
cell.textLabel?.text = repository.name
cell.detailTextLabel?.text = repository.url
}
.addDisposableTo(disposeBag)
searchBar.rx.text.orEmpty
.bind(to: viewModel.searchText)
.disposed(by: disposeBag)
searchField 에 텍스트가 입력되면 ViewModel 의 searchText 에 입력되고, 우리가 설정해둔 네트워크 호출을 트리거합니다. 또한 취소 버튼을 눌렀을 때도 상황에 맞게 작동합니다.
그리고 viewModel 의 데이터 시퀀스를 사용해서 navigationItem 을 rx.title 익스텐션을 이용해 변경해보겠습니다. 이 제목은 데이터 배열의 요소의 수랑 스트링에 있는 값을 합쳐서 만들 것입니다.
searchBar.rx.text.orEmpty
.bind(to: viewModel.searchText)
.disposed(by: disposeBag)
viewModel.data.asDriver()
.map { "\($0.count) Repositories" }
.drive(navigationItem.rx.title)
.disposed(by: disposeBag)
완성입니다!
실행하기
이제 앱을 실행해서 Search bar에 검색어를 입력해보세요. 저는 제 GitHub ID를 입력해보겠습니다.
다음과 같은 결과가 나올 것입니다.
해당 GitHub ID를 가진 사람이 만들었거나 기여한 저장소가 보일 것입니다. 다른 결과를 보고 싶다면 그냥 검색어를 지우고 새로 입력하면 됩니다. 우리가 만든 코드를 돌아보면 API를 호출하고 파싱한 다음에 뷰에 표시하는 전반적인 과정을 기존과는 다르게 적은 시간과 적은 코드로 만든 것을 확인하게 될 것입니다.
트위터에서 @NavRudraSambyal 를 팔로우하면 업데이트가 있을 때마다 받아보실 수 있습니다.
다양한 디자인 패턴과 연습할 예제들에 대해 자세히 알아보고 싶다면 제 책인 Reactive programming in Swift 4를 참조하세요.
읽어주셔서 감사합니다. 내용이 도움이 됐다면 공유 부탁드립니다 :)