😭 겪은 문제

API 페이징이 적용된 CollectionView에서, 하단으로 빠르게 스크롤시 스크롤이 멈추는 현상을 겪게 되었다.
정확히는 CollectionView Prefetching을 통해 새로운 데이터를 받아오고, 컬렉션뷰를 reload하는 시점에 스크롤이 멈추었다.
😇 원인
컬렉션뷰를 새로고침할때 사용하던 refreshControl을 네트워크 통신의 로딩으로 같이 사용해서 발생한 문제였다. 😭
private lazy var refreshControl: UIRefreshControl = {
let refreshControl = UIRefreshControl()
refreshControl.addTarget(
self,
action: #selector(viewControllerDidRefresh(_:)),
for: .valueChanged
)
return refreshControl
}()
@objc func viewControllerDidRefresh(_ sender: UIRefreshControl) {
viewModel.refreshViewController()
}
private lazy var collectionView: UICollectionView = {
... 생략 ...
let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
collectionView.refreshControl = refreshControl
collectionView.delegate = self
collectionView.prefetchDataSource = self
return collectionView
}()
collectionView의 refreshControl을 설정하면 사용자가 당겨서 새로고침 기능을 사용할 수 있게된다. 컬렉션뷰를 위에서 당기면 refreshControl이 활성화 되면서 내가 설정해준 메서드인 viewControllerDidRefresh를 수행하고, 해당 시점에 데이터의 변경사항을 컬렉션뷰에 적용(reloadData)하는 방식이다.
이 refreshControl을 페이징의 로딩에 사용한 코드는 다음과 같다..
먼저, 페이징을 위한 프리패칭 메소드를 구현하기 위해 UICollectionViewDataSourcePrefetching를 채택했다.
extension SearchViewController: UICollectionViewDataSourcePrefetching {
func collectionView(
_ collectionView: UICollectionView,
prefetchItemsAt indexPaths: [IndexPath]
) {
for indexPath in indexPaths {
viewModel.prefetchItemAt(indexPath: indexPath)
}
}
}
프리패칭 관련 로직은 뷰모델에서 수행하도록 구현했다.
extension DefaultSearchViewModel {
func prefetchItemAt(indexPath: IndexPath) {
if dataSourceItemList.count - 6 == indexPath.item {
fetchNextShoppingList()
}
}
func fetchNextShoppingList() {
// 1. 페이징 플래그 확인
guard isPagingEnabled else { return }
// 2. 검색 인덱스가 검색 최대 인덱스를 초과하는지
// 3. 현재 가지고 있는 데이터 수가 검색 상품의 총 데이터 수보다 적은지
guard searchStartIndex < Constants.API.searchIdxLimit,
let searchTotalCount,
dataSourceItemList.count < searchTotalCount
else { return }
searchStartIndex += 1
isPagingEnabled = false
self.fetchShoppingList()
}
func fetchShoppingList() {
guard let searchKeyword else {
isRefreshControlRefreshing.accept(false)
return
}
isRefreshControlRefreshing.accept(true)
fetchShoppingUseCase.fetchShoppingList(
with: searchKeyword,
display: searchDisplayCount,
start: searchStartIndex,
sort: searchSortType
) { [weak self] result in
guard let self else { return }
isRefreshControlRefreshing.accept(false)
... 생략
}
}
fetchShoppingList 메서드에서, 네트워크 통신이 시작할때 isRefreshControlRefreshing을 true로, 통신이 끝나면 false를 방출하는 것을 볼 수 있다.
마지막으로 이를 뷰컨트롤러에서 바인딩한다.
private extension SearchViewController {
// MARK: - Bind
func bindViewModel() {
... 생략
viewModel.isRefreshControlRefreshing
.asDriver()
.drive(refreshControl.rx.isRefreshing)
.disposed(by: disposeBag)
}
}
네트워크 통신의 로딩으로써 컬렉션뷰의 refreshControl을 사용했기 때문에, 단순히 인디케이터를 보여주는 것이 아니라 페이징 중인데 컬렉션뷰에게 "나 새로고침할게" 라고 말한 상황이다. 이로 인해 컬렉션뷰가 페이징(프리패칭)시 refreshControl이 animating되고, 그 시점에 컬렉션뷰의 스크롤이 멈추는 증상이 발생했다.
✅ 해결방법: UIActivityIndicatorView
이 문제는 당연하게도 ActivityIndicator로 대응하면서 해결되었다.
private lazy var activityIndicator: UIActivityIndicatorView = {
let activityIndicator = UIActivityIndicatorView(style: .large)
activityIndicator.hidesWhenStopped = true
return activityIndicator
}()
뷰모델에도 activityIndicator의 상태를 위한 output 프로퍼티를 추가했다.
protocol SearchViewModelOutput {
... 생략
var isActivityIndicatorAnimating: BehaviorRelay<Bool> { get }
}
private extension SearchViewModel {
func fetchShoppingList() {
guard let searchKeyword else {
isActivityIndicatorAnimating.accept(false)
return
}
isActivityIndicatorAnimating.accept(true)
fetchShoppingUseCase.fetchShoppingList(
with: searchKeyword,
display: searchDisplayCount,
start: searchStartIndex,
sort: searchSortType
) { [weak self] result in
guard let self else { return }
isActivityIndicatorAnimating.accept(false)
... 생략
}
}
}
마지막으로 새로 추가한 activityIndicator의 상태값을 뷰컨트롤러에서 바인딩한다.
private extension SearchViewController {
// MARK: - Bind
func bindViewModel() {
... 생략
viewModel.isActivityIndicatorAnimating
.asDriver()
.drive(activityIndicator.rx.isAnimating)
.disposed(by: disposeBag)
}
}
👍 옳게된 동작

더이상 페이징시 스크롤 끊김 현상이 발생하지 않는다.
✏️ 정리
스크롤뷰(테이블뷰, 컬렉션뷰)의 새로고침(Refresh)시 UIRefreshControl을, 비동기 로직의 로딩시 UIActivityIndicatorView를 사용하자. 인디케이터가 돌아가는건 비슷해보여도, 각자 역할이 있다.
마치며...
ActivityIndicator의 존재는 알고 있었지만, 개발 당시에는 단순히 데이터 패칭시 로딩을 보여줘야지 하고 일말의 고민도 없이refreshControl을 사용했던 것 같다. 스크롤이 끊기는 현상을 발견했을때 어느 부분에서 문제인건지 찾을 수 없어 삽질을 오랜시간 했어야 했다.
삽질하면서 느낀 것은, 어디에서 문제가 발생한건지 추측하는 것도 필요하겠지만 브레이크 포인트, log, lldb를 활용해서 실행 흐름에 따라서 문제 원인을 찾는것이 좀더 확실하고 빠르게 찾을 수 있다는 것이다. 페이징 방식에 문제가 있었나? 데이터 바인딩하면서 뭔가 실수했나? 하며 여기저기를 찾아다녔지만, 결국에는 앱의 실행 흐름에 따라 스크롤이 멈추는 시점, 네트워크 통신을 시작하는 시점, refreshControl의 상태가 변하는 시점을 디버깅하면서 문제 원인을 찾을 수 있었다.
'트러블슈팅' 카테고리의 다른 글
UITextField secureTextEntry 설정시 키체인 영역 안나오게 하기 (0) | 2023.11.22 |
---|---|
Navigation Bar의 SearchBar에서 키보드 내리기(feat. resignFirstResponder) (0) | 2023.08.09 |
다크모드와 CGColor(feat. layer.borderColor) (0) | 2023.07.26 |
😭 겪은 문제

API 페이징이 적용된 CollectionView에서, 하단으로 빠르게 스크롤시 스크롤이 멈추는 현상을 겪게 되었다.
정확히는 CollectionView Prefetching을 통해 새로운 데이터를 받아오고, 컬렉션뷰를 reload하는 시점에 스크롤이 멈추었다.
😇 원인
컬렉션뷰를 새로고침할때 사용하던 refreshControl을 네트워크 통신의 로딩으로 같이 사용해서 발생한 문제였다. 😭
private lazy var refreshControl: UIRefreshControl = {
let refreshControl = UIRefreshControl()
refreshControl.addTarget(
self,
action: #selector(viewControllerDidRefresh(_:)),
for: .valueChanged
)
return refreshControl
}()
@objc func viewControllerDidRefresh(_ sender: UIRefreshControl) {
viewModel.refreshViewController()
}
private lazy var collectionView: UICollectionView = {
... 생략 ...
let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
collectionView.refreshControl = refreshControl
collectionView.delegate = self
collectionView.prefetchDataSource = self
return collectionView
}()
collectionView의 refreshControl을 설정하면 사용자가 당겨서 새로고침 기능을 사용할 수 있게된다. 컬렉션뷰를 위에서 당기면 refreshControl이 활성화 되면서 내가 설정해준 메서드인 viewControllerDidRefresh를 수행하고, 해당 시점에 데이터의 변경사항을 컬렉션뷰에 적용(reloadData)하는 방식이다.
이 refreshControl을 페이징의 로딩에 사용한 코드는 다음과 같다..
먼저, 페이징을 위한 프리패칭 메소드를 구현하기 위해 UICollectionViewDataSourcePrefetching를 채택했다.
extension SearchViewController: UICollectionViewDataSourcePrefetching {
func collectionView(
_ collectionView: UICollectionView,
prefetchItemsAt indexPaths: [IndexPath]
) {
for indexPath in indexPaths {
viewModel.prefetchItemAt(indexPath: indexPath)
}
}
}
프리패칭 관련 로직은 뷰모델에서 수행하도록 구현했다.
extension DefaultSearchViewModel {
func prefetchItemAt(indexPath: IndexPath) {
if dataSourceItemList.count - 6 == indexPath.item {
fetchNextShoppingList()
}
}
func fetchNextShoppingList() {
// 1. 페이징 플래그 확인
guard isPagingEnabled else { return }
// 2. 검색 인덱스가 검색 최대 인덱스를 초과하는지
// 3. 현재 가지고 있는 데이터 수가 검색 상품의 총 데이터 수보다 적은지
guard searchStartIndex < Constants.API.searchIdxLimit,
let searchTotalCount,
dataSourceItemList.count < searchTotalCount
else { return }
searchStartIndex += 1
isPagingEnabled = false
self.fetchShoppingList()
}
func fetchShoppingList() {
guard let searchKeyword else {
isRefreshControlRefreshing.accept(false)
return
}
isRefreshControlRefreshing.accept(true)
fetchShoppingUseCase.fetchShoppingList(
with: searchKeyword,
display: searchDisplayCount,
start: searchStartIndex,
sort: searchSortType
) { [weak self] result in
guard let self else { return }
isRefreshControlRefreshing.accept(false)
... 생략
}
}
fetchShoppingList 메서드에서, 네트워크 통신이 시작할때 isRefreshControlRefreshing을 true로, 통신이 끝나면 false를 방출하는 것을 볼 수 있다.
마지막으로 이를 뷰컨트롤러에서 바인딩한다.
private extension SearchViewController {
// MARK: - Bind
func bindViewModel() {
... 생략
viewModel.isRefreshControlRefreshing
.asDriver()
.drive(refreshControl.rx.isRefreshing)
.disposed(by: disposeBag)
}
}
네트워크 통신의 로딩으로써 컬렉션뷰의 refreshControl을 사용했기 때문에, 단순히 인디케이터를 보여주는 것이 아니라 페이징 중인데 컬렉션뷰에게 "나 새로고침할게" 라고 말한 상황이다. 이로 인해 컬렉션뷰가 페이징(프리패칭)시 refreshControl이 animating되고, 그 시점에 컬렉션뷰의 스크롤이 멈추는 증상이 발생했다.
✅ 해결방법: UIActivityIndicatorView
이 문제는 당연하게도 ActivityIndicator로 대응하면서 해결되었다.
private lazy var activityIndicator: UIActivityIndicatorView = {
let activityIndicator = UIActivityIndicatorView(style: .large)
activityIndicator.hidesWhenStopped = true
return activityIndicator
}()
뷰모델에도 activityIndicator의 상태를 위한 output 프로퍼티를 추가했다.
protocol SearchViewModelOutput {
... 생략
var isActivityIndicatorAnimating: BehaviorRelay<Bool> { get }
}
private extension SearchViewModel {
func fetchShoppingList() {
guard let searchKeyword else {
isActivityIndicatorAnimating.accept(false)
return
}
isActivityIndicatorAnimating.accept(true)
fetchShoppingUseCase.fetchShoppingList(
with: searchKeyword,
display: searchDisplayCount,
start: searchStartIndex,
sort: searchSortType
) { [weak self] result in
guard let self else { return }
isActivityIndicatorAnimating.accept(false)
... 생략
}
}
}
마지막으로 새로 추가한 activityIndicator의 상태값을 뷰컨트롤러에서 바인딩한다.
private extension SearchViewController {
// MARK: - Bind
func bindViewModel() {
... 생략
viewModel.isActivityIndicatorAnimating
.asDriver()
.drive(activityIndicator.rx.isAnimating)
.disposed(by: disposeBag)
}
}
👍 옳게된 동작

더이상 페이징시 스크롤 끊김 현상이 발생하지 않는다.
✏️ 정리
스크롤뷰(테이블뷰, 컬렉션뷰)의 새로고침(Refresh)시 UIRefreshControl을, 비동기 로직의 로딩시 UIActivityIndicatorView를 사용하자. 인디케이터가 돌아가는건 비슷해보여도, 각자 역할이 있다.
마치며...
ActivityIndicator의 존재는 알고 있었지만, 개발 당시에는 단순히 데이터 패칭시 로딩을 보여줘야지 하고 일말의 고민도 없이refreshControl을 사용했던 것 같다. 스크롤이 끊기는 현상을 발견했을때 어느 부분에서 문제인건지 찾을 수 없어 삽질을 오랜시간 했어야 했다.
삽질하면서 느낀 것은, 어디에서 문제가 발생한건지 추측하는 것도 필요하겠지만 브레이크 포인트, log, lldb를 활용해서 실행 흐름에 따라서 문제 원인을 찾는것이 좀더 확실하고 빠르게 찾을 수 있다는 것이다. 페이징 방식에 문제가 있었나? 데이터 바인딩하면서 뭔가 실수했나? 하며 여기저기를 찾아다녔지만, 결국에는 앱의 실행 흐름에 따라 스크롤이 멈추는 시점, 네트워크 통신을 시작하는 시점, refreshControl의 상태가 변하는 시점을 디버깅하면서 문제 원인을 찾을 수 있었다.
'트러블슈팅' 카테고리의 다른 글
UITextField secureTextEntry 설정시 키체인 영역 안나오게 하기 (0) | 2023.11.22 |
---|---|
Navigation Bar의 SearchBar에서 키보드 내리기(feat. resignFirstResponder) (0) | 2023.08.09 |
다크모드와 CGColor(feat. layer.borderColor) (0) | 2023.07.26 |