문제
각 셀(Post)마다 투표를 할 수 있는 버튼이 있다. 투표 기능 테스트 중 첫번째 Post의 투표 버튼을 누르면 두번째 Post의 투표 버튼이 같이 눌리는 문제가 발생했다.
생각하기
1. Cell의 재사용 문제
예전에도 다뤄본 적 있기 때문에 가장 먼저 생각이 났다. 버튼이 제대로 눌리는지 index를 Log로 띄워보니까 Cell의 재사용 문제는 아니었다. (RxSwift와 CollectionView 관계에 대해 알아봐야겠다.) Cell의 재사용 문제가 아니니 RxSwift적?으로 접근해보자.
2. Rxswift로 접근하기
✅ 잘못된 상태 관리?
각 셀에 대한 상태가 제대로 관리되지 않으면, 버튼을 누를 때마다 잘못된 셀에 대한 상태가 변경되지 않을까?
특히, Rx에서의 비동기 처리는 상태 관리가 중요한데, 어떤 이벤트가 발생했을 때 어떤 셀에 대한 액션인지 정확히 매핑하지 않아서 그런 것일까?
✅ Observable의 구독 문제?
Observable의 구독, 해제 관리가 제대로 되지 않으면, 이전 셀의 상태가 새로운 셀에 반영되지 않을까?
예를 들면, 첫번째 Post의 postID를 받아오는 Observable의 구독이 해제되지 않으면 두번째 Post에서 투표 버튼을 Tap 했을 때, 두번째 Post의 PostID가 아니라 해제되지 않은 첫번째 Post의 PostID가 전달되지 않을까 하는 생각이 들었다.
해결하기
viewModel.postsData
.drive(feedCollectionView.rx.items(cellIdentifier: FeedCollectionViewCell.id, cellType: FeedCollectionViewCell.self)) { [weak self] row, item, cell in
let isLiked = self?.viewModel.isCurrentUserLikedPost(postId: item.id)
let isUnliked = self?.viewModel.isCurrentUserUnlikedPost(postId: item.id)
cell.onVoteBuyButtonTapped = { [weak self] in
self?.viewModel.voteBuyButtonTapped.onNext(())
self?.viewModel.postId.onNext(item.id)
}
cell.onVoteDontBuyButtonTapped = { [weak self] in
self?.viewModel.voteDontBuyButtonTapped.onNext(())
self?.viewModel.postId.onNext(item.id)
}
cell.configuration(item)
cell.setVoteButtonState(isLiked: isLiked ?? false, isUnliked: isUnliked ?? false)
}.disposed(by: disposeBag)
근본적인 문제를 해결하기 위해 viewModel에 바인딩 하는 코드를 살펴보았다. 위에서 생각한 문제들이 있나 살펴봐도 내 눈엔 보이지 않는다... 😢
혹시 두 개의 이벤트를 동시에 전송해서 문제가 발생하지 않았나 하는 생각이 들었다.
Rx에서 이벤트 처리 순서가 보장되지 않거나 비동기적으로 처리될 때, 예상치 못한 동작이 발생할 수 있지 않을까?
곰곰이 생각해 보았다 .. !
import RxCocoa
import RxSwift
import FirebaseAuth
import FirebaseFirestore
protocol FeedViewModelType {
...
// Input
var voteBuyButtonTapped: AnyObserver<Void> { get }
var voteDontBuyButtonTapped: AnyObserver<Void> { get }
var postId: AnyObserver<String> { get }
...
}
class FeedViewModel {
...
// Input
private let inputVoteBuyButtonTapped = PublishSubject<Void>()
private let inputVoteDontBuyButtonTapped = PublishSubject<Void>()
private let inputPostId = PublishSubject<String>()
init() {
likeVote()
unlikeVote()
fetchPosts()
}
private func likeVote() {
let voteBuyButtonTappedWithPostId = Observable.zip(inputVoteBuyButtonTapped, inputPostId)
voteBuyButtonTappedWithPostId
.subscribe(onNext: { _, id in
FireBaseService.shared.vote(postId: id, vote: "like")
})
.disposed(by: disposeBag)
}
private func unlikeVote() {
let voteDontBuyButtonTappedWithPostId = Observable.zip(inputVoteDontBuyButtonTapped, inputPostId)
voteDontBuyButtonTappedWithPostId
.subscribe(onNext: { _, id in
FireBaseService.shared.vote(postId: id, vote: "unlike")
})
.disposed(by: disposeBag)
}
...
}
extension FeedViewModel: FeedViewModelType {
...
var voteBuyButtonTapped: AnyObserver<Void> {
inputVoteBuyButtonTapped.asObserver()
}
var voteDontBuyButtonTapped: AnyObserver<Void> {
inputVoteDontBuyButtonTapped.asObserver()
}
var postId: AnyObserver<String> {
inputPostId.asObserver()
}
...
}
원래 viewModel에서 voteBuyButtonTapped, voteDontBuyButtonTapped, postId 이벤트를 받아서 zip연산자로 액션을 처리했다.
혹시 두 개의 버튼 클릭 이벤트(voteBuyButtonTapped, voteDontBuyButtonTapped)와 단일 postId 스트림을 zip으로 결합하여 사용하고 있는 것에 문제가 있는 것은 아닐까?
단일 postId 스트림이 최신 값을 유지하고, 두 버튼 클릭 이벤트 모두 동알한 postId를 사용하여 결합되어 있다. 이로 인해 한 버튼 클릭 시 두 개의 이벤트가 모두 실행되는 현상이 발생하는 것이 아닐까?
그럼 해결은 간단하다. 각 버튼 클릭 이벤트에 대해서 독립적인 postId 스트림을 사용하여, 동시성 문제를 해결하고 각 이벤트가 독립적으로 작동하도록 하면 된다.
import RxCocoa
import RxSwift
import FirebaseAuth
import FirebaseFirestore
protocol FeedViewModelType {
...
// Input
var voteBuyButtonTapped: AnyObserver<Void> { get }
var voteBuyPostId: AnyObserver<String> { get }
var voteDontBuyButtonTapped: AnyObserver<Void> { get }
var voteDontBuyPostId: AnyObserver<String> { get }
...
}
class FeedViewModel {
...
// Input
private let inputVoteBuyButtonTapped = PublishSubject<Void>()
private let inputVoteBuyPostId = PublishSubject<String>()
private let inputVoteDontBuyButtonTapped = PublishSubject<Void>()
private let inputVoteDontBuyPostId = PublishSubject<String>()
init() {
likeVote()
unlikeVote()
fetchPosts()
}
private func likeVote() {
let voteBuyButtonTappedWithPostID = Observable.zip(inputVoteBuyButtonTapped, inputVoteBuyPostId)
voteBuyButtonTappedWithPostID
.subscribe(onNext: { _, id in
FireBaseService.shared.vote(postId: id, vote: "like")
})
.disposed(by: disposeBag)
}
private func unlikeVote() {
let voteDontBuyButtonTappedWithPostID = Observable.zip(inputVoteDontBuyButtonTapped, inputVoteDontBuyPostId)
voteDontBuyButtonTappedWithPostID
.subscribe(onNext: { _, id in
FireBaseService.shared.vote(postId: id, vote: "unlike")
})
.disposed(by: disposeBag)
}
...
}
extension FeedViewModel: FeedViewModelType {
...
var voteBuyButtonTapped: AnyObserver<Void> {
inputVoteBuyButtonTapped.asObserver()
}
var voteBuyPostId: AnyObserver<String> {
inputVoteBuyPostId.asObserver()
}
var voteDontBuyButtonTapped: AnyObserver<Void> {
inputVoteDontBuyButtonTapped.asObserver()
}
var voteDontBuyPostId: AnyObserver<String> {
inputVoteDontBuyPostId.asObserver()
}
...
}
각 버튼 클릭 이벤트에 대해 독립적인 Post ID 스트림을 사용하여 각 이벤트가 독립적으로 작동하도록 viewModel을 수정했다.
viewModel.postsData
.drive(feedCollectionView.rx.items(cellIdentifier: FeedCollectionViewCell.id, cellType: FeedCollectionViewCell.self)) { [weak self] row, item, cell in
let isLiked = self?.viewModel.isCurrentUserLikedPost(postId: item.id)
let isUnliked = self?.viewModel.isCurrentUserUnlikedPost(postId: item.id)
cell.onVoteBuyButtonTapped = { [weak self] in
self?.viewModel.voteBuyButtonTapped.onNext(())
self?.viewModel.voteBuyPostId.onNext(item.id)
}
cell.onVoteDontBuyButtonTapped = { [weak self] in
self?.viewModel.voteDontBuyButtonTapped.onNext(())
self?.viewModel.voteDontBuyPostId.onNext(item.id)
}
cell.configuration(item)
cell.setVoteButtonState(isLiked: isLiked ?? false, isUnliked: isUnliked ?? false)
}.disposed(by: disposeBag)
viewModel 바인딩 로직도 수정했다.
정상적으로 작동한다 !!
또 다른 방법?
이번에는 하나의 이벤트로 합쳐서 처리해보자.
import RxCocoa
import RxSwift
import FirebaseAuth
import FirebaseFirestore
protocol FeedViewModelType {
...
// Input
var voteBuyButtonTapped: AnyObserver<(String, String)> { get }
var voteDontBuyButtonTapped: AnyObserver<(String, String)> { get }
...
}
class FeedViewModel {
...
// Input
private let inputVoteBuyButtonTapped = PublishSubject<(String, String)>()
private let inputVoteDontBuyButtonTapped = PublishSubject<(String, String)>()
init() {
likeVote()
unlikeVote()
fetchPosts()
}
private func likeVote() {
inputVoteBuyButtonTapped
.subscribe(onNext: { id, type in
FireBaseService.shared.vote(postId: id, vote: type)
})
.disposed(by: disposeBag)
}
private func unlikeVote() {
inputVoteDontBuyButtonTapped
.subscribe(onNext: { id, type in
FireBaseService.shared.vote(postId: id, vote: type)
})
.disposed(by: disposeBag)
}
...
}
extension FeedViewModel: FeedViewModelType {
...
var voteBuyButtonTapped: AnyObserver<(String, String)> {
inputVoteBuyButtonTapped.asObserver()
}
var voteDontBuyButtonTapped: AnyObserver<(String, String)> {
inputVoteDontBuyButtonTapped.asObserver()
}
...
}
voteType과 postId를 함께 전달할 수 있게 (String, String) 형태의 튜플로 수정하였다. 그리고 이벤트가 발생하면 vote 액션을 처리하도록 하였다.
viewModel.postsData
.drive(feedCollectionView.rx.items(cellIdentifier: FeedCollectionViewCell.id, cellType: FeedCollectionViewCell.self)) { [weak self] row, item, cell in
let isLiked = self?.viewModel.isCurrentUserLikedPost(postId: item.id)
let isUnliked = self?.viewModel.isCurrentUserUnlikedPost(postId: item.id)
cell.onVoteBuyButtonTapped = { [weak self] in
self?.viewModel.voteBuyButtonTapped.onNext((item.id, "like"))
}
cell.onVoteDontBuyButtonTapped = { [weak self] in
self?.viewModel.voteDontBuyButtonTapped.onNext((item.id, "unlike"))
}
cell.configuration(item)
cell.setVoteButtonState(isLiked: isLiked ?? false, isUnliked: isUnliked ?? false)
}.disposed(by: disposeBag)
마찬가지로 voteType과 postId를 함께 전달하여 이벤트를 처리하도록 수정했다
다시 생각해보기
문제의 핵심이 뭘까?
1. 동시성 문제
voteBuyButtonTapped와 PostId 이벤트가 거의 동시에 발생하면, 이 두 이벤트가 처리되는 순서가 달라질 수 있지 않을까?
따라서, voteBuyButtonTapped 이벤트가 발생했을 때, postId가 아직 설정되지 않았거나, 반대로 postId가 설정된 후에 voteBuyButtonTapped 이벤트가 발생할 수 있지 않을까?
2. 이벤트 스트림
두 개의 독립적인 스트림으로 이벤트를 전달하면서, 두 이벤트 간의 관계가 명확하게 설정되지 않았다?
물론 내 생각이다 !
앞으로 한 개 이상의 이벤트 스트림을 처리하게 된다면, 다수의 이벤트를 하나의 스트림으로 결합해서 처리하던가, 이벤트 스트림 간의 관계를 명확하게 설정하면서 개발해야겠다.
'ToyProject - 사카마카 (살까말까 고민 될 때는 사카마카)' 카테고리의 다른 글
[사카마카] 키보드 이벤트를 감지하여 화면의 레이아웃을 업데이트 해보자. (0) | 2024.06.01 |
---|---|
[사카마카/문제해결] 앱 내에서 웹을 보여줄 때 발생하는 스킴 문제를 해결해보자. (0) | 2024.05.30 |
[사카마카] 앱 내에서 웹을 보여주는 방법에 대해 알아보자. (0) | 2024.05.30 |
[사카마카] Lottie로 애니메이션을 사용해보자. (0) | 2024.05.29 |
[사카마카] UINavigation에 대해 다르게 접근해보자. (0) | 2024.05.27 |